Maximum Flow
What you’ll learn
- What a flow network is: a directed graph with a source, a sink, and a capacity on every edge.
- The max-flow min-cut theorem — why the maximum amount you can push from source to sink always equals the capacity of the cheapest “cut” separating them.
- The residual graph: how “undoing” flow is modeled with back edges.
- Ford-Fulkerson with BFS (Edmonds-Karp) — a runnable max-flow algorithm, and why using BFS specifically bounds its running time.
- Modeling bipartite matching as a flow problem.
Flow networks
A flow network is a directed graph where every edge (u, v)(u, v) has a
capacity — the maximum amount of “flow” it can carry. One node is the
source ss (where flow originates), another is the sink tt (where
it’s collected). A valid flow must respect two rules: no edge carries more
than its capacity, and every node except ss/tt sends out exactly as much
as it receives (flow conservation). The question: what’s the maximum
total flow you can push from ss to tt?
graph LR
N0["source (0)"] -->|"cap 3"| N1["1"]
N0 -->|"cap 2"| N2["2"]
N1 -->|"cap 2"| N3["sink (3)"]
N1 -->|"cap 1"| N2
N2 -->|"cap 3"| N3
The residual graph
Ford-Fulkerson’s core trick: whenever you push flow along an edge, add a back edge in the opposite direction with capacity equal to the flow you just pushed. That back edge represents “you can undo up to this much flow” — which lets a later augmenting path effectively reroute flow that turned out to be a bad choice, without ever explicitly undoing anything.
graph LR
N0["source (0)"] -->|"residual 1"| N1["1"]
N1 -.->|"back-edge 2"| N0
N0 -->|"cap 2"| N2["2"]
N1 -->|"residual 0"| N3["sink (3)"]
N3 -.->|"back-edge 2"| N1
N1 -->|"cap 1"| N2
N2 -->|"cap 3"| N3
The forward edges shrink by the flow pushed; the dashed back edges grow by that same amount. An augmenting-path search treats any edge with positive residual capacity — forward or back — as usable.
The max-flow min-cut theorem
Split the nodes into two sets SS (containing ss) and TT (containing tt).
The cut capacity is the sum of capacities of edges going from SS to
TT. The theorem:
In words: the maximum flow you can push equals the smallest “choke
point” separating source from sink. In the network above, cutting
S = {0}S = {0} from everything else costs 3 + 2 = 53 + 2 = 5 — and, as the code below
confirms, 5 is exactly the max flow. No cut can ever be cheaper than the
true max flow, and no flow can ever exceed the cheapest cut — that’s the
theorem’s whole content.
Ford-Fulkerson with BFS: Edmonds-Karp
“Ford-Fulkerson” is a method — repeatedly find any augmenting path from
ss to tt in the residual graph, push the bottleneck capacity along it,
repeat until no path remains. Edmonds-Karp is Ford-Fulkerson with one
specific rule: always find the augmenting path using BFS (fewest edges
first). That single choice is what gives it a guaranteed polynomial bound.
from collections import deque, defaultdict
def edmonds_karp(capacity, source, sink):
graph = defaultdict(list) # adjacency: who does each node point at (either direction)
cap = defaultdict(int) # residual capacity for every directed pair
for (u, v), c in capacity.items():
graph[u].append(v)
graph[v].append(u) # reverse direction exists in the residual graph too
cap[(u, v)] += c # cap[(v, u)] starts at 0 -- the back edge
def bfs_augmenting_path():
parent = {source: None}
queue = deque([source])
while queue:
u = queue.popleft()
if u == sink:
break
for v in graph[u]:
if v not in parent and cap[(u, v)] > 0:
parent[v] = u
queue.append(v)
if sink not in parent:
return None, 0
bottleneck = float("inf")
v = sink
while parent[v] is not None:
u = parent[v]
bottleneck = min(bottleneck, cap[(u, v)])
v = u
v = sink
while parent[v] is not None:
u = parent[v]
cap[(u, v)] -= bottleneck # use up forward capacity
cap[(v, u)] += bottleneck # grow the back edge by the same amount
v = u
return True, bottleneck
max_flow = 0
while True:
found, bottleneck = bfs_augmenting_path()
if not found:
break
max_flow += bottleneck
return max_flow
capacity = {
(0, 1): 3, (0, 2): 2,
(1, 3): 2, (1, 2): 1,
(2, 3): 3,
}
print("max flow:", edmonds_karp(capacity, source=0, sink=3))from collections import deque, defaultdict
def edmonds_karp(capacity, source, sink):
graph = defaultdict(list) # adjacency: who does each node point at (either direction)
cap = defaultdict(int) # residual capacity for every directed pair
for (u, v), c in capacity.items():
graph[u].append(v)
graph[v].append(u) # reverse direction exists in the residual graph too
cap[(u, v)] += c # cap[(v, u)] starts at 0 -- the back edge
def bfs_augmenting_path():
parent = {source: None}
queue = deque([source])
while queue:
u = queue.popleft()
if u == sink:
break
for v in graph[u]:
if v not in parent and cap[(u, v)] > 0:
parent[v] = u
queue.append(v)
if sink not in parent:
return None, 0
bottleneck = float("inf")
v = sink
while parent[v] is not None:
u = parent[v]
bottleneck = min(bottleneck, cap[(u, v)])
v = u
v = sink
while parent[v] is not None:
u = parent[v]
cap[(u, v)] -= bottleneck # use up forward capacity
cap[(v, u)] += bottleneck # grow the back edge by the same amount
v = u
return True, bottleneck
max_flow = 0
while True:
found, bottleneck = bfs_augmenting_path()
if not found:
break
max_flow += bottleneck
return max_flow
capacity = {
(0, 1): 3, (0, 2): 2,
(1, 3): 2, (1, 2): 1,
(2, 3): 3,
}
print("max flow:", edmonds_karp(capacity, source=0, sink=3))Complexity
Each BFS is , and Edmonds-Karp needs at most augmentations, giving total. That’s polynomial and safe for CP-sized graphs (hundreds to low thousands of nodes/edges); pure Ford-Fulkerson’s bound depends on the capacities themselves and can be far worse.
Modeling: bipartite matching as flow
Maximum bipartite matching — pairing up left-side and right-side nodes,
one-to-one, using only compatible pairs — is a max-flow problem in
disguise: source ->-> every left node (capacity 1), every right node ->->
sink (capacity 1), and a capacity-1 edge between every compatible
(left, right)(left, right) pair. Max flow = maximum matching size. In practice, it’s
usually solved with a direct augmenting-path search (Kuhn’s algorithm)
rather than building the full flow network explicitly — same idea, less
bookkeeping:
def max_bipartite_matching(left_n, right_n, adj):
match_right = [-1] * right_n # match_right[r] = which left node is matched to right node r
def try_augment(l, visited):
for r in adj[l]:
if not visited[r]:
visited[r] = True
# if r is free, OR its current match can be rerouted elsewhere, take r
if match_right[r] == -1 or try_augment(match_right[r], visited):
match_right[r] = l
return True
return False
matching_size = 0
for l in range(left_n):
visited = [False] * right_n
if try_augment(l, visited):
matching_size += 1
return matching_size, match_right
# Left nodes 0,1,2 (e.g. workers); right nodes 0,1,2 (e.g. tasks they can do).
adj = {
0: [0, 1],
1: [0],
2: [1, 2],
}
size, match_right = max_bipartite_matching(3, 3, adj)
print("max matching size:", size)
print("right -> left assignment:", match_right)def max_bipartite_matching(left_n, right_n, adj):
match_right = [-1] * right_n # match_right[r] = which left node is matched to right node r
def try_augment(l, visited):
for r in adj[l]:
if not visited[r]:
visited[r] = True
# if r is free, OR its current match can be rerouted elsewhere, take r
if match_right[r] == -1 or try_augment(match_right[r], visited):
match_right[r] = l
return True
return False
matching_size = 0
for l in range(left_n):
visited = [False] * right_n
if try_augment(l, visited):
matching_size += 1
return matching_size, match_right
# Left nodes 0,1,2 (e.g. workers); right nodes 0,1,2 (e.g. tasks they can do).
adj = {
0: [0, 1],
1: [0],
2: [1, 2],
}
size, match_right = max_bipartite_matching(3, 3, adj)
print("max matching size:", size)
print("right -> left assignment:", match_right)Each call to try_augmenttry_augment is exactly an augmenting-path search in the
residual graph of the equivalent flow network — “if my preferred task is
taken, can its current worker be rerouted to a different task?” is the
same rerouting idea the residual graph’s back edges capture above.
Practice — real LeetCode problems
A warning before you start: LeetCode almost never wants a literal Dinic implementation. What it asks instead are matching and assignment problems — the things max flow was invented to solve — with constraints small enough that a bitmask DP fits. So each exercise below is graded on the DP that actually passes, and each editorial names the flow formulation you would reach for if the constraints grew. Knowing both, and knowing which one the constraints are asking for, is the actual skill.
LC 1349 — Maximum Students Taking Exam · Hard
Problem. In a classroom grid, "#""#" is a broken seat and ".""." is usable. A
student can copy from the seats immediately left, right, upper-left and
upper-right of them — but not directly in front. Return the maximum number of
students who can sit with no one able to copy from anyone.
Constraints. 1 <= rows, cols <= 81 <= rows, cols <= 8.
Examples.
[["#",".","#","#",".","#"],[".","#","#","#","#","."],["#",".","#","#",".","#"]][["#",".","#","#",".","#"],[".","#","#","#","#","."],["#",".","#","#",".","#"]]
gives 44 · [[".","#"],["#","#"],["#","."],["#","#"],[".","#"]][[".","#"],["#","#"],["#","."],["#","#"],[".","#"]] gives 33
Editorial · approach, complexity, follow-ups
The DP. Only the previous row can interact with the current one, so the state
is (row, mask of the previous row). With cols <= 8cols <= 8 there are 256 masks, and
rows * 256 * 256rows * 256 * 256 is about 500,000 operations.
The three legality tests, each one shift:
| Test | Meaning |
|---|---|
cur & ~freecur & ~free | someone is on a broken seat |
cur & (cur << 1)cur & (cur << 1) | two students are horizontally adjacent |
prev & (cur << 1)prev & (cur << 1), prev & (cur >> 1)prev & (cur >> 1) | a student can copy diagonally |
Why this is a flow problem underneath. The conflict graph — one node per
usable seat, an edge for every copying pair — is bipartite: colour each seat
by the parity of its column. Every conflict, horizontal or diagonal, joins columns
cc and c ± 1c ± 1, so it always joins opposite parities. There is never an
odd cycle.
That matters because of König’s theorem: in a bipartite graph, the maximum
independent set has size V - (maximum matching)V - (maximum matching). Seating students with no
conflicts is exactly a maximum independent set on that graph. So the answer is
also (number of usable seats) minus (maximum bipartite matching), and maximum
bipartite matching is max flow with unit capacities — Hopcroft-Karp, or plain
Hungarian augmenting paths.
Both routes are correct. The DP is , so it lives or dies
by the column count. The flow route is in the seat count and does
not care how wide the room is. LeetCode caps the grid at 8 x 88 x 8, which is a clear
vote for the DP — but naming König’s theorem is what turns a passing answer into a
strong one.
- The diagonal rule is not symmetric with the vertical one. Directly in front
is explicitly allowed, so there is no
prev & curprev & curtest. Adding one is the most common wrong answer. - A fully broken row contributes only the mask 0, and the DP continues through
it correctly —
bestbestnever becomes empty becausecur = 0cur = 0always survives. [["#"]][["#"]]gives 0. No usable seats, andmax(best.values())max(best.values())is 0 rather than an error, because the mask-0 entry is always there.cur & ~freecur & ~freerelies on Python’s arbitrary-precision two’s complement. It works, butcur & free != curcur & free != cursays the same thing more plainly.- Only the previous row matters, not all rows above. Convincing yourself of that is what justifies the state.
Follow-ups you should expect: “What if there were 30 columns?” — is
hopeless, so switch to the matching formulation. “What if the vertical neighbour
also conflicted?” — add prev & curprev & cur to the test; the graph stays bipartite by
column parity. “Knight-move conflicts?” — still bipartite by colour, so matching
still applies. “Return the actual seating?” — store the predecessor mask per state
and walk back. “Maximum independent set on a general graph?” — NP-hard; the
bipartite structure here is the entire reason it is tractable.
LC 1879 — Minimum XOR Sum of Two Arrays · Hard
Problem. Given two arrays of equal length nn, rearrange nums2nums2 to minimise
the XOR sum sum(nums1[i] ^ nums2[i])sum(nums1[i] ^ nums2[i]). Return that minimum.
Constraints. 1 <= n <= 141 <= n <= 14, 0 <= nums1[i], nums2[i] < 10**70 <= nums1[i], nums2[i] < 10**7.
Examples. nums1 = [1,2], nums2 = [2,3]nums1 = [1,2], nums2 = [2,3] gives 22
(swap to [3,2][3,2]: 1^3 + 2^2 = 2 + 01^3 + 2^2 = 2 + 0) ·
nums1 = [1,0,3], nums2 = [5,3,4]nums1 = [1,0,3], nums2 = [5,3,4] gives 88
Editorial · approach, complexity, follow-ups
The assignment problem: pair up two sets of nn items at minimum total cost.
Brute force is — about 87 billion at n = 14n = 14. The bitmask DP is
, roughly 230,000 states-times-transitions. That gap is why the
constraint is 14 and not 20.
The state trick. ii and maskmask are redundant: you have placed exactly
popcount(mask)popcount(mask) entries of nums1nums1, so i == popcount(mask)i == popcount(mask) always. Keeping both
is clearer and memoizing on the pair is harmless — but mentioning that one number
suffices shows you have understood the structure, and it halves the memo key.
The flow view. This is min-cost bipartite perfect matching. Build a source
into every nums1nums1 node with capacity 1, an edge i -> ji -> j with capacity 1 and cost
nums1[i] ^ nums2[j]nums1[i] ^ nums2[j], and every nums2nums2 node into a sink with capacity 1. Min-cost
max-flow gives the answer, as does the Hungarian algorithm in —
polynomial, and therefore the right answer if nn were 200 instead of 14. Naming it
is worth real credit.
Time . Space .
- Greedy fails. Pairing each
nums1[i]nums1[i]with whichevernums2[j]nums2[j]minimises its own XOR is wrong:[1,2][1,2]with[2,3][2,3]greedily takes1^3 = 21^3 = 2and then is forced into2^2 = 02^2 = 0, which happens to be optimal, but[1,0,3][1,0,3]with[5,3,4][5,3,4]breaks it. A local choice steals the partner another element needed more. - XOR is not monotonic, so sorting both arrays is not a valid strategy either — and that is the intuition most people reach for first.
n = 1n = 1returnsnums1[0] ^ nums2[0]nums1[0] ^ nums2[0], which is 0 for[0][0]and[0][0].[1,2,3][1,2,3]with[3,2,1][3,2,1]returns 0 — a perfect reversal pairs each with its equal.cache_clear()cache_clear()matters because the cache is created per call here but the closure capturesnums1nums1andnums2nums2; clearing it keeps memory flat when the grader runs many cases.
Follow-ups you should expect: ”n = 200n = 200?” — the Hungarian algorithm, .
“Which pairing?” — store the chosen jj per state and walk forward. “Maximise
instead?” — swap minmin for maxmax; nothing else changes. “Unequal array lengths?” —
pad with zero-cost dummy nodes. “Minimise the maximum pair XOR instead of the
sum?” — binary search the answer and test for a perfect matching using only edges
below the threshold; that is bottleneck assignment, and a genuinely different
algorithm.
LC 1595 — Minimum Cost to Connect Two Groups of Points · Hard
Problem. Two groups of sizes mm and nn with m <= nm <= n. Connecting point ii
of group 1 to point jj of group 2 costs cost[i][j]cost[i][j]. Every point in both groups
must end up connected to at least one point in the other group. Return the minimum
total cost.
Constraints. 1 <= m, n <= 121 <= m, n <= 12, 1 <= cost[i][j] <= 1001 <= cost[i][j] <= 100.
Examples. cost = [[15,96],[36,2]]cost = [[15,96],[36,2]] gives 1717 ·
cost = [[1,3,5],[4,1,1],[1,5,3]]cost = [[1,3,5],[4,1,1],[1,5,3]] gives 44 ·
cost = [[2,5,1],[3,4,7],[8,1,2],[6,2,4],[3,8,8]]cost = [[2,5,1],[3,4,7],[8,1,2],[6,2,4],[3,8,8]] gives 1010
Editorial · approach, complexity, follow-ups
The word to notice is at least one. This is an edge cover, not a matching: a point may take several edges, and the two groups need not be the same size.
Two facts make the DP small, and both are worth stating as claims you can defend:
- One edge per group-1 point is enough while sweeping. Giving a group-1 point a second edge only helps by covering some group-2 point — and the final patch-up step will cover that point anyway, at a cost no higher than any specific choice here. So the sweep never needs to branch on multiple edges.
- The leftovers are independent. Once group 1 is exhausted, each uncovered group-2 point may attach to whichever group-1 point it likes, with no interaction between them. So each simply takes its column minimum, precomputed once.
Together the state is (group-1 index, covered mask), giving states with transitions each.
The flow view. Minimum-cost edge cover on a bipartite graph is solvable in
polynomial time — it reduces to minimum-cost perfect matching on a transformed
graph, and hence to min-cost max-flow. That is the answer for large inputs.
With m, n <= 12m, n <= 12, is about 590,000 operations, so the
bitmask DP wins on simplicity.
Time . Space .
- Greedy fails. Giving every point its own cheapest edge double-counts and
overshoots: on
[[1,3,5],[4,1,1],[1,5,3]][[1,3,5],[4,1,1],[1,5,3]]the row minima sum to 3 but the answer is 4, because those choices leave a group-2 point uncovered. - The base case must be a sum, not zero. Forgetting to charge the uncovered group-2 points is the main wrong answer, and it silently under-reports.
m <= nm <= nis promised by the problem but the code never relies on it — the sweep is over group 1 and the mask over group 2 either way.1 x 11 x 1returns the single cost, since both points need each other.- All costs are positive, which is what makes “cover with as few edges as possible” the right instinct. With zero or negative costs the argument for one edge per point would need re-examining.
Follow-ups you should expect: ”n = 30n = 30?” — states is too many;
switch to min-cost flow. “Each point needs at least kk connections?” — the mask
must count, not just flag, so the state becomes base-(k+1)(k+1) digits and grows fast.
“Maximise total cost instead?” — the one-edge argument breaks, because extra edges
now always help; you would take every edge. “Return the connections?” — store the
chosen jj per state, then add the patch-up edges. “Why not just matching?” — a
perfect matching does not exist when m != nm != n, which is exactly why this is a cover.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| — | Maximum Bipartite Matching (classic) | — | The augmenting-path matching code above, usually framed as a GfG/CP-judge problem rather than a single canonical LeetCode number |
Beyond that one, max flow rarely appears by name on LeetCode — but recognizing “this is secretly a flow/matching problem” is a real skill for harder assignment- and scheduling-style questions, where flow gives a correct answer even when a direct combinatorial argument doesn’t.
Recap
- A flow network has a source, a sink, and capacities; a valid flow respects capacities and conserves flow at every other node.
- The max-flow min-cut theorem: max flow always equals the cheapest cut separating source from sink.
- The residual graph’s back edges are what let an algorithm reroute flow instead of committing to a bad early choice.
- Edmonds-Karp = Ford-Fulkerson with BFS-found augmenting paths, giving a guaranteed bound.
- Bipartite matching is max flow with unit capacities everywhere — many real assignment problems reduce to this same shape once you find the right source/sink/capacities.
That closes out the advanced-graphs phase — between cycle detection, SCCs/bridges, and max flow, you now have the tools for the graph problems that go beyond plain BFS/DFS traversal.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
