Algorithm Search⌘K

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