Skip to content

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?

diagram A small flow network: edge labels are capacities mermaid

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.

diagram Residual graph after pushing 2 units along 0 -> 1 -> 3 mermaid

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:

max flow(s,t)  =  minS,TuS,  vTcap(u,v)\text{max flow}(s, t) \;=\; \min_{S,\,T} \sum_{u \in S,\; v \in T} \text{cap}(u, v)

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.

edmonds_karp.py
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))
edmonds_karp.py
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 O(E)O(E), and Edmonds-Karp needs at most O(VE)O(VE) augmentations, giving O(VE2)O(V E^2) 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:

bipartite_matching.py
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)
bipartite_matching.py
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:

TestMeaning
cur & ~freecur & ~freesomeone 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 O(rows4cols)O(rows \cdot 4^{cols}), so it lives or dies by the column count. The flow route is O(EV)O(E\sqrt{V}) 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 & cur test. Adding one is the most common wrong answer.
  • A fully broken row contributes only the mask 0, and the DP continues through it correctly — bestbest never becomes empty because cur = 0cur = 0 always survives.
  • [["#"]][["#"]] gives 0. No usable seats, and max(best.values())max(best.values()) is 0 rather than an error, because the mask-0 entry is always there.
  • cur & ~freecur & ~free relies on Python’s arbitrary-precision two’s complement. It works, but cur & free != curcur & free != cur says 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?” — 4304^{30} 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 n!n! — about 87 billion at n = 14n = 14. The bitmask DP is O(2nn)O(2^n \cdot n), 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 O(n3)O(n^3) — polynomial, and therefore the right answer if nn were 200 instead of 14. Naming it is worth real credit.

Time O(2nn)O(2^n \cdot n). Space O(2n)O(2^n).

  • Greedy fails. Pairing each nums1[i]nums1[i] with whichever nums2[j]nums2[j] minimises its own XOR is wrong: [1,2][1,2] with [2,3][2,3] greedily takes 1^3 = 21^3 = 2 and then is forced into 2^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 = 1 returns nums1[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 captures nums1nums1 and nums2nums2; clearing it keeps memory flat when the grader runs many cases.

Follow-ups you should expect:n = 200n = 200?” — the Hungarian algorithm, O(n3)O(n^3). “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:

  1. 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.
  2. 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 m2nm \cdot 2^n states with nn 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, 1240961212 \cdot 4096 \cdot 12 is about 590,000 operations, so the bitmask DP wins on simplicity.

Time O(m2nn)O(m \cdot 2^n \cdot n). Space O(m2n)O(m \cdot 2^n).

  • 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 <= n is 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 1 returns 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?” — 2302^{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

#ProblemDifficultyThe 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 O(VE2)O(V E^2) 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 coffee

Was this page helpful?

Let us know how we did