Part 3 · Data Structures · Module 20

Arrays & Dynamic Arrays

The array is the most important data structure in computing: a row of equal-sized slots side by side in memory. Python's list is built on one. In this module you will see exactly how, build your own dynamic array, and meet Python's typed arrays, raw bytes, and zero-copy views.

⏱ ~2 hoursPrerequisites: M19, M06, M11

The problem: find item 700,000 instantly

Suppose you store a million sensor readings and someone asks for reading number 700,000. If the readings were scattered around memory with each one pointing to the next, you would have to walk 700,000 steps. With an array, you compute where reading 700,000 lives with one multiplication and one addition, and jump straight there. That is the whole idea: position determines address.

Every access takes about the same time. Position 0 and position 999,999 are equally easy to reach. Hold onto that fact; the rest of the module explains why it is true and what it costs.

Contiguous memory and the indexing formula

Computer memory is a giant numbered sequence of bytes. Each byte has an address, an integer. An array reserves one unbroken (contiguous) run of addresses and divides it into slots of equal size. If the array starts at address base and each slot is size bytes wide, then slot i starts at

address(i) = base + i × size

That is one multiply and one add, whatever i is: O(1). It is also why indexes start at 0: index 0 is the base address itself, with offset zero.

42719 388 index 0index 1index 2 index 3index 4 100010081016 102410321040 address(3) = 1000 + 3 × 8 = 1024
An array of five 8-byte slots. Every slot's address is computable from its index.

Two requirements make the formula work: the slots must be equal size and the memory must be contiguous. Keep both in mind; they explain both the strengths (fast indexing, great cache behaviour) and the weaknesses (inserting in the middle means shifting, growing means moving) of arrays.

Static vs dynamic arrays

A static array has a fixed capacity chosen when it is created. In C, int a[10]; is ten ints, forever. You cannot append an eleventh: the memory right after the array may already belong to something else.

A dynamic array (also called a growable array, resizable array, or vector) hides that limit. It keeps a static array internally, plus two numbers:

Appending writes into slot size and increments it, as long as size < capacity. When the array is full, it resizes: allocate a bigger static array, copy every element across, release the old one. That copy is O(n), but as you proved in M19, growing by a constant factor makes it rare enough that appends are amortized O(1).

old (cap 4, full) abcd new (cap 8) abcd e copy 4 items (O(n)), then write "e"
A resize: allocate double the space, copy everything, then append. Rare, so cheap on average.

How CPython's list really works

A Python list can hold anything: [1, "two", 3.0, None]. But an array needs equal-sized slots, and a string is not the same size as an integer. CPython's solution: the list does not store the objects themselves. It stores pointers to them. Every pointer is 8 bytes on a 64-bit machine, so the slots are equal-sized even though the objects are not.

In C, CPython's list object (PyListObject) looks roughly like this:

# Simplified view of Include/cpython/listobject.h
# typedef struct {
#     PyObject_VAR_HEAD          /* reference count, type pointer, ob_size (the length) */
#     PyObject **ob_item;        /* pointer to a C array of PyObject* pointers */
#     Py_ssize_t allocated;      /* capacity: number of slots in ob_item */
# } PyListObject;
PyListObject ob_size = 3 allocated = 4 ob_item ──► ptrptr ptr(spare) int 1str "two" float 3.0 8 bytes per slot
[1, "two", 3.0]: the list's array holds pointers; the objects live elsewhere on the heap.

Consequences you can now explain:

Over-allocation

When an append needs more room, CPython's list_resize computes the new capacity roughly as

new_allocated = (newsize + (newsize >> 3) + 6) & ~3   # about 1.125 × newsize, rounded to a multiple of 4

That is a growth factor of about 1.125, much gentler than doubling. Less memory is wasted, at the price of more frequent resizes; amortized append is still O(1) because growth is still proportional. The capacities you will see are 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, …

What each operation costs, and why

OperationCostWhat happens in the array
a[i], a[i] = xO(1)address arithmetic
a.append(x)O(1) amortizedwrite slot size; occasionally resize
a.pop()O(1)clear the last slot
a.insert(i, x)O(n − i)shift slots i..n−1 one to the right
a.pop(i), del a[i]O(n − i)shift slots i+1..n−1 one to the left
a.remove(x)O(n)scan to find x, then shift
x in aO(n)linear scan
a[i:j]O(j − i)copy that many pointers into a new list

Inserting at index i must make a gap, and in a contiguous block the only way to make a gap is to move everything after it. Insert at the front, and every element moves.

abcd insert(1, "X") moves 3 items first aXbcd
Shifting is the price of contiguity.

Building a DynamicArray

Let's build our own dynamic array. To keep things honest we will pretend Python only gives us a fixed-size block: we create storage with [None] * capacity and then only index into it, never calling append, insert, or slicing on it. That simulates a C static array.

class DynamicArray:
    def __init__(self):
        self._size = 0                       # slots in use
        self._capacity = 1                   # slots allocated
        self._data = [None] * self._capacity # our "static array"

    def __len__(self):
        return self._size

    @property
    def capacity(self):
        return self._capacity

    def _resize(self, new_capacity):
        new_data = [None] * new_capacity
        for i in range(self._size):          # the O(n) copy
            new_data[i] = self._data[i]
        self._data = new_data
        self._capacity = new_capacity

The special methods you met in M11 make it feel built-in: __len__ for len(), __getitem__ for a[i], __iter__ for for loops. The exercises in Practice build the rest step by step: indexing with negative indexes, append with resizing, insert with shifting, and remove.

Typed arrays: the array module

When every element is the same primitive type, storing pointers to separate objects is wasteful. The standard library's array.array stores raw machine values directly in a contiguous buffer, like a C array, while still growing like a list. You pick a typecode:

TypecodeC typeTypical bytesPython type
'b' / 'B'signed / unsigned char1int
'h' / 'H'short2int
'i' / 'I'int4int
'q' / 'Q'long long8int
'f' / 'd'float / double4 / 8float

Typed arrays enforce the type and range of every value, support append, extend, slicing, and tobytes()/frombytes() for fast binary I/O. Reading an element creates a fresh Python object ("boxing"), so element-by-element Python loops are not faster than with a list; the win is memory and I/O. For fast arithmetic over whole arrays you want NumPy.

Bytes and bytearray

bytes is an immutable array of integers 0–255, one byte each: exactly how files, network packets, and images are stored. bytearray is its mutable twin, a dynamic array of bytes.

memoryview and the buffer protocol

Slicing a bytes or bytearray copies the data: O(k) time and memory. For big binary data (a 500 MB video frame, a network buffer) that is wasteful. The buffer protocol is a C-level agreement that lets objects such as bytes, bytearray, array.array, and NumPy arrays expose their raw memory directly. A memoryview is a Python window onto such memory: slicing a memoryview creates another view, without copying.

Professional uses include parsing binary file formats without copying, reading into a pre-allocated buffer with file.readinto(view) or socket.recv_into(view), and handing data between libraries (Pillow, NumPy, PyArrow) without conversion. While a memoryview exists, the underlying bytearray cannot be resized, because resizing could move the memory out from under the view.

2-D lists and the [[0] * n] * m bug

A grid is naturally a list of rows, each row a list. The tempting way to build one is a classic bug:

Both rows change. [0] * 3 creates one row list; multiplying the outer list by 2 copies the pointer to that row, not the row. So grid[0] and grid[1] are the same object. Remember: lists store pointers, and * repeats pointers.

The fix is a comprehension, which evaluates [0] * 3 afresh for every row. [0] * 3 itself is fine because integers are immutable: sharing the same 0 object is harmless, since you can only replace a slot, not mutate the 0.

rows, cols = 3, 4
grid = [[0] * cols for _ in range(rows)]   # correct
grid[r][c]                                 # row r, column c
column_2 = [row[2] for row in grid]       # columns need a loop
transposed = [list(col) for col in zip(*grid)]

A list of lists is not a contiguous 2-D array: each row is a separate list somewhere on the heap. For a true contiguous grid, you would store a flat list of rows * cols items and compute index = r * cols + c, which is exactly what NumPy does internally ("row-major order").

Preview: NumPy arrays

NumPy's ndarray combines everything in this module: a contiguous typed buffer (like array.array), a shape and strides for multi-dimensional indexing (the r * cols + c formula, generalised), the buffer protocol, and vectorised operations that loop in C instead of Python.

The strides (32, 8) say "move 32 bytes for the next row, 8 bytes for the next column": the address formula in two dimensions. You will study NumPy properly in M48.

Pitfalls and when to use what

NeedUse
General-purpose sequence of anythinglist
Fixed record, hashabletuple
Millions of numbers, compact, simple I/Oarray.array
Fast math on numeric arrays and gridsNumPy ndarray
Binary data, immutable / mutablebytes / bytearray
Slicing big buffers without copyingmemoryview
Fast appends and pops at both endscollections.deque

Interview classics on arrays

Many interview problems are array problems in disguise: rotate an array in place, move zeros to the end, find the maximum subarray sum (Kadane's algorithm), merge two sorted arrays, and two-pointer and sliding-window problems. You will practise these techniques in M30 and M35.

Practice: build DynamicArray

Each exercise adds to the same class. The starter code contains the parts you have already built. Remember the rule: treat self._data as a fixed-size block. Only read or assign self._data[i]; never call list methods on it.

Implement __getitem__(self, index) and __setitem__(self, index, value). Support negative indexes like a list (-1 is the last item). Raise IndexError for anything out of range, including indexes beyond size even if they are below capacity.

Hint

If index < 0, add self._size. Then check 0 <= index < self._size.

Show solution
class DynamicArray:
    def __init__(self, items=()):
        self._capacity = max(1, len(items))
        self._data = [None] * self._capacity
        self._size = 0
        for x in items:
            self._data[self._size] = x
            self._size += 1

    def __len__(self):
        return self._size

    def _normalize(self, index):
        if index < 0:
            index += self._size
        if not 0 <= index < self._size:
            raise IndexError("DynamicArray index out of range")
        return index

    def __getitem__(self, index):
        return self._data[self._normalize(index)]

    def __setitem__(self, index, value):
        self._data[self._normalize(index)] = value

Implement _resize(new_capacity) (allocate [None] * new_capacity and copy items one by one) and append(value), which doubles the capacity when the array is full. Also count copies in self.copies so you can see the amortized cost.

Hint

In append: if self._size == self._capacity: self._resize(2 * self._capacity), then self._data[self._size] = value and increment.

Show solution
class DynamicArray:
    def __init__(self):
        self._capacity = 1
        self._data = [None] * self._capacity
        self._size = 0
        self.copies = 0

    def __len__(self):
        return self._size

    @property
    def capacity(self):
        return self._capacity

    def __getitem__(self, index):
        if index < 0:
            index += self._size
        if not 0 <= index < self._size:
            raise IndexError("DynamicArray index out of range")
        return self._data[index]

    def _resize(self, new_capacity):
        new_data = [None] * new_capacity
        for i in range(self._size):
            new_data[i] = self._data[i]
            self.copies += 1
        self._data = new_data
        self._capacity = new_capacity

    def append(self, value):
        if self._size == self._capacity:
            self._resize(2 * self._capacity)
        self._data[self._size] = value
        self._size += 1

Implement insert(index, value). Like list.insert, a negative index counts from the end, and an index past either end is clamped (so insert(100, x) appends and insert(-100, x) prepends). Grow if full, then shift items right, starting from the end, and write the value.

Hint

Normalise: if index < 0: index = max(0, index + size); index = min(index, size). Shift with for i in range(self._size, index, -1): self._data[i] = self._data[i - 1]. Why must you go from the end backwards?

Show solution
class DynamicArray:
    def __init__(self):
        self._capacity = 1
        self._data = [None] * self._capacity
        self._size = 0

    def __len__(self):
        return self._size

    def __iter__(self):
        for i in range(self._size):
            yield self._data[i]

    def _resize(self, new_capacity):
        new_data = [None] * new_capacity
        for i in range(self._size):
            new_data[i] = self._data[i]
        self._data, self._capacity = new_data, new_capacity

    def append(self, value):
        if self._size == self._capacity:
            self._resize(2 * self._capacity)
        self._data[self._size] = value
        self._size += 1

    def insert(self, index, value):
        if index < 0:
            index = max(0, index + self._size)
        index = min(index, self._size)
        if self._size == self._capacity:
            self._resize(2 * self._capacity)
        for i in range(self._size, index, -1):
            self._data[i] = self._data[i - 1]
        self._data[index] = value
        self._size += 1

Implement pop(index=-1) (remove and return the item; IndexError if empty or out of range) and remove(value) (delete the first item equal to value; ValueError if absent). Both shift items left and set the vacated slot to None. After removal, if the size drops to a quarter of the capacity or less (and capacity > 1), halve the capacity.

Hint

Write a helper _delete_at(i) that saves the item, shifts self._data[j] = self._data[j + 1] for j in range(i, size - 1), clears the old last slot, decrements size, maybe shrinks, and returns the item. Then pop and remove both call it.

Show solution
class DynamicArray:
    def __init__(self, items=()):
        self._capacity = max(1, len(items))
        self._data = [None] * self._capacity
        self._size = 0
        for x in items:
            self._data[self._size] = x
            self._size += 1

    def __len__(self):
        return self._size

    def __iter__(self):
        for i in range(self._size):
            yield self._data[i]

    def _resize(self, new_capacity):
        new_data = [None] * new_capacity
        for i in range(self._size):
            new_data[i] = self._data[i]
        self._data, self._capacity = new_data, new_capacity

    def _delete_at(self, i):
        item = self._data[i]
        for j in range(i, self._size - 1):
            self._data[j] = self._data[j + 1]
        self._data[self._size - 1] = None
        self._size -= 1
        if self._capacity > 1 and self._size <= self._capacity // 4:
            self._resize(max(1, self._capacity // 2))
        return item

    def pop(self, index=-1):
        if index < 0:
            index += self._size
        if not 0 <= index < self._size:
            raise IndexError("pop index out of range")
        return self._delete_at(index)

    def remove(self, value):
        for i in range(self._size):
            if self._data[i] == value:
                self._delete_at(i)
                return
        raise ValueError(f"{value!r} not in DynamicArray")

Write a class Grid(rows, cols, fill=0) that stores all cells in one flat list of length rows * cols (attribute cells) and maps (r, c) to r * cols + c. Support g[r, c] and g[r, c] = v (the key arrives as a tuple), raising IndexError for out-of-range coordinates, plus row(r) and column(c) that return new lists, and transpose() that returns a new Grid.

Hint

_index unpacks r, c = key, checks 0 <= r < rows and 0 <= c < cols, returns r * self.cols + c. row(r) is the slice cells[r*cols:(r+1)*cols]; column(c) is the slice cells[c::cols].

Show solution
class Grid:
    def __init__(self, rows, cols, fill=0):
        self.rows = rows
        self.cols = cols
        self.cells = [fill] * (rows * cols)

    def _index(self, key):
        r, c = key
        if not (0 <= r < self.rows and 0 <= c < self.cols):
            raise IndexError(f"cell {key} out of range")
        return r * self.cols + c

    def __getitem__(self, key):
        return self.cells[self._index(key)]

    def __setitem__(self, key, value):
        self.cells[self._index(key)] = value

    def row(self, r):
        return self.cells[r * self.cols:(r + 1) * self.cols]

    def column(self, c):
        return self.cells[c::self.cols]

    def transpose(self):
        t = Grid(self.cols, self.rows)
        for r in range(self.rows):
            for c in range(self.cols):
                t[c, r] = self[r, c]
        return t

Mini-project: a ring-buffer log

Mini-projectRingLog: keep the last N log lines

Servers often keep the most recent N log messages in memory so that a crash report can include "what happened just before". Storing everything would leak memory; using a list with pop(0) would be O(n) per message. The standard answer is a ring buffer (circular buffer): a fixed-size array plus a head index that wraps around with modulo arithmetic. When full, a new message overwrites the oldest one in O(1).

m6m7m3 m4m5 oldest (head) last write index = (head + i) % capacity
After writing m1–m7 into capacity 5, m1 and m2 were overwritten. Reading starts at head.

Set up

pixi init ringlog
cd ringlog
pixi add python=3.14 pytest
pixi run python -m pytest -q

Spec: ringlog.py

  • RingLog(capacity) allocates [None] * capacity once; it never grows. Reject capacity < 1 with ValueError.
  • append(message) is O(1); when full it overwrites the oldest message and increments a dropped counter.
  • __len__, __iter__ (oldest to newest), and __getitem__ where 0 is the oldest and -1 the newest.
  • latest(n) returns the newest n messages, oldest first.
  • clear() and a __repr__ like RingLog(3/5, dropped=0).
  • Each stored entry is a (timestamp, level, text) tuple; add dump(path) to write them as lines.
  • Write test_ringlog.py with at least ten pytest tests, including the wrap-around boundary.
  • The storage list is allocated once and never resized
  • append is O(1) with no shifting (only modulo arithmetic)
  • Iteration order is oldest to newest, before and after wrap-around
  • Indexing supports negative indexes and raises IndexError out of range
  • latest(n) handles n larger than the current length
  • dump(path) writes one line per entry (test it with tmp_path)
  • pixi run python -m pytest -q passes

Stretch goals

  • Compare your class with collections.deque(maxlen=N) using timeit.
  • Make RingLog generic over the entry type with type parameters (M16).
  • Store numeric samples in an array.array("d", ...) ring and compute a moving average in O(1) per sample.

Quiz

Recap

Going further