B*-tree in Python
B+ tree variant that keeps nodes at least two-thirds full by redistributing keys with siblings.
- File
b_star_tree.py, 228 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/b-star-tree/. The authors are listed in prov/FES1ZP.jsonl.
from bisect import bisect_left, bisect_right, insort
from typing import Any, Generic, Optional, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
K = TypeVar("K", bound=Ordered)
class Node(Generic[K]):
"""A leaf holds keys and a link to the next leaf. An internal node holds
separators key[0..m-1] and children c[0..m], where c[i] holds the keys k
with key[i-1] <= k < key[i]."""
def __init__(self, leaf: bool) -> None:
self.leaf = leaf
self.keys: list[K] = []
self.children: list["Node[K]"] = []
self.next: Optional["Node[K]"] = None
def entries(x: Node[Any]) -> int:
"""The keys of a leaf, or the children of an internal node."""
return len(x.keys) if x.leaf else len(x.children)
class BStarTree(Generic[K]):
"""A B*-tree in the B+ form: all keys are in linked leaves.
Each node other than the root has f to C entries, where C >= 4 is the
capacity and f = floor((2C + 1) / 3), so every such node is about two
thirds full. The root may hold up to R = 2f - 1 entries. An overflow
first shifts entries into a sibling that has room, and only when the
sibling is full splits the two nodes into three. An underflow borrows
from a sibling, or merges three nodes into two. `reads` counts node
reads; the root is kept in memory.
"""
def __init__(self, capacity: int = 4) -> None:
if capacity < 4:
raise ValueError("the capacity must be at least 4")
self.C = capacity
self.f = (2 * capacity + 1) // 3
self.R = 2 * self.f - 1
self.root: Node[K] = Node(leaf=True)
self.reads = 0
def _find_leaf(self, k: K) -> Node[K]:
x = self.root # @line L1
while not x.leaf: # @line L2
i = bisect_right(x.keys, k) # @line L3
x = x.children[i] # @line L4
self.reads += 1
return x
def search(self, k: K) -> bool:
"""Return True when k is in the tree."""
x = self._find_leaf(k)
j = bisect_left(x.keys, k)
return j < len(x.keys) and not (k < x.keys[j]) # @line L5
def range(self, a: K, b: K) -> list[K]:
"""Return the keys k with a <= k <= b, in increasing order."""
x: Optional[Node[K]] = self._find_leaf(a)
out: list[K] = []
while x is not None:
for k in x.keys:
if k < a:
continue
if b < k:
return out
out.append(k)
x = x.next
if x is not None:
self.reads += 1
return out
def insert(self, k: K) -> bool:
"""Insert k. Return False, and change nothing, if k is present."""
if self.search(k):
return False
self._insert_rec(self.root, k) # @line L6
if entries(self.root) > self.R: # @line L7
top: Node[K] = Node(leaf=False) # @line L8
top.children = [self.root]
self.root = top
self._share(top, 0, 1, 2)
return True
def _insert_rec(self, x: Node[K], k: K) -> None:
if x.leaf: # @line L9
insort(x.keys, k)
return
i = bisect_right(x.keys, k) # @line L10
self.reads += 1
self._insert_rec(x.children[i], k) # @line L11
if entries(x.children[i]) <= self.C: # @line L12
return
n = len(x.children)
for j in (i - 1, i + 1): # @line L13
if 0 <= j < n and entries(x.children[j]) < self.C:
self._share(x, min(i, j), 2, 2) # @line L14
return
j = i - 1 if i > 0 else i + 1 # @line L15
self._share(x, min(i, j), 2, 3) # @line L16
def _share(self, x: Node[K], j: int, g: int, k: int) -> None:
"""Turn the g children x.c[j .. j+g-1] into k children whose entry
counts differ by at most one, and fix the separators of x."""
group = x.children[j : j + g] # @line L17
self.reads += g - 1
leaf = group[0].leaf
if leaf:
items: list[Any] = [key for y in group for key in y.keys]
after = group[-1].next
else:
items = [c for y in group for c in y.children]
ks: list[K] = []
for t, y in enumerate(group):
ks.extend(y.keys)
if t < g - 1:
ks.append(x.keys[j + t])
q, r = divmod(len(items), k) # @line L18
nodes = group[:k] + [Node[K](leaf) for _ in range(k - g)]
seps: list[K] = []
pos = 0
for t, y in enumerate(nodes): # @line L19
size = q + (1 if t < r else 0)
if leaf:
y.keys = items[pos : pos + size]
if t > 0:
seps.append(y.keys[0])
else:
y.children = items[pos : pos + size]
y.keys = ks[pos : pos + size - 1]
if t > 0:
seps.append(ks[pos - 1])
pos += size
if leaf: # @line L20
for y, z in zip(nodes, nodes[1:]):
y.next = z
nodes[-1].next = after
x.children[j : j + g] = nodes
x.keys[j : j + g - 1] = seps
def delete(self, k: K) -> bool:
"""Delete k. Return False, and change nothing, if k is absent."""
if not self.search(k):
return False
self._delete_rec(self.root, k) # @line L21
if not self.root.leaf and len(self.root.children) == 1: # @line L22
self.root = self.root.children[0]
return True
def _delete_rec(self, x: Node[K], k: K) -> None:
if x.leaf: # @line L23
x.keys.pop(bisect_left(x.keys, k))
return
i = bisect_right(x.keys, k) # @line L24
self.reads += 1
self._delete_rec(x.children[i], k) # @line L25
if entries(x.children[i]) >= self.f: # @line L26
return
n = len(x.children)
for j in (i - 1, i + 1): # @line L27
if 0 <= j < n and entries(x.children[j]) > self.f:
self._share(x, min(i, j), 2, 2)
return
if n == 2: # @line L28
self._share(x, 0, 2, 1)
return
j = min(max(i - 1, 0), n - 3) # @line L29
total = sum(entries(y) for y in x.children[j : j + 3])
self._share(x, j, 3, 2 if total <= 2 * self.C else 3) # @line L30
def keys(self) -> list[K]:
"""Return all keys in order by walking the leaf chain."""
x = self.root
while not x.leaf:
x = x.children[0]
out: list[K] = []
y: Optional[Node[K]] = x
while y is not None:
out.extend(y.keys)
y = y.next
return out
def height(self) -> int:
"""Return the number of edges on a root-to-leaf path."""
h, x = 0, self.root
while not x.leaf:
x, h = x.children[0], h + 1
return h
def check_invariant(self) -> None:
"""Raise AssertionError if a B*-tree property fails."""
leaves: list[Node[K]] = []
depths: set[int] = set()
def walk(x: Node[K], lo: Any, hi: Any, depth: int, is_root: bool) -> None:
e = entries(x)
assert e <= (self.R if is_root else self.C)
assert is_root or e >= self.f
m = len(x.keys)
assert all(x.keys[j] < x.keys[j + 1] for j in range(m - 1))
if m:
assert lo is None or not (x.keys[0] < lo)
assert hi is None or x.keys[-1] < hi
if x.leaf:
assert not x.children
depths.add(depth)
leaves.append(x)
return
assert len(x.children) == m + 1 >= 2
bounds = [lo, *x.keys, hi]
for j, c in enumerate(x.children):
walk(c, bounds[j], bounds[j + 1], depth + 1, False)
walk(self.root, None, None, 0, True)
assert len(depths) == 1
for a, b in zip(leaves, leaves[1:]):
assert a.next is b
assert leaves[-1].next is None
test_b_star_tree.py 96 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/b-star-tree/. The authors are listed in prov/FES1ZP.jsonl.
import math
import random
import pytest
from b_star_tree import BStarTree, Node
def shape(x: Node[int]) -> str:
if x.leaf:
return "[" + " ".join(map(str, x.keys)) + "]"
return "<" + " ".join(map(str, x.keys)) + ">(" + " ".join(shape(c) for c in x.children) + ")"
def test_example() -> None:
t: BStarTree[int] = BStarTree(4)
assert (t.C, t.f, t.R) == (4, 3, 5)
want_ins = {
50: "[10 20 30 40 50]",
60: "<40>([10 20 30] [40 50 60])",
25: "<30>([10 15 20 25] [30 40 50 60])",
35: "<25 40>([10 15 20] [25 30 35] [40 50 60])",
55: "<25 45>([10 15 20] [25 30 35 40] [45 50 55 60])",
65: "<25 40 55>([10 15 20] [25 30 35] [40 45 50] [55 60 65])",
70: "<25 40 55>([10 15 20] [25 30 35] [40 45 50] [55 60 65 70])",
}
for k in [10, 20, 30, 40, 50, 60, 15, 25, 35, 45, 55, 65, 70]:
assert t.insert(k)
t.check_invariant()
if k in want_ins:
assert shape(t.root) == want_ins[k]
assert t.range(33, 57) == [35, 40, 45, 50, 55]
want_del = [
(70, "<25 40 55>([10 15 20] [25 30 35] [40 45 50] [55 60 65])"),
(65, "<25 45>([10 15 20] [25 30 35 40] [45 50 55 60])"),
(10, "<30 45>([15 20 25] [30 35 40] [45 50 55 60])"),
(20, "<35 50>([15 25 30] [35 40 45] [50 55 60])"),
(15, "<45>([25 30 35 40] [45 50 55 60])"),
(50, "<45>([25 30 35 40] [45 55 60])"),
(55, "<40>([25 30 35] [40 45 60])"),
(25, "[30 35 40 45 60]"),
]
for k, s in want_del:
assert t.delete(k)
t.check_invariant()
assert shape(t.root) == s
def test_edges() -> None:
with pytest.raises(ValueError):
BStarTree(3)
t: BStarTree[int] = BStarTree(4)
assert not t.search(1) and not t.delete(1)
assert t.range(0, 9) == []
assert t.insert(1) and not t.insert(1)
assert t.delete(1) and t.keys() == []
t.check_invariant()
def test_fill_and_height() -> None:
for C in (4, 7, 30):
t: BStarTree[int] = BStarTree(C)
n = 3000
for k in range(n):
t.insert(k)
t.check_invariant()
h = t.height()
# every non-root node has at least f entries, the root at least 2
assert h <= 1 + math.log(n / (2 * t.f), t.f) + 1e-9
assert t.keys() == list(range(n))
def test_against_set() -> None:
rng = random.Random(41)
for _ in range(600):
C = rng.randrange(4, 9)
t: BStarTree[int] = BStarTree(C)
model: set[int] = set()
for _ in range(rng.randrange(1, 120)):
k = rng.randrange(60)
op = rng.randrange(5)
if op <= 1:
assert t.insert(k) == (k not in model)
model.add(k)
elif op <= 3:
assert t.delete(k) == (k in model)
model.discard(k)
else:
a, b = sorted((rng.randrange(60), rng.randrange(60)))
assert t.search(a) == (a in model)
assert t.range(a, b) == [x for x in sorted(model) if a <= x <= b]
t.check_invariant()
assert t.keys() == sorted(model)
What the next level needs
- the tests did not run in this build