Back to Graph Theory Series

Ford-Fulkerson Method

August 30, 2026 Wasil Zafar 27 min read

Find a route with spare capacity, push as much flow as its narrowest edge permits, and repeat. The surprising part is the residual network: it remembers not only what remains possible, but also what earlier choices can still be undone.

Contents

  1. A Bit of History
  2. The Flow Model
  3. Working Principle
  4. Residual Networks
  5. Worked Example
  6. Why It Is Correct
  7. Complexity Analysis
  8. A Pathological Case
  9. Implementation
  10. Real-World Applications
  11. Choosing a Flow Algorithm
  12. Pitfalls & Debugging
  13. Exercises

A Bit of History

As introduced in Part 15, L. R. Ford Jr. and D. R. Fulkerson published “Maximal Flow Through a Network” in 1956. Their work grew from questions about the throughput and bottlenecks of transportation networks. The method and the max-flow min-cut theorem fit together unusually well: one constructs a better flow, while the other supplies a certificate that no better flow can exist.

The Flow Model

A flow network is a directed graph with a nonnegative capacity $c(u,v)$ on every edge, a source $s$, and a sink $t$. A candidate flow $f$ is feasible only when it obeys two rules:

Capacity

Never exceed an edge

For every edge, $0\le f(u,v)\le c(u,v)$. A pipe cannot carry more than its rated capacity.

Conservation

Intermediate vertices store nothing

At every $v\notin\{s,t\}$, total inflow equals total outflow.

Objective

Maximize source-to-sink throughput

The flow value $|f|$ is the net amount leaving $s$, equivalently the net amount entering $t$.

$$|f|=\sum_v f(s,v)-\sum_v f(v,s)$$

Capacity is a constraint, not an instruction. An edge of capacity 10 may carry any feasible amount from 0 through 10. The algorithm’s job is to coordinate all those local choices into the largest possible global throughput.

Working Principle

Ford–Fulkerson is called a method because it does not prescribe how to choose the next path. DFS, BFS, or another search rule may be used. Everything else follows the same loop:

  1. Start with zero flow, so every original capacity is available.
  2. Build or maintain the residual graph: the graph of adjustments still allowed.
  3. Find any source-to-sink path whose residual edges are all positive.
  4. Let $\Delta$ be the smallest residual capacity on that path—the bottleneck.
  5. Push $\Delta$ units along the path: subtract it from forward residual edges and add it to reverse residual edges.
  6. When no augmenting path exists, stop.

The bottleneck is the most that can be added without violating capacity. At least one residual edge becomes zero after each augmentation, although later reverse-edge use may make it available again.

Residual Networks: The Algorithm’s Memory

For one original edge $u\to v$ with capacity $c$ and current flow $f$, the residual graph records two different freedoms:

$$\text{forward residual}=c-f \qquad \text{reverse residual}=f$$
How pushing flow creates a reverse residual edge An original edge from U to V has capacity five. After pushing three units, the forward residual capacity is two and a reverse residual edge of capacity three permits cancelling up to three units. Original edge U V capacity 5 push 3 units flow becomes 3 / 5 Residual choices after the push U V forward: 5 − 3 = 2 reverse: undo up to 3 Reverse does not mean physical backward flow
unused forward capacitypermission to cancel prior flow
The reverse edge is bookkeeping. It lets a later augmenting path revise an earlier decision instead of being trapped by it.
INTUITION Imagine reserving seats across connecting trains. A forward residual edge counts seats still free; a reverse residual edge counts reservations you are allowed to cancel and reassign. The method is greedy about the next improvement, but the residual graph prevents that greed from being irrevocable.

Worked Example

Consider capacities $S\to A=3$, $S\to B=2$, $A\to T=2$, $B\to T=3$, and $A\to B=1$. We label the final edges as flow / capacity.

Three augmentations produce a maximum flow of five The final network sends three units from S to A and two from S to B. Two units travel A to T, one travels A to B, and three travel B to T. The three augmentations have bottlenecks two, two, and one. A cut immediately after S has capacity five. Final feasible flow Augmentation ledger minimum cut S A B T 3 / 3 2 / 2 2 / 2 3 / 3 1 / 1 1. S → A → TΔ = min(3, 2) = 2 2. S → B → TΔ = min(2, 3) = 2 3. S → A → B → TΔ = min(1, 1, 1) = 1 total flow = 2 + 2 + 1 = 5 cut capacity = 3 + 2 = 5
Every intermediate vertex conserves flow: A receives 3 and sends $2+1$; B receives $2+1$ and sends 3. The cut immediately after S has capacity 5, so the displayed flow of 5 is already certified optimal.

Why the third path matters

After the first two augmentations, the flow value is 4, but one unit remains on $S\to A$, $A\to B$, and $B\to T$. The third path combines those leftovers. Ford–Fulkerson does not require all useful paths to be obvious in the original network; it only asks whether the current residual graph contains one.

Why It Is Correct

Every augmentation preserves feasibility. Choosing $\Delta$ as the bottleneck prevents any forward residual capacity from becoming negative, and the paired forward/reverse update preserves flow conservation at internal vertices.

Now suppose the method stops. Let $R$ be the set of vertices reachable from $s$ in the final residual graph. The sink is not in $R$, or there would still be an augmenting path. For every original edge from $R$ to $V\setminus R$, the forward residual capacity is zero, so that edge is saturated. Any edge pointing back into $R$ carries no net flow across the cut in the wrong direction; otherwise its reverse residual edge would make the outside endpoint reachable.

Therefore the current flow value equals the capacity of the cut $(R,V\setminus R)$. Every feasible flow is at most every cut capacity, so this flow is maximum and this cut is minimum.

Complexity Analysis

Assume capacities are nonnegative integers and a DFS finds each augmenting path in $O(E)$. Every augmentation raises the flow value by at least 1. If the maximum value is $F$, there can be at most $F$ augmentations:

$$\text{Time: } O(EF) \qquad \text{Space: } O(V+E)$$

This is pseudo-polynomial: the bound depends on the numeric value $F$, not merely the number of bits used to encode the input. Writing a capacity of one billion takes only about 30 bits, yet the bound permits one billion augmentations.

Capacity typeTerminationWhat the usual bound says
IntegerGuaranteedAt most $F$ augmentations; $O(EF)$ with DFS
RationalGuaranteed after scaling to integersThe scaled values may make the pseudo-polynomial bound very large
IrrationalNot guaranteed for arbitrary path choicesAn adversarial sequence can keep augmenting forever and need not reach the maximum

A Pathological Case

Path Choice Is the Unspecified—and Dangerous—Part

On a small integer-capacity network, an unlucky DFS order can repeatedly choose paths whose bottleneck is 1 even while a huge amount of flow remains possible. With capacities in the millions, that means millions of searches. With irrational capacities, carefully chosen augmenting paths can even prevent termination. Ford–Fulkerson describes the improvement framework; the path-selection rule determines its performance.

Edmonds–Karp removes this ambiguity by always choosing a shortest augmenting path in edge count using BFS. That single rule gives a capacity-independent polynomial bound of $O(VE^2)$.

Implementation

The implementations use DFS to choose augmenting paths, so they are Ford–Fulkerson rather than Edmonds–Karp. They construct a private residual graph, reject negative capacities and the ambiguous $s=t$ case, aggregate repeated directed edges, and return the final source-side set $R$ of the minimum cut.

StructureMeaningInvariant
residual[u][v]Additional net flow currently allowed from $u$ to $v$Never negative
adj[u]Both original and reverse neighborsEvery residual edge can be traversed when positive
parentOne DFS tree in the residual graphReconstructs an augmenting path only when it reaches $t$
source_sideVertices reachable from $s$ after terminationDefines a minimum cut
from collections import defaultdict

def ford_fulkerson(capacity, source, sink):
    """Return (maximum flow value, source side of a minimum cut)."""
    if source == sink:
        raise ValueError("source and sink must be different")

    residual = defaultdict(int)
    adjacency = defaultdict(list)

    def link(u, v):
        if v not in adjacency[u]:
            adjacency[u].append(v)

    for (u, v), cap in capacity.items():
        if not isinstance(cap, int) or cap < 0:
            raise ValueError("capacities must be nonnegative integers")
        residual[(u, v)] += cap
        residual[(v, u)] += 0
        link(u, v)
        link(v, u)                    # reverse residual edge must be searchable

    adjacency[source]
    adjacency[sink]

    def find_path():
        parent = {source: None}
        stack = [source]
        while stack:
            u = stack.pop()
            if u == sink:
                return parent
            for v in adjacency[u]:
                if residual[(u, v)] > 0 and v not in parent:
                    parent[v] = u
                    stack.append(v)
        return None

    value = 0
    while (parent := find_path()) is not None:
        path = []
        v = sink
        while v is not None:
            path.append(v)
            v = parent[v]
        path.reverse()

        delta = min(residual[(u, v)] for u, v in zip(path, path[1:]))
        for u, v in zip(path, path[1:]):
            residual[(u, v)] -= delta
            residual[(v, u)] += delta
        value += delta

    # Vertices still reachable from s form the source side of a minimum cut.
    source_side = {source}
    stack = [source]
    while stack:
        u = stack.pop()
        for v in adjacency[u]:
            if residual[(u, v)] > 0 and v not in source_side:
                source_side.add(v)
                stack.append(v)

    return value, source_side

capacity = {
    ("S", "A"): 3, ("S", "B"): 2, ("A", "T"): 2,
    ("B", "T"): 3, ("A", "B"): 1,
}
value, source_side = ford_fulkerson(capacity, "S", "T")
print("Max flow:", value, "Cut source side:", sorted(source_side))
# Max flow: 5 Cut source side: ['S']
#include <algorithm>
#include <climits>
#include <iostream>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace std;

using Capacity = long long;
using Edge = tuple<string, string, Capacity>;
using Matrix = unordered_map<string, unordered_map<string, Capacity>>;

struct Result {
    Capacity value;
    unordered_set<string> sourceSide;
};

Result fordFulkerson(const vector<Edge>& edges,
                     const string& source, const string& sink) {
    if (source == sink) throw invalid_argument("source and sink must differ");

    Matrix residual;
    unordered_map<string, vector<string>> adjacency;
    auto link = [&](const string& u, const string& v) {
        auto& neighbors = adjacency[u];
        if (find(neighbors.begin(), neighbors.end(), v) == neighbors.end())
            neighbors.push_back(v);
    };

    for (const auto& [u, v, capacity] : edges) {
        if (capacity < 0) throw invalid_argument("negative capacity");
        residual[u][v] += capacity;  // parallel edges are aggregated
        residual[v][u] += 0;
        link(u, v);
        link(v, u);
    }
    adjacency[source];
    adjacency[sink];

    Capacity value = 0;
    while (true) {
        unordered_map<string, string> parent{{source, source}};
        vector<string> stack{source};

        while (!stack.empty() && !parent.count(sink)) {
            string u = stack.back();
            stack.pop_back();
            for (const string& v : adjacency[u]) {
                if (residual[u][v] > 0 && !parent.count(v)) {
                    parent[v] = u;
                    stack.push_back(v);
                }
            }
        }
        if (!parent.count(sink)) break;

        Capacity delta = LLONG_MAX;
        for (string v = sink; v != source; v = parent.at(v))
            delta = min(delta, residual[parent.at(v)][v]);

        for (string v = sink; v != source; v = parent.at(v)) {
            const string& u = parent.at(v);
            residual[u][v] -= delta;
            residual[v][u] += delta;
        }
        value += delta;
    }

    unordered_set<string> sourceSide{source};
    vector<string> stack{source};
    while (!stack.empty()) {
        string u = stack.back();
        stack.pop_back();
        for (const string& v : adjacency[u]) {
            if (residual[u][v] > 0 && !sourceSide.count(v)) {
                sourceSide.insert(v);
                stack.push_back(v);
            }
        }
    }
    return {value, sourceSide};
}

int main() {
    vector<Edge> edges = {
        {"S", "A", 3}, {"S", "B", 2}, {"A", "T", 2},
        {"B", "T", 3}, {"A", "B", 1}
    };
    Result result = fordFulkerson(edges, "S", "T");
    cout << "Max flow: " << result.value << " Cut source side:";
    for (const string& v : result.sourceSide) cout << ' ' << v;
    cout << '\n';
}
import java.util.*;

class FordFulkerson {
    record Edge(String from, String to, long capacity) {}
    record Result(long value, Set<String> sourceSide) {}

    static Result maxFlow(List<Edge> edges, String source, String sink) {
        if (source.equals(sink))
            throw new IllegalArgumentException("source and sink must differ");

        Map<String, Map<String, Long>> residual = new LinkedHashMap<>();
        Map<String, Set<String>> adjacency = new LinkedHashMap<>();

        for (Edge edge : edges) {
            if (edge.capacity() < 0)
                throw new IllegalArgumentException("negative capacity");
            residual.computeIfAbsent(edge.from(), k -> new LinkedHashMap<>())
                    .merge(edge.to(), edge.capacity(), Long::sum);
            residual.computeIfAbsent(edge.to(), k -> new LinkedHashMap<>())
                    .putIfAbsent(edge.from(), 0L);
            adjacency.computeIfAbsent(edge.from(), k -> new LinkedHashSet<>())
                    .add(edge.to());
            adjacency.computeIfAbsent(edge.to(), k -> new LinkedHashSet<>())
                    .add(edge.from());
        }
        adjacency.computeIfAbsent(source, k -> new LinkedHashSet<>());
        adjacency.computeIfAbsent(sink, k -> new LinkedHashSet<>());

        long value = 0;
        while (true) {
            Map<String, String> parent = new HashMap<>();
            parent.put(source, source);
            Deque<String> stack = new ArrayDeque<>();
            stack.push(source);

            while (!stack.isEmpty() && !parent.containsKey(sink)) {
                String u = stack.pop();
                for (String v : adjacency.getOrDefault(u, Set.of())) {
                    long spare = residual.getOrDefault(u, Map.of()).getOrDefault(v, 0L);
                    if (spare > 0 && !parent.containsKey(v)) {
                        parent.put(v, u);
                        stack.push(v);
                    }
                }
            }
            if (!parent.containsKey(sink)) break;

            long delta = Long.MAX_VALUE;
            for (String v = sink; !v.equals(source); v = parent.get(v)) {
                String u = parent.get(v);
                delta = Math.min(delta, residual.get(u).get(v));
            }
            for (String v = sink; !v.equals(source); v = parent.get(v)) {
                String u = parent.get(v);
                residual.get(u).merge(v, -delta, Long::sum);
                residual.get(v).merge(u, delta, Long::sum);
            }
            value += delta;
        }

        Set<String> sourceSide = new LinkedHashSet<>();
        Deque<String> stack = new ArrayDeque<>();
        sourceSide.add(source);
        stack.push(source);
        while (!stack.isEmpty()) {
            String u = stack.pop();
            for (String v : adjacency.getOrDefault(u, Set.of())) {
                long spare = residual.getOrDefault(u, Map.of()).getOrDefault(v, 0L);
                if (spare > 0 && sourceSide.add(v)) stack.push(v);
            }
        }
        return new Result(value, sourceSide);
    }

    public static void main(String[] args) {
        List<Edge> edges = List.of(
            new Edge("S", "A", 3), new Edge("S", "B", 2),
            new Edge("A", "T", 2), new Edge("B", "T", 3),
            new Edge("A", "B", 1)
        );
        Result result = maxFlow(edges, "S", "T");
        System.out.println("Max flow: " + result.value()
                           + " Cut source side: " + result.sourceSide());
        // Max flow: 5 Cut source side: [S]
    }
}

Real-World Applications

Max flow models a transferable quantity limited by local bottlenecks. The “quantity” need not be literal fluid; it can represent shipments, connections, assignments, or any interchangeable unit.

Logistics

Throughput and bottleneck planning

Warehouses become vertices and shipping lanes become capacitated edges. The max flow estimates sustainable throughput; the min cut identifies the lanes jointly limiting it.

Networks

Bandwidth allocation

Links carry capacities and routers connect them. The model reveals an upper bound on single-commodity transfer and the links whose expansion could remove the current bottleneck.

Matching

Bipartite assignment

Connect the source to left-side vertices, compatible pairs across the partition, and right-side vertices to the sink, all with unit capacity. Integral max flow then encodes a matching.

Disjoint routes

Edge-independent connectivity

Giving every edge capacity 1 turns the max-flow value into the maximum number of edge-disjoint source-to-sink paths.

When the model needs more than max flow

Basic max flow optimizes quantity only. If edges have prices, delays, or preferences, use minimum-cost flow. If several unrelated commodities share capacity, ordinary single-commodity max flow is no longer enough. Modeling the right state and constraints matters as much as choosing the algorithm.

Choosing a Flow Algorithm

MethodPath or update ruleTypical guaranteeGood fit
Ford–FulkersonAny augmenting path$O(EF)$ for integer capacities with $O(E)$ path searchTeaching, small integral instances, simple prototypes
Edmonds–KarpBFS shortest augmenting path$O(VE^2)$Predictable polynomial behavior and straightforward code
DinicLevel graphs and blocking flows$O(V^2E)$ on general networksLarger sparse networks and competitive programming
Push–relabelLocal excess pushes, no path search$O(V^3)$ with a basic implementationDense networks and high-performance implementations

These are not different optimization problems: they all compute maximum flow. They differ in how they organize residual updates and therefore how much redundant work they can avoid.

Pitfalls & Debugging

SymptomLikely causeFix
The answer is too smallReverse residual edges were not added to adjacencyAdd both $u\to v$ and $v\to u$ as searchable neighbors, even when reverse capacity begins at 0
A residual capacity becomes negativeThe path bottleneck was computed incorrectly or updates used the wrong orientationReconstruct $s\to t$ consistently and use one $\Delta=\min c_f(u,v)$ for the entire path
The program never finishes for $s=t$The empty path has no finite bottleneckReject or define this case before entering the augmentation loop
Large tests overflowFlow totals or capacities exceed a 32-bit integerUse a suitable wider type and validate additions for the expected input range
Per-edge flow looks wrong with antiparallel edgesA single residual matrix stores net adjustment, not separate edge identitiesUse indexed residual-edge objects with explicit reverse indices when individual parallel or antiparallel flows must be reported
Runtime varies across runsDFS path order depends on hash iterationUse ordered adjacency for reproducible traces; the maximum value remains the same when the method terminates

Never Omit the Reverse Update

After augmenting $u\to v$ by $\Delta$, perform both $c_f(u,v)-=\Delta$ and $c_f(v,u)+=\Delta$. The second line is not an optimization; it is what lets the method repair earlier path choices and is essential to correctness.

Exercises

Build the Intuition in Layers

  1. Trace: Re-run the worked example in a different augmenting-path order. Record every residual change and confirm the final value remains 5.
  2. Conserve: For the final SVG, verify capacity constraints and flow conservation vertex by vertex.
  3. Explain: Construct a network where a reverse residual edge is necessary to escape an earlier bad path choice.
  4. Certify: Modify the example capacities so $A\to B$ has capacity 0. Find a maximum flow and a minimum cut with the same value.
  5. Compare: Replace the DFS stack with a BFS queue. Count augmentations and edge scans on the same random integral networks.
  6. Engineer: Replace the residual matrix with indexed edge objects so parallel and antiparallel edges retain separate flow values.

A compact mental model

Ford–Fulkerson repeatedly improves a feasible solution in a graph of reversible choices. Forward residual capacity says “you may send more”; reverse residual capacity says “you may reconsider.” When the source can no longer reach the sink, the boundary of what remains reachable becomes a minimum-cut certificate that the flow is maximum.