Thorup's linear-time undirected SSSP in Python
Solves SSSP on undirected graphs with positive integer weights in O(m) time, beating Dijkstra without sorting.
- File
thorup_undirected_sssp.py, 100 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/thorup-undirected-sssp/. The authors are listed in prov/J10R6J.jsonl.
"""Thorup's SSSP for undirected graphs with positive integer weights.
The component hierarchy and the bucketed visiting order follow Thorup (1999).
To stay short, this version finds the least tentative distance in a component
with a lazy binary heap per hierarchy node instead of Thorup's split-findmin
structure and bucket arrays, so it runs in O(m r log n) time for r levels, not
O(m). The order in which it settles vertices is one that Thorup's rule allows.
"""
import heapq
import math
def _find(parent: list[int], x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def thorup_undirected_sssp(n: int, edges: list[tuple[int, int, int]], s: int, trace: list | None = None) -> list[int | None]:
"""Distances from s in an undirected graph with positive integer weights.
Vertices are 0 .. n-1; edges lists (u, v, w), each usable both ways. Returns
dist with None for vertices not reachable from s. Raises ValueError on a
weight that is not a positive integer (a negative edge u-v would give the
negative closed walk u, v, u). If trace is a list, each call
(level, lowest vertex of the component, bound) is appended to it.
"""
adj: list[list[tuple[int, int]]] = [[] for _ in range(n)]
big = 1
for u, v, w in edges:
if not isinstance(w, int) or isinstance(w, bool) or w < 1:
raise ValueError(f"weight {w!r} is not a positive integer")
adj[u].append((v, w))
adj[v].append((u, w))
big = max(big, w)
# @line L1
r = big.bit_length() # every weight is < 2^r
# anc[i][v] = id of the level-i component of v; level i joins edges of weight < 2^i
uf = list(range(n))
by_weight = sorted(edges, key=lambda e: e[2])
anc: list[list[int]] = []
j = 0
for i in range(r + 1):
while j < len(by_weight) and by_weight[j][2] < (1 << i):
a, b = _find(uf, by_weight[j][0]), _find(uf, by_weight[j][1])
if a != b:
uf[a] = b
j += 1
anc.append([_find(uf, v) for v in range(n)])
heaps = [dict() for _ in range(r + 1)] # heaps[i][component id] = list of (D, v)
dist = [math.inf] * n
done = [False] * n
def push(v: int) -> None:
for i in range(r + 1):
heapq.heappush(heaps[i].setdefault(anc[i][v], []), (dist[v], v))
def lowest(i: int, comp: int):
h = heaps[i].get(comp, [])
while h and (done[h[0][1]] or h[0][0] != dist[h[0][1]]):
heapq.heappop(h)
return h[0] if h else None
def visit(i: int, comp: int, hi: float) -> None:
if trace is not None:
trace.append((i, min(v for v in range(n) if anc[i][v] == comp), hi))
# @line L5
if i == 0:
# @line L6
v = comp
done[v] = True
for x, w in adj[v]:
# @line L7
if not done[x] and dist[v] + w < dist[x]:
dist[x] = dist[v] + w
push(x)
return
while True:
# @line L8
top = lowest(i, comp)
if top is None or top[0] >= hi:
return
# @line L9
d, v = top
b = d >> (i - 1)
# @line L10
visit(i - 1, anc[i - 1][v], (b + 1) << (i - 1))
# @line L2
dist[s] = 0
push(s)
# @line L3
visit(r, anc[r][s], math.inf)
# @line L4
return [None if d == math.inf else d for d in dist]
test_thorup_undirected_sssp.py 72 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thorup-undirected-sssp/. The authors are listed in prov/J10R6J.jsonl.
import heapq
import math
import random
import pytest
from thorup_undirected_sssp import thorup_undirected_sssp
S, A, B, C, D = range(5)
EXAMPLE = [(S, A, 1), (A, B, 2), (S, C, 5), (B, C, 1), (C, D, 3), (B, D, 6)]
def dijkstra(n, edges, s):
"""Independent oracle: Dijkstra with a binary heap on the undirected graph."""
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
adj[v].append((u, w))
dist = [None] * n
heap = [(0, s)]
while heap:
d, u = heapq.heappop(heap)
if dist[u] is not None:
continue
dist[u] = d
for v, w in adj[u]:
if dist[v] is None:
heapq.heappush(heap, (d + w, v))
return dist
def test_example_and_trace():
trace = []
assert thorup_undirected_sssp(5, EXAMPLE, S, trace) == [0, 1, 3, 4, 7]
assert trace[0] == (3, S, math.inf)
assert (1, B, 4) in trace and (1, B, 6) in trace
assert sum(1 for level, _, _ in trace if level == 0) == 5
def test_unreachable_vertices():
assert thorup_undirected_sssp(4, [(0, 1, 3), (2, 3, 1)], 0) == [0, 3, None, None]
assert thorup_undirected_sssp(1, [], 0) == [0]
def test_rejects_negative_zero_and_non_integer_weights():
for bad in (-2, 0, 1.5, True):
with pytest.raises(ValueError):
thorup_undirected_sssp(2, [(0, 1, bad)], 0)
def test_parallel_edges_and_self_loops():
edges = [(0, 1, 9), (0, 1, 4), (1, 1, 1), (1, 2, 4), (0, 2, 9)]
assert thorup_undirected_sssp(3, edges, 0) == [0, 4, 8]
def test_large_weights():
edges = [(0, 1, 2**40), (1, 2, 1), (0, 2, 2**40 + 2)]
assert thorup_undirected_sssp(3, edges, 0) == [0, 2**40, 2**40 + 1]
def test_random_against_dijkstra():
rng = random.Random(20261105)
for _ in range(1500):
n = rng.randint(1, 12)
m = rng.randint(0, 3 * n)
weights = rng.choice([[1, 2, 3], [1, 2, 4, 8, 16, 32], list(range(1, 100))])
edges = [(rng.randrange(n), rng.randrange(n), rng.choice(weights)) for _ in range(m)]
s = rng.randrange(n)
assert thorup_undirected_sssp(n, edges, s) == dijkstra(n, edges, s)
What the next level needs
- the tests did not run in this build
Read the full page about Thorup's linear-time undirected SSSP