Algorithm Search⌘K

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
persistent_binary_search_tree.pyRawGitHub
# 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

Read the full page about Persistent binary search tree