Yao's minimum spanning tree algorithm in Python
Deterministic MST algorithm with O(m log log n) running time.
- File
yao_mst.py, 122 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/yao-mst/. The authors are listed in prov/CXZBFW.jsonl.
import math
def _select(items: list, r: int, key) -> object:
"""Return the item of rank r (0-based) in items under key, by median of medians."""
while True:
if len(items) <= 5:
return sorted(items, key=key)[r]
groups = [sorted(items[i:i + 5], key=key) for i in range(0, len(items), 5)]
medians = [g[len(g) // 2] for g in groups]
pivot = _select(medians, len(medians) // 2, key)
pk = key(pivot)
low = [x for x in items if key(x) < pk]
high = [x for x in items if key(x) > pk]
if r < len(low):
items = low
elif r == len(low):
return pivot
else:
r -= len(low) + 1
items = high
def _split(items: list, k: int, key) -> list[list]:
"""Split items into k groups, each key in a group below each key in the next."""
if k == 1 or not items:
return [items] + [[] for _ in range(k - 1)]
k1 = k // 2
r = len(items) * k1 // k
if r == 0:
low, high = [], items
else:
pk = key(_select(items, r - 1, key))
low = [x for x in items if key(x) <= pk]
high = [x for x in items if key(x) > pk]
return _split(low, k1, key) + _split(high, k - k1, key)
def yao_mst(n: int, edges: list[tuple[int, int, float]], phases: int | None = None) -> list[int]:
"""Return the indices of the edges of a minimum spanning forest, in increasing order.
The vertices are 0 .. n - 1 and edges[i] = (u, v, w) is an undirected
edge of weight w. Weights may be negative and may tie; ties are broken by
the edge index, so the result is the unique minimum forest for the order
(w, index). The graph may be disconnected, and parallel edges and
self-loops are allowed. phases is the number of plain Borůvka phases
before the grouped phases; the default is ceil(log2 log2 n).
Cost: O(m log log n) time and O(n + m) extra space, where m = len(edges).
"""
key = lambda i: (edges[i][2], i)
forest: list[int] = [] # @line L1
comp = list(range(n))
def find(x: int) -> int:
while comp[x] != x:
comp[x] = comp[comp[x]]
x = comp[x]
return x
def boruvka_phase() -> bool:
best: dict[int, int] = {}
for i, (u, v, _) in enumerate(edges):
a, b = find(u), find(v)
if a == b:
continue
for c in (a, b):
if c not in best or key(i) < key(best[c]):
best[c] = i
for i in set(best.values()):
a, b = find(edges[i][0]), find(edges[i][1])
if a != b:
comp[a] = b
forest.append(i)
return bool(best)
if phases is None:
phases = math.ceil(math.log2(math.log2(n))) if n > 2 else 1
for _ in range(phases): # @line L2
if not boruvka_phase():
break
# The contracted graph H: one vertex for each component, only edges between components.
reps = sorted({find(x) for x in range(n)})
inc: dict[int, list[int]] = {r: [] for r in reps}
for i, (u, v, _) in enumerate(edges):
a, b = find(u), find(v)
if a != b:
inc[a].append(i)
inc[b].append(i)
hv = [r for r in reps if inc[r]]
k = max(1, math.ceil(math.log2(len(hv)))) if len(hv) > 1 else 1 # @line L3
groups: dict[int, list[list[int]]] = {}
cur: dict[int, int] = {}
for v in hv: # @line L4
groups[v] = _split(inc[v], k, key) # @line L5
cur[v] = 0 # @line L6
while True: # @line L7
best: dict[int, int] = {}
for x in hv: # @line L8
c = find(x) # @line L9
while True: # @line L10
g = [i for i in groups[x][cur[x]] if find(edges[i][0]) != find(edges[i][1])]
groups[x][cur[x]] = g
if g or cur[x] == k - 1:
break
cur[x] += 1
if g:
ev = min(g, key=key) # @line L11
if c not in best or key(ev) < key(best[c]):
best[c] = ev # @line L12
if not best:
break
for i in set(best.values()): # @line L13
a, b = find(edges[i][0]), find(edges[i][1])
if a != b:
comp[a] = b
forest.append(i)
return sorted(forest) # @line L14
test_yao_mst.py 137 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/yao-mst/. The authors are listed in prov/CXZBFW.jsonl.
import itertools
import random
from yao_mst import _split, yao_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):
"""The unique minimum spanning forest for the order (w, index)."""
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)):
a, b = find(edges[i][0]), find(edges[i][1])
if a != b:
parent[a] = b
out.append(i)
return sorted(out)
def components(n, edges, idx):
parent = list(range(n))
def find(x):
while parent[x] != x:
x = parent[x]
return x
for i in idx:
parent[find(edges[i][0])] = find(edges[i][1])
return len({find(x) for x in range(n)})
def brute_min_weight(n, edges):
"""Least weight of a spanning forest with the components of the graph, by trying all edge sets."""
target = components(n, edges, range(len(edges)))
size = n - target
best = None
for sub in itertools.combinations(range(len(edges)), size):
if components(n, edges, sub) == target:
w = sum(edges[i][2] for i in sub)
best = w if best is None or w < best else best
return best
def weight(edges, idx):
return sum(edges[i][2] for i in idx)
def test_example_all_phase_counts():
for p in (0, 1, 2, 3):
assert yao_mst(6, EXAMPLE, phases=p) == [1, 2, 3, 5, 8]
assert yao_mst(6, EXAMPLE) == [1, 2, 3, 5, 8]
def test_example_groups():
key = lambda i: (EXAMPLE[i][2], i)
assert _split([1, 2, 4, 5], 3, key) == [[2], [1], [4, 5]]
def test_split_is_ordered_and_balanced():
rng = random.Random(7)
for _ in range(300):
d = rng.randint(0, 40)
k = rng.randint(1, 9)
keys = [rng.randint(0, 5) for _ in range(d)]
key = lambda i: (keys[i], i)
gs = _split(list(range(d)), k, key)
assert len(gs) == k
assert sorted(x for g in gs for x in g) == list(range(d))
for g, h in zip(gs, gs[1:]):
if g and h:
assert max(map(key, g)) < min(map(key, h))
assert max(len(g) for g in gs) <= -(-d // k) + 1
def test_empty_and_single():
assert yao_mst(0, []) == []
assert yao_mst(1, []) == []
assert yao_mst(1, [(0, 0, 5)]) == []
assert yao_mst(2, [(0, 1, 3)]) == [0]
def test_ties_all_equal():
k4 = [(a, b, 1) for a, b in itertools.combinations(range(4), 2)]
for p in (0, 1, 2):
assert yao_mst(4, k4, phases=p) == kruskal_oracle(4, k4)
def test_parallel_edges_and_loops():
edges = [(0, 1, 5), (0, 1, 2), (1, 1, -9), (1, 2, 2), (2, 0, 2)]
assert yao_mst(3, edges, phases=0) == [1, 3]
def test_negative_weights():
edges = [(0, 1, -3), (1, 2, -1), (0, 2, -2)]
assert yao_mst(3, edges, phases=0) == [0, 2]
def test_disconnected():
edges = [(0, 1, 1), (1, 2, 2), (0, 2, 3), (3, 4, 1), (4, 5, 1), (3, 5, 1)]
for p in (0, 1, 5):
out = yao_mst(7, edges, phases=p)
assert out == kruskal_oracle(7, edges)
assert len(out) == 7 - 3
def test_random_against_brute_force():
rng = random.Random(1975)
for _ in range(600):
n = rng.randint(1, 7)
m = rng.randint(0, 9)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(-3, 4)) for _ in range(m)]
p = rng.choice([None, 0, 1, 2])
out = yao_mst(n, edges, phases=p)
assert out == kruskal_oracle(n, edges)
assert len(out) == n - components(n, edges, range(m))
assert weight(edges, out) == brute_min_weight(n, edges)
def test_random_large_against_kruskal():
rng = random.Random(23)
for _ in range(60):
n = rng.randint(2, 120)
m = rng.randint(0, 400)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 20)) for _ in range(m)]
for p in (None, 0):
assert yao_mst(n, edges, phases=p) == kruskal_oracle(n, edges)
What the next level needs
- the tests did not run in this build
Read the full page about Yao's minimum spanning tree algorithm