Algorithm Search⌘K

Dynamic array in Python

Array that reallocates and copies when full, giving amortized O(1) append.

File
dynamic_array.py, 71 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
dynamic_array.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dynamic-array/. The authors are listed in prov/9C8NGV.jsonl.

from typing import Generic, Iterator, TypeVar

T = TypeVar("T")


class DynamicArray(Generic[T]):
    """An array that doubles when full and halves when a quarter full.

    get and set cost O(1) in the worst case. append costs O(1) amortized
    (at most 3 units) and pop O(1) amortized (at most 2 units), counting
    one unit per item written, read or copied. The capacity is a power of
    two and is less than 4 * max(n, 1).
    """

    def __init__(self) -> None:
        self.cap = 1
        self.n = 0
        self.a: list[T | None] = [None]
        self.copies = 0  # the number of executions of line L10, for tests

    def __len__(self) -> int:
        return self.n

    def get(self, i: int) -> T:
        """Return the item at index i. Precondition: 0 <= i < n. Cost O(1)."""
        if not 0 <= i < self.n:
            raise IndexError("index out of range")
        return self.a[i]  # type: ignore[return-value]

    def set(self, i: int, x: T) -> None:
        """Replace the item at index i. Precondition: 0 <= i < n. Cost O(1)."""
        if not 0 <= i < self.n:
            raise IndexError("index out of range")
        self.a[i] = x

    def append(self, x: T) -> None:
        """Add x at the end. Cost O(1) amortized, O(n) worst case."""
        if self.n == self.cap:  # @line L1
            self._resize(2 * self.cap)
        self.a[self.n] = x  # @line L2
        self.n += 1  # @line L3

    def pop(self) -> T:
        """Remove and return the last item. Precondition: n > 0. Cost O(1) amortized."""
        if self.n == 0:
            raise IndexError("pop from an empty array")
        self.n -= 1  # @line L4
        x = self.a[self.n]  # @line L5
        self.a[self.n] = None  # drop the reference so that the item can be freed
        if self.n > 0 and 4 * self.n == self.cap:  # @line L6
            self._resize(self.cap // 2)
        return x  # type: ignore[return-value]  # @line L7

    def _resize(self, c: int) -> None:
        b: list[T | None] = [None] * c  # @line L8
        for i in range(self.n):  # @line L9
            b[i] = self.a[i]  # @line L10
            self.copies += 1
        self.a, self.cap = b, c  # @line L11

    def __iter__(self) -> Iterator[T]:
        for i in range(self.n):
            yield self.a[i]  # type: ignore[misc]

    def check_invariant(self) -> None:
        assert 0 <= self.n <= self.cap == len(self.a)
        assert self.cap & (self.cap - 1) == 0
        assert self.cap < 4 * max(self.n, 1)
test_dynamic_array.py 95 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dynamic-array/. The authors are listed in prov/9C8NGV.jsonl.

import random

import pytest

from dynamic_array import DynamicArray


def phi(n: int, cap: int) -> float:
    return 2 * n - cap if 2 * n >= cap else cap / 2 - n


def test_page_example() -> None:
    d: DynamicArray[int] = DynamicArray()
    rows = []
    for x in [1, 2, 3, 4, 5]:
        c0 = d.copies
        d.append(x)
        rows.append((d.n, d.cap, d.copies - c0))
    for want in [5, 4, 3, 2, 1]:
        c0 = d.copies
        assert d.pop() == want
        rows.append((d.n, d.cap, d.copies - c0))
    assert rows == [
        (1, 1, 0), (2, 2, 1), (3, 4, 2), (4, 4, 0), (5, 8, 4),
        (4, 8, 0), (3, 8, 0), (2, 4, 2), (1, 2, 1), (0, 2, 0),
    ]
    assert d.copies == 10


def test_edges() -> None:
    d: DynamicArray[int] = DynamicArray()
    with pytest.raises(IndexError):
        d.pop()
    with pytest.raises(IndexError):
        d.get(0)
    d.append(7)
    d.set(0, 8)
    assert d.get(0) == 8 and d.cap == 1
    assert d.pop() == 8 and d.cap == 1


def test_no_thrashing_at_boundary() -> None:
    d: DynamicArray[int] = DynamicArray()
    for x in range(64):
        d.append(x)
    assert d.cap == 64
    c0 = d.copies
    for _ in range(100):
        d.append(0)
        d.pop()
    assert d.copies - c0 == 64  # only the first append resizes


def test_amortized_bound() -> None:
    rng = random.Random(77)
    d: DynamicArray[int] = DynamicArray()
    total = 0
    m = 0
    for _ in range(5000):
        before = phi(d.n, d.cap)
        c0 = d.copies
        if d.n and rng.random() < 0.45:
            d.pop()
            bound = 2
        else:
            d.append(1)
            bound = 3
        cost = 1 + d.copies - c0
        assert cost + phi(d.n, d.cap) - before <= bound
        total += cost
        m += 1
    assert total <= 3 * m + 0.5


def test_random_against_list() -> None:
    rng = random.Random(9821)
    for _ in range(500):
        d: DynamicArray[int] = DynamicArray()
        model: list[int] = []
        for _ in range(60):
            op = rng.randrange(3)
            if op == 0:
                x = rng.randint(0, 9)
                d.append(x)
                model.append(x)
            elif op == 1 and model:
                assert d.pop() == model.pop()
            elif model:
                i = rng.randrange(len(model))
                assert d.get(i) == model[i]
            d.check_invariant()
            assert list(d) == model

What the next level needs

  • the tests did not run in this build

Read the full page about Dynamic array