Algorithm Search⌘K

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
cycle_canceling.pyRawGitHub
# 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

Read the full page about Cycle-canceling algorithm