Almost-linear-time maximum flow algorithm in Python
Max flow and min-cost flow in m^(1+o(1)) time by an l1 interior point method whose steps are min-ratio cycles kept by a dynamic data structure.
- File
almost_linear_max_flow.py, 291 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/almost-linear-max-flow/. The authors are listed in prov/8Q450K.jsonl.
"""The l1 interior point method of Chen, Kyng, Liu, Peng, Probst Gutenberg and
Sachdeva (2022), with exact min-ratio cycles in place of their dynamic data
structure, followed by rounding and an exact finish.
Integer capacities, directed edges. Antiparallel and parallel edges are kept as
separate variables. The answer is always exact: the interior point phase only
brings the flow close to optimal, and the finish makes it optimal.
"""
import math
from collections import deque
def _min_ratio_cycle(N, arcs, tol=1e-12):
"""Return (ratio, cycle) minimizing sum(w)/sum(l) over directed cycles of
the arc list (tail, head, w, l, edge, sign), or (0.0, None) if no cycle has
negative ratio. Dinkelbach iteration with Bellman-Ford as the oracle."""
lam, best = 0.0, None
while True:
cyc = _negative_cycle(N, arcs, lam, tol)
if cyc is None:
return lam, best
w = sum(arcs[i][2] for i in cyc)
l = sum(arcs[i][3] for i in cyc)
r = w / l
if best is not None and r >= lam - tol * (1 + abs(lam)):
return lam, best
lam, best = r, cyc
def _negative_cycle(N, arcs, lam, tol):
dist = [0.0] * N
pred = [-1] * N
x = -1
for _ in range(N):
x = -1
for i, (a, b, w, l, _, _) in enumerate(arcs):
nd = dist[a] + w - lam * l
if nd < dist[b] - tol * (1 + abs(dist[b])):
dist[b] = nd
pred[b] = i
x = b
if x < 0:
return None
for _ in range(N):
x = arcs[pred[x]][0]
cyc, v = [], x
while True:
i = pred[v]
cyc.append(i)
v = arcs[i][0]
if v == x:
break
cyc.reverse()
return cyc
def _bfs_path(n, cap, adj, s, t):
par = [-1] * n
par[s] = -2
q = deque([s])
while q:
v = q.popleft()
for x in adj[v]:
w = cap[x][1]
if par[w] == -1 and cap[x][0] > 0:
par[w] = x
if w == t:
return par
q.append(w)
return None
def almost_linear_max_flow(n, edges, s, t, max_steps=None, on_step=None):
"""Maximum s-t flow for integer capacities.
edges: list of (u, v, c) with c a nonnegative int. Returns (value, flow)
with flow[i] the integer flow on edges[i]. on_step(k, gap) is called after
interior point step k with gap = c^T f - F_lo, and once more at the end as
on_step(-1, number of augmenting paths used by the finish).
"""
if s == t:
raise ValueError("s and t must differ")
for _, _, c in edges:
if not isinstance(c, int) or c < 0:
raise TypeError("capacities must be nonnegative integers")
U = sum(c for u, v, c in edges if u == s and v != s) # @line L1
var = [] # (tail, head, hi, cost, original index or -1)
for i, (u, v, c) in enumerate(edges):
if c > 0 and u != v:
var.append((u, v, c, 0, i))
flow = [0] * len(edges)
if U == 0:
return 0, flow
var.append((t, s, U, -1, -1))
f = [hi / 2 for _, _, hi, _, _ in var]
imb = [0.0] * n
for (a, b, _, _, _), x in zip(var, f):
imb[a] -= x
imb[b] += x
z = n
M = 4
for v in range(n): # @line L2
if imb[v] > 0:
var.append((v, z, 2 * imb[v], M, -2))
f.append(imb[v])
elif imb[v] < 0:
var.append((z, v, -2 * imb[v], M, -2))
f.append(-imb[v])
m = len(var)
F_lo = -U - 1
alpha = 1.0 / (4 * math.log(4 * m * U + 4))
coef = 20 * m
def phi(g):
cost = sum(e[3] * x for e, x in zip(var, g))
val = coef * math.log(cost - F_lo)
for e, x in zip(var, g):
a, b = e[2] - x, x
if a <= 0 or b <= 0:
return math.inf
val += a ** -alpha + b ** -alpha
return val
steps = 0
limit = max_steps if max_steps is not None else 30 * m
cur = phi(f)
while steps < limit: # @line L3
cost = sum(e[3] * x for e, x in zip(var, f))
gap = cost - F_lo
arcs = []
for j, (a, b, hi, c, _) in enumerate(var): # @line L4
up, lo = hi - f[j], f[j]
g = coef * c / gap + alpha * up ** (-alpha - 1) - alpha * lo ** (-alpha - 1)
l = up ** (-alpha - 1) + lo ** (-alpha - 1)
arcs.append((a, b, g, l, j, 1))
arcs.append((b, a, -g, l, j, -1))
ratio, cyc = _min_ratio_cycle(n + 1, arcs) # @line L5
if cyc is None or ratio > -1e-9:
break
delta = {}
L = sum(arcs[i][3] for i in cyc)
for i in cyc:
j, sg = arcs[i][4], arcs[i][5]
delta[j] = delta.get(j, 0.0) + sg / L
room = min(((var[j][2] - f[j]) / d if d > 0 else f[j] / -d)
for j, d in delta.items() if d != 0)
eta = 0.5 * room
while eta > 1e-15: # @line L6
g2 = list(f)
for j, d in delta.items():
g2[j] += eta * d
new = phi(g2)
if new < cur:
f, cur = g2, new
break
eta /= 2
else:
break
steps += 1
if on_step:
on_step(steps, sum(e[3] * x for e, x in zip(var, f)) - F_lo)
x = _round(n + 1, var, f) # @line L7
_cancel_aux(n, var, x) # @line L8
for j, e in enumerate(var):
if e[4] >= 0:
flow[e[4]] = x[j]
cap, adj = [], [[] for _ in range(n)]
for i, (u, v, c) in enumerate(edges):
adj[u].append(len(cap))
cap.append([c - flow[i], v])
adj[v].append(len(cap))
cap.append([flow[i], u])
aug = 0
while True: # @line L9
par = _bfs_path(n, cap, adj, s, t)
if par is None:
break
b, v = math.inf, t
while v != s:
b = min(b, cap[par[v]][0])
v = cap[par[v] ^ 1][1]
v = t
while v != s:
cap[par[v]][0] -= b
cap[par[v] ^ 1][0] += b
v = cap[par[v] ^ 1][1]
aug += 1
for i in range(len(edges)):
flow[i] = cap[2 * i + 1][0]
value = sum(flow[i] for i, (u, v, _) in enumerate(edges) if u == s) - \
sum(flow[i] for i, (u, v, _) in enumerate(edges) if v == s)
if on_step:
on_step(-1, aug)
return value, flow
def _round(N, var, f):
"""Make every variable integral by pushing around cycles of fractional
variables in the direction that does not raise the cost."""
x = list(f)
eps = 1e-9
for j in range(len(x)):
r = round(x[j])
if abs(x[j] - r) < eps:
x[j] = float(r)
while True:
frac = [j for j in range(len(x)) if abs(x[j] - round(x[j])) >= eps]
if not frac:
return [int(round(v)) for v in x]
adj = {}
for j in frac:
a, b = var[j][0], var[j][1]
adj.setdefault(a, []).append((b, j, 1))
adj.setdefault(b, []).append((a, j, -1))
cyc = _undirected_cycle(adj, var[frac[0]][0])
cost = sum(var[j][3] * sg for j, sg in cyc)
d = 1 if cost <= 0 else -1
amt = min((math.ceil(x[j] - eps) - x[j]) if sg * d > 0 else (x[j] - math.floor(x[j] + eps))
for j, sg in cyc)
for j, sg in cyc:
x[j] += sg * d * amt
r = round(x[j])
if abs(x[j] - r) < eps:
x[j] = float(r)
def _undirected_cycle(adj, start):
"""Cycle in the multigraph of fractional variables, as (var, sign) pairs."""
stack = [(start, -1)]
seen = {start: None} # vertex -> (parent vertex, var, sign)
while stack:
v, via = stack.pop()
for w, j, sg in adj[v]:
if j == via:
continue
if w in seen:
pv, pw = _path(seen, v), _path(seen, w)
k = 0
while k < min(len(pv), len(pw)) and pv[k] == pw[k]:
k += 1
return ([(jj, s2) for (_, jj, s2) in pv[k:]] + [(j, sg)]
+ [(jj, -s2) for (_, jj, s2) in reversed(pw[k:])])
seen[w] = (v, j, sg)
stack.append((w, j))
raise AssertionError("no cycle among fractional variables")
def _path(seen, v):
p = []
while seen[v] is not None:
u, j, sg = seen[v]
p.append((u, j, sg))
v = u
p.reverse()
return p
def _cancel_aux(n, var, x):
"""Remove all flow on the auxiliary edges by cancelling cycles through z."""
z = n
out = {}
for j, e in enumerate(var):
out.setdefault(e[0], []).append(j)
while True:
par = {z: None}
q = deque([z])
back = None
while q and back is None:
v = q.popleft()
for j in out.get(v, []):
if x[j] <= 0:
continue
w = var[j][1]
if w == z:
back = j
break
if w not in par:
par[w] = j
q.append(w)
if back is None:
return
cyc, v = [back], var[back][0]
while v != z:
cyc.append(par[v])
v = var[par[v]][0]
amt = min(x[j] for j in cyc)
for j in cyc:
x[j] -= amt
test_almost_linear_max_flow.py 108 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/almost-linear-max-flow/. The authors are listed in prov/8Q450K.jsonl.
import random
from itertools import product
import pytest
from almost_linear_max_flow import almost_linear_max_flow
S, A, B, C, D, T = range(6)
RUNNING = [
(S, A, 10), (S, C, 10), (A, B, 4), (A, C, 2), (A, D, 8),
(C, D, 9), (D, B, 6), (B, T, 10), (D, T, 10),
]
def min_cut_brute(n, edges, s, t):
"""Oracle: the least capacity of a set containing s and not t."""
others = [v for v in range(n) if v not in (s, t)]
best = None
for bits in product((0, 1), repeat=len(others)):
side = {s} | {v for v, b in zip(others, bits) if b}
cap = sum(c for u, v, c in edges if u in side and v not in side)
best = cap if best is None else min(best, cap)
return best
def check_flow(n, edges, s, t, value, flow):
assert len(flow) == len(edges)
exc = [0] * n
for (u, v, c), f in zip(edges, flow):
assert isinstance(f, int) and 0 <= f <= c
exc[u] -= f
exc[v] += f
for v in range(n):
if v not in (s, t):
assert exc[v] == 0
assert exc[t] == value == -exc[s]
def test_running_example():
log = []
value, flow = almost_linear_max_flow(6, RUNNING, S, T, on_step=lambda k, g: log.append((k, g)))
assert value == 19
check_flow(6, RUNNING, S, T, value, flow)
# F_lo = -21 and the optimal cost is -19, so a gap below 3 means within 1 of optimal.
gaps = [g for k, g in log if k > 0]
assert gaps[0] == pytest.approx(21.0)
assert gaps[-1] < 2.01
assert log[-1] == (-1, 0) # the finish needed no augmenting path
def test_finish_alone_is_exact():
log = []
value, flow = almost_linear_max_flow(6, RUNNING, S, T, max_steps=0, on_step=lambda k, g: log.append((k, g)))
assert value == 19
check_flow(6, RUNNING, S, T, value, flow)
assert log[-1][0] == -1 and log[-1][1] > 0
def test_zero_capacity_and_antiparallel():
edges = RUNNING + [(A, S, 3), (C, A, 0), (T, D, 5)]
value, flow = almost_linear_max_flow(6, edges, S, T)
assert value == 19
check_flow(6, edges, S, T, value, flow)
edges = [(0, 1, 3), (1, 0, 2), (1, 2, 3), (2, 1, 4)]
assert almost_linear_max_flow(3, edges, 0, 2) == (3, [3, 0, 3, 0])
def test_no_path_and_degenerate():
assert almost_linear_max_flow(4, [(0, 1, 5), (3, 1, 5)], 0, 3) == (0, [0, 0])
assert almost_linear_max_flow(2, [], 0, 1) == (0, [])
assert almost_linear_max_flow(2, [(0, 1, 0)], 0, 1) == (0, [0])
assert almost_linear_max_flow(2, [(0, 0, 4), (0, 1, 2), (0, 1, 5)], 0, 1) == (7, [0, 2, 5])
assert almost_linear_max_flow(3, [(1, 0, 4), (1, 2, 4)], 0, 2) == (0, [0, 0])
def test_bad_input():
with pytest.raises(TypeError):
almost_linear_max_flow(2, [(0, 1, 1.5)], 0, 1)
with pytest.raises(TypeError):
almost_linear_max_flow(2, [(0, 1, -1)], 0, 1)
with pytest.raises(ValueError):
almost_linear_max_flow(2, [(0, 1, 1)], 1, 1)
def test_random_against_brute_force():
rng = random.Random(2022)
for _ in range(500):
n = rng.randint(2, 6)
m = rng.randint(0, 10)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 9)) for _ in range(m)]
s, t = rng.sample(range(n), 2)
value, flow = almost_linear_max_flow(n, edges, s, t)
assert value == min_cut_brute(n, edges, s, t)
check_flow(n, edges, s, t, value, flow)
def test_random_short_runs_still_exact():
rng = random.Random(11)
for _ in range(100):
n = rng.randint(2, 6)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 20)) for _ in range(rng.randint(1, 12))]
k = rng.randint(0, 5)
value, flow = almost_linear_max_flow(n, edges, 0, n - 1, max_steps=k)
assert value == min_cut_brute(n, edges, 0, n - 1)
check_flow(n, edges, 0, n - 1, value, flow)
What the next level needs
- the tests did not run in this build
Read the full page about Almost-linear-time maximum flow algorithm