← Applied algorithms
Routes and connections When capacity is the real constraint

Max flow and min cut

Fill the network. Find the bottleneck.

A clinic can move appointments through intake, triage, rooms, and discharge, but every step has a capacity. The quickest-looking route is not the question: how many appointments can reach the exit at the same time?

Max flow keeps finding an available route, sends as much as its narrowest edge allows, and leaves reverse capacity behind in case a later route should undo part of an earlier choice. When no residual route remains, the reachable side of the network exposes a cut with the same capacity.

TypeScriptGoOne capacity network in each language

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.

Max flow / min cut

Send as much as the narrowest edge allows.

CLINIC · APPOINTMENTS PER HOUR · FLOW/CAPACITYpath 1 of 3
0/20/30/30/20/20/10/20/20/5sourceintaketriagelabwarddischargeexit

source → intake → lab → discharge → exit · +2 · sending…

path runs backwards cutbold numbers are full

01/ 04
Find a route with spare capacity

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.

Find

A residual path

Only edges with spare capacity can carry the next increment.

Send

The bottleneck amount

The tightest edge limits the whole path, not the hop you happen to be looking at.

Keep

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.

TypeScriptReading
flow.ts
// 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 };
}
GoAlongside
flow.go
// 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.

The clinic run has sent 4 appointments per hour. The last search from source reaches intake, triage, lab, and ward, but not discharge. discharge → exit can take 5. Can the clinic take a fifth appointment per hour?

05 / Follow the cost

Each augmentation buys a global guarantee.

Edmonds–Karp: time and extra space
OperationTimeExtra spaceWhat it assumes
Find one augmenting pathO(E)O(V)Breadth-first search visits residual edges until it reaches the sink.
Augment one pathO(V)O(1)Subtract the bottleneck from each forward edge and add it to each reverse edge.
Edmonds–Karp max flowO(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 cutO(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 default flow_func is preflow_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.

Copy the complete example, widen ward → discharge from 2 to 3, and predict the new flow and which edges the printed cut names before you run it.

Back to applied algorithms →