Chazelle's minimum spanning tree algorithm in Python
Deterministic O(m α(m,n)) MST algorithm built on soft heaps.
- File
chazelle_mst.py, 206 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/chazelle-mst/. The authors are listed in prov/T77RVB.jsonl.
"""A simplified form of Chazelle's soft-heap MST algorithm.
This module keeps the two ideas that make Chazelle's algorithm work: pieces
grown by Prim's rule with a soft heap, which may corrupt (raise) keys, and
the filter MSF(G) = MSF(MSF of each piece + MSF of the contracted graph
without the bad edges + the bad edges). It does not reproduce Chazelle's
hierarchy of pieces with Ackermann-sized targets, so it does not reach the
O(m alpha(m, n)) bound; see the page for the costs.
"""
from typing import Any
class _Node:
__slots__ = ("ckey", "rank", "items", "left", "right")
def __init__(self, ckey: Any, items: list) -> None:
self.ckey = ckey
self.rank = 0
self.items = items
self.left: "_Node | None" = None
self.right: "_Node | None" = None
def is_leaf(self) -> bool:
return self.left is None and self.right is None
class SoftHeap:
"""Soft heap after Kaplan, Tarjan and Zwick, with rank threshold t.
Items are pairs (key, payload). Every item in a node's list has the
node's ckey as its current key, which is at least its own key and never
decreases. delete_min returns an item of smallest current key. A node of
rank at most t holds at most one item, so with t >= lg(inserts) nothing
is corrupted, and a small t corrupts many items.
"""
def __init__(self, t: int) -> None:
self.t = t
self.roots: dict[int, _Node] = {}
def _size(self, rank: int) -> int:
s = 1
for _ in range(self.t, rank):
s = (3 * s + 1) // 2
return s
def _sift(self, x: _Node) -> None:
target = self._size(x.rank)
while len(x.items) < target and not x.is_leaf():
if x.left is None or (x.right is not None and x.right.ckey < x.left.ckey):
x.left, x.right = x.right, x.left
c = x.left
x.items.extend(c.items)
x.ckey = c.ckey
c.items = []
if not c.is_leaf():
self._sift(c)
if not c.items:
x.left = None
def insert(self, key: Any, payload: Any) -> None:
x = _Node(key, [(key, payload)])
while x.rank in self.roots:
y = self.roots.pop(x.rank)
z = _Node(None, [])
z.rank = x.rank + 1
z.left, z.right = x, y
self._sift(z)
x = z
self.roots[x.rank] = x
def __bool__(self) -> bool:
return bool(self.roots)
def delete_min(self) -> tuple[Any, Any, Any]:
"""Remove an item of smallest current key; return (key, payload, ckey)."""
r = min(self.roots, key=lambda k: self.roots[k].ckey)
x = self.roots[r]
key, payload = x.items.pop()
ckey = x.ckey
if not x.items:
self._sift(x)
if not x.items:
del self.roots[r]
return key, payload, ckey
def remaining(self) -> list[tuple[Any, Any, Any]]:
"""All items still in the heap, as (key, payload, ckey)."""
out = []
stack = list(self.roots.values())
while stack:
x = stack.pop()
out.extend((k, p, x.ckey) for k, p in x.items)
stack.extend(c for c in (x.left, x.right) if c is not None)
return out
def _kruskal(n: int, edges: list[tuple[int, int, Any, int]]) -> list[int]:
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
out = []
for u, v, key, i in sorted(edges, key=lambda e: e[2]):
a, b = find(u), find(v)
if a != b:
parent[a] = b
out.append(i)
return out
def _msf(n: int, edges: list[tuple[int, int, Any, int]], piece_size: int, t: int, base: int,
log: list | None) -> list[int]:
"""edges[j] = (u, v, key, id) with distinct keys and no self-loops; returns ids."""
if len(edges) <= base: # @line L1
return _kruskal(n, edges)
adj: list[list[int]] = [[] for _ in range(n)]
for j, (u, v, _, _) in enumerate(edges):
adj[u].append(j)
adj[v].append(j)
piece = [-1] * n # @line L2
label = list(range(n)) # union-find over piece ids, for merges
bad: set[int] = set()
def find(x: int) -> int:
while label[x] != x:
label[x] = label[label[x]]
x = label[x]
return x
def far(j: int, x: int) -> int:
u, v = edges[j][0], edges[j][1]
return v if u == x else u
for r in range(n): # @line L3
if piece[r] != -1:
continue
piece[r] = r
size = 1
heap = SoftHeap(t)
for j in adj[r]:
heap.insert(edges[j][2], (j, far(j, r)))
while heap and size < piece_size: # @line L4
key, (j, y), ckey = heap.delete_min() # @line L5
if ckey != key:
bad.add(j)
if piece[y] == r: # @line L6
continue
if piece[y] != -1:
if j not in bad:
label[find(r)] = find(piece[y])
break
piece[y] = r # @line L7
size += 1
for i in adj[y]:
z = far(i, y)
if piece[z] != r:
heap.insert(edges[i][2], (i, z))
for key, (j, _), ckey in heap.remaining(): # @line L8
if ckey != key:
bad.add(j)
comp = [find(piece[x]) for x in range(n)]
ids = sorted(set(comp))
index = {c: k for k, c in enumerate(ids)}
inside: dict[int, list] = {}
between = []
for j, (u, v, key, i) in enumerate(edges):
if comp[u] == comp[v]:
inside.setdefault(comp[u], []).append((u, v, key, i))
elif j not in bad:
between.append((index[comp[u]], index[comp[v]], key, i))
if log is not None:
log.append((n, len(edges), len(ids), len(bad)))
keep = []
for group in inside.values(): # @line L9
keep.extend(_kruskal(n, group))
keep.extend(_msf(len(ids), between, piece_size, t, base, log)) # @line L10
keep_set = set(keep) | {edges[j][3] for j in bad} # @line L11
return _kruskal(n, [e for e in edges if e[3] in keep_set]) # @line L12
def chazelle_mst(n: int, edges: list[tuple[int, int, float]], piece_size: int = 8, t: int = 2,
base: int = 8, log: list | None = None) -> list[int]:
"""Return the indices of the minimum spanning forest, sorted.
The vertices are 0 .. n - 1 and edges[i] = (u, v, w) is undirected.
Weights may tie; ties are broken by the edge index, so the answer is the
unique forest that Kruskal's algorithm gives under the order (w, i).
Parallel edges and self-loops are allowed. The graph need not be
connected: the result is a minimum spanning forest.
piece_size bounds the size of a grown piece, t is the rank threshold of
the soft heaps (small t means many corrupted edges), and graphs with at
most base edges go straight to Kruskal. If log is a list, each level
appends (vertices, edges, pieces, bad edges).
"""
work = [(u, v, (w, i), i) for i, (u, v, w) in enumerate(edges) if u != v]
return sorted(_msf(n, work, max(piece_size, 2), t, base, log))
test_chazelle_mst.py 148 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/chazelle-mst/. The authors are listed in prov/T77RVB.jsonl.
import itertools
import random
from chazelle_mst import SoftHeap, chazelle_mst
EXAMPLE = [(0, 1, 4), (0, 2, 3), (1, 2, 1), (1, 3, 2), (2, 3, 4), (2, 4, 5), (3, 4, 7), (3, 5, 6), (4, 5, 2)]
def kruskal_oracle(n, edges):
parent = list(range(n))
def find(x):
while parent[x] != x:
x = parent[x]
return x
out = []
for i in sorted(range(len(edges)), key=lambda i: (edges[i][2], i)):
u, v, _ = edges[i]
a, b = find(u), find(v)
if a != b:
parent[a] = b
out.append(i)
return sorted(out)
def components(n, edges, chosen):
parent = list(range(n))
def find(x):
while parent[x] != x:
x = parent[x]
return x
for i in chosen:
parent[find(edges[i][0])] = find(edges[i][1])
return len({find(x) for x in range(n)})
def brute_force_weight(n, edges):
"""Least weight of a spanning forest with as many edges as possible."""
k = n - components(n, edges, range(len(edges)))
best = None
for c in itertools.combinations(range(len(edges)), k):
if components(n, edges, c) == n - k:
w = sum(edges[i][2] for i in c)
best = w if best is None or w < best else best
return best if best is not None else 0
def test_example():
for s, t in ((2, 0), (3, 0), (4, 1), (8, 2), (3, 10)):
assert chazelle_mst(6, EXAMPLE, piece_size=s, t=t, base=0) == [1, 2, 3, 5, 8]
def test_example_trace_with_corruption():
log = []
assert chazelle_mst(6, EXAMPLE, piece_size=3, t=0, base=0, log=log) == [1, 2, 3, 5, 8]
assert log == [(6, 9, 2, 6)]
def test_soft_heap_contract():
rng = random.Random(7)
for t in (0, 1, 3):
for _ in range(60):
heap = SoftHeap(t)
ckey_seen = {}
inserted = 0
for _ in range(rng.randint(1, 120)):
if heap and rng.random() < 0.4:
key, p, ckey = heap.delete_min()
assert ckey >= key
assert ckey >= ckey_seen.get(p, key)
rest = heap.remaining()
assert all(c >= ckey for _, _, c in rest)
for k2, p2, c2 in rest:
assert c2 >= k2 and c2 >= ckey_seen.get(p2, k2)
ckey_seen[p2] = c2
else:
heap.insert((rng.random(), inserted), inserted)
inserted += 1
def test_soft_heap_exact_when_threshold_large():
rng = random.Random(3)
keys = [rng.random() for _ in range(200)]
heap = SoftHeap(10)
for i, k in enumerate(keys):
heap.insert(k, i)
out = []
while heap:
key, _, ckey = heap.delete_min()
assert key == ckey
out.append(key)
assert out == sorted(keys)
def test_soft_heap_corrupts_with_small_threshold():
heap = SoftHeap(0)
for i in range(64):
heap.insert(i, i)
assert sum(1 for k, _, c in heap.remaining() if c != k) > 0
def test_disconnected_gives_forest():
edges = [(0, 1, 5), (1, 2, 1), (0, 2, 2), (3, 4, 7), (4, 5, 1), (3, 5, 1)]
for t in (0, 2):
got = chazelle_mst(7, edges, piece_size=2, t=t, base=0)
assert got == kruskal_oracle(7, edges) == [1, 2, 4, 5]
def test_ties_parallel_edges_and_loops():
edges = [(0, 1, 1), (0, 1, 1), (1, 2, 1), (2, 0, 1), (2, 2, 0), (2, 3, 1), (3, 0, 1)]
for t in (0, 1, 5):
assert chazelle_mst(4, edges, piece_size=2, t=t, base=0) == kruskal_oracle(4, edges) == [0, 2, 5]
def test_trivial_inputs():
assert chazelle_mst(0, []) == []
assert chazelle_mst(1, [(0, 0, 3)]) == []
assert chazelle_mst(3, []) == []
def test_random_against_kruskal():
rng = random.Random(20261105)
for _ in range(700):
n = rng.randint(1, 40)
m = rng.randint(0, 120)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(1, 6)) for _ in range(m)]
expect = kruskal_oracle(n, edges)
got = chazelle_mst(n, edges, piece_size=rng.choice([2, 3, 5, 8]), t=rng.choice([0, 1, 2, 4]),
base=rng.choice([0, 3, 8]))
assert got == expect
def test_random_small_against_brute_force():
rng = random.Random(11)
for _ in range(300):
n = rng.randint(1, 6)
m = rng.randint(0, 8)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(1, 4)) for _ in range(m)]
got = chazelle_mst(n, edges, piece_size=2, t=0, base=0)
assert components(n, edges, got) == components(n, edges, range(m))
assert len(got) == n - components(n, edges, got)
assert sum(edges[i][2] for i in got) == brute_force_weight(n, edges)
What the next level needs
- the tests did not run in this build
Read the full page about Chazelle's minimum spanning tree algorithm