Algorithm Search⌘K

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
two_three_tree.pyRawGitHub
# 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

Read the full page about 2-3 tree