Network Flow and Matchings

The question

A plant pumps water from a source s to a reservoir t through four pumping stations and nine one-way pipes. Each pipe has a capacity in units per hour:

s→a 4, s→b 9, s→c 6, a→b 6, a→c 5, a→t 2, b→d 8, c→b 3, d→t 9

0/4 0/9 0/6 0/6 0/5 0/2 0/8 0/3 0/9 s a b c d t flow value = 0
The pipeline network used throughout this lesson. Each edge is labelled flow/capacity, in units per hour; nothing flows yet.

The plant manager asks two questions. How much can reach t per hour? And, because she will have to defend the number to the board: what proof would convince a sceptic that no routing does better?

A routing is easy to check; a claim that nothing better exists is harder. This module answers both at once: the algorithm that finds the best routing also hands you a certificate, a set of pipes whose capacities add up to the answer.

This module assumes you can run a depth-first search on a directed graph. Matching (the last part) assumes nothing more.

Pushing without undoing

The obvious approach: find a path from s to t whose pipes all have room, push as much as the pipe with the least room allows, and repeat until no such path is left.

Say it picks s a b d t first. The pipe s→a has only 4, so it pushes 4. Then s b d t: b→d has 4 of its 8 left, so it pushes 4 more. Now s→a and b→d are full, and every path from s to t runs through one of them. The approach stops at 8.

4/4 4/9 0/6 4/6 0/5 0/2 8/8 0/3 8/9 s a b c d t flow value = 8
Stuck at 8: after pushing 4 along s a b d t and 4 along s b d t, every path from s to t has a full edge (s → a, b → d). Full edges are highlighted.

8 is not the answer. The 4 units on a→b are the waste: a has its own pipe to t, with room 2, and b→d is the only way on from b. If 2 of those units went a→t instead, b→d would have room for 2 more units straight from s. But an approach that only adds flow can't move units it has already sent, so the first path's mistake is permanent.

Proving optimality by brute force is no better. As we'll see, every set of stations containing s but not t gives an upper bound: 24=16 sets here, 2V−2 for V vertices.

The model: flows and cuts

A flow assigns an amount f(u,v) to every edge. It must obey two rules:

  • capacity: 0≤f(u,v)≤c(u,v) on every edge;
  • conservation: at every vertex other than s and t, what flows in equals what flows out.

The value |f| is the net amount leaving s. An s-t cut is a set S of vertices with s∈S and t∉S. Its capacity c(S) is the total capacity of the edges leaving S. Edges that enter S don't count.

from itertools import combinations

HOOK = {("s", "a"): 4, ("s", "b"): 9, ("s", "c"): 6,
        ("a", "b"): 6, ("a", "c"): 5, ("a", "t"): 2,
        ("b", "d"): 8, ("c", "b"): 3, ("d", "t"): 9}
assert {v for e in HOOK for v in e} - {"s", "t"} == set("abcd")
assert len(HOOK) == 9   # four pumping stations, nine pipes

def cut_capacity(cap, S):
    """Capacity of the edges leaving S; entering edges don't count."""
    return sum(c for (u, v), c in cap.items()
               if u in S and v not in S)

def value_of(cap, flow, s="s", t="t"):
    """The flow's value, after checking capacity and conservation."""
    net = {}
    for (u, v), c in cap.items():
        f = flow.get((u, v), 0)
        assert 0 <= f <= c, f"{u}->{v} carries {f} of {c}"
        net[u] = net.get(u, 0) - f
        net[v] = net.get(v, 0) + f
    for v, d in net.items():
        assert v in (s, t) or d == 0, f"not conserved at {v}"
    return -net[s]

sides = [{"s", *more} for r in range(5)
         for more in combinations("abcd", r)]
cuts = sorted((cut_capacity(HOOK, S), "".join(sorted(S)))
              for S in sides)
assert len(cuts) == 16 and cuts[0] == (10, "abcs") and cuts[1][0] > 10
assert cut_capacity(HOOK, {"s"}) == 19
assert cut_capacity(HOOK, set("sabcd")) == 11

STUCK = {("s", "a"): 4, ("a", "b"): 4, ("s", "b"): 4,
         ("b", "d"): 8, ("d", "t"): 8}
assert value_of(HOOK, STUCK) == 8

The smallest of the 16 cuts is S={s,a,b,c}, with capacity 10. The obvious cuts are worse: {s} has 4+9+6=19, and everything but t has 2+9=11.

Lemma (weak duality). For every flow f and every cut S, |f|=f(out of S)−f(into S)≤c(S).

Proof. Add up "out minus in" over the vertices of S. For s it is |f|, and for every other vertex of S it is 0 by conservation, so the total is |f|. In that sum, an edge with both ends in S appears once with + and once with − and cancels. What remains is the flow on edges leaving S minus the flow on edges entering S. The first is at most c(S) by the capacity rule, and the second is at least 0. ◻

So every cut is an upper bound on every flow. If we ever hold a flow and a cut with |f|=c(S), both are optimal, and the cut is the certificate the manager wants.

Predict: what is the capacity of the cut {s,b}?

18: the edges leaving it are s→a (4), s→c (6) and b→d (8). A common slip is to add the edges entering {s,b} as well, a→b (6) and c→b (3), which gives 27. Flow that comes back into S doesn't help anything leave it, so entering edges are not part of the bound.

assert cut_capacity(HOOK, {"s", "b"}) == 18
entering = HOOK[("a", "b")] + HOOK[("c", "b")]
assert cut_capacity(HOOK, {"s", "b"}) + entering == 27

Taking flow back: the residual graph

The fix for the stuck approach is to let a later path cancel flow an earlier path sent. Given a flow f, the residual graph Gf has, for every edge u→v:

  • a forward arc u→v with room c(u,v)−f(u,v): how much more can be sent;
  • a reverse arc v→u with room f(u,v): how much can be taken back.

An augmenting path is a path from s to t in Gf using only arcs with room above 0. Its bottleneck b is the least room on it. To push b along it, add b to the flow on each forward arc's edge and subtract b from the edge of each reverse arc.

Lemma (pushing keeps a flow). After a push the flow still obeys both rules, and its value has grown by b. Proof. A forward arc had room at least b, so its edge stays within capacity. A reverse arc's edge carried at least b, so it stays at 0 or more. Every vertex inside the path has one arc in and one arc out. Whatever the directions, the changes at that vertex cancel: +b in and +b out along two forward arcs; −b out and −b in along two reverse arcs; and +b in and −b in, or +b out and −b out, when the arcs are mixed. The first arc leaves s and adds b to the net outflow of s. ◻

Ford–Fulkerson is this loop. Start with the zero flow. While Gf has an augmenting path, push its bottleneck along it.

Ford–Fulkerson on the pipeline

The paths depend on how the search chooses, so we fix a rule: depth-first search, trying each vertex's residual neighbours in sorted order, never entering a vertex twice.

0/4 0/9 0/6 0/6 0/5 0/2 0/8 0/3 0/9 s a b c d t flow value = 0
Search 1 finds s a b d t. Its smallest room is 4 (s → a), so 4 units are pushed.

From s the first neighbour is a, from a it is b, from b it is d, and then t. The search finds s a b d t with bottleneck 4, the same first move as before. Now look at the residual graph:

r=4 r=9 r=6 r=2 r=4 r=5 r=2 r=4 r=4 r=3 r=5 r=4 s a b c d t flow value = 4
The residual graph after push 1 (r = room). Search 2 finds s b a t: the arc b → a is the reverse of a → b and has room 4. The bottleneck is 2 (a → t).

From s the search tries a first, but s→a is full, so it goes to b. From b the first neighbour is a, and the arc b→a has room 4: it is the reverse of a→b, which carries 4. From a it tries b (already seen), then c. Every arc with room out of c leads back to a vertex already seen, so the search backs up to a, skips s, and takes a→t. The path is s b a t, with rooms 9, 4 and 2, so the bottleneck is 2.

Pushing 2 along b→a subtracts 2 from a→b. Two of the four units that went a→b now leave a by a→t instead, which is exactly the correction the stuck approach couldn't make.

4/4 2/9 0/6 2/6 0/5 2/2 4/8 0/3 4/9 s a b c d t flow value = 6
After push 2 the value is 6, and a → b carries only 2: two units were taken back and sent a → t. Full edges are highlighted. Search 3 now finds s b d t, with bottleneck 4.

The third search finds s b d t with bottleneck 4, since b→d has room 4 again. The value is now 4+2+4=10. The fourth search fails.

F1 = {("s", "a"): 4, ("a", "b"): 4, ("b", "d"): 4, ("d", "t"): 4}
F2 = {("s", "a"): 4, ("a", "b"): 2, ("a", "t"): 2,
      ("s", "b"): 2, ("b", "d"): 4, ("d", "t"): 4}
F3 = {("s", "a"): 4, ("a", "b"): 2, ("a", "t"): 2,
      ("s", "b"): 6, ("b", "d"): 8, ("d", "t"): 8}
assert [value_of(HOOK, f) for f in (F1, F2, F3)] == [4, 6, 10]
assert F1[("a", "b")] - F2[("a", "b")] == 2   # 2 units taken back
Predict: when the fourth search fails, which vertices has it reached?

s, a, b and c. From s, the arcs to b (room 3) and c (room 6) have room, and from b the reverse arc to a has room 2, so all three are reachable. But a→t and b→d are full, and nothing else leads towards d or t. That set is exactly the smallest of the 16 cuts.

Why it stops at the maximum

r=4 r=3 r=6 r=6 r=4 r=2 r=5 r=2 r=8 r=3 r=1 r=8 s a b c d t flow value = 10 · cut capacity = 10
The residual graph at the end (value 10). From s only a, b and c are reachable (shaded). The edges leaving them, a → t and b → d, are full and have no forward arc; their capacities add up to 2 + 8 = 10.

Theorem (max-flow min-cut)

For a flow f the following are equivalent: (1) f is a maximum flow; (2) Gf has no augmenting path; (3) |f|=c(S) for some cut S. So the maximum value of a flow equals the minimum capacity of a cut.

Proof. (1) ⇒ (2): an augmenting path would raise the value by its bottleneck, which is positive. (3) ⇒ (1): by weak duality no flow exceeds c(S), and f reaches it.

(2) ⇒ (3) is the heart of it. Let S be the set of vertices reachable from s in Gf. Then s∈S, and t∉S because there is no augmenting path, so S is a cut. Take any edge u→v leaving S. If it had room, its forward arc would make v reachable, so it is full: f(u,v)=c(u,v). Take any edge v→u entering S (with u in S). If it carried any flow, its reverse arc u→v would have room and make v reachable, so it carries zero. By weak duality,

|f|=f(out of S)−f(into S)=c(S)−0=c(S).◻

The step not to skip is the one about entering edges. Without reverse arcs, a stuck flow can have flow on an edge entering the reachable set, and then |f|<c(S). The stuck flow of 8 is such a case. From s, following edges with room, you reach b and c, so S={s,b,c}, with capacity 4+8=12. The edge a→b enters S and carries 4, and 8=12−4. Those 4 units are exactly what a reverse arc would let the search take back.

The certificate for the manager is the final picture. The pipes a→t and b→d are full and together hold 10. Everything that leaves {s,a,b,c} must use one of them, so no routing gets more than 10 through.

S = {"s", "a", "b", "c"}
leaving = [e for e in HOOK if e[0] in S and e[1] not in S]
entering = [e for e in HOOK if e[0] not in S and e[1] in S]
assert all(F3.get(e, 0) == HOOK[e] for e in leaving)      # full
assert all(F3.get(e, 0) == 0 for e in entering)           # empty
assert cut_capacity(HOOK, S) == value_of(HOOK, F3) == 10

T = {"s", "b", "c"}     # reachable in the stuck flow of 8
assert cut_capacity(HOOK, T) == 12
into_T = [e for e in HOOK if e[0] not in T and e[1] in T]
assert sum(STUCK.get(e, 0) for e in into_T) == 4

Cost and integrality

We count residual arcs inspected: each time a search looks at an arc, that is one step. A graph search inspects each of the 2E residual arcs at most once, so one search costs O(V+E).

Theorem (integrality). If every capacity is an integer, every flow Ford–Fulkerson holds is integral: every edge carries an integer. Proof: by induction over the pushes. The zero flow is integral. If the flow is integral, every room is an integer, so the bottleneck is too, and the flow after the push is integral again. ◻

So every bottleneck is at least 1, and each push raises the value by at least 1. The value can never pass |f*|, the maximum, so there are at most |f*| successful searches plus the one that fails: Ford–Fulkerson terminates, and it ends with an integral maximum flow. The worst-case cost is

O((|f*|+1)·E)=O(|f*|·E)

for a connected network with |f*|≥1. This bound depends on the numbers in the input, not just its size. Multiply every capacity by a million and the bound grows a millionfold, though the input is barely longer. Module 12 builds a network where a bad choice of paths really does take that long, and gives rules for choosing paths whose cost doesn't depend on the capacities. With irrational capacities it can be worse: a badly chosen sequence of paths need not terminate at all, and its values can even converge to a number below the maximum (stated here, not shown). Rational capacities are safe: multiply them all by a common denominator and they become integers.

Integrality has a second use, bigger than the running time: an integer maximum flow always exists when the capacities are integers. Other maximum flows may split units into fractions, but you never need them.

Matching as flow

Five people and five jobs. Each person can do some of the jobs:

(0,0) (0,2) (1,1) (1,3) (1,4) (2,0) (2,1) (2,4) (3,0) (4,4)

A matching is a set of these pairs in which nobody appears twice. What is the largest one?

P0 P1 P2 P3 P4 J0 J1 J2 J3 J4
Five people (P) and five jobs (J); a line is an allowed pair. Highlighted: the only perfect matching, (0,2) (1,3) (2,1) (3,0) (4,4).

The obvious method: go through the people in order and give each the first job on their list that is still free. It gives (0,0), (1,1) and (2,4). Then person 3 wants only job 0, which is taken, and person 4 wants only job 4, also taken. It stops at 3.

The reduction: add a source s with an edge of capacity 1 to every person, an edge of capacity 1 from person x to job y for every allowed pair, and an edge of capacity 1 from every job to a sink t. In an integral flow, each person receives at most one unit and so sends it along at most one pair. Each job passes on at most one unit, so it is used at most once. The pairs carrying a unit form a matching whose size is the flow's value, and every matching gives such a flow. By integrality, a maximum flow can be taken integral, so the maximum flow is the maximum matching.

On this network, once the greedy pairs carry flow, an augmenting path for person 3 is s→P3→J0, then back along the reverse arc J0→P0, then P0→J2→t. Pushing along it takes job 0 from person 0 and moves person 0 to job 2. That is the reverse arc again: a later choice undoes an earlier one. One more augmenting path, for person 4, moves person 2 from job 4 to job 1 and person 1 from job 1 to job 3. The maximum matching has all five pairs, (0,2) (1,3) (2,1) (3,0) (4,4). It is also the only one, since persons 3 and 4 each have a single allowed job, which then forces the rest: person 0 to job 2, person 2 to job 1 and person 1 to job 3.

from itertools import permutations

PAIRS = [(0, 0), (0, 2), (1, 1), (1, 3), (1, 4),
         (2, 0), (2, 1), (2, 4), (3, 0), (4, 4)]
used, greedy = set(), []
for x in range(5):
    for p, y in PAIRS:
        if p == x and y not in used:
            used.add(y)
            greedy.append((x, y))
            break
assert greedy == [(0, 0), (1, 1), (2, 4)]
perfect = [list(enumerate(jobs)) for jobs in permutations(range(5))
           if all(pair in PAIRS for pair in enumerate(jobs))]
assert perfect == [[(0, 2), (1, 3), (2, 1), (3, 0), (4, 4)]]
forced = {3: 0, 4: 4}                  # the only job each of them may take
for x in (0, 2, 1):                    # then each of these has one free job left
    free = [y for p, y in PAIRS if p == x and y not in forced.values()]
    assert len(free) == 1
    forced[x] = free[0]
assert sorted(forced.items()) == perfect[0]

With left side L, right side R and E allowed pairs, the network has E+|L|+|R| edges. The maximum is at most |L|, so there are at most |L| augmentations: O(|L|·(E+|L|+|R|)) in the worst case, which is O(|L|·E) when every person and every job has at least one allowed pair. Compare trying every subset of the pairs.

Predict: a maximum flow in a matching network might put ½ a unit on each of two pairs. Could matching by flow then pair half a person with half a job?

Not if you use Ford–Fulkerson. With integer capacities every flow it holds is integral, so every pair carries 0 or 1. A fractional maximum flow can exist alongside it (a person with two equally good jobs, half a unit on each), but integrality guarantees a whole-number one with the same value.

Measure the claim

The bound says at most (|f*|+1)·2E arc inspections. Here is what the course's rule (depth first, sorted neighbours) actually inspects on matching networks with n people, n jobs and up to 4 random allowed jobs per person, with every capacity 1:

n |f*| E augmentations arc inspections inspections / bound
50 50 295 50 3,972 0.132
100 100 596 100 14,363 0.119
200 195 1,194 195 46,880 0.100

Augmentations equal |f*| exactly: with unit capacities every push moves one unit. The inspections stay far below the bound because a successful search stops as soon as it reaches t, often after a few steps. That is a measurement on these instances, not a guarantee. The bound is a worst case, and module 12 builds a small network on which a bad choice of paths needs thousands of augmentations.

A problem that looks different

Two data centres are joined by a mesh of cables through a dozen relay rooms. An attacker wants to cut as few cables as possible to disconnect them. An engineer wants as many routes as possible between them, no two sharing a cable, so that one cut cable never takes down two routes. Why might those two numbers always be equal? Nothing here mentions water, and the lab's last problem is a different one.

Practise

In the lab you write the push that can take flow back and watch every frame stay a valid flow; predict, then trace, the paths a search finds on a new network and read the certificate off the last one; write maximum flow with its certificate and use it for matching; count the arcs the searches inspect against the bound; and solve a staffing problem that doesn't say what it is.

Recap

You can now: check a flow's capacity and conservation rules and a cut's capacity; build the residual graph and push along a path that takes flow back; prove that maximum flow equals minimum cut, reading the cut off the final residual graph; and reduce matching to flow using integrality.

Invariant: Ford–Fulkerson always holds a valid flow. When no augmenting path is left, the edges leaving the reachable set are full and the edges entering it are empty, so the value equals that cut's capacity.

Complexity achieved: O(|f*|·E) worst case with integer capacities, and O(|L|·E) for bipartite matching with E allowed pairs. Certifying optimality by brute force means trying 2V−2 cuts.

Failure mode: no reverse arcs. The search stops at a flow that is not maximum (8 instead of 10 here), and nothing in the output warns you.

In real software: SciPy's scipy.sparse.csgraph.maximum_flow offers method='edmonds_karp' and method='dinic', and maximum_bipartite_matching computes matchings with Hopcroft–Karp. NetworkX's minimum_cut returns the cut value together with the two sides of the cut.

Retrieval (module 08): after the last search you need the set of vertices reachable from s in the residual graph. Could union-find compute it?

Check yourself

  1. When no augmenting path is left, why is the set of vertices reachable from s a minimum cut? Include the edges that enter it.
  2. Change the capacity of b→d to 8.5 and leave the rest alone. What is the maximum flow, and does the integrality theorem still apply?
  3. On the pipeline, compare the approach that never takes flow back with Ford–Fulkerson. Where exactly does the first get stuck, and which residual arc frees it?

Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…