Ford-Fulkerson, max-flow min-cut, bipartite matching — optimizing flow through networks.
You are an engineer at a water utility. Your city has a reservoir (the source) and a treatment plant (the sink). Between them lies a network of pipes, each with a maximum capacity in gallons per minute. Some pipes are fat, some are thin. Some paths from reservoir to plant pass through many intermediate junctions. The question every city planner asks: what is the maximum volume of water we can push from the reservoir to the treatment plant per minute?
This is the maximum flow problem. It appears everywhere: data through a computer network, cars through a highway system, goods through a supply chain, electrical current through a circuit. Any time you have a directed graph with edge capacities and you want to move as much "stuff" as possible from point A to point B, you have a max flow problem.
The simulation below shows a pipe network. Water flows from the source S (left, blue) to the sink T (right, green). Each pipe has a capacity. Click "Send Flow" to push water through the network greedily. Notice how some pipes become fully used (saturated) while others have leftover room. The total flow reaching T is shown at the top. Is this the maximum possible? Can we do better?
Click "Send Flow" to push water through the network. "Reset" to start over. Watch which pipes saturate first.
The greedy approach — always picking the first available path and sending as much flow as possible — does not always find the maximum. Sometimes you need to "undo" flow on one path to reroute it through a better one. This is the key insight of the Ford-Fulkerson method, which we will derive from scratch in Chapter 2.
By the end of this lesson, you will be able to: (1) model real-world problems as flow networks, (2) run Ford-Fulkerson and Edmonds-Karp by hand, (3) prove the max-flow min-cut theorem, (4) reduce bipartite matching to max flow, and (5) code everything in Python. Let us begin.
Before we can maximize flow, we need to define precisely what a flow network is and what rules flow must obey. A flow network is a directed graph G = (V, E) with two special vertices: a source s (where flow originates) and a sink t (where flow is consumed). Every edge (u, v) has a non-negative capacity c(u, v) ≥ 0. If edge (u, v) is not in E, we define c(u, v) = 0.
A flow in this network is a function f : V × V → R that assigns a real number to every pair of vertices, subject to two constraints.
For all u, v ∈ V:
You cannot send more flow through a pipe than its capacity allows. You also cannot send negative flow (that would mean flow is going backwards, which we handle differently via the residual graph in Chapter 2).
For all u ∈ V − {s, t}:
For every vertex that is not the source or sink, the total flow coming in must equal the total flow going out. No flow is created or destroyed at intermediate nodes. Think of it as water flowing through junctions — what pours in must pour out.
The value of a flow f, denoted |f|, is the total net flow leaving the source:
By flow conservation, this also equals the total net flow entering the sink. The maximum flow problem asks: find a flow f that maximizes |f| while respecting both constraints.
Consider this network with 4 vertices {s, a, b, t} and 5 edges:
| Edge | Capacity |
|---|---|
| s → a | 10 |
| s → b | 10 |
| a → b | 2 |
| a → t | 8 |
| b → t | 12 |
One valid flow: f(s,a)=8, f(s,b)=10, f(a,b)=0, f(a,t)=8, f(b,t)=10. Check conservation at a: in=8, out=8. At b: in=10, out=10. Value = 18. Can we do better? We could push f(s,a)=10, f(a,b)=2, f(a,t)=8, f(s,b)=10, f(b,t)=12. Check: at a, in=10, out=10. At b, in=10+2=12, out=12. Value = 20. This is the maximum.
Adjust the flow on each edge using sliders. The sim enforces capacity constraints (red if violated) and shows flow conservation at each node. Try to maximize |f|.
python # Flow network representation using adjacency dict def create_flow_network(): # capacity[u][v] = capacity of edge u->v capacity = { 's': {'a': 10, 'b': 10}, 'a': {'b': 2, 't': 8}, 'b': {'t': 12}, 't': {} } # flow[u][v] = current flow on edge u->v flow = {u: {v: 0 for v in capacity[u]} for u in capacity} return capacity, flow def flow_value(flow, source): """Total flow leaving the source.""" return sum(flow[source].values()) def check_conservation(flow, node, all_nodes): """Check flow in == flow out at a node.""" flow_in = sum(flow[u].get(node, 0) for u in all_nodes) flow_out = sum(flow[node].get(v, 0) for v in all_nodes) return flow_in == flow_out
We now know what a valid flow is. But how do we find the maximum flow? The Ford-Fulkerson method is a beautifully simple idea: keep finding paths from s to t that can carry more flow, and send flow along them, until no such path exists.
The magic ingredient is the residual graph. Given a flow network G and a flow f, the residual graph Gf tells us how much additional flow we can push through each edge — including the possibility of "undoing" flow we already sent.
For every pair of vertices u, v, the residual capacity is:
The forward residual is the remaining capacity on an edge — how much more you can push forward. The backward residual is the flow currently on an edge — how much you can "cancel" by pushing flow in the reverse direction. This is the key trick. By allowing backward edges in the residual graph, we let the algorithm correct suboptimal earlier decisions.
An augmenting path is any path from s to t in the residual graph Gf where every edge has positive residual capacity. The bottleneck of this path is the minimum residual capacity along it — this is how much additional flow we can send.
Network: s → a (cap 10), s → b (cap 10), a → b (cap 2), a → t (cap 8), b → t (cap 12).
Iteration 1: Find path s → a → t. Residual caps: s→a is 10, a→t is 8. Bottleneck = 8. Send 8 units. Now f(s,a)=8, f(a,t)=8. Residual: s→a has 2 left, a→t has 0 left (saturated), backward edges a→s=8 and t→a=8 appear.
Iteration 2: Find path s → b → t. Residual caps: s→b is 10, b→t is 12. Bottleneck = 10. Send 10. Now f(s,b)=10, f(b,t)=10. Total flow = 18.
Iteration 3: Find path s → a → b → t. Residual caps: s→a is 2, a→b is 2, b→t is 2. Bottleneck = 2. Send 2. Now f(s,a)=10, f(a,b)=2, f(b,t)=12. Total flow = 20.
Iteration 4: No more augmenting paths (s→a is 0, s→b is 0). Done. Maximum flow = 20.
Click "Next Step" to watch Ford-Fulkerson find augmenting paths and update the residual graph. The blue edges show the current augmenting path. Orange numbers show flow/capacity.
python from collections import defaultdict def ford_fulkerson(graph, source, sink): """ graph: dict of dict, graph[u][v] = capacity Returns: max flow value, flow dict """ # Build adjacency with reverse edges for residual graph capacity = defaultdict(lambda: defaultdict(int)) for u in graph: for v in graph[u]: capacity[u][v] = graph[u][v] flow = defaultdict(lambda: defaultdict(int)) max_flow = 0 def dfs(node, target, visited, bottleneck): """Find augmenting path via DFS, return bottleneck.""" if node == target: return bottleneck visited.add(node) for neighbor in capacity: residual = capacity[node][neighbor] - flow[node][neighbor] if residual > 0 and neighbor not in visited: pushed = dfs(neighbor, target, visited, min(bottleneck, residual)) if pushed > 0: flow[node][neighbor] += pushed flow[neighbor][node] -= pushed # reverse edge return pushed return 0 while True: pushed = dfs(source, sink, set(), float('inf')) if pushed == 0: break max_flow += pushed return max_flow, flow # Example usage G = {'s': {'a': 10, 'b': 10}, 'a': {'b': 2, 't': 8}, 'b': {'t': 12}, 't': {}} result, flows = ford_fulkerson(G, 's', 't') print(f"Max flow: {result}") # Max flow: 20
Ford-Fulkerson says "find any augmenting path." But which path should we pick? DFS wanders deep into the graph and might find a long, winding path with a tiny bottleneck. What if we always picked the shortest augmenting path (fewest edges)?
That single change — use BFS instead of DFS to find augmenting paths — transforms Ford-Fulkerson into the Edmonds-Karp algorithm. The runtime becomes O(VE2), completely independent of the maximum flow value. This is a polynomial guarantee that DFS-based Ford-Fulkerson lacks.
The key insight: when we use BFS, the shortest-path distance from s to any vertex v in the residual graph never decreases across iterations. It can only stay the same or increase. This is called the monotonicity lemma.
Why does this help? Each augmenting path saturates at least one edge (the bottleneck). Once an edge is saturated, the shortest-path distance to its endpoints must increase by at least 2 before that edge can become unsaturated again (via a backward edge). Since shortest paths can increase at most V times, each edge can be a bottleneck at most V/2 times. There are at most E edges. So the total number of augmentations is at most O(VE). Each BFS takes O(E). Total: O(VE · E) = O(VE2).
Consider: s → a (cap 1000), s → b (cap 1000), a → b (cap 1), a → t (cap 1000), b → t (cap 1000). Max flow = 2000.
DFS worst case: If DFS alternately finds s→a→b→t and s→b→a→t (using the a↔b edge and its reverse), each sending only 1 unit, it takes 2000 iterations.
BFS (Edmonds-Karp): BFS finds s→a→t (length 2) first, sends 1000. Then s→b→t (length 2), sends 1000. Done in 2 iterations.
Watch BFS (Edmonds-Karp, teal) vs DFS (Ford-Fulkerson, orange) find augmenting paths on the same graph. BFS finds shorter, more productive paths.
python from collections import defaultdict, deque def edmonds_karp(graph, source, sink): """Edmonds-Karp: Ford-Fulkerson with BFS. O(VE^2).""" capacity = defaultdict(lambda: defaultdict(int)) adj = defaultdict(set) # adjacency list for BFS for u in graph: for v, cap in graph[u].items(): capacity[u][v] += cap adj[u].add(v) adj[v].add(u) # reverse edge for residual flow = defaultdict(lambda: defaultdict(int)) max_flow = 0 while True: # BFS to find shortest augmenting path parent = {source: None} queue = deque([source]) while queue and sink not in parent: u = queue.popleft() for v in adj[u]: residual = capacity[u][v] - flow[u][v] if residual > 0 and v not in parent: parent[v] = u queue.append(v) if sink not in parent: break # no augmenting path # Trace path and find bottleneck bottleneck = float('inf') v = sink while parent[v] is not None: u = parent[v] bottleneck = min(bottleneck, capacity[u][v] - flow[u][v]) v = u # Augment flow along path v = sink while parent[v] is not None: u = parent[v] flow[u][v] += bottleneck flow[v][u] -= bottleneck # maintain skew symmetry v = u max_flow += bottleneck return max_flow
The max-flow min-cut theorem is one of the most beautiful results in combinatorial optimization. It says that the maximum amount of flow you can push through a network equals the minimum capacity of edges you need to cut to completely disconnect the source from the sink. Two seemingly different optimization problems have the same answer.
An s-t cut (S, T) is a partition of the vertices into two sets S and T such that s ∈ S and t ∈ T. The capacity of the cut is the total capacity of edges crossing from S to T:
Think of it as: if you severed every edge from S to T, no flow could reach the sink. The capacity of the cut is the "cost" of severing those edges. A minimum cut is the s-t cut with the smallest capacity.
Note: edges from T to S do not count toward the cut capacity. This is asymmetric — only edges going from the source side to the sink side matter.
The following three statements are equivalent:
(1 ⇒ 2): If f is maximum and there exists an augmenting path, we could increase the flow — contradiction.
(2 ⇒ 3): If there is no augmenting path, define S = {vertices reachable from s in Gf} and T = V − S. Since t is not reachable, t ∈ T. For any edge (u, v) with u ∈ S, v ∈ T: the residual capacity cf(u, v) = 0 (otherwise v would be reachable). This means f(u, v) = c(u, v) — every forward edge across the cut is saturated. And for any edge (v, u) with v ∈ T, u ∈ S: f(v, u) = 0 (otherwise the backward edge would make v reachable from S). So the net flow across the cut equals ∑ c(u, v) = c(S, T).
(3 ⇒ 1): For any flow f and any cut (S, T), the net flow across the cut equals |f| (by flow conservation — what leaves S must equal what leaves s). Since f(u, v) ≤ c(u, v) for each edge, |f| ≤ c(S, T). If |f| = c(S, T), then f is maximum (no flow can exceed any cut capacity).
After running max flow (e.g., Edmonds-Karp), the min cut falls out for free:
Click "Run Max Flow" to find the maximum flow. Then click "Show Min Cut" to see which edges form the minimum cut. The cut capacity equals the max flow value.
python def find_min_cut(graph, source, sink): """Find min cut after running max flow.""" # First, compute max flow (reuse Edmonds-Karp internals) capacity = defaultdict(lambda: defaultdict(int)) adj = defaultdict(set) for u in graph: for v, cap in graph[u].items(): capacity[u][v] += cap adj[u].add(v) adj[v].add(u) flow = defaultdict(lambda: defaultdict(int)) # Run Edmonds-Karp (same as before)... max_flow = 0 while True: parent = {source: None} queue = deque([source]) while queue and sink not in parent: u = queue.popleft() for v in adj[u]: if capacity[u][v] - flow[u][v] > 0 and v not in parent: parent[v] = u queue.append(v) if sink not in parent: break bottleneck = float('inf') v = sink while parent[v] is not None: u = parent[v]; bottleneck = min(bottleneck, capacity[u][v] - flow[u][v]); v = u v = sink while parent[v] is not None: u = parent[v]; flow[u][v] += bottleneck; flow[v][u] -= bottleneck; v = u max_flow += bottleneck # BFS from source in residual graph to find S S = set() queue = deque([source]) S.add(source) while queue: u = queue.popleft() for v in adj[u]: if v not in S and capacity[u][v] - flow[u][v] > 0: S.add(v) queue.append(v) # Min cut edges: (u,v) where u in S, v not in S, and capacity > 0 cut_edges = [] for u in S: for v in adj[u]: if v not in S and capacity[u][v] > 0: cut_edges.append((u, v, capacity[u][v])) return max_flow, S, cut_edges
This is the payoff chapter. Everything we have learned — flow networks, Ford-Fulkerson, residual graphs, max-flow min-cut — comes together to solve one of the most practical problems in computer science: bipartite matching.
You have 5 students and 5 projects. Each student is interested in some subset of projects. Assign students to projects so that (1) each student gets at most one project, (2) each project is assigned to at most one student, and (3) the total number of assigned pairs is maximized. This is the maximum bipartite matching problem.
A bipartite graph is a graph whose vertices can be divided into two sets L (left) and R (right) such that every edge goes from L to R. There are no edges within L or within R. A matching is a set of edges where no vertex appears more than once. A maximum matching is a matching with the most edges possible.
Here is the brilliant reduction: transform the bipartite matching problem into a max flow problem.
Why this works: Capacity 1 on all edges means each student (left vertex) can be "used" at most once (s sends at most 1 unit to each L vertex) and each project (right vertex) can absorb at most 1 unit (each R vertex sends at most 1 to t). Flow conservation ensures each unit of flow represents a single student-project assignment. Max flow maximizes the total assignments.
This is the showcase simulation. You have students on the left and projects on the right. Click pairs to add edges (interest connections). Then click "Find Matching" to run max flow and see the maximum matching highlighted. Try adding and removing edges to see how the matching changes.
Click a student (left), then a project (right) to add an edge. Click "Find Matching" to compute the maximum matching via max flow. "Clear" to start over. "Random" for a random graph.
python def max_bipartite_matching(left, right, edges): """ left: list of left vertices (students) right: list of right vertices (projects) edges: list of (u, v) where u in left, v in right Returns: list of matched (u, v) pairs """ # Build flow network graph = defaultdict(lambda: defaultdict(int)) adj = defaultdict(set) s, t = '__source__', '__sink__' for u in left: graph[s][u] = 1 adj[s].add(u); adj[u].add(s) for v in right: graph[v][t] = 1 adj[v].add(t); adj[t].add(v) for u, v in edges: graph[u][v] = 1 adj[u].add(v); adj[v].add(u) # Run Edmonds-Karp flow = defaultdict(lambda: defaultdict(int)) max_flow = 0 while True: parent = {s: None} queue = deque([s]) while queue and t not in parent: u = queue.popleft() for v in adj[u]: if v not in parent and graph[u][v] - flow[u][v] > 0: parent[v] = u; queue.append(v) if t not in parent: break v = t while parent[v] is not None: u = parent[v] flow[u][v] += 1; flow[v][u] -= 1 v = u max_flow += 1 # Extract matched pairs matching = [] for u in left: for v in right: if flow[u][v] > 0: matching.append((u, v)) return matching # Example students = ['Alice', 'Bob', 'Carol', 'Dave'] projects = ['ML', 'Web', 'DB', 'OS'] interests = [('Alice','ML'), ('Alice','Web'), ('Bob','ML'), ('Bob','DB'), ('Carol','Web'), ('Dave','OS')] result = max_bipartite_matching(students, projects, interests) print(result) # e.g. [('Alice','Web'), ('Bob','ML'), ('Carol','Web'?), ('Dave','OS')]
In a bipartite graph, the size of the maximum matching equals the size of the minimum vertex cover (the smallest set of vertices that touches every edge). This is the bipartite version of max-flow min-cut, and it follows directly from it. The min cut in our flow network corresponds to a minimum vertex cover.
Max flow is a universal tool. Dozens of problems that look nothing like "pushing water through pipes" can be reduced to max flow. The art is recognizing the reduction. Here are six classic applications, each with its reduction explained.
You have an image. Each pixel should be classified as "foreground" or "background." For each pixel i, you have a likelihood score fi (how likely it is foreground) and bi (how likely it is background). Adjacent pixels that get different labels incur a penalty pij (because natural images have smooth regions).
Reduction: Create a flow network. Source = foreground, sink = background. For each pixel i: edge from s to i with capacity fi, edge from i to t with capacity bi. For each pair of adjacent pixels i, j: edges (i, j) and (j, i) each with capacity pij. The minimum cut assigns each pixel to S (foreground) or T (background), minimizing the total cost of misclassified pixels plus boundary penalties.
Can team X still win the league? This seemingly simple question has a surprisingly elegant max-flow solution. If every team that could possibly overtake X has to play each other, and there is not enough "capacity" for them to all lose enough games, then X is eliminated.
Reduction: Source connects to game-nodes (each remaining game between two teams). Each game-node connects to the two teams playing, with capacity ∞ (someone must win). Each team connects to the sink with capacity = max(wins X can finish with) − current wins of that team. If max flow does not saturate all source edges, team X is eliminated.
An airline has a set of flight legs that must be flown. Each leg has a departure airport, arrival airport, and time. What is the minimum number of airplanes needed to fly all legs? This reduces to bipartite matching: create a node for each leg, connect leg i to leg j if the same plane could fly leg i then leg j (arrival of i is at same airport as departure of j, with enough turnaround time). Maximum matching = maximum number of legs that can share planes. Minimum planes = total legs − max matching.
Given a communication network, what is the minimum number of edges whose failure disconnects two specific nodes? By the max-flow min-cut theorem (with all capacities = 1), this equals the maximum number of edge-disjoint paths between the two nodes. More disjoint paths = more reliable.
You have a set of projects. Each project has a profit (positive or negative). Some projects depend on others (if you do project A, you must also do project B). Select a subset of projects to maximize total profit, respecting all dependencies. This is the max weight closure problem, solvable via min cut.
You need to assign interviewers to respondents. Each interviewer can handle at most k interviews. Each respondent needs exactly one interview. Interviewers have preferences (will only interview certain respondents). This is bipartite matching with degree constraints — directly a max flow problem.
A simplified 4x4 pixel grid. Each pixel has a foreground score (blue) and background score (red). Adjacent pixels prefer to have the same label. Click "Segment" to find the min cut that optimally labels each pixel.
| Application | Source | Sink | What flows | What is cut |
|---|---|---|---|---|
| Image Segmentation | Foreground | Background | Label assignments | Pixel labels + boundaries |
| Baseball Elimination | Games | Win limits | Game outcomes | Impossible wins |
| Airline Scheduling | Departure legs | Arrival legs | Plane assignments | New planes needed |
| Network Reliability | Node A | Node B | Disjoint paths | Critical edges |
| Project Selection | Profit source | Cost sink | Selected projects | Rejected profit / forced cost |
| Bipartite Matching | Left set | Right set | Assignments | Unmatched vertices |
Edmonds-Karp at O(VE2) is good, but for large graphs (millions of vertices), we need faster algorithms. Three major improvements dominate practice.
Dinic's algorithm (1970) improves on Edmonds-Karp by finding multiple augmenting paths at the same distance before increasing the distance. The key concepts:
Level graph: After BFS, assign each vertex its BFS distance from s. The level graph contains only edges (u, v) where level(v) = level(u) + 1. This prunes backward and sideways edges, leaving only "forward progress" edges.
Blocking flow: A flow in the level graph is "blocking" if every path from s to t in the level graph has at least one saturated edge. You find a blocking flow by doing multiple DFS passes through the level graph, using a "current edge" pointer that avoids re-exploring dead ends. This takes O(VE) per phase.
Phases: Each phase does one BFS (build level graph) + one blocking flow. After a blocking flow, the shortest-path distance increases by at least 1. Since the shortest path can be at most V, there are at most V phases. Total: O(V · VE) = O(V2E).
Push-relabel (Goldberg & Tarjan, 1988) takes a completely different approach. Instead of finding augmenting paths from s to t, it works locally:
Preflow: Unlike a flow, a preflow allows vertices to have more incoming flow than outgoing (creating "excess"). The source pushes as much flow as possible onto its neighbors. Each vertex then tries to push excess forward toward the sink.
Height function: Each vertex has a "height" h(v). Flow can only be pushed downhill: from u to v if h(u) = h(v) + 1. If a vertex has excess but no lower neighbor with residual capacity, it relabels (increases its height) to find one.
Operations: Push(u, v) sends min(excess(u), residual(u,v)) from u to v. Relabel(u) sets h(u) = 1 + min(h(v)) over all v with residual(u,v) > 0. Keep pushing and relabeling until no vertex (except s and t) has excess.
The basic algorithm runs in O(V2E). With the "highest label" heuristic (always process the vertex with the highest excess), it runs in O(V2√E). With the "gap relabeling" optimization, it is O(V3) in the worst case but extremely fast in practice — often faster than Dinic's on dense graphs.
What if each edge has both a capacity and a cost per unit of flow? The min-cost max-flow problem finds the maximum flow with the minimum total cost. This is solved by replacing BFS (find shortest by hops) with Bellman-Ford or SPFA (find shortest by cost) in the augmenting-path algorithm. Each augmentation uses the cheapest available path.
Applications: assigning workers to jobs where each assignment has a different productivity (min-cost max bipartite matching), transportation problems, and network design.
| Algorithm | Time | Best for | Implementation |
|---|---|---|---|
| Edmonds-Karp | O(VE2) | Small graphs, simple problems, interview whiteboard | Easy — just BFS + augment |
| Dinic's | O(V2E) | Competitive programming, unit-capacity (O(E√V)) | Medium — level graph + blocking flow |
| Push-Relabel | O(V2√E) | Dense graphs, max performance in practice | Hard — preflow, heights, gap relabeling |
| Min-Cost Max-Flow | O(V2E · C) | When edge costs matter (assignment problems) | Medium — Bellman-Ford + augment |
Watch Edmonds-Karp (teal) and Dinic's (purple) race to find max flow on the same random graph. The bar shows augmentations completed. Dinic's finds blocking flows in batches.
python def dinic(graph, source, sink): """Dinic's algorithm. O(V^2 E).""" capacity = defaultdict(lambda: defaultdict(int)) adj = defaultdict(set) for u in graph: for v, cap in graph[u].items(): capacity[u][v] += cap adj[u].add(v); adj[v].add(u) flow = defaultdict(lambda: defaultdict(int)) all_nodes = set(adj.keys()) def bfs_levels(): """BFS to build level graph. Returns level dict or None.""" level = {source: 0} queue = deque([source]) while queue: u = queue.popleft() for v in adj[u]: if v not in level and capacity[u][v] - flow[u][v] > 0: level[v] = level[u] + 1 queue.append(v) return level if sink in level else None def dfs_block(u, pushed, level, iterators): """DFS to find blocking flow using current-edge optimization.""" if u == sink: return pushed while True: try: v = next(iterators[u]) except StopIteration: return 0 if level.get(v, -1) == level[u] + 1: residual = capacity[u][v] - flow[u][v] if residual > 0: d = dfs_block(v, min(pushed, residual), level, iterators) if d > 0: flow[u][v] += d; flow[v][u] -= d return d return 0 max_flow = 0 while True: level = bfs_levels() if level is None: break iterators = {u: iter(adj[u]) for u in all_nodes} while True: pushed = dfs_block(source, float('inf'), level, iterators) if pushed == 0: break max_flow += pushed return max_flow
Max flow problems in interviews come in two flavors: (1) implement Ford-Fulkerson / Edmonds-Karp from scratch, and (2) recognize that a problem reduces to max flow. Here is your cheat sheet for both.
| If you see... | Think... | Reduction |
|---|---|---|
| "Maximum number of X that can be assigned/matched" | Bipartite matching | Source → left, left → right, right → sink, cap 1 |
| "Minimum number of X to disconnect/block" | Min cut | Max flow = min cut |
| "Maximum number of edge-disjoint paths" | Max flow with unit caps | Each edge cap 1, max flow = disjoint paths |
| "Maximum number of vertex-disjoint paths" | Vertex splitting | Split each vertex v into v_in and v_out with edge cap 1 |
| "Can all X be satisfied simultaneously?" | Feasibility = max flow saturation | Check if max flow saturates all source edges |
| "Minimum cost assignment" | Min-cost max flow | Add costs to edges, use SPFA-based augmentation |
| "Partition into two groups minimizing penalty" | Min cut | Source = group A, sink = group B, penalties as capacities |
The most common interview ask. You need to code this from memory in 15 minutes. Here is the minimal, clean implementation:
python from collections import deque, defaultdict def max_flow(n, edges, s, t): """ n: number of vertices (0-indexed) edges: list of (u, v, capacity) Returns: maximum flow value """ cap = [[0] * n for _ in range(n)] adj = [[] for _ in range(n)] for u, v, c in edges: cap[u][v] += c adj[u].append(v) adj[v].append(u) # for residual flow = [[0] * n for _ in range(n)] total = 0 while True: # BFS par = [-1] * n par[s] = s q = deque([s]) while q and par[t] == -1: u = q.popleft() for v in adj[u]: if par[v] == -1 and cap[u][v] - flow[u][v] > 0: par[v] = u q.append(v) if par[t] == -1: break # Find bottleneck b = float('inf') v = t while v != s: u = par[v] b = min(b, cap[u][v] - flow[u][v]) v = u # Augment v = t while v != s: u = par[v] flow[u][v] += b flow[v][u] -= b v = u total += b return total
Given adjacency list of a bipartite graph, find maximum matching. The trick: vertex numbering. Left vertices = 0..L-1, right = L..L+R-1, source = L+R, sink = L+R+1.
python def max_matching(L, R, edges): """L, R: number of left/right vertices. edges: list of (l, r).""" n = L + R + 2 s, t = L + R, L + R + 1 flow_edges = [] for i in range(L): flow_edges.append((s, i, 1)) # source to left for j in range(R): flow_edges.append((L + j, t, 1)) # right to sink for l, r in edges: flow_edges.append((l, L + r, 1)) # left to right return max_flow(n, flow_edges, s, t)
| Problem | Key idea | Difficulty |
|---|---|---|
| LeetCode 1349: Max Students Taking Exam | Bipartite matching on grid seats (no adjacent cheating) | Hard |
| LeetCode 1066: Campus Bikes II | Min-cost bipartite matching (workers to bikes) | Medium |
| LeetCode 785: Is Graph Bipartite? | BFS/DFS coloring (prerequisite for matching) | Medium |
| CF 498C: Array and Operations | Reduce prime-factor pairing to bipartite matching | Hard |
| SPOJ MATCHING: Fast bipartite matching | Dinic's or Hopcroft-Karp on bipartite graph | Hard |
Click to place vertices, drag between vertices to add edges (enter capacity in prompt). Set source and sink, then run max flow. Practice building and solving small networks.
Maximum flow sits at the crossroads of graph algorithms, optimization, and combinatorics. Here is how it connects to everything else you have studied.
| Topic | Connection to Max Flow |
|---|---|
| Ch 22: Graph Algorithms | BFS is the engine inside Edmonds-Karp. DFS drives basic Ford-Fulkerson. Topological understanding helps recognize DAG-based flow problems. |
| Ch 24: Shortest Paths | Shortest paths in the residual graph (Bellman-Ford) give min-cost max flow. The level graph in Dinic's is built via BFS shortest paths. |
| Ch 16: Greedy Algorithms | The greedy approach fails for max flow (need augmenting paths with "undo" capability). But greedy does work for the special case of finding a single augmenting path. |
| Ch 15: Dynamic Programming | Min-cost flow can sometimes be formulated as a DP over edges. The successive shortest path method shares structure with DP state transitions. |
| Linear Programming | Max flow is a special case of linear programming. The LP dual of max flow is min cut. This duality is the deep reason the max-flow min-cut theorem holds. |
| Bipartite Graphs | Maximum matching, vertex cover, edge cover, and independent set in bipartite graphs all reduce to max flow or min cut via Konig's theorem. |
The maximum flow problem continues to inspire cutting-edge research. In 2022, Chen et al. achieved an almost-linear-time algorithm for max flow: O(E1+o(1)). This was a breakthrough in theoretical CS, settling a decades-old question about the optimal complexity of max flow. The algorithm uses interior-point methods from continuous optimization combined with graph decomposition — a beautiful marriage of discrete and continuous math.
Continue your graph algorithms journey:
| Lesson | What you will learn |
|---|---|
| Ch 22: Graph Algorithms | BFS and DFS — the traversals that power Ford-Fulkerson and Edmonds-Karp |
| Ch 24: Shortest Paths | Bellman-Ford and Dijkstra — used in min-cost max flow |
| Ch 16: Greedy Algorithms | When greedy works and when it does not — max flow is a key counterexample |
| Ch 15: Dynamic Programming | Optimization structure that connects to min-cost flow formulations |
"What I cannot create, I do not understand." — Richard Feynman