Algorithm Search⌘K

Left-leaning red-black tree in Python

A red-black variant restricting red links to the left, which simplifies insertion and deletion to a small set of cases.

File
left_leaning_red_black_tree.py, 246 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
left_leaning_red_black_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-leaning-red-black-tree/. The authors are listed in prov/1ZQX62.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; `red` is the color of the link from its parent."""

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

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


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. O(1)."""
    x = h.right
    assert x is not None
    h.right = x.left
    x.left = h
    x.red = h.red
    h.red = RED
    return x


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


def flip_colors(h: Node[K]) -> None:
    """Invert the colors of h and its two children. O(1)."""
    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 on the way up. O(1)."""
    if is_red(h.right) and not is_red(h.left):  # @line L6
        h = rotate_left(h)
    if is_red(h.left) and h.left is not None and is_red(h.left.left):  # @line L7
        h = rotate_right(h)
    if is_red(h.left) and is_red(h.right):  # @line L8
        flip_colors(h)
    return h  # @line L9


def put(h: Node[K] | None, k: K) -> Node[K]:
    """Insert k below h and return the new root of the subtree."""
    if h is None:  # @line L1
        return Node(k)
    if k < h.key:  # @line L2
        h.left = put(h.left, k)
    elif h.key < k:  # @line L3
        h.right = put(h.right, k)
    else:  # @line L4
        return h
    return fix_up(h)  # @line L5


def move_red_left(h: Node[K]) -> Node[K]:
    """h is red and h.left, h.left.left are black: make h.left or one of its
    children red, borrowing from the right sibling when it can. O(1)."""
    flip_colors(h)  # @line L16
    assert h.right is not None
    if is_red(h.right.left):  # @line L17
        h.right = rotate_right(h.right)
        h = rotate_left(h)
        flip_colors(h)
    return h


def move_red_right(h: Node[K]) -> Node[K]:
    """h is red and h.right, h.right.left are black: make h.right or one of
    its children red. O(1)."""
    flip_colors(h)  # @line L18
    assert h.left is not None
    if is_red(h.left.left):  # @line L19
        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 k from the subtree of h and return its new root. O(log n).

    Precondition: k is in the subtree, and h is red, or h.left is red, or
    h.right is red with k >= h.key (h is not a 2-node).
    """
    if k < h.key:  # @line L20
        assert h.left is not None
        if not is_red(h.left) and not is_red(h.left.left):  # @line L21
            h = move_red_left(h)
        assert h.left is not None
        h.left = remove(h.left, k)  # @line L22
    else:
        if is_red(h.left):  # @line L23
            h = rotate_right(h)
        if not (k < h.key) and not (h.key < k) and h.right is None:  # @line L24
            return None
        assert h.right is not None
        if not is_red(h.right) and not is_red(h.right.left):  # @line L25
            h = move_red_right(h)
        assert h.right is not None
        if not (k < h.key) and not (h.key < k):  # @line L26
            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)  # @line L27
    return fix_up(h)  # @line L28


class LeftLeaningRedBlackTree(Generic[K]):
    """An ordered set kept as a left-leaning red-black tree (the 2-3 form).

    Keys must be totally ordered. Search, insertion and deletion cost
    O(log n) time in the worst case; the height is at most 2 lg(n + 1).
    """

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

    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 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  # @line L10
        self.size += 1
        return True

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent. O(log n)."""
        if not self.contains(k):  # @line L11
            return False
        assert self.root is not None
        if not is_red(self.root.left) and not is_red(self.root.right):  # @line L12
            self.root.red = RED
        self.root = remove(self.root, k)  # @line L13
        if self.root is not None:  # @line L14
            self.root.red = BLACK
        self.size -= 1  # @line L15
        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:
        """The number of nodes on the longest downward path."""

        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 __len__(self) -> int:
        return self.size

    def check_invariant(self) -> None:
        """Raise AssertionError unless the tree is a valid left-leaning
        red-black tree: search order, black root, no red right link, no two
        red links in a row, and the same number of black links on every
        path from the root to nil."""
        assert not is_red(self.root), "the root is black"
        count = 0

        def walk(h: Node[K] | None, lo: K | None, hi: K | None) -> int:
            nonlocal count
            if h is None:
                return 0
            count += 1
            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 = walk(h.left, lo, h.key)
            br = walk(h.right, h.key, hi)
            assert bl == br, "perfect black balance"
            return bl + (0 if h.red else 1)

        walk(self.root, None, None)
        assert count == self.size
test_left_leaning_red_black_tree.py 125 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-leaning-red-black-tree/. The authors are listed in prov/1ZQX62.jsonl.

import math
import random

import pytest

from left_leaning_red_black_tree import LeftLeaningRedBlackTree, Node, remove


def shape(h: "Node[str] | Node[int] | None") -> object:
    """A node is written key, with * when its link is red."""
    if h is None:
        return None
    s = str(h.key) + ("*" if h.red else "")
    if h.left is None and h.right is None:
        return s
    return (shape(h.left), s, shape(h.right))


def test_page_example() -> None:
    t: LeftLeaningRedBlackTree[str] = LeftLeaningRedBlackTree()
    want = {
        "E": ("E*", "S", None),
        "A": ("A", "E", "S"),
        "R": ("A", "E", ("R*", "S", None)),
        "H": ((("A*", "C", None), "E*", "H"), "R", "S"),
        "P": ((("A*", "C", None), "E", "H"), "M", ("P", "R", ("S*", "X", None))),
        "L": (
            (("A*", "C", None), "E", ("H*", "L", None)),
            "M",
            ("P", "R", ("S*", "X", None)),
        ),
    }
    for k in "SEARCHXMPL":
        assert t.insert(k)
        t.check_invariant()
        if k in want:
            assert shape(t.root) == want[k]
    deletes = {
        "E": ((("A*", "C", None), "H", "L"), "M", ("P", "R", ("S*", "X", None))),
        "X": ((("A*", "C", None), "H", "L"), "M", ("P", "R", "S")),
        "S": ((("A*", "C", None), "H*", "L"), "M", ("P*", "R", None)),
    }
    for k, w in deletes.items():
        assert t.delete(k)
        t.check_invariant()
        assert shape(t.root) == w
    assert "".join(t.keys()) == "ACHLMPR"


def test_edge_cases() -> None:
    t: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
    assert not t.delete(5)
    assert not t.contains(5)
    assert t.height() == 0
    t.check_invariant()
    assert t.insert(5)
    assert not t.insert(5)
    assert len(t) == 1
    assert t.delete(5)
    assert t.root is None
    t.check_invariant()


def test_root_with_two_black_children() -> None:
    # (1, 2, 3) with black children: delete must first make the root red
    t: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
    for k in [1, 2, 3]:
        t.insert(k)
    assert shape(t.root) == ("1", "2", "3")
    assert t.delete(1)
    t.check_invariant()
    assert shape(t.root) == ("2*", "3", None)


def test_remove_needs_a_present_key() -> None:
    # REMOVE assumes the key is there; DELETE searches first
    t: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
    for k in [1, 2, 3]:
        t.insert(k)
    assert t.root is not None
    with pytest.raises((AssertionError, AttributeError)):
        remove(t.root, 0)
    t2: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
    for k in [1, 2, 3]:
        t2.insert(k)
    assert not t2.delete(0)
    t2.check_invariant()
    assert list(t2.keys()) == [1, 2, 3]


def test_sorted_input_height() -> None:
    t: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
    n = 4095
    for k in range(n):
        t.insert(k)
    t.check_invariant()
    assert t.height() <= 2 * math.log2(n + 1)
    for k in range(n - 1, -1, -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(2008)
    for _ in range(600):
        t: LeftLeaningRedBlackTree[int] = LeftLeaningRedBlackTree()
        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 list(t.keys()) == sorted(model)
        for k in range(40):
            assert t.contains(k) == (k in model)
        if model:
            assert t.height() <= 2 * math.log2(len(model) + 1)

What the next level needs

  • the tests did not run in this build

Read the full page about Left-leaning red-black tree