Arrays & Dynamic Arrays

Understand how arrays store data in contiguous memory, why indexing is O(1), and how dynamic arrays like Python's list grow behind the scenes

Introduction

The array is the oldest and simplest data structure, and nearly every other structure in this course is built on top of it or defined in contrast to it. Its performance characteristics come directly from one decision: storing elements in one contiguous block of memory. That single choice is what makes indexing instant and insertion at the front expensive. Python's built-in list is not a raw array, it's a dynamic array that automatically grows as you add elements, and understanding how that growth works explains a lot about why some list operations are fast and others quietly become a performance problem at scale.

What is an Array?

An array is a collection of elements, all of the same fixed size, stored in one contiguous block of memory. Because every element takes up the same number of bytes, the computer can calculate the exact memory address of any element instantly, without searching for it.

Why Indexing is O(1)

To find items[i], the computer doesn't walk through the array element by element. It computes the address directly:

address(items[i]) = base_address + i * element_size

One multiplication and one addition, regardless of whether the array holds 10 elements or 10 million.

def get_price(prices: list[float], index: int) -> float:
    # Direct memory address calculation: O(1), regardless of list size
    return prices[index]

prices: list[float] = [19.99, 24.99, 9.99, 49.99]
print(get_price(prices, 2))

Here is that idea laid out in memory. Each cell is one 8-byte float, and the small offset under each is how many bytes past the start it begins:

0
1
2
3
19.99
24.99
9.99
49.99
+0
+8
+16
+24
Figure 1: A contiguous block of four 8-byte floats. items[2] (amber) sits at base + 2·8 = base + 16. That address is computed in one step, never searched for, which is exactly why indexing is O(1) whatever the length.

Static vs Dynamic Arrays

Static Arrays

Fixed size, decided at creation time. Common in lower-level languages like C. In Python, a NumPy array is the closest equivalent, its size is fixed once allocated, and functions like np.append actually build and return a brand new array rather than growing the original in place.

Dynamic Arrays

Grow automatically as elements are added. Python's list, Java's ArrayList, and C++'s std::vector are all dynamic arrays built on top of a static array that gets replaced when it runs out of room.

Gotcha: Python's own array module is easy to mistake for a static array because of its name, but it actually resizes dynamically too, just like list. The difference is that it stores homogeneous primitive values compactly (all ints or all floats, for example) instead of general Python object references.

How Dynamic Arrays Grow (Amortized Analysis)

A dynamic array starts with a small backing array. When it fills up, appending one more element requires: allocating a new, larger backing array (commonly double the size), copying every existing element into it, an O(n) operation, and only then placing the new element. CPython's actual list growth pattern isn't a clean doubling, but you can watch it grow in real time with sys.getsizeof:

import sys

items: list[int] = []
previous_capacity = 0

for i in range(12):
    items.append(i)
    capacity = sys.getsizeof(items)
    if capacity != previous_capacity:
        print(f"len={len(items)} bytes={capacity}")
        previous_capacity = capacity
Expected Output:
len=1 bytes=88
len=5 bytes=120
len=9 bytes=184

Notice the list doesn't resize on every single append, it resizes in occasional bursts and over-allocates room for future growth. That over-allocation is exactly what makes append fast most of the time.

Building the Idea from Scratch

Here's a simplified dynamic array that doubles its capacity whenever it fills up, the same core idea behind Python's list:

class DynamicArray:
    def __init__(self) -> None:
        self._capacity: int = 4
        self._size: int = 0
        self._data: list[int | None] = [None] * self._capacity

    def append(self, value: int) -> None:
        if self._size == self._capacity:
            self._resize(self._capacity * 2)   # grow before the array is full
        self._data[self._size] = value
        self._size += 1

    def _resize(self, new_capacity: int) -> None:
        new_data: list[int | None] = [None] * new_capacity
        for i in range(self._size):
            new_data[i] = self._data[i]        # copy every existing element: O(n)
        self._data = new_data
        self._capacity = new_capacity

    def __getitem__(self, index: int) -> int:
        if index >= self._size:
            raise IndexError("index out of range")
        return self._data[index]

    def __len__(self) -> int:
        return self._size
arr = DynamicArray()
for i in range(10):
    arr.append(i)

print(len(arr), arr[9])
Expected Output:
10 9
Why Append is "Amortized" O(1)

Most appends are O(1), just write to the next free slot. Occasionally an append triggers an O(n) resize. But because resizing doubles the capacity, the resizes become exponentially rarer as the array grows. Spread that occasional O(n) cost evenly across all the appends that led up to it, and the average cost per append works out to O(1). This averaging technique is called amortized analysis.

Time Complexity of Common Operations

Not every array operation costs the same. Adding or removing at the end is cheap, doing it anywhere else forces every following element to shift.

def insert_at_front(items: list[int], value: int) -> list[int]:
    # Every existing element must shift right by one position: O(n)
    items.insert(0, value)
    return items

def append_at_end(items: list[int], value: int) -> list[int]:
    # No shifting needed, the new slot is already reserved: O(1) amortized
    items.append(value)
    return items
OperationPythonComplexityWhy
Access by indexitems[i]O(1)Direct address calculation
Append at enditems.append(x)O(1) amortizedOccasional resize, averaged out
Pop from enditems.pop()O(1)No shifting required
Insert / delete at front or middleitems.insert(0, x)O(n)Every following element must shift
Search for a valuex in itemsO(n)Elements aren't sorted or indexed by value
Get the lengthlen(items)O(1)Length is tracked as metadata, not counted

The O(n²) Trap: Repeated Front Insertion

A single insert(0, x) is O(n). Calling it inside a loop that runs n times turns an innocent-looking function into an O(n²) one, exactly the kind of hidden cost Lesson 1 warned you to watch for.

❌ Quadratic: shifting on every insert
def build_list_badly(n: int) -> list[int]:
    items: list[int] = []
    for i in range(n):
        items.insert(0, i)   # O(n) shift, repeated n times -> O(n^2) total
    return items
✅ Linear: build forward and reverse once, or use a deque
def build_list_efficiently(n: int) -> list[int]:
    items: list[int] = [i for i in range(n)]
    items.reverse()          # a single O(n) pass instead of n separate shifts
    return items

from collections import deque

def build_deque_efficiently(n: int) -> deque[int]:
    items: deque[int] = deque()
    for i in range(n):
        items.appendleft(i)  # O(1) per call, deque is backed by a doubly linked list of blocks
    return items
Rule of thumb: if you find yourself repeatedly inserting or removing from the front of a Python list, reach for collections.deque instead. It's backed by a doubly linked list of blocks, giving O(1) operations at both ends.

Key Takeaways

  • Contiguous memory is what makes indexing O(1) - the address of any element is computed directly, never searched for
  • Python's list is a dynamic array - it over-allocates and resizes in bursts so most appends stay O(1)
  • Append and pop from the end are cheap, O(1) amortized, but insert or delete anywhere else is O(n)
  • Amortized analysis spreads the occasional expensive resize across many cheap appends to get an average O(1) cost
  • Repeated front insertion is a classic O(n²) trap - prefer building forward and reversing once, or use collections.deque