Persistent red-black tree in Python
A red-black tree with a purely functional balance step, giving O(log n) persistent insert and delete.
- File
persistent_red_black_tree.py, 198 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/persistent-red-black-tree/. The authors are listed in prov/3F3XMG.jsonl.
from __future__ import annotations
from typing import Generic, Iterator, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: "Ordered", /) -> bool: ...
K = TypeVar("K", bound=Ordered)
RED, BLACK = True, False
class _Node(Generic[K]):
"""A tree node. Its fields are set once and never change."""
__slots__ = ("red", "left", "key", "right")
def __init__(self, red: bool, left: _Node[K] | None, key: K, right: _Node[K] | None) -> None:
self.red = red
self.left = left
self.key = key
self.right = right
Tree = _Node[K] | None
def _is_red(t: Tree[K]) -> bool:
return t is not None and t.red
def _is_black(t: Tree[K]) -> bool:
return t is not None and not t.red
def _blacken(t: Tree[K]) -> Tree[K]:
if t is None or not t.red:
return t
return _Node(BLACK, t.left, t.key, t.right)
def _redden(t: _Node[K]) -> _Node[K]:
assert not t.red, "REDDEN is applied only to a black node"
return _Node(RED, t.left, t.key, t.right)
def _balance(a: Tree[K], x: K, b: Tree[K]) -> _Node[K]:
"""Make a node from a, x, b and repair a red node with a red child below it."""
if a is not None and b is not None and a.red and b.red: # @line L10
return _Node(RED, _blacken(a), x, _blacken(b))
if a is not None and a.red and _is_red(a.left): # @line L11
assert a.left is not None
return _Node(RED, _blacken(a.left), a.key, _Node(BLACK, a.right, x, b))
if a is not None and a.red and a.right is not None and a.right.red: # @line L12
m = a.right
return _Node(RED, _Node(BLACK, a.left, a.key, m.left), m.key, _Node(BLACK, m.right, x, b))
if b is not None and b.red and _is_red(b.right): # @line L13
return _Node(RED, _Node(BLACK, a, x, b.left), b.key, _blacken(b.right))
if b is not None and b.red and b.left is not None and b.left.red: # @line L14
m = b.left
return _Node(RED, _Node(BLACK, a, x, m.left), m.key, _Node(BLACK, m.right, b.key, b.right))
return _Node(BLACK, a, x, b) # @line L15
def _ins(t: Tree[K], k: K) -> _Node[K]:
if t is None: # @line L3
return _Node(RED, None, k, None)
if k < t.key: # @line L4
if not t.red: # @line L5
return _balance(_ins(t.left, k), t.key, t.right)
return _Node(RED, _ins(t.left, k), t.key, t.right) # @line L6
if not t.red: # @line L7 @line L8
return _balance(t.left, t.key, _ins(t.right, k))
return _Node(RED, t.left, t.key, _ins(t.right, k)) # @line L9
def _bal_left(l: Tree[K], x: K, r: Tree[K]) -> _Node[K]:
"""l has black height one less than r. Return a tree of the black height of r."""
if l is not None and l.red: # @line L25
return _Node(RED, _blacken(l), x, r)
assert r is not None
if not r.red: # @line L26
return _balance(l, x, _redden(r))
a = r.left # @line L27
assert a is not None and r.right is not None
return _Node(RED, _Node(BLACK, l, x, a.left), a.key, _balance(a.right, r.key, _redden(r.right))) # @line L28
def _bal_right(l: Tree[K], x: K, r: Tree[K]) -> _Node[K]:
"""The mirror image of _bal_left: r has black height one less than l."""
if r is not None and r.red: # @line L29
return _Node(RED, l, x, _blacken(r))
assert l is not None
if not l.red: # @line L30
return _balance(_redden(l), x, r)
a = l.right # @line L31
assert a is not None and l.left is not None
return _Node(RED, _balance(_redden(l.left), l.key, a.left), a.key, _Node(BLACK, a.right, x, r))
def _fuse(a: Tree[K], b: Tree[K]) -> Tree[K]:
"""Join two trees of the same black height; every key of a is less than every key of b."""
if a is None: # @line L32
return b
if b is None: # @line L33
return a
if a.red and b.red: # @line L34
c = _fuse(a.right, b.left) # @line L35
if c is not None and c.red: # @line L36
return _Node(RED, _Node(RED, a.left, a.key, c.left), c.key, _Node(RED, c.right, b.key, b.right))
return _Node(RED, a.left, a.key, _Node(RED, c, b.key, b.right)) # @line L37
if not a.red and not b.red: # @line L38
c = _fuse(a.right, b.left) # @line L39
if c is not None and c.red: # @line L40
return _Node(RED, _Node(BLACK, a.left, a.key, c.left), c.key, _Node(BLACK, c.right, b.key, b.right))
return _bal_left(a.left, a.key, _Node(BLACK, c, b.key, b.right)) # @line L41
if b.red: # @line L42
return _Node(RED, _fuse(a, b.left), b.key, b.right)
return _Node(RED, a.left, a.key, _fuse(a.right, b)) # @line L43
def _del(t: Tree[K], k: K) -> Tree[K]:
"""Precondition: k is a key of t."""
assert t is not None
if k < t.key: # @line L18
if _is_black(t.left): # @line L19
return _bal_left(_del(t.left, k), t.key, t.right)
return _Node(RED, _del(t.left, k), t.key, t.right) # @line L20
if t.key < k: # @line L21
if _is_black(t.right): # @line L22
return _bal_right(t.left, t.key, _del(t.right, k))
return _Node(RED, t.left, t.key, _del(t.right, k)) # @line L23
return _fuse(t.left, t.right) # @line L24
class PersistentRedBlackTree(Generic[K]):
"""A red-black tree with purely functional insert and delete.
Each update returns a new tree and leaves self unchanged. The height is at most
2 lg(n + 1), so contains, insert and delete take O(log n) time, and an update
makes O(log n) new nodes.
"""
__slots__ = ("_root",)
def __init__(self, _root: Tree[K] = None) -> None:
self._root = _root
def contains(self, k: K) -> bool:
"""True when k is a key of this version. O(log n)."""
t = self._root
while t is not None and (k < t.key or t.key < k):
t = t.left if k < t.key else t.right
return t is not None
def insert(self, k: K) -> PersistentRedBlackTree[K]:
"""Return the version with k added. O(log n)."""
if self.contains(k): # @line L1
return self
return PersistentRedBlackTree(_blacken(_ins(self._root, k))) # @line L2
def delete(self, k: K) -> PersistentRedBlackTree[K]:
"""Return the version with k removed. O(log n)."""
if not self.contains(k): # @line L16
return self
return PersistentRedBlackTree(_blacken(_del(self._root, k))) # @line L17
def keys(self) -> Iterator[K]:
"""The keys in increasing order."""
stack: list[_Node[K]] = []
t = self._root
while stack or t is not None:
while t is not None:
stack.append(t)
t = t.left
t = stack.pop()
yield t.key
t = t.right
def check_invariant(self) -> int:
"""Check the search order and the red-black rules; return the black height."""
def go(t: Tree[K], lo: K | None, hi: K | None) -> int:
if t is None:
return 0
assert (lo is None or lo < t.key) and (hi is None or t.key < hi)
if t.red:
assert not _is_red(t.left) and not _is_red(t.right)
hl, hr = go(t.left, lo, t.key), go(t.right, t.key, hi)
assert hl == hr
return hl + (0 if t.red else 1)
assert not _is_red(self._root)
return go(self._root, None, None)
test_persistent_red_black_tree.py 75 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/persistent-red-black-tree/. The authors are listed in prov/3F3XMG.jsonl.
import random
from persistent_red_black_tree import PersistentRedBlackTree
def shape(t: PersistentRedBlackTree[int]) -> str:
def go(n: object) -> str:
if n is None:
return "."
c = "R" if n.red else "B" # type: ignore[attr-defined]
left, right = go(n.left), go(n.right) # type: ignore[attr-defined]
if left == "." and right == ".":
return f"{c}{n.key}" # type: ignore[attr-defined]
return f"({left} {c}{n.key} {right})" # type: ignore[attr-defined]
return go(t._root)
def test_example() -> None:
vs = [PersistentRedBlackTree[int]()]
for k in [1, 2, 3, 4, 5]:
vs.append(vs[-1].insert(k))
assert shape(vs[3]) == "(B1 B2 B3)"
assert shape(vs[4]) == "(B1 B2 (. B3 R4))"
assert shape(vs[5]) == "(B1 B2 (B3 R4 B5))"
v6 = vs[5].delete(2)
assert shape(v6) == "((. B1 R3) B4 B5)"
assert list(vs[5].keys()) == [1, 2, 3, 4, 5]
assert list(v6.keys()) == [1, 3, 4, 5]
for v in vs + [v6]:
v.check_invariant()
def test_edges() -> None:
e = PersistentRedBlackTree[int]()
assert e.delete(3) is e and not e.contains(3) and e.check_invariant() == 0
one = e.insert(3)
assert one.insert(3) is one and one.delete(4) is one
assert list(one.delete(3).keys()) == []
def test_sorted_input_stays_balanced() -> None:
t = PersistentRedBlackTree[int]()
for k in range(4096):
t = t.insert(k)
bh = t.check_invariant()
assert bh <= 13 # black height at most lg(n + 1) + 1
for k in range(0, 4096, 2):
t = t.delete(k)
t.check_invariant()
assert list(t.keys()) == list(range(1, 4096, 2))
def test_random_versions_against_sets() -> None:
rng = random.Random(5150)
for _ in range(600):
versions = [PersistentRedBlackTree[int]()]
models: list[frozenset[int]] = [frozenset()]
for _ in range(rng.randint(1, 50)):
j = rng.randrange(len(versions))
k = rng.randint(0, 25)
if rng.random() < 0.55:
versions.append(versions[j].insert(k))
models.append(models[j] | {k})
else:
versions.append(versions[j].delete(k))
models.append(models[j] - {k})
versions[-1].check_invariant()
for v, m in zip(versions, models):
v.check_invariant()
assert list(v.keys()) == sorted(m)
assert all(v.contains(k) == (k in m) for k in range(-1, 27))
What the next level needs
- the tests did not run in this build