Cycle-canceling algorithm in Python
Start from any feasible flow and cancel negative-cost cycles in the residual graph.
- File
cycle_canceling.py, 116 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/cycle-canceling/. The authors are listed in prov/M028R9.jsonl.
from collections import deque
from typing import Callable, Optional
def cycle_canceling(
n: int,
edges: list[tuple[int, int, int, int]],
s: int,
t: int,
on_cancel: Optional[Callable[[list[int], int, int], None]] = None,
) -> tuple[int, int, list[int]]:
"""Minimum-cost maximum s-t flow: a maximum flow, then cancel negative residual cycles.
The network has the vertices 0 .. n-1 and the directed edges (u, v, cap, cost) of
the list, with integer cap >= 0 and integer cost of any sign. Parallel,
antiparallel and zero-capacity edges and self-loops are allowed; each edge keeps
its own flow. A self-loop of negative cost is a negative cycle by itself and ends
up full. Requires s != t. The optional callback gets (cycle as a vertex list with
the first vertex repeated at the end, amount, cycle cost per unit) for each cancel.
Returns (value, cost, flow): flow[i] is the flow on edges[i]; among all maximum
flows this one has the least total cost.
Each cancel costs O(nm) (Bellman-Ford) and lowers the cost by at least 1.
"""
if s == t:
raise ValueError("source and sink must differ")
head: list[int] = [] # arc 2i is edge i forward, arc 2i + 1 is edge i backward
res: list[int] = []
cost: list[int] = []
adj: list[list[int]] = [[] for _ in range(n)]
for u, v, cap, w in edges: # @line L1
if not isinstance(cap, int) or not isinstance(w, int):
raise TypeError("capacities and costs must be integers")
if cap < 0:
raise ValueError("capacities must be nonnegative")
adj[u].append(len(head))
head.append(v)
res.append(cap)
cost.append(w)
adj[v].append(len(head))
head.append(u)
res.append(0)
cost.append(-w)
value = _max_flow(n, adj, head, res, s, t) # @line L2
arcs = range(len(head))
while True: # @line L3
dist = [0] * n # @line L4
pred = [-1] * n
last = -1
for _ in range(n):
last = -1
for a in arcs:
if res[a] > 0:
u = head[a ^ 1]
v = head[a]
if dist[u] + cost[a] < dist[v]:
dist[v] = dist[u] + cost[a]
pred[v] = a
last = v
if last == -1:
break
if last == -1: # @line L5
break
x = last # @line L6
for _ in range(n):
x = head[pred[x] ^ 1]
cycle_arcs = [] # @line L7
v = x
while True:
a = pred[v]
cycle_arcs.append(a)
v = head[a ^ 1]
if v == x:
break
cycle_arcs.reverse()
delta = min(res[a] for a in cycle_arcs) # @line L8
for a in cycle_arcs:
res[a] -= delta
res[a ^ 1] += delta
if on_cancel is not None:
on_cancel([x] + [head[a] for a in cycle_arcs], delta, sum(cost[a] for a in cycle_arcs))
flow = [res[2 * i + 1] for i in range(len(edges))]
total = sum(f * e[3] for f, e in zip(flow, edges))
return value, total, flow # @line L9
def _max_flow(n: int, adj: list[list[int]], head: list[int], res: list[int], s: int, t: int) -> int:
"""Edmonds-Karp on the residual arrays, ignoring costs; returns the flow value."""
value = 0
while True:
parent = [-1] * n
parent[s] = -2
queue = deque([s])
while queue and parent[t] == -1:
u = queue.popleft()
for a in adj[u]:
if res[a] > 0 and parent[head[a]] == -1:
parent[head[a]] = a
queue.append(head[a])
if parent[t] == -1:
return value
delta = None
v = t
while v != s:
a = parent[v]
delta = res[a] if delta is None else min(delta, res[a])
v = head[a ^ 1]
v = t
while v != s:
a = parent[v]
res[a] -= delta
res[a ^ 1] += delta
v = head[a ^ 1]
value += delta
test_cycle_canceling.py 106 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cycle-canceling/. The authors are listed in prov/M028R9.jsonl.
import random
from itertools import product
import pytest
from cycle_canceling import cycle_canceling
S, A, B, C, D, T = range(6)
RUNNING = [
(S, A, 10, 2), (S, C, 10, 2), (A, B, 4, 6), (A, C, 2, 3), (A, D, 8, 1),
(C, D, 9, 2), (D, B, 6, 1), (B, T, 10, 1), (D, T, 10, 3),
]
def brute(n, edges, s, t):
"""Oracle: enumerate every integral flow; return (max value, least cost at that value)."""
best = None
for f in product(*[range(c + 1) for _, _, c, _ in edges]):
exc = [0] * n
for (u, v, _, _), x in zip(edges, f):
exc[u] -= x
exc[v] += x
if any(exc[v] for v in range(n) if v not in (s, t)):
continue
key = (-(-exc[s]), sum(x * e[3] for x, e in zip(f, edges)))
if best is None or key < best:
best = key
return -best[0], best[1]
def check(n, edges, s, t, value, cost, flow):
exc = [0] * n
for (u, v, c, _), f in zip(edges, flow):
assert 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[s] == value
assert sum(f * e[3] for f, e in zip(flow, edges)) == cost
def test_running_example() -> None:
log = []
value, cost, flow = cycle_canceling(6, RUNNING, S, T, lambda c, d, w: log.append((c, d, w)))
assert (value, cost) == (19, 126)
assert flow == [10, 9, 3, 0, 7, 9, 6, 9, 10]
assert log == [([D, B, T, D], 1, -1), ([A, D, T, B, A], 1, -3)]
check(6, RUNNING, S, T, value, cost, flow)
def test_no_path_zero_capacity_and_empty() -> None:
assert cycle_canceling(3, [(0, 1, 5, 1), (2, 1, 5, 1)], 0, 2) == (0, 0, [0, 0])
assert cycle_canceling(3, [(0, 1, 5, 1), (1, 2, 0, -9)], 0, 2) == (0, 0, [0, 0])
assert cycle_canceling(2, [], 0, 1) == (0, 0, [])
def test_negative_cycle_away_from_the_path() -> None:
# a negative cycle not connected to s or t is still cancelled
edges = [(0, 1, 2, 1), (2, 3, 3, -2), (3, 2, 5, 1)]
value, cost, flow = cycle_canceling(4, edges, 0, 1)
assert (value, cost, flow) == (2, 2 - 3, [2, 3, 3])
def test_negative_self_loop_is_filled() -> None:
value, cost, flow = cycle_canceling(2, [(0, 1, 1, 0), (0, 0, 4, -1)], 0, 1)
assert (value, cost, flow) == (1, -4, [1, 4])
def test_antiparallel_edges_keep_their_own_flow() -> None:
edges = [(0, 1, 3, 1), (1, 2, 2, 4), (2, 1, 1, -5), (2, 3, 2, 1), (0, 2, 1, 1)]
value, cost, flow = cycle_canceling(4, edges, 0, 3)
assert (value, cost) == brute(4, edges, 0, 3)
check(4, edges, 0, 3, value, cost, flow)
assert flow[1] >= 1 and flow[2] == 1 # the cycle a b a has cost -1 and is used
def test_cheaper_long_path() -> None:
edges = [(0, 3, 1, 10), (0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1)]
value, cost, flow = cycle_canceling(4, edges, 0, 3)
assert (value, cost) == (2, 13)
def test_bad_input() -> None:
with pytest.raises(ValueError):
cycle_canceling(2, [(0, 1, 1, 0)], 0, 0)
with pytest.raises(ValueError):
cycle_canceling(2, [(0, 1, -1, 0)], 0, 1)
with pytest.raises(TypeError):
cycle_canceling(2, [(0, 1, 1.5, 0)], 0, 1)
def test_random_against_brute_force() -> None:
rng = random.Random(2718)
for _ in range(600):
n = rng.randint(2, 5)
m = rng.randint(0, 6)
edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 3), rng.randint(-4, 6)) for _ in range(m)]
s, t = rng.sample(range(n), 2)
value, cost, flow = cycle_canceling(n, edges, s, t)
assert (value, cost) == brute(n, edges, s, t)
check(n, edges, s, t, value, cost, flow)
What the next level needs
- the tests did not run in this build