Skew binary random-access list in Python
A persistent random-access list using skew binary numbers for O(1) cons and O(log n) index.
- File
skew_binary_random_access_list.py, 164 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/skew-binary-random-access-list/. The authors are listed in prov/RA7KJS.jsonl.
"""Okasaki's skew binary random-access list.
A version is a linked list of cells (weight, tree, rest). Each tree is a
complete binary tree of weight 2^k - 1 that stores its items in preorder.
The weights grow along the list, except that the first two may be equal;
they are the digits of n in skew binary. Nodes are tuples (x, left, right),
with None children for a leaf. Nothing is mutated, so every version stays
valid, and an update copies only one path and one prefix of the list.
"""
from typing import Any, Iterable, Iterator, List, Optional, Tuple
Tree = Tuple[Any, Any, Any]
Cells = Optional[Tuple[int, Tree, Any]]
class SkewList:
"""An immutable random-access list."""
__slots__ = ("_cells", "_n")
def __init__(self, _cells: Cells = None, _n: int = 0) -> None:
"""The empty list (use the methods to build others). Cost: O(1)."""
self._cells = _cells
self._n = _n
@staticmethod
def from_iterable(items: Iterable[Any]) -> "SkewList":
"""A list with the given items in order. Cost: Θ(n)."""
xs = list(items)
s = SkewList()
for x in reversed(xs):
s = s.cons(x)
return s
def __len__(self) -> int:
"""Number of items. Cost: O(1)."""
return self._n
def is_empty(self) -> bool:
"""Whether the list is empty. Cost: O(1)."""
return self._cells is None
def cons(self, x: Any) -> "SkewList":
"""A new list with x in front. Cost: Θ(1) worst case."""
c = self._cells
if c is not None and c[2] is not None and c[0] == c[2][0]: # @line L1
w1, t1, (w2, t2, rest) = c
return SkewList((1 + w1 + w2, (x, t1, t2), rest), self._n + 1) # @line L2
return SkewList((1, (x, None, None), c), self._n + 1) # @line L3
def head(self) -> Any:
"""The first item. Cost: Θ(1)."""
if self._cells is None:
raise IndexError("head of an empty list")
return self._cells[1][0] # @line L4
def tail(self) -> "SkewList":
"""The list without its first item. Cost: Θ(1) worst case."""
if self._cells is None:
raise IndexError("tail of an empty list")
w, t, rest = self._cells # @line L5
if w == 1: # @line L6
return SkewList(rest, self._n - 1)
h = w // 2 # @line L7
return SkewList((h, t[1], (h, t[2], rest)), self._n - 1)
def _find(self, i: int) -> Tuple[List[Tuple[int, Tree]], int, Tree, int, Cells]:
if not 0 <= i < self._n:
raise IndexError("index out of range")
before: List[Tuple[int, Tree]] = []
c = self._cells
assert c is not None
while i >= c[0]: # @line L8
before.append((c[0], c[1]))
i -= c[0]
c = c[2]
assert c is not None
w, t, rest = c # @line L9
return before, w, t, i, rest
def __getitem__(self, i: int) -> Any:
"""Item i. Cost: O(min(i, log n))."""
_, w, t, i, _ = self._find(i)
while i != 0: # @line L10
w //= 2 # @line L11
if i <= w: # @line L12
t, i = t[1], i - 1
else:
t, i = t[2], i - 1 - w
return t[0] # @line L13
def update(self, i: int, y: Any) -> "SkewList":
"""A new list with item i set to y. Cost: O(min(i, log n))."""
before, w, t, i, rest = self._find(i) # @line L14
path: List[Tuple[Tree, bool]] = []
h = w
while i != 0:
h //= 2
if i <= h:
path.append((t, True))
t, i = t[1], i - 1
else:
path.append((t, False))
t, i = t[2], i - 1 - h
new: Tree = (y, t[1], t[2]) # @line L15
for p, went_left in reversed(path): # @line L16
new = (p[0], new, p[2]) if went_left else (p[0], p[1], new)
cells: Cells = (w, new, rest)
for bw, bt in reversed(before): # @line L17
cells = (bw, bt, cells)
return SkewList(cells, self._n)
def __iter__(self) -> Iterator[Any]:
"""Items in order, iteratively. Cost: Θ(n)."""
c = self._cells
while c is not None:
stack = [c[1]]
while stack:
t = stack.pop()
yield t[0]
if t[2] is not None:
stack.append(t[2])
if t[1] is not None:
stack.append(t[1])
c = c[2]
def to_list(self) -> List[Any]:
"""All items in order. Cost: Θ(n)."""
return list(self)
def weights(self) -> List[int]:
"""The tree weights, front first: the skew binary digits of n."""
out, c = [], self._cells
while c is not None:
out.append(c[0])
c = c[2]
return out
def check_invariant(self) -> None:
"""Check that weights are 2^k - 1, grow strictly after the first
pair, sum to n, and that each tree is complete of its weight.
Cost: Θ(n)."""
ws = self.weights()
assert sum(ws) == self._n
for k, w in enumerate(ws):
assert w >= 1 and (w + 1) & w == 0
if k >= 1:
assert ws[k - 1] < w or (k == 1 and ws[0] == w)
c = self._cells
while c is not None:
stack = [(c[1], c[0])]
while stack:
t, w = stack.pop()
if w == 1:
assert t[1] is None and t[2] is None
else:
assert t[1] is not None and t[2] is not None
stack.append((t[1], w // 2))
stack.append((t[2], w // 2))
c = c[2]
test_skew_binary_random_access_list.py 87 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/skew-binary-random-access-list/. The authors are listed in prov/RA7KJS.jsonl.
import random
import pytest
from skew_binary_random_access_list import SkewList
def test_example() -> None:
expected = {1: [1], 2: [1, 1], 3: [3], 4: [1, 3], 5: [1, 1, 3], 6: [3, 3],
7: [7], 8: [1, 7], 9: [1, 1, 7], 10: [3, 7]}
s = SkewList()
for n in range(1, 11):
s = s.cons(10 - n)
assert s.weights() == expected[n]
assert s.to_list() == list(range(10))
t2 = s._cells[2][1]
assert t2[0] == 3 and t2[1][0] == 4 and t2[2][0] == 7 and t2[2][1][0] == 8
assert s[8] == 8
u = s.update(8, 80)
assert u.to_list() == [0, 1, 2, 3, 4, 5, 6, 7, 80, 9]
assert s[8] == 8
assert u._cells[1] is s._cells[1] # the first tree is shared
assert u._cells[2][1][1] is t2[1] # the subtree 4, 5, 6 is shared
tl = s.tail()
assert tl.weights() == [1, 1, 7] and tl.to_list() == list(range(1, 10))
for v in (s, u, tl):
v.check_invariant()
def test_edges() -> None:
e = SkewList()
assert e.is_empty() and len(e) == 0 and e.to_list() == []
with pytest.raises(IndexError):
e.head()
with pytest.raises(IndexError):
e.tail()
with pytest.raises(IndexError):
e[0]
one = e.cons("a")
assert one.head() == "a" and one.tail().is_empty()
with pytest.raises(IndexError):
one[1]
with pytest.raises(IndexError):
one.update(-1, "b")
assert one.update(0, "b").to_list() == ["b"] and one.to_list() == ["a"]
def test_deep() -> None:
n = 300000
s = SkewList.from_iterable(range(n))
assert len(s) == n and s[0] == 0 and s[n - 1] == n - 1 and s[123456] == 123456
assert len(s.weights()) <= 2 * n.bit_length()
u = s.update(n - 1, -1)
assert u[n - 1] == -1 and s[n - 1] == n - 1
t = s
for _ in range(1000):
t = t.tail()
assert t.head() == 1000 and len(t) == n - 1000
s.check_invariant()
def test_random_against_model() -> None:
rng = random.Random(1995)
for _ in range(500):
versions = [(SkewList(), [])]
for _ in range(rng.randint(1, 40)):
s, m = rng.choice(versions)
op = rng.randrange(4)
if op == 0 or not m:
x = rng.randint(0, 99)
versions.append((s.cons(x), [x] + m))
elif op == 1:
versions.append((s.tail(), m[1:]))
elif op == 2:
i, y = rng.randrange(len(m)), rng.randint(0, 99)
m2 = list(m)
m2[i] = y
versions.append((s.update(i, y), m2))
else:
i = rng.randrange(len(m))
assert s[i] == m[i] and s.head() == m[0]
for s, m in versions:
assert s.to_list() == m and len(s) == len(m)
s.check_invariant()
What the next level needs
- the tests did not run in this build