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
# 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