2-3 tree in Python
A perfectly balanced search tree whose internal nodes have 2 or 3 children, with all leaves at the same depth.
- File
two_three_tree.py, 207 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/two-three-tree/. The authors are listed in prov/3W4BM4.jsonl.
from __future__ import annotations
from bisect import bisect_left
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 2-node (one key) or a 3-node (two keys).
A leaf has no children; an internal node has len(keys) + 1 children.
During an update a node may hold 0 keys (underflow) or 3 keys (overflow)
for a moment.
"""
__slots__ = ("keys", "kids")
def __init__(self, keys: list[K], kids: list[Node[K]] | None = None) -> None:
self.keys = keys
self.kids: list[Node[K]] = kids if kids is not None else []
def is_leaf(self) -> bool:
return not self.kids
Split = tuple[K, Node[K], Node[K]]
class TwoThreeTree(Generic[K]):
"""An ordered set kept as a 2-3 tree: every internal node has 2 or 3
children and all leaves are at the same depth.
Keys must be totally ordered. Search, insertion and deletion cost
O(log n) time in the worst case; the height is at most lg(n + 1).
"""
def __init__(self) -> None:
self.root: Node[K] | None = None
self.size = 0
# search -----------------------------------------------------------
def contains(self, k: K) -> bool:
"""Return whether k is in the set. O(log n)."""
t = self.root
while t is not None:
i = bisect_left(t.keys, k)
if i < len(t.keys) and not (k < t.keys[i]):
return True
t = t.kids[i] if t.kids else None
return False
# insertion --------------------------------------------------------
def insert(self, k: K) -> bool:
"""Add k; return False if it was already present. O(log n)."""
if self.root is None: # @line L1
self.root = Node([k])
self.size = 1
return True
before = self.size
r = self._ins(self.root, k) # @line L2
if r is not None: # @line L3
m, a, b = r
self.root = Node([m], [a, b])
return self.size > before
def _ins(self, t: Node[K], k: K) -> Split[K] | None:
i = bisect_left(t.keys, k) # @line L7
if i < len(t.keys) and not (k < t.keys[i]): # @line L4
return None
if t.is_leaf(): # @line L5
t.keys.insert(i, k)
self.size += 1
else: # @line L6
r = self._ins(t.kids[i], k) # @line L8
if r is None: # @line L9
return None
m, a, b = r
t.keys.insert(i, m) # @line L10
t.kids[i : i + 1] = [a, b]
if len(t.keys) == 3: # @line L11
x, y, z = t.keys
if t.is_leaf(): # @line L12
return (y, Node([x]), Node([z]))
return (y, Node([x], t.kids[:2]), Node([z], t.kids[2:]))
return None # @line L13
# deletion ---------------------------------------------------------
def delete(self, k: K) -> bool:
"""Remove k; return False if it was absent. O(log n)."""
if self.root is None: # @line L14
return False
before = self.size
self._del(self.root, k) # @line L15
if not self.root.keys: # @line L16
self.root = self.root.kids[0] if self.root.kids else None
return self.size < before
def _del(self, t: Node[K], k: K) -> None:
i = bisect_left(t.keys, k)
found = i < len(t.keys) and not (k < t.keys[i])
if t.is_leaf(): # @line L17
if found:
del t.keys[i]
self.size -= 1
return
if found: # @line L18
s = t.kids[i + 1] # @line L19
while s.kids:
s = s.kids[0]
t.keys[i] = s.keys[0]
k = s.keys[0]
i += 1
self._del(t.kids[i], k) # @line L20
if not t.kids[i].keys: # @line L21
self._fix(t, i)
def _fix(self, t: Node[K], i: int) -> None:
c = t.kids[i]
# prefer the left sibling, else the right one
if i > 0 and len(t.kids[i - 1].keys) == 2: # @line L22
s = t.kids[i - 1]
c.keys.insert(0, t.keys[i - 1]) # @line L23
t.keys[i - 1] = s.keys.pop()
if s.kids:
c.kids.insert(0, s.kids.pop())
return
if i + 1 < len(t.kids) and len(t.kids[i + 1].keys) == 2:
s = t.kids[i + 1]
c.keys.append(t.keys[i])
t.keys[i] = s.keys.pop(0)
if s.kids:
c.kids.append(s.kids.pop(0))
return
if i > 0: # @line L24
s = t.kids[i - 1]
s.keys.append(t.keys.pop(i - 1)) # @line L25
s.kids.extend(c.kids)
del t.kids[i]
else:
s = t.kids[1]
s.keys.insert(0, t.keys.pop(0))
s.kids[0:0] = c.kids
del t.kids[0]
# queries ----------------------------------------------------------
def keys(self) -> Iterator[K]:
"""Yield the keys in increasing order. O(n)."""
def walk(t: Node[K]) -> Iterator[K]:
if t.is_leaf():
yield from t.keys
return
for j, key in enumerate(t.keys):
yield from walk(t.kids[j])
yield key
yield from walk(t.kids[-1])
if self.root is not None:
yield from walk(self.root)
def height(self) -> int:
"""The number of nodes on a root-to-leaf path (0 when empty)."""
h, t = 0, self.root
while t is not None:
h += 1
t = t.kids[0] if t.kids else None
return h
def __len__(self) -> int:
return self.size
def check_invariant(self) -> None:
"""Raise AssertionError unless the tree is a valid 2-3 tree."""
if self.root is None:
assert self.size == 0
return
depths: set[int] = set()
count = 0
def walk(t: Node[K], d: int, lo: K | None, hi: K | None) -> None:
nonlocal count
assert 1 <= len(t.keys) <= 2, "a node has 1 or 2 keys"
assert all(t.keys[j] < t.keys[j + 1] for j in range(len(t.keys) - 1))
for key in t.keys:
assert lo is None or lo < key
assert hi is None or key < hi
count += len(t.keys)
if t.is_leaf():
depths.add(d)
return
assert len(t.kids) == len(t.keys) + 1, "children = keys + 1"
bounds = [lo, *t.keys, hi]
for j, kid in enumerate(t.kids):
walk(kid, d + 1, bounds[j], bounds[j + 1])
walk(self.root, 1, None, None)
assert len(depths) == 1, "all leaves at the same depth"
assert count == self.size
test_two_three_tree.py 108 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/two-three-tree/. The authors are listed in prov/3W4BM4.jsonl.
import math
import random
from two_three_tree import Node, TwoThreeTree
def shape(t: "Node[int] | None") -> object:
if t is None:
return None
if not t.kids:
return t.keys
return [t.keys] + [shape(c) for c in t.kids]
def test_page_example() -> None:
t: TwoThreeTree[int] = TwoThreeTree()
steps = {
80: [[50], [20], [80]],
30: [[20, 50], [10], [30], [80]],
90: [[50], [[20], [10], [30]], [[80], [60], [90]]],
25: [[50], [[20, 30], [10], [25], [40]], [[80], [60, 70], [90]]],
}
for k in [50, 20, 80, 10, 30, 60, 90, 40, 70, 25]:
assert t.insert(k)
t.check_invariant()
if k in steps:
assert shape(t.root) == steps[k]
assert t.height() == 3
deletes = {
10: [[50], [[30], [20, 25], [40]], [[80], [60, 70], [90]]],
50: [[60], [[30], [20, 25], [40]], [[80], [70], [90]]],
90: [[30, 60], [20, 25], [40], [70, 80]],
20: [[30, 60], [25], [40], [70, 80]],
40: [[30, 70], [25], [60], [80]],
}
for k, want in deletes.items():
assert t.delete(k)
t.check_invariant()
assert shape(t.root) == want
assert t.height() == 2
assert list(t.keys()) == [25, 30, 60, 70, 80]
def test_edge_cases() -> None:
t: TwoThreeTree[int] = TwoThreeTree()
assert not t.contains(1)
assert not t.delete(1)
assert t.height() == 0
t.check_invariant()
assert t.insert(1)
assert not t.insert(1)
assert len(t) == 1
assert t.contains(1)
assert t.delete(1)
assert t.root is None
assert len(t) == 0
t.check_invariant()
def test_duplicate_does_not_split() -> None:
# inserting a key already in a full leaf must not split it
t: TwoThreeTree[int] = TwoThreeTree()
t.insert(1)
t.insert(2)
assert not t.insert(2)
assert shape(t.root) == [1, 2]
def test_sorted_input_and_height() -> None:
t: TwoThreeTree[int] = TwoThreeTree()
n = 2000
for k in range(n):
t.insert(k)
if k % 97 == 0:
t.check_invariant()
t.check_invariant()
# height between log3(n + 1) and lg(n + 1)
h = t.height()
assert math.log(n + 1, 3) <= h <= math.log2(n + 1)
for k in range(0, n, 2):
assert t.delete(k)
t.check_invariant()
assert list(t.keys()) == list(range(1, n, 2))
def test_random_against_set() -> None:
rng = random.Random(1970)
for _ in range(600):
t: TwoThreeTree[int] = TwoThreeTree()
model: set[int] = set()
for _ in range(rng.randrange(1, 60)):
k = rng.randrange(40)
if rng.random() < 0.6:
assert t.insert(k) == (k not in model)
model.add(k)
else:
assert t.delete(k) == (k in model)
model.discard(k)
t.check_invariant()
assert len(t) == len(model)
assert list(t.keys()) == sorted(model)
for k in range(40):
assert t.contains(k) == (k in model)
if model:
assert t.height() <= math.log2(len(model) + 1)
What the next level needs
- the tests did not run in this build