Skew heap in Python
Self-adjusting leftist-style heap with amortized O(log n) merge and no balance bookkeeping.
- File
skew_heap.py, 129 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/skew-heap/. The authors are listed in prov/PY5NQC.jsonl.
from typing import Any, Generic, Iterator, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
K = TypeVar("K", bound=Ordered)
class Node(Generic[K]):
"""A node: a key and two subtrees. There is no balance field."""
__slots__ = ("key", "left", "right")
def __init__(self, key: K) -> None:
self.key = key
self.left: Node[K] | None = None
self.right: Node[K] | None = None
def merge(a: "Node[K] | None", b: "Node[K] | None") -> "Node[K] | None":
"""Top-down skew merge of two heap-ordered trees; return the new root.
Equal to the recursive MERGE of the page, done with a loop, since the length
of a merge path is bounded only by n.
"""
if a is None: # @line L1
return b
if b is None:
return a
if b.key < a.key: # @line L2
a, b = b, a
root = a
while True:
nxt = a.right # @line L3
a.right = a.left # @line L4
if nxt is None: # MERGE(NIL, b) = b
a.left = b # @line L5
break
if b.key < nxt.key:
nxt, b = b, nxt
a.left = nxt # the smaller root of the rest; the merge goes on below it
a = nxt
return root # @line L6
class SkewHeap(Generic[K]):
"""A min-heap stored as a skew heap (Sleator and Tarjan, 1986)."""
def __init__(self) -> None:
self.root: Node[K] | None = None
self.n = 0
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: "SkewHeap[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 heavy_count(self) -> int:
"""The potential of the analysis: nodes whose right subtree is larger than the left."""
size: dict[int, int] = {}
order: list[Node[K]] = []
stack = [self.root] if self.root is not None else []
while stack:
v = stack.pop()
order.append(v)
stack.extend(c for c in (v.left, v.right) if c is not None)
heavy = 0
for v in reversed(order):
sl = size[id(v.left)] if v.left is not None else 0
sr = size[id(v.right)] if v.right is not None else 0
size[id(v)] = sl + sr + 1
heavy += sr > sl
return heavy
def check_invariant(self) -> None:
"""Check heap order and the count."""
count = 0
stack = [self.root] if self.root is not None else []
while stack:
v = stack.pop()
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)
assert count == self.n, "count"
test_skew_heap.py 95 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/skew-heap/. The authors are listed in prov/PY5NQC.jsonl.
import heapq
import random
import pytest
from skew_heap import Node, SkewHeap, 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: SkewHeap[int] = SkewHeap()
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(8, 5)", "1(3(8, 5), -)", "1(7, 3(8, 5))", "1(2(3(8, 5), -), 7)"]
assert h.delete_min() == 1
assert sh(h.root) == "2(7, 3(8, 5))"
def test_example_meld() -> None:
x: SkewHeap[int] = SkewHeap()
for k in [1, 3, 8, 5, 7, 2]:
x.insert(k)
y: SkewHeap[int] = SkewHeap()
for k in [4, 6, 9, 10]:
y.insert(k)
assert sh(x.root) == "1(2(3(5, -), -), 7(8, -))"
assert sh(y.root) == "4(6(10, -), 9)"
x.meld(y)
x.check_invariant()
assert sh(x.root) == "1(4(7(9, 8), 6(10, -)), 2(3(5, -), -))"
assert len(x) == 10 and y.is_empty()
def test_empty() -> None:
h: SkewHeap[int] = SkewHeap()
assert merge(None, None) is None
with pytest.raises(IndexError):
h.find_min()
with pytest.raises(IndexError):
h.delete_min()
assert h.heavy_count() == 0
def test_long_paths_no_recursion() -> None:
h: SkewHeap[int] = SkewHeap()
for k in range(20000, 0, -1):
h.insert(k)
other: SkewHeap[int] = SkewHeap()
for k in range(20001, 40001):
other.insert(k)
h.meld(other)
out = [h.delete_min() for _ in range(2000)]
assert out == list(range(1, 2001))
def test_random_against_heapq() -> None:
rng = random.Random(1986)
for _ in range(600):
heaps = [SkewHeap[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])
assert 0 <= heaps[i].heavy_count() <= len(heaps[i])
What the next level needs
- the tests did not run in this build