Leftist heap in Python
Binary heap tree that keeps the right spine short, so merge runs in O(log n).
- File
leftist_heap.py, 130 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/leftist-heap/. The authors are listed in prov/Z662CN.jsonl.
from collections import deque
from typing import Any, Generic, Iterable, Iterator, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
K = TypeVar("K", bound=Ordered)
class Node(Generic[K]):
"""A node: key, left and right subtrees, and rank s (null path length)."""
__slots__ = ("key", "left", "right", "s")
def __init__(self, key: K) -> None:
self.key = key
self.left: Node[K] | None = None
self.right: Node[K] | None = None
self.s = 1
def rank(v: "Node[Any] | None") -> int:
"""The rank s(v): 0 for an empty tree, else the length of the right spine."""
return 0 if v is None else v.s
def merge(a: "Node[K] | None", b: "Node[K] | None") -> "Node[K] | None":
"""Merge two leftist heaps and return the root.
This is the recursive MERGE of the page, done with an explicit stack: walk
down the two right spines, then fix the ranks on the way back up.
"""
if a is None: # @line L1
return b
if b is None:
return a
path: list[Node[K]] = []
while a is not None and b is not None:
if b.key < a.key: # @line L2
a, b = b, a
path.append(a)
a = a.right # MERGE(a.right, b) continues with this pair
rest = a if a is not None else b
for v in reversed(path):
v.right = rest # @line L3
if rank(v.left) < rank(v.right): # @line L4
v.left, v.right = v.right, v.left
v.s = rank(v.right) + 1 # @line L5
rest = v # @line L6
return rest
class LeftistHeap(Generic[K]):
"""A min-heap stored as a leftist tree."""
def __init__(self, items: Iterable[K] = ()) -> None:
"""Build a heap from items in O(n) time by merging in FIFO order."""
q: deque[Node[K]] = deque(Node(x) for x in items)
self.n = len(q)
while len(q) > 1:
q.append(merge(q.popleft(), q.popleft())) # type: ignore[arg-type]
self.root: Node[K] | None = q[0] if q else None
def __len__(self) -> int:
return self.n
def is_empty(self) -> bool:
"""Return True if the heap holds no item."""
return self.root is None
def insert(self, key: K) -> None:
"""Add key to the heap."""
self.root = merge(self.root, Node(key)) # @line L7
self.n += 1
def find_min(self) -> K:
"""Return a smallest key; the heap must not be empty."""
if self.root is None:
raise IndexError("find_min on an empty heap")
return self.root.key
def delete_min(self) -> K:
"""Remove and return a smallest key; the heap must not be empty."""
r = self.root
if r is None:
raise IndexError("delete_min on an empty heap")
self.root = merge(r.left, r.right) # @line L8
self.n -= 1
return r.key # @line L9
def meld(self, other: "LeftistHeap[K]") -> None:
"""Move all items of other into this heap; other becomes empty."""
self.root = merge(self.root, other.root)
self.n += other.n
other.root, other.n = None, 0
def keys(self) -> Iterator[K]:
"""Yield all keys in preorder."""
stack = [self.root] if self.root is not None else []
while stack:
v = stack.pop()
yield v.key
for c in (v.right, v.left):
if c is not None:
stack.append(c)
def check_invariant(self) -> None:
"""Check heap order, the leftist property, the ranks and the count."""
count = 0
order: list[Node[K]] = []
stack = [self.root] if self.root is not None else []
while stack:
v = stack.pop()
order.append(v)
count += 1
for c in (v.left, v.right):
if c is not None:
assert not (c.key < v.key), "heap order"
stack.append(c)
for v in reversed(order): # children before parents
assert rank(v.left) >= rank(v.right), "leftist property"
assert v.s == rank(v.right) + 1, "rank"
assert count == self.n, "count"
if self.n > 0:
assert rank(self.root) <= (self.n + 1).bit_length() - 1, "rank bound"
test_leftist_heap.py 103 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/leftist-heap/. The authors are listed in prov/Z662CN.jsonl.
import heapq
import random
import pytest
from leftist_heap import LeftistHeap, Node, merge
def sh(v: "Node[int] | None") -> str:
"""The tree as key(left, right), as written on the page."""
if v is None:
return "-"
if v.left is None and v.right is None:
return str(v.key)
return f"{v.key}({sh(v.left)}, {sh(v.right)})"
def test_example_inserts() -> None:
h: LeftistHeap[int] = LeftistHeap()
seen = []
for k in [5, 3, 8, 1, 7, 2]:
h.insert(k)
h.check_invariant()
seen.append(sh(h.root))
assert seen == ["5", "3(5, -)", "3(5, 8)", "1(3(5, 8), -)", "1(3(5, 8), 7)", "1(3(5, 8), 2(7, -))"]
assert h.delete_min() == 1
assert sh(h.root) == "2(3(5, 8), 7)"
assert h.delete_min() == 2
assert sh(h.root) == "3(5, 7(8, -))"
h.check_invariant()
def test_example_meld() -> None:
x: LeftistHeap[int] = LeftistHeap()
for k in [1, 3, 8, 5, 7, 2]:
x.insert(k)
y: LeftistHeap[int] = LeftistHeap()
for k in [4, 6, 9, 10]:
y.insert(k)
assert sh(x.root) == "1(5(8, 7), 2(3, -))"
assert sh(y.root) == "4(6, 9(10, -))"
x.meld(y)
x.check_invariant()
assert sh(x.root) == "1(5(8, 7), 2(4(6, 9(10, -)), 3))"
assert x.root is not None and x.root.s == 3
assert len(x) == 10 and len(y) == 0 and y.is_empty()
def test_empty() -> None:
h: LeftistHeap[int] = LeftistHeap()
h.check_invariant()
assert h.is_empty() and len(h) == 0
assert merge(None, None) is None
with pytest.raises(IndexError):
h.delete_min()
with pytest.raises(IndexError):
h.find_min()
def test_duplicates_and_sorted_inputs() -> None:
for data in ([2] * 50, list(range(200)), list(range(200, 0, -1))):
h = LeftistHeap(data)
h.check_invariant()
out = [h.delete_min() for _ in range(len(data))]
assert out == sorted(data)
def test_increasing_inserts_keep_spine_short() -> None:
h: LeftistHeap[int] = LeftistHeap()
for k in range(10000):
h.insert(k)
h.check_invariant()
assert h.root is not None and h.root.s <= 13
def test_random_against_heapq() -> None:
rng = random.Random(20240611)
for _ in range(600):
heaps = [LeftistHeap[int]() for _ in range(3)]
refs: list[list[int]] = [[] for _ in range(3)]
for _ in range(rng.randrange(1, 60)):
i = rng.randrange(3)
op = rng.random()
if op < 0.55:
k = rng.randrange(-20, 20)
heaps[i].insert(k)
heapq.heappush(refs[i], k)
elif op < 0.85:
if refs[i]:
assert heaps[i].find_min() == refs[i][0]
assert heaps[i].delete_min() == heapq.heappop(refs[i])
else:
j = rng.randrange(3)
if i != j:
heaps[i].meld(heaps[j])
refs[i] = refs[i] + refs[j]
heapq.heapify(refs[i])
refs[j] = []
heaps[i].check_invariant()
assert sorted(heaps[i].keys()) == sorted(refs[i])
What the next level needs
- the tests did not run in this build