Algorithm Search⌘K

B^ε-tree in Python

Write-optimized tree that buffers updates in internal nodes and flushes them in batches.

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

from bisect import bisect_left, bisect_right
from typing import Any, Generic, Optional, Protocol, TypeVar, Union


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


K = TypeVar("K", bound=Ordered)
V = TypeVar("V")

PUT, DEL = 0, 1


class Leaf(Generic[K, V]):
    """A leaf: at most L keys in increasing order, with their values."""

    def __init__(self, keys: list[K], vals: list[V]) -> None:
        self.keys = keys
        self.vals = vals


class Inner(Generic[K, V]):
    """An internal node: pivots, children and a buffer of messages.

    The buffer maps a key to its newest pending message, (PUT, value) or
    (DEL, None). All messages in the buffer are newer than all messages
    below this node."""

    def __init__(self) -> None:
        self.pivots: list[K] = []
        self.children: list[Union["Inner[K, V]", Leaf[K, V]]] = []
        self.buf: dict[K, tuple[int, Any]] = {}


Node = Union[Inner[K, V], Leaf[K, V]]


class BEpsilonTree(Generic[K, V]):
    """A B^epsilon-tree: a B+ tree whose internal nodes have fan-out at most
    F and a buffer of at most C pending messages.

    Inserts and deletes are messages put in the root buffer. A full buffer
    is flushed by moving the largest group of messages that go to one child
    down to that child. A search reads the buffers on its path, so its
    answer is immediate. With F = B^eps and C = Theta(B), an update costs
    O(log_B N / (eps B^(1 - eps))) amortized I/Os and a search
    O(log_B N / eps). `ios` counts node reads and writes, one block each.
    """

    def __init__(self, block_size: int = 8, fanout: int = 3) -> None:
        if block_size < 2 or fanout < 3:
            raise ValueError("need block_size >= 2 and fanout >= 3")
        self.C = block_size  # buffer capacity of an internal node
        self.L = block_size  # keys in a leaf
        self.F = fanout
        self.root: Node[K, V] = Leaf([], [])
        self.ios = 0
        self.deletes = 0
        self.base = 0

    # queries -------------------------------------------------------------

    def get(self, k: K, default: Optional[V] = None) -> Optional[V]:
        """Return the value of k, or default if k is absent."""
        x = self.root  # @line L1
        while isinstance(x, Inner):  # @line L2
            self.ios += 1
            if k in x.buf:  # @line L3
                kind, v = x.buf[k]
                return v if kind == PUT else default
            x = x.children[bisect_right(x.pivots, k)]  # @line L4
        self.ios += 1
        i = bisect_left(x.keys, k)  # @line L5
        return x.vals[i] if i < len(x.keys) and not (k < x.keys[i]) else default

    def __contains__(self, k: K) -> bool:
        sentinel = object()
        return self.get(k, sentinel) is not sentinel  # type: ignore[arg-type]

    # updates -------------------------------------------------------------

    def put(self, k: K, v: V) -> None:
        """Insert k with value v, or replace the value of k."""
        self._upsert(k, (PUT, v))

    def delete(self, k: K) -> None:
        """Delete k; deleting an absent key changes nothing."""
        self._upsert(k, (DEL, None))
        self.deletes += 1
        if self.deletes > max(self.L, self.base):
            self._rebuild()

    def _upsert(self, k: K, msg: tuple[int, Any]) -> None:
        if isinstance(self.root, Leaf):  # @line L6
            self._apply(self.root, {k: msg})
        else:
            self.root.buf[k] = msg  # @line L7
            if len(self.root.buf) > self.C:
                self._flush(self.root)
        while self._too_big(self.root):  # @line L8
            new_root: Inner[K, V] = Inner()
            new_root.children = [self.root]
            self.root = new_root
            self._split(new_root, 0)

    def _flush(self, x: Inner[K, V]) -> None:
        self.ios += 1
        while len(x.buf) > self.C:  # @line L9
            groups: dict[int, list[K]] = {}
            for key in x.buf:
                groups.setdefault(bisect_right(x.pivots, key), []).append(key)
            i = max(groups, key=lambda j: len(groups[j]))  # @line L10
            c = x.children[i]
            batch = {key: x.buf.pop(key) for key in groups[i]}
            self.ios += 2  # read and write the child
            if isinstance(c, Leaf):  # @line L11
                self._apply(c, batch)
            else:
                c.buf.update(batch)
                if len(c.buf) > self.C:  # @line L12
                    self._flush(c)
            if self._too_big(c):  # @line L13
                self._split(x, i)

    def _apply(self, leaf: Leaf[K, V], batch: dict[K, tuple[int, Any]]) -> None:
        d = dict(zip(leaf.keys, leaf.vals))
        for k, (kind, v) in batch.items():
            if kind == PUT:
                d[k] = v
            else:
                d.pop(k, None)
        leaf.keys = sorted(d)
        leaf.vals = [d[k] for k in leaf.keys]

    def _too_big(self, x: Node[K, V]) -> bool:
        if isinstance(x, Leaf):
            return len(x.keys) > self.L
        return len(x.children) > self.F

    def _split(self, p: Inner[K, V], i: int) -> None:
        """Split child i of p into two halves, repeating while too big."""
        c = p.children[i]
        if isinstance(c, Leaf):  # @line L14
            h = len(c.keys) // 2
            right: Node[K, V] = Leaf(c.keys[h:], c.vals[h:])
            pivot = c.keys[h]
            c.keys, c.vals = c.keys[:h], c.vals[:h]
        else:
            h = len(c.children) // 2
            r: Inner[K, V] = Inner()
            pivot = c.pivots[h - 1]
            r.children, r.pivots = c.children[h:], c.pivots[h:]
            c.children, c.pivots = c.children[:h], c.pivots[: h - 1]
            r.buf = {k: m for k, m in c.buf.items() if not (k < pivot)}
            c.buf = {k: m for k, m in c.buf.items() if k < pivot}
            right = r
        self.ios += 2
        p.children.insert(i + 1, right)
        p.pivots.insert(i, pivot)
        for j in (i + 1, i):
            if self._too_big(p.children[j]):
                self._split(p, j)

    # whole-tree operations --------------------------------------------------

    def items(self) -> list[tuple[K, V]]:
        """All live pairs in key order, resolving the pending messages."""

        def content(x: Node[K, V]) -> dict[K, V]:
            if isinstance(x, Leaf):
                return dict(zip(x.keys, x.vals))
            d: dict[K, V] = {}
            for c in x.children:
                d.update(content(c))
            for k, (kind, v) in x.buf.items():
                if kind == PUT:
                    d[k] = v
                else:
                    d.pop(k, None)
            return d

        d = content(self.root)
        return [(k, d[k]) for k in sorted(d)]

    def __len__(self) -> int:
        return len(self.items())

    def _rebuild(self) -> None:
        pairs = self.items()
        self.ios += 2 * (len(pairs) // self.L + 1)
        half = max(1, self.L // 2 + 1)
        level: list[Node[K, V]] = [
            Leaf([k for k, _ in pairs[i : i + half]], [v for _, v in pairs[i : i + half]])
            for i in range(0, len(pairs), half)
        ] or [Leaf([], [])]
        lows: list[Any] = [lf.keys[0] if isinstance(lf, Leaf) and lf.keys else None for lf in level]
        while len(level) > 1:
            nxt: list[Node[K, V]] = []
            nlows: list[Any] = []
            step = max(2, (self.F + 1) // 2)
            for i in range(0, len(level), step):
                y: Inner[K, V] = Inner()
                y.children = level[i : i + step]
                y.pivots = lows[i + 1 : i + step]
                nxt.append(y)
                nlows.append(lows[i])
            if len(nxt[-1].children) == 1 and len(nxt) > 1:
                last = nxt.pop()
                prev = nxt[-1]
                assert isinstance(prev, Inner) and isinstance(last, Inner)
                prev.pivots.append(nlows.pop())
                prev.children.extend(last.children)
            level, lows = nxt, nlows
        self.root = level[0]
        self.base = len(pairs)
        self.deletes = 0

    def height(self) -> int:
        h, x = 1, self.root
        while isinstance(x, Inner):
            x, h = x.children[0], h + 1
        return h

    def check_invariant(self) -> None:
        """Raise AssertionError if a property of the tree fails."""
        depths: set[int] = set()

        def walk(x: Node[K, V], lo: Any, hi: Any, depth: int) -> None:
            if isinstance(x, Leaf):
                assert len(x.keys) == len(x.vals) and len(x.keys) <= self.L
                assert all(a < b for a, b in zip(x.keys, x.keys[1:]))
                for k in x.keys:
                    assert (lo is None or not (k < lo)) and (hi is None or k < hi)
                depths.add(depth)
                return
            assert 2 <= len(x.children) <= self.F
            assert len(x.pivots) == len(x.children) - 1
            assert all(a < b for a, b in zip(x.pivots, x.pivots[1:]))
            for p in x.pivots:
                assert (lo is None or lo < p) and (hi is None or p < hi)
            assert len(x.buf) <= self.C
            for k in x.buf:
                assert (lo is None or not (k < lo)) and (hi is None or k < hi)
            bounds = [lo, *x.pivots, hi]
            for j, c in enumerate(x.children):
                walk(c, bounds[j], bounds[j + 1], depth + 1)

        walk(self.root, None, None, 0)
        assert len(depths) == 1
test_b_epsilon_tree.py 100 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/b-epsilon-tree/. The authors are listed in prov/NA7JW5.jsonl.

import math
import random

import pytest

from b_epsilon_tree import BEpsilonTree, Inner, Leaf


def shape(x: "Inner[int, str] | Leaf[int, str]") -> str:
    if isinstance(x, Leaf):
        return "[" + " ".join(map(str, x.keys)) + "]"
    buf = " ".join(("+" if m[0] == 0 else "-") + str(k) for k, m in sorted(x.buf.items()))
    inner = " ".join(shape(c) for c in x.children)
    return "<" + " ".join(map(str, x.pivots)) + "|" + buf + ">(" + inner + ")"


def test_example() -> None:
    t: BEpsilonTree[int, str] = BEpsilonTree(block_size=4, fanout=3)
    for k in [10, 20, 30, 40, 50]:
        t.put(k, str(k))
    assert shape(t.root) == "<30|>([10 20] [30 40 50])"
    for k in [60, 70, 80, 90]:
        t.put(k, str(k))
    assert shape(t.root) == "<30|+60 +70 +80 +90>([10 20] [30 40 50])"
    t.put(15, "15")
    assert shape(t.root) == "<30 60|+15>([10 20] [30 40 50] [60 70 80 90])"
    for k in [25, 35, 45]:
        t.put(k, str(k))
    t.delete(20)
    assert shape(t.root) == "<30 60|+35 +45>([10 15 25] [30 40 50] [60 70 80 90])"
    t.put(55, "55")
    t.delete(70)
    assert shape(t.root) == "<30 60|+35 +45 +55 -70>([10 15 25] [30 40 50] [60 70 80 90])"
    # the answers come from the buffers, before the messages reach a leaf
    assert t.get(20) is None and t.get(70) is None
    assert t.get(45) == "45" and t.get(80) == "80"
    t.put(5, "5")
    assert shape(t.root) == (
        "<45|>(<30|+5>([10 15 25] [30 35 40]) <60|-70>([45 50 55] [60 70 80 90]))"
    )
    t.check_invariant()
    assert [k for k, _ in t.items()] == [5, 10, 15, 25, 30, 35, 40, 45, 50, 55, 60, 80, 90]


def test_edges() -> None:
    with pytest.raises(ValueError):
        BEpsilonTree(4, 2)
    t: BEpsilonTree[int, int] = BEpsilonTree(2, 3)
    assert t.get(1) is None and 1 not in t and len(t) == 0
    t.delete(1)
    t.put(1, 1)
    t.put(1, 2)
    assert t.get(1) == 2 and len(t) == 1
    t.delete(1)
    assert 1 not in t
    t.check_invariant()


def test_against_dict() -> None:
    rng = random.Random(7)
    for _ in range(500):
        t: BEpsilonTree[int, int] = BEpsilonTree(rng.randrange(2, 7), rng.randrange(3, 6))
        d: dict[int, int] = {}
        for i in range(rng.randrange(1, 300)):
            k = rng.randrange(120)
            r = rng.random()
            if r < 0.25:
                t.delete(k)
                d.pop(k, None)
            elif r < 0.7:
                t.put(k, i)
                d[k] = i
            else:
                assert t.get(k) == d.get(k)
            if i % 31 == 0:
                t.check_invariant()
        t.check_invariant()
        assert t.items() == sorted(d.items())


def test_io_bound() -> None:
    B, F = 64, 8  # F = B^(1/2)
    t: BEpsilonTree[int, int] = BEpsilonTree(B, F)
    rng = random.Random(1)
    N = 20000
    for k in rng.sample(range(10 * N), N):
        t.put(k, k)
    upd = t.ios
    h = t.height()
    # an update pays about F/B per level, far below one I/O
    assert upd < N * (h * 4 * F / B)
    assert upd < N / 2
    t.ios = 0
    for k in rng.sample(range(10 * N), 1000):
        t.get(k)
    assert t.ios <= 1000 * h
    assert h <= 2 + math.log(N, F / 2)

What the next level needs

  • the tests did not run in this build

Read the full page about B^ε-tree