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
# 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