Algorithm Search⌘K

Fully persistent array in Python

An array whose every version supports reads and writes, with bounds in the log-log or log factors.

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

"""A fully persistent array by path copying over a balanced binary tree.

Version t is the root roots[t].  A read walks from the root to a leaf, and a
write copies the nodes on that path, so both take O(log n) time and a write
adds O(log n) nodes.  No node is ever changed after it is built.
"""

from collections.abc import Sequence
from typing import Any, Generic, TypeVar

T = TypeVar("T")


class _Leaf:
    __slots__ = ("value",)

    def __init__(self, value: Any) -> None:
        self.value = value


class _Node:
    __slots__ = ("left", "right")

    def __init__(self, left: Any, right: Any) -> None:
        self.left = left
        self.right = right


def _build(a: Sequence[Any], lo: int, hi: int) -> Any:
    if hi - lo == 1:  # @line L1
        return _Leaf(a[lo])
    mid = (lo + hi) // 2  # @line L2
    return _Node(_build(a, lo, mid), _build(a, mid, hi))  # @line L3


def _get(v: Any, n: int, i: int) -> Any:
    lo, hi = 0, n  # @line L4
    while hi - lo > 1:  # @line L5
        mid = (lo + hi) // 2  # @line L6
        if i < mid:  # @line L7
            v, hi = v.left, mid
        else:
            v, lo = v.right, mid
    return v.value  # @line L8


def _set(v: Any, lo: int, hi: int, i: int, x: Any) -> Any:
    if hi - lo == 1:  # @line L9
        return _Leaf(x)
    mid = (lo + hi) // 2  # @line L10
    if i < mid:  # @line L11
        return _Node(_set(v.left, lo, mid, i, x), v.right)
    return _Node(v.left, _set(v.right, mid, hi, i, x))  # @line L12


class FullyPersistentArray(Generic[T]):
    """An array of fixed length n whose versions are numbered 0, 1, 2, ...

    Version 0 is the initial array.  set(t, i, x) makes a new version from
    version t, for any existing t, and returns its number.
    """

    __slots__ = ("_n", "_roots", "_nodes")

    def __init__(self, initial: Sequence[T]) -> None:
        self._n = len(initial)
        root = _build(initial, 0, self._n) if self._n else None
        self._roots: list[Any] = [root]
        self._nodes = 2 * self._n - 1 if self._n else 0

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

    def versions(self) -> int:
        return len(self._roots)

    def _check(self, t: int, i: int) -> None:
        if not 0 <= t < len(self._roots):
            raise IndexError("no such version")
        if not 0 <= i < self._n:
            raise IndexError("index out of range")

    def get(self, t: int, i: int) -> T:
        self._check(t, i)
        return _get(self._roots[t], self._n, i)

    def set(self, t: int, i: int, x: T) -> int:
        self._check(t, i)
        self._roots.append(_set(self._roots[t], 0, self._n, i, x))  # @line L13
        self._nodes += _path_length(self._n, i)
        return len(self._roots) - 1

    def to_list(self, t: int) -> list[T]:
        if not 0 <= t < len(self._roots):
            raise IndexError("no such version")
        out: list[T] = []

        def walk(v: Any) -> None:
            if isinstance(v, _Leaf):
                out.append(v.value)
            else:
                walk(v.left)
                walk(v.right)

        if self._n:
            walk(self._roots[t])
        return out

    def node_count(self) -> int:
        """The number of nodes built so far, over all versions."""
        return self._nodes


def _path_length(n: int, i: int) -> int:
    """The number of nodes on the path from the root to leaf i."""
    lo, hi, k = 0, n, 1
    while hi - lo > 1:
        mid = (lo + hi) // 2
        if i < mid:
            hi = mid
        else:
            lo = mid
        k += 1
    return k
test_fully_persistent_array.py 67 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fully-persistent-array/. The authors are listed in prov/MX556Y.jsonl.

import math
import random

import pytest

from fully_persistent_array import FullyPersistentArray


def test_example() -> None:
    a = FullyPersistentArray([10, 20, 30, 40])
    assert a.node_count() == 7
    v1 = a.set(0, 2, 33)
    v2 = a.set(0, 0, 11)
    v3 = a.set(v1, 3, 44)
    assert (v1, v2, v3) == (1, 2, 3)
    assert a.to_list(0) == [10, 20, 30, 40]
    assert a.to_list(1) == [10, 20, 33, 40]
    assert a.to_list(2) == [11, 20, 30, 40]
    assert a.to_list(3) == [10, 20, 33, 44]
    assert a.get(3, 2) == 33 and a.get(2, 2) == 30
    assert a.node_count() == 16


def test_edges() -> None:
    e: FullyPersistentArray[int] = FullyPersistentArray([])
    assert len(e) == 0 and e.to_list(0) == []
    with pytest.raises(IndexError):
        e.get(0, 0)
    one = FullyPersistentArray([5])
    v = one.set(0, 0, 6)
    assert one.get(0, 0) == 5 and one.get(v, 0) == 6
    with pytest.raises(IndexError):
        one.get(2, 0)
    with pytest.raises(IndexError):
        one.set(0, 1, 0)


def test_random_version_tree() -> None:
    rng = random.Random(556)
    checks = 0
    for n in (1, 2, 3, 7, 8, 13, 64, 100):
        init = [rng.randrange(1000) for _ in range(n)]
        a = FullyPersistentArray(init)
        ref = [list(init)]
        for _ in range(300):
            t = rng.randrange(len(ref))
            if rng.random() < 0.5:
                i = rng.randrange(n)
                x = rng.randrange(1000)
                v = a.set(t, i, x)
                assert v == len(ref)
                new = list(ref[t])
                new[i] = x
                ref.append(new)
            else:
                i = rng.randrange(n)
                assert a.get(t, i) == ref[t][i]
            checks += 1
        for t, r in enumerate(ref):
            assert a.to_list(t) == r
            checks += 1
        bound = (2 * n - 1) + (len(ref) - 1) * (math.ceil(math.log2(n)) + 1)
        assert a.node_count() <= bound
    assert checks >= 500

What the next level needs

  • the tests did not run in this build

Read the full page about Fully persistent array