Near-linear-time negative-weight SSSP in Python
Computes SSSP with negative integer weights in O(m log^8 n log W) expected time by weight scaling, low-diameter decompositions and a Dijkstra and Bellman-Ford hybrid.
- File
near_linear_negative_sssp.py, 249 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/near-linear-negative-sssp/. The authors are listed in prov/KXQB8V.jsonl.
"""Negative-weight SSSP by scaling, low-diameter decomposition and ElimNeg.
This follows the structure of Bernstein, Nanongkai and Wulff-Nilsen (2022):
SPMAIN scales the weights, SCALEDOWN halves the most negative weight with a
potential, and ELIMNEG is the Dijkstra and Bellman-Ford hybrid. The low-diameter
decomposition here is a simple random ball carving. Correctness does not depend
on the decomposition; only the proven running time does.
"""
import heapq
import math
import random
class NegativeCycle(Exception):
"""Raised inside the recursion when a negative cycle is found."""
def _sccs(vs, arcs):
"""Strongly connected components of (vs, arcs), in topological order."""
adj = {v: [] for v in vs}
for u, v, _ in arcs:
adj[u].append(v)
index, low, on, stack, comps = {}, {}, set(), [], []
counter = 0
for root in vs:
if root in index:
continue
work = [(root, iter(adj[root]))]
index[root] = low[root] = counter
counter += 1
stack.append(root)
on.add(root)
while work:
v, it = work[-1]
nxt = next(it, None)
if nxt is not None:
if nxt not in index:
index[nxt] = low[nxt] = counter
counter += 1
stack.append(nxt)
on.add(nxt)
work.append((nxt, iter(adj[nxt])))
elif nxt in on:
low[v] = min(low[v], index[nxt])
continue
work.pop()
if work:
low[work[-1][0]] = min(low[work[-1][0]], low[v])
if low[v] == index[v]:
comp = []
while True:
x = stack.pop()
on.discard(x)
comp.append(x)
if x == v:
break
comps.append(comp)
comps.reverse()
return comps
def _ldd(vs, arcs, diameter, rng):
"""Random ball carving on nonnegative weights. Returns the set of cut arcs."""
out_adj = {v: [] for v in vs}
in_adj = {v: [] for v in vs}
for i, (u, v, w) in enumerate(arcs):
out_adj[u].append((v, w, i))
in_adj[v].append((u, w, i))
left = set(vs)
order = list(vs)
rng.shuffle(order)
p = min(1.0, 4 * math.log(len(vs) + 2) / max(diameter, 1))
cut = set()
for c in order:
if c not in left:
continue
r = 0
while r < diameter and rng.random() >= p:
r += 1
forward = rng.random() < 0.5
adj = out_adj if forward else in_adj
dist = {c: 0}
heap = [(0, c)]
ball = set()
while heap:
d, x = heapq.heappop(heap)
if x in ball or d > dist[x]:
continue
ball.add(x)
for y, w, _ in adj[x]:
if y in left and d + w <= r and d + w < dist.get(y, math.inf):
dist[y] = d + w
heapq.heappush(heap, (d + w, y))
for x in ball:
for y, _, i in adj[x]:
if y in left and y not in ball:
cut.add(i)
left -= ball
return cut
def _elim_neg(vs, arcs):
"""Distances from a virtual source with 0-arcs to every vertex (ELIMNEG)."""
# @line L15
pos = {v: [] for v in vs}
neg = {v: [] for v in vs}
for u, v, w in arcs:
(neg if w < 0 else pos)[u].append((v, w))
d = {v: 0 for v in vs}
heap = [(0, v) for v in vs]
heapq.heapify(heap)
rounds = 0
# @line L16
while heap:
# @line L17
rounds += 1
if rounds > len(vs):
raise NegativeCycle
# @line L18
marked = set()
while heap:
dv, v = heapq.heappop(heap)
if dv > d[v]:
continue
marked.add(v)
for x, w in pos[v]:
if dv + w < d[x]:
d[x] = dv + w
heapq.heappush(heap, (d[x], x))
# @line L19
for v in marked:
for x, w in neg[v]:
if d[v] + w < d[x]:
d[x] = d[v] + w
heapq.heappush(heap, (d[x], x))
# @line L20
return d
def _scale_down(vs, arcs, delta, b, rng):
"""Return phi with w(u, v) + phi(u) - phi(v) >= -b, given w >= -2b."""
# @line L7
arcs_b = [(u, v, w + b if w < 0 else w) for u, v, w in arcs]
phi2 = {v: 0 for v in vs}
# @line L8
if delta > 2 and len(vs) > 1:
# @line L9
cut = _ldd(vs, [(u, v, max(0, w)) for u, v, w in arcs_b], delta * b // 4, rng)
kept = [a for i, a in enumerate(arcs) if i not in cut]
# @line L10
comps = _sccs(vs, kept)
where = {v: j for j, comp in enumerate(comps) for v in comp}
# @line L11
phi1 = {}
for comp in comps:
inner = set(comp)
sub = [(u, v, w) for u, v, w in arcs if u in inner and v in inner]
phi1.update(_scale_down(comp, sub, delta // 2, b, rng))
# @line L12
best = [0] * len(comps)
for u, v, w in arcs_b:
if where[u] < where[v]:
best[where[v]] = min(best[where[v]], w + phi1[u] - phi1[v])
level, acc = [0] * len(comps), 0
for j in range(len(comps)):
acc += best[j]
level[j] = acc
phi2 = {v: phi1[v] + level[where[v]] for v in vs}
# @line L13
reduced = [(u, v, w + phi2[u] - phi2[v]) for u, v, w in arcs_b]
psi = _elim_neg(vs, reduced)
# @line L14
return {v: phi2[v] + psi[v] for v in vs}
def near_linear_negative_sssp(n: int, edges: list[tuple[int, int, int]], s: int, seed: int = 0) -> list[int | None] | None:
"""Single-source shortest paths with integer weights of any sign.
Vertices are 0 .. n-1 and edges lists arcs (u, v, w) with integer w. Returns the
list of distances from s, with None for vertices not reachable from s, or None
when a negative cycle is reachable from s. The result does not depend on seed;
only the work done does. Raises ValueError on a weight that is not an integer.
"""
for _, _, w in edges:
if not isinstance(w, int) or isinstance(w, bool):
raise ValueError(f"weight {w!r} is not an integer")
rng = random.Random(seed)
# @line L1
out = [[] for _ in range(n)]
for u, v, w in edges:
out[u].append(v)
seen = [False] * n
seen[s] = True
stack = [s]
while stack:
u = stack.pop()
for v in out[u]:
if not seen[v]:
seen[v] = True
stack.append(v)
vs = [v for v in range(n) if seen[v]]
arcs = [(u, v, w) for u, v, w in edges if seen[u] and seen[v]]
k = len(vs)
# @line L2
worst = max([0] + [-w for _, _, w in arcs])
bar = [(u, v, 2 * k * w) for u, v, w in arcs]
big = 1
while big < 2 * k * max(1, worst):
big *= 2
phi = {v: 0 for v in vs}
try:
# @line L3
b = big
while b > 1:
b //= 2
# @line L4
reduced = [(u, v, w + phi[u] - phi[v]) for u, v, w in bar]
psi = _scale_down(vs, reduced, k, b, rng)
phi = {v: phi[v] + psi[v] for v in vs}
except NegativeCycle:
return None
# @line L5
star = [(u, v, w + phi[u] - phi[v] + 1, w0) for (u, v, w), (_, _, w0) in zip(bar, arcs)]
if any(ws < 0 for _, _, ws, _ in star):
return None
# @line L6
adj = [[] for _ in range(n)]
for u, v, ws, w0 in star:
adj[u].append((v, ws, w0))
key = [math.inf] * n
dist: list[int | None] = [None] * n
key[s], dist[s] = 0, 0
done = [False] * n
heap = [(0, s)]
while heap:
d, u = heapq.heappop(heap)
if done[u]:
continue
done[u] = True
for v, ws, w0 in adj[u]:
if not done[v] and d + ws < key[v]:
key[v] = d + ws
dist[v] = dist[u] + w0
heapq.heappush(heap, (key[v], v))
return dist
test_near_linear_negative_sssp.py 86 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/near-linear-negative-sssp/. The authors are listed in prov/KXQB8V.jsonl.
import random
import pytest
from near_linear_negative_sssp import near_linear_negative_sssp
S, T, X, Y, Z = range(5)
G1 = [(S, T, 6), (S, Y, 7), (T, X, 5), (T, Y, 8), (T, Z, -4), (X, T, -2), (Y, X, -3), (Y, Z, 9), (Z, S, 2), (Z, X, 7)]
def bellman_ford(n, edges, s):
"""Independent oracle: Bellman-Ford with a final negative-cycle check."""
inf = float("inf")
d = [inf] * n
d[s] = 0
for _ in range(n - 1):
for u, v, w in edges:
if d[u] + w < d[v]:
d[v] = d[u] + w
for u, v, w in edges:
if d[u] + w < d[v]:
return None
return [None if x == inf else x for x in d]
def test_clrs_example():
assert near_linear_negative_sssp(5, G1, S) == [0, 2, 4, 7, -2]
def test_reachable_negative_cycle():
g = [(u, v, -6 if (u, v) == (X, T) else w) for u, v, w in G1]
assert near_linear_negative_sssp(5, g, S) is None
def test_negative_cycle_not_reachable_is_ignored():
edges = [(0, 1, 4), (1, 2, -1), (3, 4, -2), (4, 3, 1)]
assert near_linear_negative_sssp(5, edges, 0) == [0, 4, 3, None, None]
def test_unreachable_and_single_vertex():
assert near_linear_negative_sssp(3, [(1, 2, -5)], 0) == [0, None, None]
assert near_linear_negative_sssp(1, [], 0) == [0]
assert near_linear_negative_sssp(1, [(0, 0, -1)], 0) is None
assert near_linear_negative_sssp(1, [(0, 0, 0)], 0) == [0]
def test_non_integer_weight_rejected():
with pytest.raises(ValueError):
near_linear_negative_sssp(2, [(0, 1, 1.5)], 0)
def test_result_does_not_depend_on_seed():
rng = random.Random(7)
edges = [(rng.randrange(12), rng.randrange(12), rng.randint(-4, 9)) for _ in range(40)]
want = bellman_ford(12, edges, 0)
for seed in range(20):
assert near_linear_negative_sssp(12, edges, 0, seed) == want
def test_random_against_bellman_ford():
rng = random.Random(20261105)
cycles = 0
for case in range(800):
n = rng.randint(1, 10)
m = rng.randint(0, 3 * n)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(-6, 12)) for _ in range(m)]
s = rng.randrange(n)
want = bellman_ford(n, edges, s)
cycles += want is None
assert near_linear_negative_sssp(n, edges, s, seed=case) == want
assert cycles > 50
def test_larger_graphs_without_negative_cycles():
rng = random.Random(99)
for case in range(30):
n = rng.randint(20, 40)
pot = [rng.randint(0, 50) for _ in range(n)]
edges = []
for _ in range(4 * n):
u, v = rng.randrange(n), rng.randrange(n)
edges.append((u, v, rng.randint(0, 20) + pot[v] - pot[u]))
assert near_linear_negative_sssp(n, edges, 0, seed=case) == bellman_ford(n, edges, 0)
What the next level needs
- the tests did not run in this build
Read the full page about Near-linear-time negative-weight SSSP