Introduction to Algorithms (CLRS) — Chapter 26

Maximum Flow

Ford-Fulkerson, max-flow min-cut, bipartite matching — optimizing flow through networks.

Prerequisites: BFS + Directed graphs. That's it.
10
Chapters
9+
Simulations
5
Interview Dimensions

Chapter 0: The Problem

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?

Pipe Network — How Much Water Can Flow?

Click "Send Flow" to push water through the network. "Reset" to start over. Watch which pipes saturate first.

Total flow: 0

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.

Why max flow is a pillar of CS. The maximum flow problem, along with its dual the minimum cut, is one of the most versatile tools in algorithm design. It solves bipartite matching, image segmentation, baseball elimination, airline scheduling, project selection, and network reliability problems — all by reducing them to "push stuff through a graph." If you master this one chapter, you unlock solutions to dozens of seemingly unrelated problems.

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.

Concept check: Why can a greedy approach (always sending max flow along the first available path) fail to find the maximum flow?

Chapter 1: Flow Networks

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.

Constraint 1: Capacity Constraint

For all u, v ∈ V:

0 ≤ f(u, v) ≤ c(u, 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).

Constraint 2: Flow Conservation

For all u ∈ V − {s, t}:

v ∈ V f(v, u) = ∑v ∈ V f(u, v)

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

The value of a flow f, denoted |f|, is the total net flow leaving the source:

|f| = ∑v ∈ V f(s, v) − ∑v ∈ V f(v, s)

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.

Why flow conservation makes this hard. Without flow conservation, you could just send capacity(s, v) on every edge out of s and be done. But conservation forces every unit of flow that leaves s to navigate all the way to t without leaking or accumulating at any intermediate node. This creates global dependencies — increasing flow on one edge might require increasing flow on many other edges, which might bump against their capacities.

A Worked Example

Consider this network with 4 vertices {s, a, b, t} and 5 edges:

EdgeCapacity
s → a10
s → b10
a → b2
a → t8
b → t12

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.

Flow Network Explorer

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|.

s→a 0
s→b 0
a→b 0
a→t 0
b→t 0
Adjust sliders to set flow values.
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
Concept check: In a flow network with source s and sink t, vertex v (not s or t) has incoming flow of 7 and outgoing flow of 5. Is this a valid flow?

Chapter 2: Ford-Fulkerson Method

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.

Residual Capacity

For every pair of vertices u, v, the residual capacity is:

cf(u, v) = c(u, v) − f(u, v)    if (u,v) ∈ E (forward edge: remaining room)
cf(v, u) = f(u, v)    if (u,v) ∈ E (backward edge: flow we can undo)

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.

Why backward edges are genius. Suppose you greedily sent 10 units along path s → a → b → t. Later you discover that a → b is a bottleneck — there is a much better path that needs a → b capacity. The backward edge b → a in the residual graph has capacity 10 (the flow you already sent). By pushing flow along a path that uses this backward edge, you effectively reroute those 10 units through a different path. You never literally "undo" flow — you just rearrange it.

Augmenting Paths

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.

The Algorithm

1. Initialize
Set f(u, v) = 0 for all edges. Build the residual graph Gf (initially identical to G since no flow exists).
2. Find augmenting path
Find any path p from s to t in Gf where every edge has cf > 0. If no such path exists, STOP — current flow is maximum.
3. Compute bottleneck
b = min cf(u, v) over all edges (u, v) on path p.
4. Augment flow
For each edge (u, v) on path p: if (u,v) is a forward edge, increase f(u,v) by b. If (u,v) is a backward edge (meaning (v,u) ∈ E), decrease f(v,u) by b.
5. Update residual graph
Recompute cf for all affected edges. Go to step 2.
↻ repeat

Worked Example: Step by Step

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.

Ford-Fulkerson Step-Through

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.

Click Next Step to begin.
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
Runtime. Ford-Fulkerson with DFS runs in O(E · |f*|) where |f*| is the max flow value. If capacities are integers, each iteration adds at least 1 unit of flow, so it terminates. But with irrational capacities, it can loop forever! And even with integers, if max flow is huge (say 109), that is 109 iterations. We fix this in the next chapter.
Concept check: In the residual graph, a backward edge from v to u with capacity 5 means:

Chapter 3: Edmonds-Karp

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.

Why BFS Gives a Polynomial Bound

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).

The power of shortest-path choice. DFS might find a path of length 100 when a path of length 2 exists. BFS always finds the shortest. This prevents pathological behavior like the classic "zigzag" example where DFS-based Ford-Fulkerson takes 2M iterations on a graph where BFS would take 2. The improvement from O(E · |f*|) to O(VE2) is the difference between "might run forever" and "always finishes quickly."

Worked Example: BFS vs DFS

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.

BFS vs DFS Augmenting Path Comparison

Watch BFS (Edmonds-Karp, teal) vs DFS (Ford-Fulkerson, orange) find augmenting paths on the same graph. BFS finds shorter, more productive paths.

BFS: 0 iterations, flow=0
DFS: 0 iterations, flow=0
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
Implementation detail: skew symmetry. We maintain flow[u][v] = -flow[v][u]. This means we do not need to track forward vs backward edges separately. The residual capacity is always capacity[u][v] - flow[u][v]. When we increase flow[u][v] by b, we decrease flow[v][u] by b (which increases the residual in the reverse direction). This is the standard implementation trick used in competitive programming.
Concept check: The Edmonds-Karp algorithm runs in O(VE2). What is the key property of BFS that enables this polynomial bound?

Chapter 4: Max-Flow Min-Cut Theorem

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.

What Is a Cut?

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:

c(S, T) = ∑u ∈ S, v ∈ T c(u, v)

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 Theorem

The following three statements are equivalent:

Max-Flow Min-Cut Theorem.
  1. f is a maximum flow in G.
  2. The residual graph Gf contains no augmenting path from s to t.
  3. |f| = c(S, T) for some s-t cut (S, T) — the flow value equals some cut capacity.
Since |f| ≤ c(S, T) for any flow f and any cut (S, T), and we found a flow and cut that are equal, the flow must be maximum and the cut must be minimum.

Proof Sketch

(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).

Finding the Min Cut

After running max flow (e.g., Edmonds-Karp), the min cut falls out for free:

1. Run max flow
Get the final residual graph Gf.
2. BFS from s in Gf
S = all vertices reachable from s in the residual graph. T = V − S.
3. The cut edges
All edges (u, v) with u ∈ S and v ∈ T in the original graph are the min-cut edges. They are all saturated.
Max-Flow = Min-Cut Visualizer

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.

Ready.
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
Concept check: After running max flow, you find that vertices {s, a, c} are reachable from s in the residual graph, and {b, d, t} are not. Edge (a, b) has capacity 5 and edge (c, d) has capacity 3. Edge (b, a) has capacity 7. What is the min cut capacity?

Chapter 5: Bipartite Matching

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.

The Problem

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.

Reduction to Max Flow

Here is the brilliant reduction: transform the bipartite matching problem into a max flow problem.

1. Add source s
Create a new source vertex s. Add an edge from s to every vertex in L with capacity 1.
2. Add sink t
Create a new sink vertex t. Add an edge from every vertex in R to t with capacity 1.
3. Set middle edges
For every edge (u, v) in the bipartite graph (u ∈ L, v ∈ R), add an edge with capacity 1.
4. Run max flow
The max flow value equals the maximum matching size. The matched pairs are the edges in L → R that carry flow = 1.

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.

The unit-capacity trick. When all capacities are 1, Ford-Fulkerson runs in O(VE) — each augmentation takes O(E) for the BFS, and there are at most V/2 augmentations (max matching ≤ min(|L|, |R|) ≤ V/2). This is faster than general Edmonds-Karp for this special case.

The Big Simulation

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.

Interactive Bipartite Matching

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.

Click a student, then a project to add edges.
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')]

Konig's Theorem

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.

Concept check: You reduce a bipartite matching problem to max flow by adding source s and sink t with capacity-1 edges. Why does capacity 1 guarantee that each vertex is matched at most once?

Chapter 6: Applications

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.

1. Image Segmentation

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.

2. Baseball Elimination

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.

3. Airline Scheduling

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.

4. Network Reliability

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.

5. Project Selection (Max Weight Closure)

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.

6. Survey Design

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.

Application Gallery: Image Segmentation via Min Cut

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.

Click a pixel to toggle its foreground/background preference, then Segment.
ApplicationSourceSinkWhat flowsWhat is cut
Image SegmentationForegroundBackgroundLabel assignmentsPixel labels + boundaries
Baseball EliminationGamesWin limitsGame outcomesImpossible wins
Airline SchedulingDeparture legsArrival legsPlane assignmentsNew planes needed
Network ReliabilityNode ANode BDisjoint pathsCritical edges
Project SelectionProfit sourceCost sinkSelected projectsRejected profit / forced cost
Bipartite MatchingLeft setRight setAssignmentsUnmatched vertices
The reduction pattern. Every max-flow application follows the same template: (1) identify what "flows" through the network, (2) identify the source (where it originates) and sink (where it is consumed), (3) set capacities to encode constraints, (4) run max flow or min cut. The hard part is step 1 — seeing that a problem is a flow problem. Once you see it, the rest is mechanical.
Concept check: In the image segmentation reduction, why do we add edges between adjacent pixels with capacity equal to the smoothness penalty?

Chapter 7: Advanced Algorithms

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 — O(V2E)

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).

Why Dinic's matters for unit-capacity graphs. On graphs with unit capacities (like bipartite matching), Dinic's runs in O(E√V). This is because each phase finds at least one augmenting path, and there are at most √V phases before the matching is optimal. This makes it the algorithm of choice for bipartite matching in competitive programming.

Push-Relabel — O(V2E) or O(V3)

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.

Min-Cost Max-Flow

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.

When to Use Which

AlgorithmTimeBest forImplementation
Edmonds-KarpO(VE2)Small graphs, simple problems, interview whiteboardEasy — just BFS + augment
Dinic'sO(V2E)Competitive programming, unit-capacity (O(E√V))Medium — level graph + blocking flow
Push-RelabelO(V2√E)Dense graphs, max performance in practiceHard — preflow, heights, gap relabeling
Min-Cost Max-FlowO(V2E · C)When edge costs matter (assignment problems)Medium — Bellman-Ford + augment
Algorithm Race: Edmonds-Karp vs Dinic's

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.

Click Race to compare algorithms.
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
Concept check: Dinic's algorithm on a unit-capacity bipartite graph (used for matching) runs in O(E√V). Why is it faster than the general O(V2E) bound?

Chapter 8: Interview Arsenal

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.

The Cheat Sheet

If you see...Think...Reduction
"Maximum number of X that can be assigned/matched"Bipartite matchingSource → left, left → right, right → sink, cap 1
"Minimum number of X to disconnect/block"Min cutMax flow = min cut
"Maximum number of edge-disjoint paths"Max flow with unit capsEach edge cap 1, max flow = disjoint paths
"Maximum number of vertex-disjoint paths"Vertex splittingSplit each vertex v into v_in and v_out with edge cap 1
"Can all X be satisfied simultaneously?"Feasibility = max flow saturationCheck if max flow saturates all source edges
"Minimum cost assignment"Min-cost max flowAdd costs to edges, use SPFA-based augmentation
"Partition into two groups minimizing penalty"Min cutSource = group A, sink = group B, penalties as capacities

Coding Drill 1: Implement Edmonds-Karp

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

Coding Drill 2: Bipartite Matching via Max Flow

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)

LeetCode Patterns

ProblemKey ideaDifficulty
LeetCode 1349: Max Students Taking ExamBipartite matching on grid seats (no adjacent cheating)Hard
LeetCode 1066: Campus Bikes IIMin-cost bipartite matching (workers to bikes)Medium
LeetCode 785: Is Graph Bipartite?BFS/DFS coloring (prerequisite for matching)Medium
CF 498C: Array and OperationsReduce prime-factor pairing to bipartite matchingHard
SPOJ MATCHING: Fast bipartite matchingDinic's or Hopcroft-Karp on bipartite graphHard

Common Interview Mistakes

Mistake 1: Forgetting reverse edges. The most common bug. When you augment flow on edge (u,v), you MUST also update the reverse edge (v,u). Without reverse edges, the algorithm cannot "undo" suboptimal decisions and may find a suboptimal flow.
Mistake 2: Not adding reverse edges to adjacency list. Even if edge (v,u) does not exist in the original graph, it must exist in the adjacency list for the residual graph. When building the graph, always add both adj[u].append(v) and adj[v].append(u).
Mistake 3: Using DFS instead of BFS. In an interview, always use BFS (Edmonds-Karp). It has a polynomial time guarantee. DFS-based Ford-Fulkerson can be exponential on certain inputs. The interviewer will notice.
Interview Drill: Build Your Own Flow Network

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.

Click "Add Vertex" then click canvas to place. Drag between vertices to add edges.
Interview question: You have N workers and N jobs. Each worker can do a subset of jobs. You want to assign each worker to exactly one job, maximizing the number of assignments. What algorithm do you use and what is the time complexity?

Chapter 9: Connections

Maximum flow sits at the crossroads of graph algorithms, optimization, and combinatorics. Here is how it connects to everything else you have studied.

Where Max Flow Fits

TopicConnection to Max Flow
Ch 22: Graph AlgorithmsBFS is the engine inside Edmonds-Karp. DFS drives basic Ford-Fulkerson. Topological understanding helps recognize DAG-based flow problems.
Ch 24: Shortest PathsShortest 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 AlgorithmsThe 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 ProgrammingMin-cost flow can sometimes be formulated as a DP over edges. The successive shortest path method shares structure with DP state transitions.
Linear ProgrammingMax 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 GraphsMaximum matching, vertex cover, edge cover, and independent set in bipartite graphs all reduce to max flow or min cut via Konig's theorem.

The Hierarchy of Flow Algorithms

Ford-Fulkerson (1956)
The method: any augmenting path. O(E · |f*|). Foundation of everything.
Edmonds-Karp (1972)
Use BFS. O(VE2). First polynomial bound.
Dinic's (1970, popularized later)
Level graph + blocking flow. O(V2E). O(E√V) for unit-capacity.
Push-Relabel (1988)
Local operations, no paths. O(V2√E) with highest-label. Fastest in practice for dense graphs.
Orlin's (2013) / King-Rao-Tarjan
O(VE). Theoretical optimum for sparse graphs. Rarely implemented.

Beyond CLRS

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.

What to study next. If you enjoyed max flow, explore: (1) min-cost flow for optimization with costs, (2) matching in general graphs (Edmonds' blossom algorithm — much harder than bipartite), (3) multi-commodity flow (multiple source-sink pairs sharing edges — NP-hard in general), and (4) network design (build the network, not just flow through it).

Related Lessons

Continue your graph algorithms journey:

LessonWhat you will learn
Ch 22: Graph AlgorithmsBFS and DFS — the traversals that power Ford-Fulkerson and Edmonds-Karp
Ch 24: Shortest PathsBellman-Ford and Dijkstra — used in min-cost max flow
Ch 16: Greedy AlgorithmsWhen greedy works and when it does not — max flow is a key counterexample
Ch 15: Dynamic ProgrammingOptimization structure that connects to min-cost flow formulations

"What I cannot create, I do not understand." — Richard Feynman

Final question: You are given a directed graph and asked to find the maximum number of vertex-disjoint paths from s to t (paths that share no intermediate vertices). How do you reduce this to max flow?