Algorithm Search⌘K

Scapegoat tree in Python

A BST without per-node balance data that rebuilds a subtree rooted at a scapegoat when it becomes too unbalanced; O(log n) amortized.

File
scapegoat_tree.py, 213 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
scapegoat_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/scapegoat-tree/. The authors are listed in prov/096NRC.jsonl.

from __future__ import annotations

from fractions import Fraction
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 plain search-tree node: no size, height or color is stored."""

    __slots__ = ("key", "left", "right")

    def __init__(self, key: K) -> None:
        self.key = key
        self.left: Node[K] | None = None
        self.right: Node[K] | None = None


def size(t: Node[K] | None) -> int:
    """Count the nodes of the subtree t. O(size)."""
    count = 0
    stack = [t]
    while stack:
        x = stack.pop()
        if x is not None:
            count += 1
            stack.append(x.left)
            stack.append(x.right)
    return count


def flatten(t: Node[K] | None) -> list[Node[K]]:
    """Return the nodes of t in key order. O(size)."""
    out: list[Node[K]] = []
    stack: list[Node[K]] = []
    x = t
    while stack or x is not None:
        while x is not None:
            stack.append(x)
            x = x.left
        x = stack.pop()
        out.append(x)
        x = x.right
    return out


def build(a: list[Node[K]], lo: int, hi: int) -> Node[K] | None:
    """Link a[lo:hi] into a perfectly balanced tree. O(hi - lo)."""
    if lo >= hi:  # @line L11
        return None
    mid = (lo + hi) // 2  # @line L12
    t = a[mid]
    t.left = build(a, lo, mid)
    t.right = build(a, mid + 1, hi)
    return t


def rebuild(w: Node[K]) -> Node[K]:
    """Rebuild the subtree w into a perfectly balanced tree. O(size(w))."""
    a = flatten(w)  # @line L9
    t = build(a, 0, len(a))  # @line L10
    assert t is not None
    return t


class ScapegoatTree(Generic[K]):
    """An ordered set kept as a scapegoat tree (Galperin and Rivest 1993).

    The parameter alpha, with 1/2 < alpha < 1, is a Fraction. The depth of
    every node is at most log_{1/alpha} q, where q is an upper bound on the
    size with alpha*q <= n <= q. Search costs O(log n) in the worst case;
    insertion and deletion cost O(log n) amortized and O(n) in the worst case.
    """

    def __init__(self, alpha: Fraction = Fraction(2, 3)) -> None:
        if not Fraction(1, 2) < alpha < 1:
            raise ValueError("alpha must satisfy 1/2 < alpha < 1")
        self.alpha = alpha
        self.root: Node[K] | None = None
        self.n = 0
        self.q = 0
        self.rebuilt = 0  # nodes moved by rebuilds, for the tests

    def _too_deep(self, d: int) -> bool:
        """Return whether d > log_{1/alpha} q, in exact arithmetic."""
        a = self.alpha
        return a.denominator**d > self.q * a.numerator**d

    def contains(self, k: K) -> bool:
        """Return whether k is in the set. O(log n)."""
        t = self.root
        while t is not None:
            if k < t.key:
                t = t.left
            elif t.key < k:
                t = t.right
            else:
                return True
        return False

    def insert(self, k: K) -> bool:
        """Add k; return False if it was already present."""
        path: list[Node[K]] = []
        t = self.root
        while t is not None:  # @line L1
            path.append(t)
            if k < t.key:
                t = t.left
            elif t.key < k:
                t = t.right
            else:
                return False
        u = Node(k)
        if not path:
            self.root = u
        elif k < path[-1].key:
            path[-1].left = u
        else:
            path[-1].right = u
        self.n += 1  # @line L2
        self.q += 1
        if self._too_deep(len(path)):  # @line L3
            child, child_size = u, 1
            j = len(path) - 1
            while True:  # @line L4
                w = path[j]
                sibling = w.right if child is w.left else w.left
                w_size = child_size + 1 + size(sibling)
                if child_size * self.alpha.denominator > self.alpha.numerator * w_size:
                    break
                child, child_size = w, w_size
                j -= 1
            self.rebuilt += w_size
            new = rebuild(w)  # @line L5
            if j == 0:
                self.root = new
            elif path[j - 1].left is w:
                path[j - 1].left = new
            else:
                path[j - 1].right = new
        return True

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent."""
        parent: Node[K] | None = None
        t = self.root
        while t is not None and (k < t.key or t.key < k):  # @line L6
            parent = t
            t = t.left if k < t.key else t.right
        if t is None:
            return False
        if t.left is not None and t.right is not None:
            parent, s = t, t.right
            while s.left is not None:
                parent, s = s, s.left
            t.key = s.key
            t = s
        child = t.left if t.left is not None else t.right
        if parent is None:
            self.root = child
        elif parent.left is t:
            parent.left = child
        else:
            parent.right = child
        self.n -= 1  # @line L7
        if self.n * self.alpha.denominator < self.alpha.numerator * self.q:  # @line L8
            if self.root is not None:
                self.rebuilt += self.n
                self.root = rebuild(self.root)
            self.q = self.n
        return True

    def __contains__(self, k: K) -> bool:
        return self.contains(k)

    def __len__(self) -> int:
        return self.n

    def __iter__(self) -> Iterator[K]:
        return (x.key for x in flatten(self.root))

    def keys(self) -> list[K]:
        return list(self)

    def height(self) -> int:
        """Height in edges; -1 for the empty tree."""
        best = -1
        stack: list[tuple[Node[K] | None, int]] = [(self.root, 0)]
        while stack:
            x, d = stack.pop()
            if x is not None:
                best = max(best, d)
                stack.append((x.left, d + 1))
                stack.append((x.right, d + 1))
        return best

    def check_invariant(self) -> None:
        """Assert search order, the counters and the depth bound."""
        ks = self.keys()
        assert all(a < b for a, b in zip(ks, ks[1:])), "order"
        assert len(ks) == self.n, "count"
        assert self.alpha * self.q <= self.n <= self.q, "alpha q <= n <= q"
        h = self.height()
        assert h < 0 or not self._too_deep(h), "depth bound"
test_scapegoat_tree.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/scapegoat-tree/. The authors are listed in prov/096NRC.jsonl.

import math
import random
from fractions import Fraction

import pytest

from scapegoat_tree import Node, ScapegoatTree, build, flatten


def shape(t):
    if t is None:
        return None
    if t.left is None and t.right is None:
        return t.key
    return (shape(t.left), t.key, shape(t.right))


def test_page_example():
    s = ScapegoatTree()
    for k in [1, 2, 3, 4]:
        s.insert(k)
    assert shape(s.root) == (None, 1, (None, 2, (None, 3, 4)))
    assert s.rebuilt == 0
    s.insert(5)
    # node 2 is the scapegoat; its subtree 2..5 is rebuilt with root 4
    assert shape(s.root) == (None, 1, ((2, 3, None), 4, 5))
    assert s.rebuilt == 4
    s.check_invariant()
    s.delete(1)
    assert (s.n, s.q) == (4, 5)
    assert shape(s.root) == ((2, 3, None), 4, 5)
    s.delete(5)  # 3 < (2/3) * 5, so the whole tree is rebuilt
    assert (s.n, s.q) == (3, 3)
    assert shape(s.root) == (2, 3, 4)
    s.check_invariant()


def test_build_takes_the_middle():
    nodes = [Node(k) for k in range(6)]
    t = build(nodes, 0, 6)
    assert shape(t) == ((0, 1, 2), 3, (4, 5, None))
    assert [x.key for x in flatten(t)] == list(range(6))


def test_edge_cases():
    s = ScapegoatTree()
    assert not s.delete(1)
    assert s.height() == -1
    assert s.insert(7) and not s.insert(7)
    assert s.delete(7) and len(s) == 0 and s.root is None
    s.check_invariant()
    with pytest.raises(ValueError):
        ScapegoatTree(Fraction(1, 2))


@pytest.mark.parametrize("alpha", [Fraction(2, 3), Fraction(4, 7), Fraction(9, 10)])
def test_sorted_input_and_amortized_work(alpha):
    n = 3000
    s = ScapegoatTree(alpha)
    for k in range(n):
        s.insert(k)
        if k % 97 == 0:
            assert s.height() <= math.log(s.q) / math.log(1 / alpha) + 1e-9
    s.check_invariant()
    # total rebuild work is O(n log n); the constant depends on alpha
    assert s.rebuilt <= 4 * n * math.log2(n) / (2 * float(alpha) - 1)


def test_random_against_set():
    rng = random.Random(1993)
    for case in range(600):
        alpha = rng.choice([Fraction(2, 3), Fraction(3, 5), Fraction(3, 4), Fraction(5, 6)])
        s = ScapegoatTree(alpha)
        ref = set()
        for _ in range(rng.randint(0, 80)):
            k = rng.randint(0, 40)
            if rng.random() < 0.6:
                assert s.insert(k) == (k not in ref)
                ref.add(k)
            else:
                assert s.delete(k) == (k in ref)
                ref.discard(k)
            s.check_invariant()
        assert s.keys() == sorted(ref)
        for k in range(-1, 42):
            assert (k in s) == (k in ref)

What the next level needs

  • the tests did not run in this build

Read the full page about Scapegoat tree