Algorithm Search⌘K

Weight-balanced tree in Python

A BST that keeps each subtree's size within a fixed ratio of its sibling's, balanced by rotations that use subtree sizes.

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

from __future__ import annotations

from typing import Generic, Iterator, Protocol, TypeVar


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


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

DELTA = 3  # a child may weigh at most DELTA times its sibling
GAMMA = 2  # single rotation if inner weight < GAMMA * outer weight


class Node(Generic[K]):
    """An immutable node; size is the number of keys in the subtree."""

    __slots__ = ("key", "left", "right", "size")

    def __init__(self, left: Node[K] | None, key: K, right: Node[K] | None) -> None:
        self.key = key
        self.left = left
        self.right = right
        self.size = size(left) + size(right) + 1


def size(t: Node[K] | None) -> int:
    return 0 if t is None else t.size


def weight(t: Node[K] | None) -> int:
    return size(t) + 1


def balance(l: Node[K] | None, k: K, r: Node[K] | None) -> Node[K]:
    """Join l < k < r, where l and r are weight-balanced and their weights
    were in balance before one key was added to or removed from one side."""
    if DELTA * weight(l) < weight(r):  # @line L1
        assert r is not None
        return rotate_left(l, k, r)
    if DELTA * weight(r) < weight(l):  # @line L2
        assert l is not None
        return rotate_right(l, k, r)
    return Node(l, k, r)  # @line L3


def rotate_left(l: Node[K] | None, k: K, r: Node[K]) -> Node[K]:
    rl, rr = r.left, r.right
    if weight(rl) < GAMMA * weight(rr):  # @line L4
        return Node(Node(l, k, rl), r.key, rr)
    assert rl is not None  # @line L5
    return Node(Node(l, k, rl.left), rl.key, Node(rl.right, r.key, rr))


def rotate_right(l: Node[K], k: K, r: Node[K] | None) -> Node[K]:
    ll, lr = l.left, l.right
    if weight(lr) < GAMMA * weight(ll):
        return Node(ll, l.key, Node(lr, k, r))
    assert lr is not None
    return Node(Node(ll, l.key, lr.left), lr.key, Node(lr.right, k, r))


def insert(t: Node[K] | None, k: K) -> Node[K]:
    """Return t with k added. O(log n)."""
    if t is None:  # @line L6
        return Node(None, k, None)
    if k < t.key:  # @line L7
        return balance(insert(t.left, k), t.key, t.right)
    if t.key < k:  # @line L8
        return balance(t.left, t.key, insert(t.right, k))
    return t


def delete_min(t: Node[K]) -> tuple[K, Node[K] | None]:
    if t.left is None:
        return t.key, t.right
    m, l = delete_min(t.left)
    return m, balance(l, t.key, t.right)


def delete_max(t: Node[K]) -> tuple[K, Node[K] | None]:
    if t.right is None:
        return t.key, t.left
    m, r = delete_max(t.right)
    return m, balance(t.left, t.key, r)


def glue(l: Node[K] | None, r: Node[K] | None) -> Node[K] | None:
    """Join two subtrees of one balanced node, without the key between."""
    if l is None:  # @line L11
        return r
    if r is None:
        return l
    if size(l) > size(r):  # @line L12
        m, l2 = delete_max(l)
        return balance(l2, m, r)
    m, r2 = delete_min(r)
    return balance(l, m, r2)


def delete(t: Node[K] | None, k: K) -> Node[K] | None:
    """Return t with k removed. O(log n)."""
    if t is None:
        return None
    if k < t.key:  # @line L9
        return balance(delete(t.left, k), t.key, t.right)
    if t.key < k:
        return balance(t.left, t.key, delete(t.right, k))
    return glue(t.left, t.right)  # @line L10


class WeightBalancedTree(Generic[K]):
    """An ordered set kept as a weight-balanced tree with (delta, gamma) =
    (3, 2), as in Adams (1993) and Hirai and Yamamoto (2011).

    Nodes are immutable, so old versions stay valid. Search, insertion,
    deletion, rank and select cost O(log n) in the worst case.
    """

    def __init__(self) -> None:
        self.root: Node[K] | None = None

    def contains(self, k: K) -> bool:
        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."""
        n = size(self.root)
        self.root = insert(self.root, k)
        return size(self.root) > n

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent."""
        n = size(self.root)
        self.root = delete(self.root, k)
        return size(self.root) < n

    def select(self, i: int) -> K:
        """Return the key of rank i (0-based). O(log n)."""
        if not 0 <= i < size(self.root):
            raise IndexError(i)
        t = self.root
        while t is not None:
            s = size(t.left)
            if i < s:
                t = t.left
            elif i > s:
                i -= s + 1
                t = t.right
            else:
                return t.key
        raise AssertionError("unreachable")

    def rank(self, k: K) -> int:
        """Return the number of keys less than k. O(log n)."""
        r, t = 0, self.root
        while t is not None:
            if k < t.key:
                t = t.left
            elif t.key < k:
                r += size(t.left) + 1
                t = t.right
            else:
                return r + size(t.left)
        return r

    def __contains__(self, k: K) -> bool:
        return self.contains(k)

    def __len__(self) -> int:
        return size(self.root)

    def __iter__(self) -> Iterator[K]:
        stack: list[Node[K]] = []
        t = self.root
        while stack or t is not None:
            while t is not None:
                stack.append(t)
                t = t.left
            t = stack.pop()
            yield t.key
            t = t.right

    def keys(self) -> list[K]:
        return list(self)

    def height(self) -> int:
        """Height in edges; -1 for the empty tree."""
        def h(t: Node[K] | None) -> int:
            return -1 if t is None else 1 + max(h(t.left), h(t.right))
        return h(self.root)

    def check_invariant(self) -> None:
        """Assert order, sizes and the weight rule at every node."""
        def go(t: Node[K] | None) -> None:
            if t is None:
                return
            assert t.size == size(t.left) + size(t.right) + 1, "size"
            assert weight(t.left) <= DELTA * weight(t.right), "left too heavy"
            assert weight(t.right) <= DELTA * weight(t.left), "right too heavy"
            go(t.left)
            go(t.right)
        go(self.root)
        ks = self.keys()
        assert all(a < b for a, b in zip(ks, ks[1:])), "order"
test_weight_balanced_tree.py 96 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/weight-balanced-tree/. The authors are listed in prov/0YHN5A.jsonl.

import math
import random

from weight_balanced_tree import WeightBalancedTree, balance, insert


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_sorted():
    w = WeightBalancedTree()
    for k in [1, 2, 3]:
        w.insert(k)
    assert shape(w.root) == (None, 1, (None, 2, 3))  # weights 1 and 3 are allowed
    w.insert(4)  # weights 1 and 4: single left rotation at 1
    assert shape(w.root) == (1, 2, (None, 3, 4))
    for k in [5, 6, 7]:
        w.insert(k)
    assert shape(w.root) == (1, 2, (3, 4, (None, 5, (None, 6, 7))))
    w.insert(8)  # weights 2 and 7 at the root 2: single left rotation
    assert shape(w.root) == ((1, 2, 3), 4, (5, 6, (None, 7, 8)))
    w.check_invariant()


def test_page_example_double_rotation():
    w = WeightBalancedTree()
    for k in [1, 4, 2]:
        w.insert(k)
    assert shape(w.root) == (None, 1, (2, 4, None))
    w.insert(3)  # at 1: weights 1 and 4, inner weight 3 is not < 2 * 1
    assert shape(w.root) == (1, 2, (3, 4, None))
    w.check_invariant()


def test_page_example_delete():
    w = WeightBalancedTree()
    for k in range(1, 11):
        w.insert(k)
    w.delete(1)
    assert shape(w.root) == ((None, 2, 3), 4, (5, 6, (7, 8, (None, 9, 10))))
    w.delete(2)  # at 4: weights 2 and 7, single left rotation
    assert shape(w.root) == ((3, 4, 5), 6, (7, 8, (None, 9, 10)))
    w.check_invariant()


def test_persistence_and_edges():
    w = WeightBalancedTree()
    assert not w.delete(3) and w.height() == -1 and w.rank(5) == 0
    assert w.insert(3) and not w.insert(3)
    old = w.root
    w.insert(9)
    assert shape(old) == 3 and shape(w.root) == (None, 3, 9)
    assert w.delete(3) and w.delete(9) and len(w) == 0
    assert shape(balance(None, 1, None)) == 1
    assert shape(insert(None, 5)) == 5


def test_height_bound():
    for keys in (list(range(5000)), list(range(5000, 0, -1))):
        w = WeightBalancedTree()
        for k in keys:
            w.insert(k)
        w.check_invariant()
        n = len(w)
        assert w.height() <= math.log((n + 1) / 2, 4 / 3)


def test_random_against_set():
    rng = random.Random(1972)
    for case in range(600):
        w = WeightBalancedTree()
        ref = set()
        for _ in range(rng.randint(0, 90)):
            k = rng.randint(0, 50)
            if rng.random() < 0.6:
                assert w.insert(k) == (k not in ref)
                ref.add(k)
            else:
                assert w.delete(k) == (k in ref)
                ref.discard(k)
            w.check_invariant()
        s = sorted(ref)
        assert w.keys() == s and len(w) == len(s)
        for i, k in enumerate(s):
            assert w.select(i) == k and w.rank(k) == i
        for k in range(-1, 52):
            assert (k in w) == (k in ref)
            assert w.rank(k) == sum(1 for x in s if x < k)

What the next level needs

  • the tests did not run in this build

Read the full page about Weight-balanced tree