Network Flow and Matchings
The question
A plant pumps water from a source to a reservoir 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
The plant manager asks two questions. How much can reach 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 to 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 has only 4, so it pushes 4. Then s b d t:
has 4 of its 8 left, so it pushes 4 more. Now and are full, and
every path from to runs through one of them. The approach stops at 8.
8 is not the answer. The 4 units on are the waste: has its own pipe to , with room 2, and is the only way on from . If 2 of those units went instead, would have room for 2 more units straight from . 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 but not gives an upper bound: sets here, for vertices.
The model: flows and cuts
A flow assigns an amount to every edge. It must obey two rules:
- capacity: on every edge;
- conservation: at every vertex other than and , what flows in equals what flows out.
The value is the net amount leaving . An - cut is a set of vertices with and . Its capacity is the total capacity of the edges leaving . Edges that enter 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 , with capacity 10. The obvious cuts are worse: has , and everything but has .
Lemma (weak duality). For every flow and every cut , .
Proof. Add up "out minus in" over the vertices of . For it is , and for every other vertex of it is 0 by conservation, so the total is . In that sum, an edge with both ends in appears once with and once with and cancels. What remains is the flow on edges leaving minus the flow on edges entering . The first is at most 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 , both are optimal, and the cut is the certificate the manager wants.
Predict: what is the capacity of the cut ?
18: the edges leaving it are (4), (6) and (8). A common slip is to add the edges entering as well, (6) and (3), which gives 27. Flow that comes back into 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 , the residual graph has, for every edge :
- a forward arc with room : how much more can be sent;
- a reverse arc with room : how much can be taken back.
An augmenting path is a path from to in using only arcs with room above 0. Its bottleneck is the least room on it. To push along it, add to the flow on each forward arc's edge and subtract 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 . Proof. A forward arc had room at least , so its edge stays within capacity. A reverse arc's edge carried at least , 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: in and out along two forward arcs; out and in along two reverse arcs; and in and in, or out and out, when the arcs are mixed. The first arc leaves and adds to the net outflow of .
Ford–Fulkerson is this loop. Start with the zero flow. While 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.
From the first neighbour is , from it is , from it is , and then . The
search finds s a b d t with bottleneck 4, the same first move as before. Now look at the
residual graph:
From the search tries first, but is full, so it goes to . From the
first neighbour is , and the arc has room 4: it is the reverse of ,
which carries 4. From it tries (already seen), then . Every arc with room out of
leads back to a vertex already seen, so the search backs up to , skips , and takes
. The path is s b a t, with rooms 9, 4 and 2, so the
bottleneck is 2.
Pushing 2 along subtracts 2 from . Two of the four units that went now leave by instead, which is exactly the correction the stuck approach couldn't make.
The third search finds s b d t with bottleneck 4, since has room 4 again. The value
is now . 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?
, , and . From , the arcs to (room 3) and (room 6) have room, and from the reverse arc to has room 2, so all three are reachable. But and are full, and nothing else leads towards or . That set is exactly the smallest of the 16 cuts.
Why it stops at the maximum
Theorem (max-flow min-cut)
For a flow the following are equivalent: (1) is a maximum flow; (2) has no augmenting path; (3) for some cut . 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 , and reaches it.
(2) ⇒ (3) is the heart of it. Let be the set of vertices reachable from in . Then , and because there is no augmenting path, so is a cut. Take any edge leaving . If it had room, its forward arc would make reachable, so it is full: . Take any edge entering (with in ). If it carried any flow, its reverse arc would have room and make reachable, so it carries zero. By weak duality,
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 . The stuck flow of 8 is such a case. From , following edges with room, you reach and , so , with capacity . The edge enters and carries 4, and . 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 and are full and together hold 10. Everything that leaves 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 residual arcs at most once, so one search costs .
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 , the maximum, so there are at most successful searches plus the one that fails: Ford–Fulkerson terminates, and it ends with an integral maximum flow. The worst-case cost is
for a connected network with . 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?
The obvious method: go through the people in order and give each the first job on their list that is still free. It gives , and . 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 with an edge of capacity 1 to every person, an edge of capacity 1 from person to job for every allowed pair, and an edge of capacity 1 from every job to a sink . 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 , then back along the reverse arc , then . 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, . 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 , right side and allowed pairs, the network has edges. The maximum is at most , so there are at most augmentations: in the worst case, which is 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 arc inspections. Here is what the course's rule (depth first, sorted neighbours) actually inspects on matching networks with people, jobs and up to 4 random allowed jobs per person, with every capacity 1:
| 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 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 , 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: worst case with integer capacities, and for bipartite matching with allowed pairs. Certifying optimality by brute force means trying 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 in the residual graph. Could union-find compute it?
Check yourself
- When no augmenting path is left, why is the set of vertices reachable from a minimum cut? Include the edges that enter it.
- Change the capacity of to 8.5 and leave the rest alone. What is the maximum flow, and does the integrality theorem still apply?
- 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.