Pettie-Ramachandran optimal MST algorithm in Python
Algorithm whose comparison count is optimal among decision-tree algorithms; running time is unknown.
- File
pettie_ramachandran_mst.py, 192 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/pettie-ramachandran-mst/. The authors are listed in prov/BJQNXT.jsonl.
"""A structural model of the Pettie-Ramachandran optimal MST algorithm.
The recursion, the partition into small pieces with corrupted edges, the
contraction, the dense step and the two Borůvka steps follow the algorithm.
Three parts are replaced by simple stand-ins, which do not change the output:
* The optimal decision trees for the pieces are replaced by Kruskal's
algorithm on each piece. A decision tree computes the same forest.
* The soft heap is replaced by a binary heap. The caller may pass a function
`corrupt` that raises the key of an edge when it enters the heap, which
models the corruption of a soft heap. With no function, nothing is
corrupted.
* The linear-time dense MSF algorithm is replaced by Kruskal's algorithm.
So this code shows the structure and is checked for correctness; it does not
have the optimal running time.
"""
import heapq
import math
from typing import Callable
Edge = tuple[int, int, float, int] # (u, v, w, index in the input)
def _kruskal(n: int, edges: list[Edge]) -> list[Edge]:
"""Minimum spanning forest for the key (w, index)."""
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 e in sorted(edges, key=lambda e: (e[2], e[3])):
a, b = find(e[0]), find(e[1])
if a != b:
parent[a] = b
out.append(e)
return out
def _contract(n: int, forest: list[Edge]) -> tuple[int, list[int]]:
"""Label the components of (0..n-1, forest) by 0..c-1."""
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v, _, _ in forest:
parent[find(u)] = find(v)
label: dict[int, int] = {}
lab = []
for x in range(n):
lab.append(label.setdefault(find(x), len(label)))
return len(label), lab
def _relabel(lab: list[int], edges: list[Edge]) -> list[Edge]:
"""Map edges through the labels and drop the self-loops."""
return [(lab[u], lab[v], w, i) for u, v, w, i in edges if lab[u] != lab[v]]
def _partition(n: int, edges: list[Edge], r: int, corrupt) -> tuple[list[list[int]], set[int], list[Edge]]:
"""Grow pieces of at most r vertices with a heap that may corrupt keys.
Returns the pieces, the set C of corrupted edge indices, and the link
edges by which a piece stopped at an earlier piece.
"""
adj: list[list[Edge]] = [[] for _ in range(n)]
for e in edges:
adj[e[0]].append(e)
if e[1] != e[0]:
adj[e[1]].append(e)
dead = [False] * n
bad: set[int] = set()
pieces: list[list[int]] = []
links: list[Edge] = []
for v in range(n):
if dead[v]:
continue
inside = {v}
heap: list = []
raised: set[int] = set()
def insert(x: int) -> None:
for e in adj[x]:
y = e[1] if e[0] == x else e[0]
if e[3] in bad or y in inside:
continue
bump = corrupt(e[3]) if corrupt else 0
if bump > 0:
raised.add(e[3])
heapq.heappush(heap, ((e[2] + bump, e[3]), x, y, e))
insert(v)
while heap and len(inside) < r:
_, x, y, e = heapq.heappop(heap)
if e[3] in raised:
bad.add(e[3])
continue
if y in inside:
continue
if dead[y]:
links.append(e)
break
inside.add(y)
insert(y)
bad |= {item[3][3] for item in heap if item[3][3] in raised}
for x in inside:
dead[x] = True
pieces.append(sorted(inside))
return pieces, bad, links
def _boruvka_step(n: int, edges: list[Edge]) -> list[Edge]:
"""One Borůvka step: the least edge at each vertex, for the key (w, index)."""
best: dict[int, Edge] = {}
for e in edges:
for x in (e[0], e[1]):
if x not in best or (e[2], e[3]) < (best[x][2], best[x][3]):
best[x] = e
return list({e[3]: e for e in best.values()}.values())
def _optimal_msf(n: int, edges: list[Edge], r: int | None, corrupt) -> set[int]:
edges = [e for e in edges if e[0] != e[1]]
if not edges: # @line L1
return set()
rr = r if r is not None else max(2, math.ceil(math.log2(max(2.0, math.log2(max(2.0, math.log2(max(2, n)))))))) # @line L2
pieces, bad, links = _partition(n, edges, rr, corrupt) # @line L3
piece_of = [0] * n
for k, p in enumerate(pieces):
for x in p:
piece_of[x] = k
inner: list[list[Edge]] = [[] for _ in pieces]
for e in edges:
if e[3] not in bad and piece_of[e[0]] == piece_of[e[1]]:
inner[piece_of[e[0]]].append(e)
forest = list(links) # @line L4
for p, es in zip(pieces, inner):
local = {x: j for j, x in enumerate(p)}
sub = [(local[u], local[v], w, i) for u, v, w, i in es]
back = {e[3]: e for e in es}
forest += [back[e[3]] for e in _kruskal(len(p), sub)]
na, lab = _contract(n, forest)
ga = _relabel(lab, [e for e in edges if e[3] not in bad]) # @line L5
fa_ids = {e[3] for e in _kruskal(na, ga)} # @line L6
gb = [e for e in edges if e[3] in fa_ids or e[3] in bad] + forest # @line L7
gb = list({e[3]: e for e in gb}.values())
fb: set[int] = set() # @line L8
nc, cur = n, gb
for _ in range(2):
step = _boruvka_step(nc, cur)
if not step:
break
fb |= {e[3] for e in step}
nc, lab2 = _contract(nc, step)
cur = _relabel(lab2, cur)
rest = _optimal_msf(nc, cur, r, corrupt) # @line L9
return fb | rest # @line L10
def pettie_ramachandran_mst(
n: int,
edges: list[tuple[int, int, float]],
r: int | None = None,
corrupt: Callable[[int], float] | 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. r is the largest piece size, by default
max(2, ceil(log2 log2 log2 n)). corrupt(i) returns how much to raise the
key of edge i when it enters the partition heap; 0 means no corruption.
Cost: with the stand-ins of this module, O(m log m) time per level of the
recursion and O(n + m) extra space per level, where m = len(edges).
"""
es = [(u, v, w, i) for i, (u, v, w) in enumerate(edges)]
return sorted(_optimal_msf(n, es, r, corrupt))
test_pettie_ramachandran_mst.py 140 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/pettie-ramachandran-mst/. The authors are listed in prov/BJQNXT.jsonl.
import itertools
import random
from pettie_ramachandran_mst import _kruskal, _partition, pettie_ramachandran_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)))
best = None
for sub in itertools.combinations(range(len(edges)), n - target):
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 test_example():
assert pettie_ramachandran_mst(6, EXAMPLE) == [1, 2, 3, 5, 8]
assert pettie_ramachandran_mst(6, EXAMPLE, r=3) == [1, 2, 3, 5, 8]
def test_example_trace_with_corruption():
es = [(u, v, w, i) for i, (u, v, w) in enumerate(EXAMPLE)]
corrupt = lambda i: 5 if i == 2 else 0
pieces, bad, links = _partition(6, es, 3, corrupt)
assert pieces == [[0, 1, 2], [3], [4, 5]]
assert bad == {2}
assert sorted(e[3] for e in links) == [3, 5]
assert pettie_ramachandran_mst(6, EXAMPLE, r=3, corrupt=corrupt) == [1, 2, 3, 5, 8]
def test_partition_forest_is_in_msf_without_corrupted():
rng = random.Random(2002)
for _ in range(300):
n = rng.randint(1, 15)
m = rng.randint(0, 40)
es = [(rng.randrange(n), rng.randrange(n), rng.randint(-4, 6), i) for i in range(m)]
es = [e for e in es if e[0] != e[1]]
p = rng.random()
corrupt = lambda i: rng.random() * 10 if rng.random() < p else 0
r = rng.randint(1, 6)
pieces, bad, links = _partition(n, es, r, corrupt)
assert sorted(x for pc in pieces for x in pc) == list(range(n))
assert all(len(pc) <= max(r, 1) for pc in pieces)
clean = [e for e in es if e[3] not in bad]
msf = {e[3] for e in _kruskal(n, clean)}
assert all(e[3] in msf and e[3] not in bad for e in links)
def test_empty_and_small():
assert pettie_ramachandran_mst(0, []) == []
assert pettie_ramachandran_mst(1, [(0, 0, 1)]) == []
assert pettie_ramachandran_mst(2, [(0, 1, 7)]) == [0]
def test_ties_all_equal():
k5 = [(a, b, 1) for a, b in itertools.combinations(range(5), 2)]
for r in (1, 2, 3):
assert pettie_ramachandran_mst(5, k5, r=r) == kruskal_oracle(5, k5)
def test_parallel_loops_negative():
edges = [(0, 1, 5), (0, 1, -2), (1, 1, -9), (1, 2, -2), (2, 0, -2)]
assert pettie_ramachandran_mst(3, edges, r=2) == [1, 3]
def test_disconnected():
edges = [(0, 1, 1), (1, 2, 2), (0, 2, 3), (3, 4, 1), (4, 5, 1), (3, 5, 1)]
out = pettie_ramachandran_mst(7, edges, r=2, corrupt=lambda i: 3 if i % 2 else 0)
assert out == kruskal_oracle(7, edges)
assert len(out) == 7 - 3
def test_all_corrupted():
rng = random.Random(4)
for _ in range(50):
n = rng.randint(2, 12)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 5)) for _ in range(rng.randint(0, 30))]
assert pettie_ramachandran_mst(n, edges, r=4, corrupt=lambda i: 100) == kruskal_oracle(n, edges)
def test_random_against_brute_force():
rng = random.Random(2000)
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.random()
corrupt = lambda i: rng.random() * 8 if rng.random() < p else 0
r = rng.choice([None, 1, 2, 3, 5])
out = pettie_ramachandran_mst(n, edges, r=r, corrupt=corrupt)
assert out == kruskal_oracle(n, edges)
assert sum(edges[i][2] for i in out) == brute_min_weight(n, edges)
def test_random_large_against_kruskal():
rng = random.Random(31)
for _ in range(80):
n = rng.randint(2, 150)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 30)) for _ in range(rng.randint(0, 500))]
p = rng.random()
corrupt = lambda i: rng.random() * 20 if rng.random() < p else 0
assert pettie_ramachandran_mst(n, edges, r=rng.choice([None, 2, 4, 8]), corrupt=corrupt) == kruskal_oracle(n, edges)
What the next level needs
- the tests did not run in this build
Read the full page about Pettie-Ramachandran optimal MST algorithm