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
# 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