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:
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:
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.
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 = capacityExpected 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._sizearr = 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| Operation | Python | Complexity | Why |
|---|---|---|---|
| Access by index | items[i] | O(1) | Direct address calculation |
| Append at end | items.append(x) | O(1) amortized | Occasional resize, averaged out |
| Pop from end | items.pop() | O(1) | No shifting required |
| Insert / delete at front or middle | items.insert(0, x) | O(n) | Every following element must shift |
| Search for a value | x in items | O(n) | Elements aren't sorted or indexed by value |
| Get the length | len(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 itemscollections.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