Goldberg's scaling algorithm in Python
Scaling algorithm for negative integer weights using potentials, running in O(sqrt(n) m log N) time.
- File
goldberg_scaling_sssp.py, 203 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/goldberg-scaling-sssp/. The authors are listed in prov/A13K1H.jsonl.
import heapq
import math
INF = math.inf
def goldberg_scaling(
adj: list[list[tuple[int, int]]], s: int, trace: list | None = None
) -> tuple[list, list] | None:
"""Single-source shortest paths with integer weights, by Goldberg's scaling method.
The graph has the vertices 0 .. n-1; adj[u] lists the edges (v, w) out of u, and
every w must be an integer (negative allowed). Returns (d, parent) with d[v] the
distance from s (math.inf if unreachable) and parent[v] the previous vertex on a
shortest path, or None if a negative cycle is reachable from s. Negative cycles
that s does not reach are ignored. If trace is a list, one entry
(scale, case, number of vertices fixed) is appended per refinement step.
"""
n = len(adj)
for u in range(n):
for _, w in adj[u]:
if not isinstance(w, int):
raise ValueError("weights must be integers")
# restrict to the vertices reachable from s
seen = [False] * n # @line L1
seen[s] = True
stack = [s]
while stack:
u = stack.pop()
for v, _ in adj[u]:
if not seen[v]:
seen[v] = True
stack.append(v)
edges = [(u, v, w) for u in range(n) if seen[u] for v, w in adj[u]]
big = max([-w for _, _, w in edges] + [0])
b = big.bit_length()
p = [0] * n
for i in range(1, b + 1): # @line L2
shift = b - i
wi = [-((-w) >> shift) for _, _, w in edges] # ceil(w / 2^shift)
p = [2 * x for x in p] # @line L3
if not _refine(n, edges, wi, p, seen, i, trace):
return None
# all reduced costs are now >= 0: Dijkstra, then undo the potentials
radj: list[list[tuple[int, int]]] = [[] for _ in range(n)]
for u, v, w in edges:
radj[u].append((v, w + p[u] - p[v]))
dr = [INF] * n
parent: list = [None] * n
dr[s] = 0
heap = [(0, s)]
while heap: # @line L11
du, u = heapq.heappop(heap)
if du > dr[u]:
continue
for v, r in radj[u]:
if du + r < dr[v]:
dr[v], parent[v] = du + r, u
heapq.heappush(heap, (dr[v], v))
d = [dr[v] - p[s] + p[v] if dr[v] < INF else INF for v in range(n)]
return d, parent
def _refine(n, edges, wi, p, live, scale, trace) -> bool:
"""Make every reduced cost wi + p[u] - p[v] (all >= -1) nonnegative by changing p.
Returns False if it finds that the weights wi have a negative cycle."""
out: list[list[int]] = [[] for _ in range(n)]
for e, (u, _, _) in enumerate(edges):
out[u].append(e)
def cost(e):
u, v, _ = edges[e]
return wi[e] + p[u] - p[v]
while True:
improvable = {edges[e][1] for e in range(len(edges)) if cost(e) == -1} # @line L4
k = len(improvable)
if k == 0:
return True
comp, order = _scc(n, out, edges, cost, live) # @line L5
for e in range(len(edges)):
u, v, _ = edges[e]
if comp[u] == comp[v] and cost(e) == -1:
return False
# layers: d(C) = least admissible-path cost ending in component C
dc = [0] * len(order) # @line L6
via: list = [None] * len(order) # the -1 or 0 edge that achieves dc
for c in range(len(order)): # topological order of the components
for u in order[c]:
for e in out[u]:
v = edges[e][1]
ce = cost(e)
if ce <= 0 and comp[v] != c and dc[c] + ce < dc[comp[v]]:
dc[comp[v]], via[comp[v]] = dc[c] + ce, e
layer: dict[int, int] = {}
for v in improvable:
layer[dc[comp[v]]] = layer.get(dc[comp[v]], 0) + 1
j, size = min(layer.items(), key=lambda kv: (-kv[1], kv[0]))
if size * size >= k: # @line L7
for v in range(n):
if live[v] and dc[comp[v]] <= j:
p[v] -= 1
if trace is not None:
trace.append((scale, "cut", size))
continue
# a path whose -1 edges enter x_1, ..., x_L: walk back from the deepest component
c = min(range(len(order)), key=lambda c: dc[c]) # @line L8
xs = []
while via[c] is not None:
e = via[c]
if cost(e) == -1:
xs.append(edges[e][1])
c = comp[edges[e][0]]
xs.reverse()
big_l = len(xs)
# D(v) = min(0, min_j dist+(x_j, v) - j), with lengths max(cost, 0), by buckets
lab = [0] * n
for jj, x in enumerate(xs, start=1):
lab[x] = min(lab[x], -jj)
buckets: list[list[int]] = [[] for _ in range(big_l + 1)]
for v in range(n):
if live[v]:
buckets[lab[v] + big_l].append(v)
done = [False] * n
for level in range(-big_l, 1):
for u in buckets[level + big_l]:
if done[u] or lab[u] != level:
continue
done[u] = True
for e in out[u]:
v = edges[e][1]
nl = level + max(cost(e), 0)
if nl < lab[v]:
lab[v] = nl
buckets[nl + big_l].append(v)
for v in range(n):
if live[v]:
p[v] += lab[v] # @line L9
for x in xs: # @line L10
for e in range(len(edges)):
if edges[e][1] == x and cost(e) == -1:
return False # only possible with a negative cycle
if trace is not None:
trace.append((scale, "path", big_l))
def _scc(n, out, edges, cost, live):
"""Strongly connected components of the admissible graph (cost <= 0), by Tarjan.
Returns comp[v] and the components as vertex lists in topological order."""
index = [-1] * n
low = [0] * n
on = [False] * n
comp = [-1] * n
st: list[int] = []
comps: list[list[int]] = []
counter = 0
for r in range(n):
if not live[r] or index[r] != -1:
continue
work = [(r, 0)]
index[r] = low[r] = counter
counter += 1
st.append(r)
on[r] = True
while work:
u, k = work[-1]
if k < len(out[u]):
work[-1] = (u, k + 1)
e = out[u][k]
if cost(e) > 0:
continue
v = edges[e][1]
if index[v] == -1:
index[v] = low[v] = counter
counter += 1
st.append(v)
on[v] = True
work.append((v, 0))
elif on[v]:
low[u] = min(low[u], index[v])
else:
work.pop()
if work:
low[work[-1][0]] = min(low[work[-1][0]], low[u])
if low[u] == index[u]:
members = []
while True:
x = st.pop()
on[x] = False
comp[x] = len(comps)
members.append(x)
if x == u:
break
comps.append(members)
# Tarjan finds components in reverse topological order
m = len(comps)
comp = [m - 1 - c if c != -1 else -1 for c in comp]
return comp, comps[::-1]
test_goldberg_scaling_sssp.py 119 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/goldberg-scaling-sssp/. The authors are listed in prov/A13K1H.jsonl.
import math
import random
import pytest
from goldberg_scaling_sssp import goldberg_scaling
INF = math.inf
# s=0, a=1, b=2, c=3
EXAMPLE = [(0, 1, -1), (1, 2, -3), (1, 3, 4)]
def build(n, edges):
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
return adj
def bellman_ford(adj, s):
"""Independent oracle: distances from s, or None if a negative cycle is reachable."""
n = len(adj)
d = [INF] * n
d[s] = 0
for _ in range(n - 1):
for u in range(n):
for v, w in adj[u]:
if d[u] + w < d[v]:
d[v] = d[u] + w
for u in range(n):
for v, w in adj[u]:
if d[u] + w < d[v]:
return None
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
u = parent[v]
assert d[v] == d[u] + min(w for x, w in adj[u] if x == v)
def test_example() -> None:
trace = []
d, parent = goldberg_scaling(build(4, EXAMPLE), 0, trace)
assert d == [0, -1, -4, 3]
assert parent == [None, 0, 1, 1]
assert trace == [(1, "cut", 1), (2, "path", 2)]
def test_reachable_negative_cycle() -> None:
adj = build(3, [(0, 1, 1), (1, 2, -2), (2, 1, 1)])
trace = []
assert goldberg_scaling(adj, 0, trace) is None
assert trace == [(1, "cut", 1)] # found in scale 2, inside an admissible component
def test_unreachable_negative_cycle_is_ignored() -> None:
adj = build(4, [(0, 1, 1), (2, 3, -5), (3, 2, 1)])
d, parent = goldberg_scaling(adj, 0)
assert d == [0, 1, INF, INF] and parent == [None, 0, None, None]
def test_negative_self_loop() -> None:
assert goldberg_scaling(build(2, [(0, 1, 2), (1, 1, -1)]), 0) is None
def test_zero_weight_cycle() -> None:
adj = build(3, [(0, 1, -2), (1, 2, 3), (2, 1, -3)])
d, _ = goldberg_scaling(adj, 0)
assert d == [0, -2, 1]
def test_nonnegative_weights_need_no_scale() -> None:
trace = []
d, _ = goldberg_scaling(build(3, [(0, 1, 5), (1, 2, 0), (0, 2, 7)]), 0, trace)
assert d == [0, 5, 5] and trace == []
def test_non_integer_weight_is_rejected() -> None:
with pytest.raises(ValueError):
goldberg_scaling(build(2, [(0, 1, -0.5)]), 0)
def test_long_negative_path_uses_path_steps() -> None:
# a path of -1 edges: every layer has one improvable vertex, so the path case runs
n = 30
adj = build(n, [(i, i + 1, -1) for i in range(n - 1)])
trace = []
d, _ = goldberg_scaling(adj, 0, trace)
assert d == [-i for i in range(n)]
assert [kind for _, kind, _ in trace] == ["path"]
def test_random_against_bellman_ford() -> None:
rng = random.Random(1995)
negative = 0
for _ in range(1500):
n = rng.randint(1, 9)
low = rng.choice([1, 4, 40, 1000])
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(-low, 3 * low))
for _ in range(rng.randint(0, 3 * n))]
adj = build(n, edges)
s = rng.randrange(n)
want = bellman_ford(adj, s)
got = goldberg_scaling(adj, s)
if want is None:
negative += 1
assert got is None
else:
assert got is not None and got[0] == want
check_tree(adj, s, got[0], got[1])
assert negative > 100
What the next level needs
- the tests did not run in this build