01 / The idea
The narrowest step sets the throughput.
Each corridor in the clinic takes a set number of appointments an hour. Intake takes 2 from the front door and triage takes 3. Intake can send 3 on to the lab and 2 to the ward; triage can send 2 to the lab and 1 to the ward. The lab and the ward can each pass 2 to discharge, and discharge can let 5 out. Push appointments down every corridor at once and some corridor is double-booked.
Max flow sends the greatest amount from a source to a sink without exceeding any edge capacity. The units are appointments per hour here; the same shape can describe packets, water, or jobs.
Send as much as the narrowest edge allows.
source → intake → lab → discharge → exit · +2 · sending…
path runs backwards cutbold numbers are full
Find a route with spare capacity
Breadth-first search finds the shortest route with spare capacity: source → intake → lab → discharge → exit. Its narrowest step has room for 2, so 2 appointments an hour go along it. source → intake and lab → discharge are now full.
Reduced motion: choose a scene to see its completed state.
Read this scene
Breadth-first search finds the shortest route with spare capacity: source → intake → lab → discharge → exit. Its narrowest step has room for 2, so 2 appointments an hour go along it. source → intake and lab → discharge are now full.
Breadth-first search finds the shortest route with spare capacity: source → intake → lab → discharge → exit. Its narrowest step has room for 2, so 2 appointments an hour go along it. source → intake and lab → discharge are now full.
Watch and Step through replay the same augmenting paths. Try it runs the TypeScript model on a fresh network.
The example sends 2, then 1, then 1. The third route is the interesting one: it undoes one of intake’s lab appointments so that intake can use the ward instead. The result is 4 appointments per hour, and that number is more than a routing that happened to work. The two full corridors into discharge hold 2 + 2 = 4, so no arrangement can send more, even though the exit itself has room for 5.
02 / Name the rule
Augment a path, then keep the option to take it back.
Start every edge with zero flow. Search the residual network for a path from source to sink. Its bottleneck is the smallest remaining capacity on that path. Add that much flow forward and the same amount of possible cancellation backward.
A residual path
Only edges with spare capacity can carry the next increment.
The bottleneck amount
The tightest edge limits the whole path, not the hop you happen to be looking at.
A reverse option
A later search may run backwards along a used edge, canceling earlier flow to reroute it.
The stopping rule is precise: when the sink is unreachable in the residual network, the current flow is maximum. The final reachable nodes define the source side of a cut; every edge crossing that boundary is full. The cut’s capacity equals the flow value in this example.
Why is the cut a proof?Every source-to-sink path must cross it
Any route from the source side to the sink has to use at least one edge crossing the cut. Those crossing edges can carry at most their capacities added together. If the current flow already fills that total, no later arrangement can exceed it. This is the max-flow min-cut relationship, not a claim that the cut is the only possible one.
03 / Read the shape
Flow is an answer; residual capacity is the working memory.
Basic form validates the network, builds residual edges, and runs Edmonds–Karp until no path is left. In the wild turns the residual network into per-edge flow and a cut certificate. At the call site prints the clinic’s augmenting paths, the choice the third one undoes, and the cut.
Validate the network, build the residual edges, and send the bottleneck of each shortest augmenting path until none is left, noting any edge a path runs backwards along.
// Edmonds–Karp max flow over a small directed capacity network.
export const MAX_NODES = 32;
export const MAX_EDGES = 128;
export const MAX_CAPACITY = 1_000;
export type Edge = { from: string; to: string; capacity: number };
export type Network = { nodes: string[]; edges: Edge[] };
export type FlowErrorCode =
| 'too-big'
| 'bad-node'
| 'duplicate-node'
| 'unknown-node'
| 'self-edge'
| 'duplicate-edge'
| 'opposite-edge'
| 'bad-capacity'
| 'bad-terminal';
export class FlowError extends Error {
readonly code: FlowErrorCode;
constructor(code: FlowErrorCode, message: string) {
super(message);
this.name = 'FlowError';
this.code = code;
}
}
export type Pair = { from: string; to: string };
// cancels lists original edges this path runs backwards along, undoing earlier flow.
export type Augmentation = { path: string[]; bottleneck: number; total: number; cancels: Pair[] };
export type Residual = Map<string, Map<string, number>>;
export type Augmented = {
value: number;
paths: Augmentation[];
adjacency: Map<string, string[]>;
residual: Residual;
};
function validate(network: Network, source: string, sink: string) {
if (network.nodes.length > MAX_NODES || network.edges.length > MAX_EDGES)
throw new FlowError('too-big', `up to ${MAX_NODES} nodes and ${MAX_EDGES} edges`);
const nodes = new Set<string>();
for (const node of network.nodes) {
if (!/^[a-z][a-z0-9-]{0,23}$/.test(node))
throw new FlowError('bad-node', `node ids are lowercase slugs: ${node}`);
if (nodes.has(node)) throw new FlowError('duplicate-node', `node appears twice: ${node}`);
nodes.add(node);
}
if (!nodes.has(source) || !nodes.has(sink))
throw new FlowError('unknown-node', 'source and sink must be known nodes');
if (source === sink) throw new FlowError('bad-terminal', 'source and sink must differ');
const pairs = new Set<string>();
for (const edge of network.edges) {
if (!nodes.has(edge.from) || !nodes.has(edge.to))
throw new FlowError(
'unknown-node',
`edge has an unknown endpoint: ${edge.from} → ${edge.to}`
);
if (edge.from === edge.to) throw new FlowError('self-edge', `edge loops at ${edge.from}`);
if (!Number.isInteger(edge.capacity) || edge.capacity < 0 || edge.capacity > MAX_CAPACITY)
throw new FlowError('bad-capacity', `capacities are whole numbers from 0 to ${MAX_CAPACITY}`);
const pair = `${edge.from}\0${edge.to}`;
const reverse = `${edge.to}\0${edge.from}`;
if (pairs.has(pair))
throw new FlowError('duplicate-edge', `edge appears twice: ${edge.from} → ${edge.to}`);
if (pairs.has(reverse))
throw new FlowError(
'opposite-edge',
`use one directed edge per pair: ${edge.from} → ${edge.to}`
);
pairs.add(pair);
}
}
function neighbours(network: Network) {
const result = new Map<string, string[]>();
for (const node of network.nodes) result.set(node, []);
for (const edge of network.edges) {
result.get(edge.from)!.push(edge.to);
result.get(edge.to)!.push(edge.from);
}
return result;
}
// Every original edge starts with its full capacity forward and 0 backward.
function residualMap(network: Network): Residual {
const residual: Residual = new Map();
for (const node of network.nodes) residual.set(node, new Map());
for (const edge of network.edges) {
residual.get(edge.from)!.set(edge.to, edge.capacity);
residual.get(edge.to)!.set(edge.from, 0);
}
return residual;
}
// Breadth-first search over edges that still have residual capacity: the shortest route.
function findPath(
adjacency: Map<string, string[]>,
residual: Residual,
source: string,
sink: string
) {
const parent = new Map<string, string | null>([[source, null]]);
const queue = [source];
for (let head = 0; head < queue.length && !parent.has(sink); head++) {
const from = queue[head];
for (const to of adjacency.get(from)!) {
if (residual.get(from)!.get(to)! <= 0 || parent.has(to)) continue;
parent.set(to, from);
queue.push(to);
if (to === sink) break;
}
}
if (!parent.has(sink)) return null;
const path: string[] = [];
for (let at: string | null = sink; at !== null; at = parent.get(at)!) path.push(at);
path.reverse();
return path;
}
// Edmonds–Karp: send each shortest path's bottleneck until the sink is unreachable.
export function augment(network: Network, source: string, sink: string): Augmented {
validate(network, source, sink);
const adjacency = neighbours(network);
const residual = residualMap(network);
const original = new Set(network.edges.map((edge) => `${edge.from}\0${edge.to}`));
const paths: Augmentation[] = [];
let value = 0;
while (true) {
const path = findPath(adjacency, residual, source, sink);
if (!path) break;
let bottleneck = Infinity;
for (let i = 0; i < path.length - 1; i++)
bottleneck = Math.min(bottleneck, residual.get(path[i])!.get(path[i + 1])!);
const cancels: Pair[] = [];
for (let i = 0; i < path.length - 1; i++) {
const from = path[i];
const to = path[i + 1];
// Sending forward uses up capacity and leaves the same amount to take back later.
residual.get(from)!.set(to, residual.get(from)!.get(to)! - bottleneck);
residual.get(to)!.set(from, residual.get(to)!.get(from)! + bottleneck);
if (!original.has(`${from}\0${to}`)) cancels.push({ from: to, to: from });
}
value += bottleneck;
paths.push({ path, bottleneck, total: value, cancels });
}
return { value, paths, adjacency, residual };
} // Edmonds–Karp max flow over a small directed capacity network.
const (
MaxNodes = 32
MaxEdges = 128
MaxCapacity = 1000
)
type Edge struct {
From, To string
Capacity int
}
type Network struct {
Nodes []string
Edges []Edge
}
type FlowError struct{ Code, Message string }
func (e *FlowError) Error() string { return e.Message }
type Pair struct{ From, To string }
// Cancels lists original edges this path runs backwards along, undoing earlier flow.
type Augmentation struct {
Path []string
Bottleneck, Total int
Cancels []Pair
}
type Residual map[string]map[string]int
type Augmented struct {
Value int
Paths []Augmentation
Adjacency map[string][]string
Residual Residual
}
var nodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,23}$`)
func validate(network Network, source, sink string) error {
if len(network.Nodes) > MaxNodes || len(network.Edges) > MaxEdges {
return &FlowError{"too-big", fmt.Sprintf("up to %d nodes and %d edges", MaxNodes, MaxEdges)}
}
nodes := map[string]bool{}
for _, node := range network.Nodes {
if !nodePattern.MatchString(node) {
return &FlowError{"bad-node", "node ids are lowercase slugs: " + node}
}
if nodes[node] {
return &FlowError{"duplicate-node", "node appears twice: " + node}
}
nodes[node] = true
}
if !nodes[source] || !nodes[sink] {
return &FlowError{"unknown-node", "source and sink must be known nodes"}
}
if source == sink {
return &FlowError{"bad-terminal", "source and sink must differ"}
}
pairs := map[string]bool{}
for _, edge := range network.Edges {
if !nodes[edge.From] || !nodes[edge.To] {
return &FlowError{"unknown-node", fmt.Sprintf("edge has an unknown endpoint: %s → %s", edge.From, edge.To)}
}
if edge.From == edge.To {
return &FlowError{"self-edge", "edge loops at " + edge.From}
}
if edge.Capacity < 0 || edge.Capacity > MaxCapacity {
return &FlowError{"bad-capacity", fmt.Sprintf("capacities are whole numbers from 0 to %d", MaxCapacity)}
}
pair, reverse := edge.From+"\x00"+edge.To, edge.To+"\x00"+edge.From
if pairs[pair] {
return &FlowError{"duplicate-edge", fmt.Sprintf("edge appears twice: %s → %s", edge.From, edge.To)}
}
if pairs[reverse] {
return &FlowError{"opposite-edge", fmt.Sprintf("use one directed edge per pair: %s → %s", edge.From, edge.To)}
}
pairs[pair] = true
}
return nil
}
func neighbours(network Network) map[string][]string {
result := map[string][]string{}
for _, node := range network.Nodes {
result[node] = []string{}
}
for _, edge := range network.Edges {
result[edge.From] = append(result[edge.From], edge.To)
result[edge.To] = append(result[edge.To], edge.From)
}
return result
}
// Every original edge starts with its full capacity forward and 0 backward.
func residualMap(network Network) Residual {
residual := Residual{}
for _, node := range network.Nodes {
residual[node] = map[string]int{}
}
for _, edge := range network.Edges {
residual[edge.From][edge.To] = edge.Capacity
residual[edge.To][edge.From] = 0
}
return residual
}
// Breadth-first search over edges that still have residual capacity: the shortest route.
func findPath(adjacency map[string][]string, residual Residual, source, sink string) []string {
parent := map[string]string{source: ""}
queue := []string{source}
reached := func(node string) bool { _, ok := parent[node]; return ok }
for head := 0; head < len(queue) && !reached(sink); head++ {
from := queue[head]
for _, to := range adjacency[from] {
if residual[from][to] <= 0 || reached(to) {
continue
}
parent[to] = from
queue = append(queue, to)
if to == sink {
break
}
}
}
if !reached(sink) {
return nil
}
path := []string{}
for at := sink; at != ""; at = parent[at] {
path = append(path, at)
}
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}
// Augment is Edmonds–Karp: send each shortest path's bottleneck until the sink is unreachable.
func Augment(network Network, source, sink string) (Augmented, error) {
if err := validate(network, source, sink); err != nil {
return Augmented{}, err
}
adjacency := neighbours(network)
residual := residualMap(network)
original := map[Pair]bool{}
for _, edge := range network.Edges {
original[Pair{edge.From, edge.To}] = true
}
paths := []Augmentation{}
value := 0
for {
path := findPath(adjacency, residual, source, sink)
if path == nil {
break
}
bottleneck := MaxCapacity
for i := 0; i < len(path)-1; i++ {
bottleneck = min(bottleneck, residual[path[i]][path[i+1]])
}
cancels := []Pair{}
for i := 0; i < len(path)-1; i++ {
from, to := path[i], path[i+1]
// Sending forward uses up capacity and leaves the same amount to take back later.
residual[from][to] -= bottleneck
residual[to][from] += bottleneck
if !original[Pair{from, to}] {
cancels = append(cancels, Pair{to, from})
}
}
value += bottleneck
paths = append(paths, Augmentation{path, bottleneck, value, cancels})
}
return Augmented{value, paths, adjacency, residual}, nil
} Reading the TypeScriptBreadth-first search and reverse edges
The residual map stores both directions for every original edge. A parent map
reconstructs the path found by breadth-first search. Updating a forward entry down and a
reverse entry up is what lets the next search reconsider an earlier route. When a path
steps along a pair that is not an original edge, it is running backwards, and cancels records the edge it undoes.
Reading the GoNested maps, an empty parent, and *FlowError
Residual is a map[string]map[string]int, the same shape as the
TypeScript’s nested Map. In findPath the source’s parent is "", which is safe because a node id is never empty, and reached asks the map with the two-value form so a missing key and an empty parent
stay different.
Augment starts the bottleneck at MaxCapacity, since no edge
can hold more, and narrows it with the built-in min from Go 1.21. The set
of original edges is keyed by the Pair struct itself. Errors come back as a *FlowError whose Code and Message match the TypeScript.
What is refusedBounded, directed, integral input
Both examples bound the network at 32 nodes, 128 edges, and capacities from 0 through
1,000. Node ids are unique lowercase slugs matching ^[a-z][a-z0-9-]{0,23}$ in both languages. Self-edges, duplicate or
opposite pairs, unknown endpoints, and identical source/sink terminals are refused so the
visual has one unambiguous residual edge per pair.
04 / Try a decision
Is this a path, or is it the cut?
A failed search says the current routing is stuck. It does not yet say the routing is the best there is. Decide what the last search proves.
05 / Follow the cost
Each augmentation buys a global guarantee.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Find one augmenting path | O(E) | O(V) | Breadth-first search visits residual edges until it reaches the sink. |
| Augment one path | O(V) | O(1) | Subtract the bottleneck from each forward edge and add it to each reverse edge. |
| Edmonds–Karp max flow | O(VE²) | O(V + E) | Always taking a shortest residual path bounds the number of augmentations by O(VE), whatever the capacities are. |
| Read the minimum cut | O(V + E) | O(V) | Reachable nodes in the final residual graph form the source side of a cut. |
With V nodes and E edges, Edmonds–Karp uses breadth-first search to choose a shortest residual path, giving O(VE²) worst-case time and O(V + E) working space. The visual stores every path for inspection; production code can keep only the residual network and the final flow.
For a small integer network, a simpler Ford–Fulkerson search may be fine. The BFS choice makes progress predictable and avoids depending on the numeric capacities for its termination bound.
06 / Give it a real job
Tell the booking desk the number, and the planner the cut.
Each evening a clinic’s planning service reads tomorrow’s staffing, turns it into corridor
capacities, and runs maxFlow once. It hands back two answers, and each has an owner.
The booking desk gets the value: promise no more than 4 appointments an hour. The planner gets
the cut: lab → discharge and ward → discharge are the corridors to staff up if tomorrow needs
more.
The cut is what makes the second answer useful. Widening any other corridor, the exit included, changes nothing, because every appointment still has to cross one of those two full corridors. Widen ward → discharge from 2 to 3 and the value rises to 5. The cut the code returns then is the two corridors from the front door, 2 + 3, so the next limit is at the door, not at discharge. The planner sees that before anyone is put on the rota.
It leaves out everything that isn’t capacity: which patient goes when, who is urgent, and
how long each visit takes. flows says how many appointments each corridor carries,
not which ones. This runs in the planning service on the server, once per day’s staffing; the
booking page only shows the number it produced, and nothing in a component computes it.
07 / Make the call
Use flow when capacity, not distance, is the question.
Choose max flow for throughput, assignment, transport, or bandwidth limits. Choose Dijkstra or A* when one route’s cost matters. Choose Hungarian assignment when each worker and job must be paired once; it is a different one-to-one objective. For a stable preference, use deferred acceptance.
In Python, NetworkX answers both questions: maximum_flow returns the value and
the flow on each edge, and minimum_cut returns the cut’s value and the two sides.
Its default method is preflow-push rather than Edmonds–Karp. The totals agree; only the route
to them differs.
SourcesLibrary documentation, checked 23 September 2026
- NetworkX 3.7,
maximum_flow: “Find a maximum single-commodity flow.” It returns the flow value and a dictionary with “the value of the flow that went through each edge”; the defaultflow_funcispreflow_push. - NetworkX 3.7,
minimum_cut: “Compute the value and the node partition of a minimum (s, t)-cut.”
08 / Take the idea with you
Explain a bottleneck without saying “max flow.”
“Keep finding a way through that still has room, and send as much as its tightest step allows. If a later way needs room an earlier one took, let it hand some of that back. When no way through is left, look at where you got stuck: the full steps along that boundary are the real limit, and they add up to exactly what you sent.”
Before moving on, pick something you know that moves in steps, like a checkout queue, a build pipeline, or a kitchen at dinner time. Name the step you would widen, then ask whether every way through really has to cross it.
Connections to follow nextRelated lessons
- BFS and DFS is the search that finds each augmenting path, and the one that finds the cut at the end.
- Hungarian assignment pairs each worker with one job at the least cost, where flow only counts how many get through.
- Dijkstra’s shortest path answers the other question about a network: the cheapest single route, not the most at once.