Algorithm Search⌘K

Order-statistic tree in Python

A balanced BST augmented with subtree sizes so that the i-th smallest key and the rank of a key are found in O(log n) time.

File
order_statistic_tree.py, 275 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
order_statistic_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/order-statistic-tree/. The authors are listed in prov/S2AJN7.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)

RED = True
BLACK = False


class Node(Generic[K]):
    """A node of a left-leaning red-black tree with its subtree size."""

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

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


def size(x: Node[K] | None) -> int:
    """The number of nodes in the subtree of x. O(1)."""
    return 0 if x is None else x.size  # @line L13


def update(x: Node[K]) -> None:
    """Recompute x.size from its children, whose sizes are right. O(1)."""
    x.size = 1 + size(x.left) + size(x.right)  # @line L14


def is_red(h: Node[K] | None) -> bool:
    return h is not None and h.red


def rotate_left(h: Node[K]) -> Node[K]:
    """Turn the red right link of h into a left link; fix both sizes. O(1)."""
    x = h.right  # @line L15
    assert x is not None
    h.right = x.left
    x.left = h
    x.red = h.red
    h.red = RED
    update(h)  # @line L16
    update(x)
    return x


def rotate_right(h: Node[K]) -> Node[K]:
    """Turn the red left link of h into a right link; fix both sizes. O(1)."""
    x = h.left
    assert x is not None
    h.left = x.right
    x.right = h
    x.red = h.red
    h.red = RED
    update(h)
    update(x)
    return x


def flip_colors(h: Node[K]) -> None:
    assert h.left is not None and h.right is not None
    h.red = not h.red
    h.left.red = not h.left.red
    h.right.red = not h.right.red


def fix_up(h: Node[K]) -> Node[K]:
    """Restore the left-leaning shape at h and recompute its size. O(1)."""
    if is_red(h.right) and not is_red(h.left):
        h = rotate_left(h)
    if is_red(h.left) and h.left is not None and is_red(h.left.left):
        h = rotate_right(h)
    if is_red(h.left) and is_red(h.right):
        flip_colors(h)
    update(h)  # @line L17
    return h


def put(h: Node[K] | None, k: K) -> Node[K]:
    if h is None:
        return Node(k)
    if k < h.key:
        h.left = put(h.left, k)
    else:
        h.right = put(h.right, k)
    return fix_up(h)


def move_red_left(h: Node[K]) -> Node[K]:
    flip_colors(h)
    assert h.right is not None
    if is_red(h.right.left):
        h.right = rotate_right(h.right)
        h = rotate_left(h)
        flip_colors(h)
    return h


def move_red_right(h: Node[K]) -> Node[K]:
    flip_colors(h)
    assert h.left is not None
    if is_red(h.left.left):
        h = rotate_right(h)
        flip_colors(h)
    return h


def delete_min(h: Node[K]) -> Node[K] | None:
    if h.left is None:
        return None
    if not is_red(h.left) and not is_red(h.left.left):
        h = move_red_left(h)
    assert h.left is not None
    h.left = delete_min(h.left)
    return fix_up(h)


def remove(h: Node[K], k: K) -> Node[K] | None:
    """Delete the present key k below h (see left-leaning-red-black-tree)."""
    if k < h.key:
        assert h.left is not None
        if not is_red(h.left) and not is_red(h.left.left):
            h = move_red_left(h)
        assert h.left is not None
        h.left = remove(h.left, k)
    else:
        if is_red(h.left):
            h = rotate_right(h)
        if not (k < h.key) and not (h.key < k) and h.right is None:
            return None
        assert h.right is not None
        if not is_red(h.right) and not is_red(h.right.left):
            h = move_red_right(h)
        assert h.right is not None
        if not (k < h.key) and not (h.key < k):
            m = h.right
            while m.left is not None:
                m = m.left
            h.key = m.key
            h.right = delete_min(h.right)
        else:
            h.right = remove(h.right, k)
    return fix_up(h)


class OrderStatisticTree(Generic[K]):
    """An ordered set with rank and select, kept as a left-leaning red-black
    tree in which each node stores the size of its subtree.

    Keys must be totally ordered. Ranks start at 0: select(i) is the key
    with exactly i smaller keys, and rank(k) is the number of keys less than
    k. All operations cost O(log n) time in the worst case.
    """

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

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

    def contains(self, k: K) -> bool:
        """Return whether k is in the set. O(log n)."""
        h = self.root
        while h is not None:
            if k < h.key:
                h = h.left
            elif h.key < k:
                h = h.right
            else:
                return True
        return False

    def select(self, i: int) -> K:
        """Return the key with exactly i smaller keys. O(log n).

        Precondition: 0 <= i < len(self); else IndexError.
        """
        if not 0 <= i < size(self.root):
            raise IndexError("rank out of range")
        x = self.root  # @line L1
        while True:  # @line L2
            assert x is not None
            r = size(x.left)  # @line L3
            if i == r:  # @line L4
                return x.key
            if i < r:  # @line L5
                x = x.left
            else:  # @line L6
                i = i - r - 1
                x = x.right

    def rank(self, k: K) -> int:
        """Return the number of keys less than k; k need not be present.
        O(log n)."""
        r, x = 0, self.root  # @line L7
        while x is not None:  # @line L8
            if k < x.key:  # @line L9
                x = x.left
            elif not (x.key < k):  # @line L10
                return r + size(x.left)
            else:  # @line L11
                r += size(x.left) + 1
                x = x.right
        return r  # @line L12

    def insert(self, k: K) -> bool:
        """Add k; return False if it was already present. O(log n)."""
        if self.contains(k):
            return False
        self.root = put(self.root, k)
        self.root.red = BLACK
        return True

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent. O(log n)."""
        if not self.contains(k):
            return False
        assert self.root is not None
        if not is_red(self.root.left) and not is_red(self.root.right):
            self.root.red = RED
        self.root = remove(self.root, k)
        if self.root is not None:
            self.root.red = BLACK
        return True

    def keys(self) -> Iterator[K]:
        """Yield the keys in increasing order. O(n)."""
        stack: list[Node[K]] = []
        h = self.root
        while stack or h is not None:
            while h is not None:
                stack.append(h)
                h = h.left
            h = stack.pop()
            yield h.key
            h = h.right

    def height(self) -> int:
        def ht(h: Node[K] | None) -> int:
            return 0 if h is None else 1 + max(ht(h.left), ht(h.right))

        return ht(self.root)

    def check_invariant(self) -> None:
        """Raise AssertionError unless the tree is a valid left-leaning
        red-black tree and every stored size is right."""
        assert not is_red(self.root), "the root is black"

        def walk(h: Node[K] | None, lo: K | None, hi: K | None) -> tuple[int, int]:
            if h is None:
                return 0, 0
            assert lo is None or lo < h.key
            assert hi is None or h.key < hi
            assert not is_red(h.right), "no red right link"
            if h.red:
                assert not is_red(h.left), "no two red links in a row"
            bl, nl = walk(h.left, lo, h.key)
            br, nr = walk(h.right, h.key, hi)
            assert bl == br, "perfect black balance"
            assert h.size == 1 + nl + nr, "size field"
            return bl + (0 if h.red else 1), h.size

        walk(self.root, None, None)
test_order_statistic_tree.py 110 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/order-statistic-tree/. The authors are listed in prov/S2AJN7.jsonl.

import bisect
import random

import pytest

from order_statistic_tree import Node, OrderStatisticTree


def sizes(h: "Node[str] | None") -> object:
    if h is None:
        return None
    return (sizes(h.left), (h.key, h.size), sizes(h.right))


def test_page_example() -> None:
    t: OrderStatisticTree[str] = OrderStatisticTree()
    for c in "SEARCHXMPL":
        assert t.insert(c)
        t.check_invariant()
    assert sizes(t.root) == (
        (((None, ("A", 1), None), ("C", 2), None), ("E", 5), ((None, ("H", 1), None), ("L", 2), None)),
        ("M", 10),
        ((None, ("P", 1), None), ("R", 4), ((None, ("S", 1), None), ("X", 2), None)),
    )
    assert t.select(6) == "P"
    assert t.rank("Q") == 7
    assert t.rank("L") == 4
    assert t.insert("D")
    t.check_invariant()
    assert t.root is not None and t.root.size == 11
    assert t.root.left is not None and t.root.left.size == 6
    assert t.root.left.left is not None and t.root.left.left.key == "C"
    assert t.root.left.left.size == 3
    assert [t.select(i) for i in range(11)] == sorted("SEARCHXMPLD")


def test_edge_cases() -> None:
    t: OrderStatisticTree[int] = OrderStatisticTree()
    assert len(t) == 0
    assert t.rank(5) == 0
    with pytest.raises(IndexError):
        t.select(0)
    assert t.insert(5)
    assert not t.insert(5)
    assert t.select(0) == 5
    assert t.rank(5) == 0
    assert t.rank(6) == 1
    assert t.rank(4) == 0
    with pytest.raises(IndexError):
        t.select(1)
    with pytest.raises(IndexError):
        t.select(-1)
    assert t.delete(5)
    assert not t.delete(5)
    assert len(t) == 0
    t.check_invariant()


def test_ranks_start_at_zero() -> None:
    # Pitfall: select(rank(k)) == k for a present key, with 0-based ranks.
    t: OrderStatisticTree[int] = OrderStatisticTree()
    for k in [30, 10, 20]:
        t.insert(k)
    assert t.select(0) == 10
    assert t.rank(10) == 0
    for k in [10, 20, 30]:
        assert t.select(t.rank(k)) == k


def test_sizes_after_rotations() -> None:
    # Pitfall: a rotation must recompute the lower node first.
    t: OrderStatisticTree[int] = OrderStatisticTree()
    for k in range(1000):
        t.insert(k)
    t.check_invariant()
    for k in range(0, 1000, 3):
        t.delete(k)
    t.check_invariant()
    rest = [k for k in range(1000) if k % 3]
    assert [t.select(i) for i in range(len(rest))] == rest


def test_random_against_sorted_list() -> None:
    rng = random.Random(1990)
    for _ in range(500):
        t: OrderStatisticTree[int] = OrderStatisticTree()
        s: list[int] = []
        for _ in range(rng.randrange(1, 70)):
            k = rng.randrange(50)
            j = bisect.bisect_left(s, k)
            present = j < len(s) and s[j] == k
            if rng.random() < 0.6:
                assert t.insert(k) == (not present)
                if not present:
                    s.insert(j, k)
            else:
                assert t.delete(k) == present
                if present:
                    del s[j]
            t.check_invariant()
            assert len(t) == len(s)
            q = rng.randrange(-1, 52)
            assert t.rank(q) == bisect.bisect_left(s, q)
            if s:
                i = rng.randrange(len(s))
                assert t.select(i) == s[i]
        assert list(t.keys()) == s

What the next level needs

  • the tests did not run in this build

Read the full page about Order-statistic tree