Persistent binary search tree in Python
A binary search tree updated by path copying, so each insert or delete yields a new root with O(log n) new nodes when balanced.
- File
persistent_binary_search_tree.py, 131 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-binary-search-tree/. The authors are listed in prov/F9C8A3.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)
class _Node(Generic[K]):
"""A tree node. Its fields are set once, when it is made, and never change."""
__slots__ = ("key", "left", "right")
def __init__(self, key: K, left: _Node[K] | None, right: _Node[K] | None) -> None:
self.key = key
self.left = left
self.right = right
def _descend(t: _Node[K] | None, k: K) -> tuple[list[_Node[K]], _Node[K] | None]:
"""Return the nodes passed on the search path for k, and the node of k (or None)."""
path: list[_Node[K]] = [] # @line L15
while t is not None and (k < t.key or t.key < k): # @line L16
path.append(t) # @line L17
t = t.left if k < t.key else t.right # @line L18
return path, t # @line L19
def _rebuild(path: list[_Node[K]], k: K, c: _Node[K] | None) -> _Node[K] | None:
"""Copy the nodes of path bottom up; in each copy, c replaces the child toward k."""
for y in reversed(path): # @line L20
if k < y.key:
c = _Node(y.key, c, y.right) # @line L21
else:
c = _Node(y.key, y.left, c) # @line L22
return c # @line L23
class PersistentBinarySearchTree(Generic[K]):
"""An unbalanced binary search tree updated by path copying.
Each update returns a new tree and leaves self unchanged. On a version of
height h, contains takes O(h) time, and insert and delete take O(h) time and
make at most h + 1 new nodes. No recursion is used, so a tall tree is safe.
"""
__slots__ = ("_root",)
def __init__(self, _root: _Node[K] | None = None) -> None:
self._root = _root
def contains(self, k: K) -> bool:
"""True when k is a key of this version. O(h)."""
t = self._root
while t is not None and (k < t.key or t.key < k): # @line L1
t = t.left if k < t.key else t.right # @line L2
return t is not None # @line L3
def insert(self, k: K) -> PersistentBinarySearchTree[K]:
"""Return the version with k added. O(h) time and new nodes."""
path, x = _descend(self._root, k) # @line L4
if x is not None: # @line L5
return self
return PersistentBinarySearchTree(_rebuild(path, k, _Node(k, None, None))) # @line L6
def delete(self, k: K) -> PersistentBinarySearchTree[K]:
"""Return the version with k removed. O(h) time and new nodes."""
path, x = _descend(self._root, k) # @line L7
if x is None: # @line L8
return self
if x.left is None: # @line L9
c = x.right
elif x.right is None: # @line L10
c = x.left
else: # @line L11
q: list[_Node[K]] = [] # @line L12
y = x.right
while y.left is not None:
q.append(y)
y = y.left
c = _Node(y.key, x.left, _rebuild(q, y.key, y.right)) # @line L13
return PersistentBinarySearchTree(_rebuild(path, k, c)) # @line L14
def keys(self) -> Iterator[K]:
"""The keys in increasing order, by an in-order walk with an explicit stack."""
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 height(self) -> int:
"""The number of nodes on a longest root-to-leaf path (0 for the empty tree)."""
best = 0
stack: list[tuple[_Node[K], int]] = [(self._root, 1)] if self._root else []
while stack:
t, d = stack.pop()
best = max(best, d)
for ch in (t.left, t.right):
if ch is not None:
stack.append((ch, d + 1))
return best
def check_invariant(self) -> None:
"""Check the search-tree order: the in-order keys strictly increase."""
ks = list(self.keys())
assert all(ks[i] < ks[i + 1] for i in range(len(ks) - 1))
def count_nodes(*versions: PersistentBinarySearchTree[K]) -> int:
"""The number of distinct nodes reachable from the given versions."""
seen: set[int] = set()
stack = [v._root for v in versions if v._root is not None]
while stack:
t = stack.pop()
if id(t) in seen:
continue
seen.add(id(t))
stack.extend(ch for ch in (t.left, t.right) if ch is not None)
return len(seen)
test_persistent_binary_search_tree.py 63 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/persistent-binary-search-tree/. The authors are listed in prov/F9C8A3.jsonl.
import random
from persistent_binary_search_tree import PersistentBinarySearchTree, count_nodes
def test_example() -> None:
vs = [PersistentBinarySearchTree[int]()]
for k in [50, 30, 70, 20, 40, 60, 80]:
vs.append(vs[-1].insert(k))
assert count_nodes(*vs) == 1 + 2 + 2 + 3 + 3 + 3 + 3
v7 = vs[7]
v8 = v7.insert(35)
assert count_nodes(v7, v8) - count_nodes(v7) == 4
v9 = v8.delete(50)
assert count_nodes(v8, v9) - count_nodes(v8) == 2
assert v9._root is not None and v9._root.key == 60
assert list(v7.keys()) == [20, 30, 40, 50, 60, 70, 80]
assert list(v8.keys()) == [20, 30, 35, 40, 50, 60, 70, 80]
assert list(v9.keys()) == [20, 30, 35, 40, 60, 70, 80]
# v9 shares the whole left subtree of v8.
assert v8._root is not None and v9._root.left is v8._root.left
def test_edges() -> None:
e = PersistentBinarySearchTree[int]()
assert not e.contains(1) and list(e.keys()) == [] and e.delete(1) is e
one = e.insert(1)
assert one.insert(1) is one and one.delete(2) is one
assert list(one.delete(1).keys()) == [] and list(one.keys()) == [1]
def test_sorted_input_is_tall_but_safe() -> None:
t = PersistentBinarySearchTree[int]()
for k in range(5000):
t = t.insert(k)
assert t.height() == 5000
assert t.contains(4999) and not t.contains(5000)
t2 = t.delete(0)
assert t.contains(0) and not t2.contains(0)
def test_random_versions_against_sets() -> None:
rng = random.Random(8361)
for _ in range(500):
versions = [PersistentBinarySearchTree[int]()]
models: list[frozenset[int]] = [frozenset()]
for _ in range(rng.randint(1, 30)):
j = rng.randrange(len(versions))
k = rng.randint(0, 15)
if rng.random() < 0.6:
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):
assert list(v.keys()) == sorted(m)
for k in range(-1, 17):
assert v.contains(k) == (k in m)
What the next level needs
- the tests did not run in this build