Breaking the sorting barrier for directed SSSP in Python
Deterministic directed SSSP in O(m log^(2/3) n) time on sparse graphs with nonnegative weights, beating Dijkstra's sorting bound.
- File
breaking_sorting_barrier_sssp.py, 193 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/breaking-sorting-barrier-sssp/. The authors are listed in prov/WVPFW7.jsonl.
import heapq
import math
INF = math.inf
TOP = (INF, INF, INF, INF) # a bound above every finite label
class _Frontier:
"""The structure D: keys with values; Insert keeps the smaller value, Pull removes
up to M keys of smallest value and returns a bound separating them from the rest.
This version uses a binary heap with lazy deletion. It has the same behaviour as
the block-based structure of Duan et al., but not its amortized time bounds.
"""
def __init__(self, m: int, bound):
self.m = m
self.bound = bound
self.val: dict[int, tuple] = {}
self.heap: list = []
def insert(self, key: int, value) -> None:
if key not in self.val or value < self.val[key]:
self.val[key] = value
heapq.heappush(self.heap, (value, key))
def batch_prepend(self, pairs) -> None:
for key, value in pairs: # all values are below every value already present
self.insert(key, value)
def _clean(self) -> None:
while self.heap and self.val.get(self.heap[0][1]) != self.heap[0][0]:
heapq.heappop(self.heap)
def pull(self):
out = []
self._clean()
while self.heap and len(out) < self.m:
_, key = heapq.heappop(self.heap)
del self.val[key]
out.append(key)
self._clean()
sep = self.heap[0][0] if self.heap else self.bound
return sep, out
def __bool__(self) -> bool:
return bool(self.val)
def bmssp_sssp(
adj: list[list[tuple[int, float]]],
s: int,
k: int | None = None,
t: int | None = None,
trace: list | None = None,
) -> tuple[list, list]:
"""Single-source shortest paths by the bounded multi-source recursion of Duan et al.
The graph has the vertices 0 .. n-1; adj[u] lists the edges (v, w) out of u, and
every w must be a real number >= 0 (ValueError otherwise). Returns (d, parent):
d[v] is the distance from s (math.inf if unreachable), parent[v] the previous
vertex on a shortest path (None for s and unreachable vertices). k and t are the
parameters of the paper, floor(log^(1/3) n) and floor(log^(2/3) n) by default.
If trace is a list, each call appends (level, bound length, frontier, pivots,
returned bound length, completed set) when it returns.
"""
n = len(adj)
for u in range(n):
for _, w in adj[u]:
if not w >= 0: # also rejects NaN
raise ValueError("weights must be nonnegative")
lg = math.log2(max(n, 2))
k = k or max(1, int(lg ** (1 / 3)))
t = t or max(1, int(lg ** (2 / 3)))
top_level = max(1, math.ceil(math.log2(n + 1) / t))
# label of v: (length, hops, v, predecessor); all labels of different vertices
# differ, and extending a path by an edge makes its label strictly larger
lab: list[tuple] = [(INF, INF, v, -1) for v in range(n)] # @line L1
lab[s] = (0, 0, s, -1)
def ext(u: int, v: int, w: float) -> tuple:
lu = lab[u]
return (lu[0] + w, lu[1] + 1, v, u)
def find_pivots(bound, frontier: list[int]):
w_all = set(frontier)
layer = list(frontier)
for _ in range(k): # k rounds of Bellman-Ford from the frontier
nxt = set()
for u in layer:
for v, w in adj[u]:
c = ext(u, v, w)
if c <= lab[v]:
lab[v] = c
if c < bound:
nxt.add(v)
w_all |= nxt
if len(w_all) > k * len(frontier):
return list(frontier), w_all
layer = list(nxt)
# forest of tight predecessor edges inside W; pivots are big-tree roots in S
children: dict[int, list[int]] = {}
roots = []
for v in w_all:
p = lab[v][3]
if p in w_all and _tight(p, v):
children.setdefault(p, []).append(v)
else:
roots.append(v)
fset = set(frontier)
pivots = []
for r in roots:
if r in fset:
size, stack = 0, [r]
while stack and size < k:
x = stack.pop()
size += 1
stack.extend(children.get(x, ()))
if size >= k:
pivots.append(r)
return pivots, w_all
def _tight(p: int, v: int) -> bool:
for x, w in adj[p]:
if x == v and ext(p, v, w) == lab[v]:
return True
return False
def base_case(bound, x: int):
heap = [(lab[x], x)]
got: list[int] = []
seen = set()
while heap and len(got) < k + 1: # Dijkstra from x, at most k + 1 vertices
key, u = heapq.heappop(heap)
if u in seen or key != lab[u]:
continue
seen.add(u)
got.append(u)
for v, w in adj[u]:
c = ext(u, v, w)
if c <= lab[v] and c < bound and v not in seen:
lab[v] = c
heapq.heappush(heap, (c, v))
if len(got) <= k:
return bound, set(got)
b2 = max(lab[v] for v in got)
return b2, {v for v in got if lab[v] < b2}
def bmssp(level: int, bound, frontier: list[int]):
if level == 0: # @line L2
b_out, done = base_case(bound, frontier[0])
if trace is not None:
trace.append((0, bound[0], sorted(frontier), None, b_out[0], sorted(done)))
return b_out, done
pivots, w_all = find_pivots(bound, frontier) # @line L3
d = _Frontier(2 ** ((level - 1) * t), bound) # @line L4
for x in pivots:
d.insert(x, lab[x])
result = None
done: set[int] = set()
while len(done) < k * 2 ** (level * t) and d: # @line L5
bi, si = d.pull() # @line L6
si = [x for x in si if x not in done] # already complete in this call
if not si:
continue
bpi, ui = bmssp(level - 1, bi, si) # @line L7
done |= ui
result = bpi
prepend = []
for u in ui: # @line L8
for v, w in adj[u]:
c = ext(u, v, w)
if c <= lab[v]:
lab[v] = c
if bi <= c < bound:
d.insert(v, c) # @line L9
elif bpi <= c < bi:
prepend.append((v, c))
prepend += [(x, lab[x]) for x in si if bpi <= lab[x] < bi]
d.batch_prepend(prepend) # @line L10
b_out = bound if result is None else min(result, bound)
done |= {x for x in w_all if lab[x] < b_out} # @line L11
if trace is not None:
trace.append((level, bound[0], sorted(frontier), sorted(pivots), b_out[0], sorted(done)))
return b_out, done
bmssp(top_level, TOP, [s]) # @line L12
dist = [lab[v][0] for v in range(n)]
parent = [lab[v][3] if v != s and lab[v][0] < INF else None for v in range(n)]
return dist, parent
test_breaking_sorting_barrier_sssp.py 138 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/breaking-sorting-barrier-sssp/. The authors are listed in prov/WVPFW7.jsonl.
import heapq
import math
import random
import pytest
from breaking_sorting_barrier_sssp import bmssp_sssp
INF = math.inf
# s=0, a=1, b=2, c=3, d=4, e=5
EXAMPLE = [(0, 1, 1), (0, 2, 4), (1, 2, 2), (1, 3, 6), (2, 3, 1), (2, 4, 5), (3, 4, 1), (3, 5, 3), (4, 5, 1)]
def build(n, edges):
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
return adj
def dijkstra(adj, s):
"""Independent oracle: plain binary-heap Dijkstra."""
d = [INF] * len(adj)
d[s] = 0
heap = [(0, s)]
while heap:
du, u = heapq.heappop(heap)
if du > d[u]:
continue
for v, w in adj[u]:
if du + w < d[v]:
d[v] = du + w
heapq.heappush(heap, (d[v], v))
return d
def check_tree(adj, s, d, parent):
for v in range(len(adj)):
if v == s or d[v] == INF:
assert parent[v] is None
continue
p = parent[v]
assert p is not None
assert any(x == v and d[p] + w == d[v] for x, w in adj[p])
# parent pointers lead back to s without cycles
for v in range(len(adj)):
seen = set()
while v is not None and d[v] < INF:
assert v not in seen
seen.add(v)
v = parent[v]
def test_example():
adj = build(6, EXAMPLE)
d, parent = bmssp_sssp(adj, 0)
assert d == [0, 1, 3, 4, 5, 6]
assert parent == [None, 0, 1, 2, 3, 4]
def test_example_trace():
adj = build(6, EXAMPLE)
trace = []
d, _ = bmssp_sssp(adj, 0, k=2, t=1, trace=trace)
assert d == [0, 1, 3, 4, 5, 6]
assert trace == [
(0, INF, [0], None, 3, [0, 1]),
(0, INF, [2], None, 5, [2, 3]),
(1, INF, [0], [0], 5, [0, 1, 2, 3]),
(0, INF, [4], None, INF, [4, 5]),
(1, INF, [4], [4], INF, [4, 5]),
(2, INF, [0], [0], INF, [0, 1, 2, 3, 4, 5]),
(3, INF, [0], [0], INF, [0, 1, 2, 3, 4, 5]),
]
def test_unreachable():
adj = build(5, [(0, 1, 2), (3, 4, 1), (4, 0, 1)])
d, parent = bmssp_sssp(adj, 0)
assert d == [0, 2, INF, INF, INF]
assert parent == [None, 0, None, None, None]
def test_negative_weight_rejected():
with pytest.raises(ValueError):
bmssp_sssp(build(3, [(0, 1, 1), (1, 2, -1)]), 0)
def test_negative_cycle_rejected():
with pytest.raises(ValueError):
bmssp_sssp(build(3, [(0, 1, 1), (1, 2, -3), (2, 1, 1)]), 0)
def test_nan_rejected():
with pytest.raises(ValueError):
bmssp_sssp(build(2, [(0, 1, math.nan)]), 0)
def test_zero_cycle_self_loop_parallel():
adj = build(4, [(0, 1, 0), (1, 0, 0), (1, 1, 0), (1, 2, 5), (1, 2, 2), (2, 3, 0), (3, 2, 0)])
d, parent = bmssp_sssp(adj, 0)
assert d == [0, 0, 2, 2]
check_tree(adj, 0, d, parent)
def test_single_vertex():
assert bmssp_sssp([[]], 0) == ([0], [None])
def test_random_against_dijkstra():
rng = random.Random(2025)
for _ in range(1500):
n = rng.randint(1, 60)
adj = [[] for _ in range(n)]
for _ in range(rng.randint(0, 4 * n)):
w = rng.choice([0, 1, 2, 3, rng.randint(0, 50), rng.random() * 10])
adj[rng.randrange(n)].append((rng.randrange(n), w))
s = rng.randrange(n)
k = rng.choice([None, 1, 2, 3])
t = rng.choice([None, 1, 2, 3])
d, parent = bmssp_sssp(adj, s, k, t)
assert d == dijkstra(adj, s)
check_tree(adj, s, d, parent)
def test_larger_default_parameters():
rng = random.Random(17)
for _ in range(10):
n = rng.randint(200, 600)
adj = [[] for _ in range(n)]
for _ in range(3 * n):
adj[rng.randrange(n)].append((rng.randrange(n), rng.randint(0, 100)))
d, parent = bmssp_sssp(adj, 0)
assert d == dijkstra(adj, 0)
check_tree(adj, 0, d, parent)
What the next level needs
- the tests did not run in this build
Read the full page about Breaking the sorting barrier for directed SSSP