Algorithm Search⌘K

Binary random-access list in Python

A persistent list of complete binary trees sized by binary digits, giving O(log n) index and update.

File
binary_random_access_list.py, 200 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
binary_random_access_list.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-random-access-list/. The authors are listed in prov/00WGSN.jsonl.

from collections.abc import Iterator
from typing import Generic, Optional, TypeVar, Union

T = TypeVar("T")


class Leaf(Generic[T]):
    __slots__ = ("item",)

    def __init__(self, item: T) -> None:
        self.item = item


class Node(Generic[T]):
    """A complete binary tree with w leaves, w a power of two at least 2."""

    __slots__ = ("w", "left", "right")

    def __init__(self, w: int, left: "Tree[T]", right: "Tree[T]") -> None:
        self.w, self.left, self.right = w, left, right


Tree = Union[Leaf[T], Node[T]]

# A digit is None (Zero) or a tree (One t). The digit list is an immutable
# linked list, least significant digit first: None, or a pair (digit, rest).
Digit = Optional[Tree[T]]
Digits = Optional[tuple[Digit[T], "Digits[T]"]]


def size(t: Tree[T]) -> int:
    return 1 if isinstance(t, Leaf) else t.w


def link(t1: Tree[T], t2: Tree[T]) -> Node[T]:
    """Join two complete trees of the same size; the leaves of t1 come first."""
    return Node(size(t1) + size(t2), t1, t2)


def cons_tree(t: Tree[T], ds: Digits[T]) -> Digits[T]:
    if ds is None:  # @line L2
        return (t, None)
    d, rest = ds
    if d is None:  # @line L3
        return (t, rest)
    u = d  # @line L4
    return (None, cons_tree(link(t, u), rest))  # @line L5


def uncons_tree(ds: Digits[T]) -> tuple[Tree[T], Digits[T]]:
    if ds is None:  # @line L6
        raise IndexError("empty list")
    d, rest = ds
    if d is not None and rest is None:  # @line L7
        return d, None
    if d is not None:  # @line L8
        return d, (None, rest)
    t, rest2 = uncons_tree(rest)  # @line L9
    assert isinstance(t, Node)
    return t.left, (t.right, rest2)  # @line L10


def update_tree(i: int, y: T, t: Tree[T]) -> Tree[T]:
    if isinstance(t, Leaf):  # @line L25
        return Leaf(y)
    half = t.w // 2  # @line L26
    if i < half:  # @line L27
        return Node(t.w, update_tree(i, y, t.left), t.right)
    return Node(t.w, t.left, update_tree(i - half, y, t.right))  # @line L28


def update_digits(i: int, y: T, ds: Digits[T]) -> Digits[T]:
    if ds is None:
        raise IndexError("index out of range")
    d, rest = ds
    if d is None:  # @line L21
        return (None, update_digits(i, y, rest))
    t = d  # @line L22
    if i < size(t):  # @line L23
        return (update_tree(i, y, t), rest)
    return (t, update_digits(i - size(t), y, rest))  # @line L24


class BinaryRandomAccessList(Generic[T]):
    """Okasaki's binary random-access list: a persistent sequence.

    The list of n items is a list of digits of n in binary, least significant
    first; digit k is Zero or One t, where t is a complete binary tree with 2^k
    leaves. cons, head, tail, lookup and update each take O(log n) time in the
    worst case and make O(log n) new nodes. Every method leaves self unchanged.
    """

    __slots__ = ("_ds", "_n")

    def __init__(self) -> None:
        self._ds: Digits[T] = None
        self._n = 0

    @classmethod
    def _of(cls, ds: Digits[T], n: int) -> "BinaryRandomAccessList[T]":
        r: BinaryRandomAccessList[T] = cls.__new__(cls)
        r._ds, r._n = ds, n
        return r

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

    def is_empty(self) -> bool:
        return self._ds is None

    def cons(self, x: T) -> "BinaryRandomAccessList[T]":
        """Return the list with x added at the front."""
        return self._of(cons_tree(Leaf(x), self._ds), self._n + 1)  # @line L1

    def head(self) -> T:
        """Return the first item. Raises IndexError on an empty list."""
        t, _ = uncons_tree(self._ds)  # @line L11
        assert isinstance(t, Leaf)
        return t.item

    def tail(self) -> "BinaryRandomAccessList[T]":
        """Return the list without its first item. Raises IndexError on an empty list."""
        _, ds = uncons_tree(self._ds)  # @line L12
        return self._of(ds, self._n - 1)

    def lookup(self, i: int) -> T:
        """Return item i (0-based). Raises IndexError if i is out of range."""
        if i < 0:
            raise IndexError("index out of range")
        ds = self._ds
        while ds is not None:  # @line L13
            d, ds = ds
            if d is not None:  # @line L14
                if i < size(d):  # @line L15
                    t: Tree[T] = d
                    while isinstance(t, Node):  # @line L16
                        half = t.w // 2
                        if i < half:  # @line L17
                            t = t.left
                        else:
                            i, t = i - half, t.right
                    return t.item  # @line L18
                i -= size(d)  # @line L19
        raise IndexError("index out of range")  # @line L20

    def update(self, i: int, y: T) -> "BinaryRandomAccessList[T]":
        """Return the list with item i replaced by y. Raises IndexError if out of range."""
        if not 0 <= i < self._n:
            raise IndexError("index out of range")
        return self._of(update_digits(i, y, self._ds), self._n)

    def __iter__(self) -> Iterator[T]:
        ds = self._ds
        while ds is not None:
            d, ds = ds
            if d is not None:
                stack: list[Tree[T]] = [d]
                while stack:
                    t = stack.pop()
                    if isinstance(t, Leaf):
                        yield t.item
                    else:
                        stack.append(t.right)
                        stack.append(t.left)

    def to_list(self) -> list[T]:
        return list(self)

    def digits(self) -> list[int]:
        """The binary digits of the representation, least significant first."""
        out, ds = [], self._ds
        while ds is not None:
            d, ds = ds
            out.append(0 if d is None else 1)
        return out

    def check_invariant(self) -> None:
        """Digit k is Zero or a complete tree of 2^k leaves; the last digit is One; sizes add to n."""
        ds, k, total, last = self._ds, 0, 0, None
        while ds is not None:
            d, ds = ds
            last = d
            if d is not None:
                _check_complete(d, 1 << k)
                total += 1 << k
            k += 1
        assert self._ds is None or last is not None
        assert total == self._n


def _check_complete(t: Tree[T], w: int) -> None:
    if isinstance(t, Leaf):
        assert w == 1
        return
    assert t.w == w and w >= 2
    _check_complete(t.left, w // 2)
    _check_complete(t.right, w // 2)
test_binary_random_access_list.py 76 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-random-access-list/. The authors are listed in prov/00WGSN.jsonl.

import random

import pytest

from binary_random_access_list import BinaryRandomAccessList


def build(items: list[int]) -> BinaryRandomAccessList[int]:
    r: BinaryRandomAccessList[int] = BinaryRandomAccessList()
    for x in reversed(items):
        r = r.cons(x)
    return r


def test_example() -> None:
    r: BinaryRandomAccessList[int] = BinaryRandomAccessList()
    digits = []
    for x in [10, 20, 30, 40, 50, 60]:
        r = r.cons(x)
        r.check_invariant()
        digits.append(r.digits())
    assert digits == [[1], [0, 1], [1, 1], [0, 0, 1], [1, 0, 1], [0, 1, 1]]
    assert r.to_list() == [60, 50, 40, 30, 20, 10]
    assert r.lookup(3) == 30
    u = r.update(3, 99)
    assert u.to_list() == [60, 50, 40, 99, 20, 10]
    assert r.to_list() == [60, 50, 40, 30, 20, 10]
    t = r.tail()
    assert t.digits() == [1, 0, 1] and t.to_list() == [50, 40, 30, 20, 10]
    assert t.tail().digits() == [0, 0, 1]


def test_edges() -> None:
    e: BinaryRandomAccessList[int] = BinaryRandomAccessList()
    assert e.is_empty() and len(e) == 0
    for f in (e.head, e.tail, lambda: e.lookup(0), lambda: e.update(0, 1)):
        with pytest.raises(IndexError):
            f()
    one = e.cons(7)
    assert one.head() == 7 and one.tail().is_empty() and one.tail().digits() == []
    with pytest.raises(IndexError):
        one.lookup(1)
    with pytest.raises(IndexError):
        one.lookup(-1)
    p = build(list(range(16)))
    assert p.digits() == [0, 0, 0, 0, 1]
    assert [p.lookup(i) for i in range(16)] == list(range(16))


def test_random_against_list() -> None:
    rng = random.Random(77)
    for _ in range(500):
        versions = [(BinaryRandomAccessList(), [])]
        for _ in range(rng.randrange(1, 40)):
            r, m = versions[rng.randrange(len(versions))]
            op = rng.random()
            if m and op < 0.25:
                assert r.head() == m[0]
                r2, m2 = r.tail(), m[1:]
            elif m and op < 0.5:
                i = rng.randrange(len(m))
                y = rng.randrange(1000)
                assert r.lookup(i) == m[i]
                r2, m2 = r.update(i, y), m[:i] + [y] + m[i + 1:]
            else:
                x = rng.randrange(1000)
                r2, m2 = r.cons(x), [x] + m
            r2.check_invariant()
            assert len(r2) == len(m2)
            versions.append((r2, m2))
        for r, m in versions:
            assert r.to_list() == m
            assert r.digits() == [int(b) for b in reversed(bin(len(m))[2:])] if m else r.digits() == []

What the next level needs

  • the tests did not run in this build

Read the full page about Binary random-access list