Graphs/Network Flow

Lesson 6.83,226 words

Network Flow

How much can flow through a network from source to sink? We build flow networks with capacity and conservation constraints, increase a flow by pushing along augmenting paths in the residual graph, and see how reverse edges let the algorithm undo earlier routing.

╌╌╌╌

Imagine a network of pipes carrying water from a source to a sink, each pipe with a maximum capacity. How much water can you push through end to end? This is the maximum flow problem, and its reach extends far past plumbing: routing traffic, scheduling jobs, matching applicants to jobs, even segmenting images all reduce to it. Max-flow is one of algorithm design's great modeling tools; the art, as Erickson stresses, lies in recognizing a flow problem.1

Flow networks

Think of routing a single commodity (water, electricity, traffic, money) from a source to a sink across a network whose edges have limited throughput.

A flow is a function on the edges. The single most useful piece of bookkeeping is the net flow out of a vertex , written using the boundary symbol :

A flow is feasible when it obeys two rules:

  • Non-negativity & capacity. for every edge: no edge runs backward, and none carries more than its capacity.
  • Conservation. for every vertex : nothing is created or destroyed at an interior node. (When conservation holds at every vertex, is a circulation.)

The value of an flow is the net amount leaving the source, . A short computation shows this is the same as the net amount arriving at the sink: summing over all vertices counts each edge's flow once with a and once with a , so ; conservation kills every interior term, leaving , i.e. . The maximum-flow problem is to find a feasible flow of greatest value.

Below is a worked network; each edge is labeled (flow over capacity), realizing units pushed from to :

A flow network with edges labeled flow over capacity, carrying a flow of value 12 from to .

Conservation is the statement that each interior node balances. Take vertex : its incident flows are the outgoing , , and the incoming , so

The same check at reads , and at the source . Checking at every interior vertex this way is how you verify a flow is feasible.

flow_network.pypython
from __future__ import annotations

from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar

Label = TypeVar("Label", bound=Hashable)

class FlowEdge(Generic[Label]):
  """
    A directed edge of a flow network: capacity, current flow, and a link\n
    to its `residual` partner (the reverse edge sharing the same flow).\n
    The residual capacity — how much more can be pushed along this edge —\n
    is `capacity - flow`; pushing `amount` here pulls the same amount off\n
    the partner, so cancelling earlier flow is just a push on the reverse.\n
  """

  def __init__(
    self,
    source: FlowVertex[Label],
    target: FlowVertex[Label],
    capacity: float,
  ) -> None:
    self.source: FlowVertex[Label] = source
    self.target: FlowVertex[Label] = target
    self.capacity: float = capacity
    self.flow: float = 0.0
    self.residual: FlowEdge[Label] = self  # patched in by add_edge.

  def residual_capacity(self) -> float:
    """
      The spare room on this edge: how much more flow it can carry.\n
    """
    return self.capacity - self.flow

  def push(self, amount: float) -> None:
    """
      Send `amount` more along this edge, cancelling the same amount on\n
      the partner so the pair stays a consistent forward/backward view.\n
    """
    self.flow += amount
    self.residual.flow -= amount

  def __repr__(self) -> str:
    return (
      f"FlowEdge({self.source.label!r} -> {self.target.label!r}, "
      f"flow={self.flow}/{self.capacity})"
    )

class FlowVertex(Generic[Label]):
  """
    A flow-network vertex: a label plus the list of edges leaving it,\n
    including the synthetic reverse edges that make up the residual graph.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.outgoing: list[FlowEdge[Label]] = []

  def __repr__(self) -> str:
    return f"FlowVertex({self.label!r})"

class FlowNetwork(Generic[Label]):
  """
    A directed flow network of FlowVertex objects linked by FlowEdge pairs.\n
    Every `add_edge` inserts both a forward edge (the real capacity) and a\n
    zero-capacity reverse edge; together they form the residual graph that\n
    augmenting-path algorithms search. Reading residual capacities and\n
    pushing flow are then uniform over forward and backward edges alike.\n
  """

  def __init__(self) -> None:
    self._vertices: dict[Label, FlowVertex[Label]] = {}

  def add_vertex(self, label: Label) -> FlowVertex[Label]:
    """
      Return the vertex for `label`, creating it if it is absent.\n
    """
    vertex: Optional[FlowVertex[Label]] = self._vertices.get(label)
    if vertex is None:
      vertex = FlowVertex(label)
      self._vertices[label] = vertex
    return vertex

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    capacity: float,
  ) -> FlowEdge[Label]:
    """
      Add a forward edge of the given `capacity` and its zero-capacity\n
      reverse partner, wiring the two together as residuals. Returns the\n
      forward edge so callers (e.g. matching) can read its flow later.\n
    """
    source: FlowVertex[Label] = self.add_vertex(source_label)
    target: FlowVertex[Label] = self.add_vertex(target_label)

    # forward carries the real capacity; backward starts empty for cancellation.
    forward: FlowEdge[Label] = FlowEdge(source, target, capacity)
    backward: FlowEdge[Label] = FlowEdge(target, source, 0.0)
    forward.residual = backward
    backward.residual = forward

    source.outgoing.append(forward)
    target.outgoing.append(backward)
    return forward

  def vertex(self, label: Label) -> FlowVertex[Label]:
    """
      The vertex carrying `label` (raises KeyError if absent).\n
    """
    return self._vertices[label]

  @property
  def vertices(self) -> list[FlowVertex[Label]]:
    """
      Every vertex, in insertion order.\n
    """
    return list(self._vertices.values())

  def edges(self) -> Iterator[FlowEdge[Label]]:
    """
      Each real (positive-capacity) forward edge once; reverse partners\n
      and any zero-capacity edges are skipped.\n
    """
    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        if edge.capacity > 0:
          yield edge

  def value(self, source_label: Label) -> float:
    """
      The value of the current flow: the net flow leaving the source.\n
    """
    source: FlowVertex[Label] = self._vertices[source_label]
    return sum(edge.flow for edge in source.outgoing if edge.capacity > 0)

  def reset_flow(self) -> None:
    """
      Zero every edge's flow, restoring the empty feasible flow.\n
    """
    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        edge.flow = 0.0

  def __contains__(self, label: Label) -> bool:
    return label in self._vertices

  def __iter__(self) -> Iterator[FlowVertex[Label]]:
    return iter(self._vertices.values())

  def __len__(self) -> int:
    return len(self._vertices)

Augmenting paths and the residual graph

How do we increase a flow? Find a path from to that still has spare room and push more along it. The bookkeeping device that makes this precise, and makes the algorithm correct, is the residual graph.

Concretely, each original edge splits into three cases on its residual capacity :

  • If (empty), put with .
  • If (partly used), put both: with , and with .
  • If (saturated), put only the reverse with .

(We assume has no anti-parallel edges, at most one of , is in , so these reverse edges are unambiguous. Any graph can be preprocessed to satisfy this by splitting an edge through a dummy vertex.)

The backward edges are the subtle part that matters: pushing flow along cancels existing flow on , letting the algorithm undo earlier, suboptimal decisions. That is why a greedy fill paths until stuck approach can get stuck below optimum, while augmenting paths cannot. Below, the feasible flow from the worked network (top, with vertex omitted since all of its edges are empty) and the residual graph it induces (bottom); each residual edge is labeled with its capacity :

A feasible flow (top) and the residual graph it induces (bottom), with residual capacities labeled. Saturated edges (, ) survive only as reverse edges; empty edges () only as forward edges.

A single augmentation is easy to picture on a small network. Each edge below is labeled ; the highlighted residual path has residual capacities , so its bottleneck is . Pushing one unit along it saturates and lifts the flow value from to , with every interior vertex still balanced:

One augmentation: the residual path has capacities , so its bottleneck is ; pushing raises the flow value from to .

Why the back edges matter

It is tempting to skip the reverse edges entirely: repeatedly find an path with spare forward capacity, saturate it, and stop when none remains. This greedy scheme is wrong, and the smallest counterexample shows why. Take the diamond below, every edge of capacity ; its maximum flow is (route and ). Suppose greedy first picks the zigzag path and pushes along it. Now every remaining forward option is blocked: has spare room but is saturated, and has spare room but is saturated. Greedy halts at value , half the optimum.

The residual graph repairs exactly this. Because carries flow , contains the reverse edge with capacity , and the path becomes available. Pushing along it cancels the unit on (its flow drops back to ), rerouting the first unit through while the new unit takes . No water flows backward; the algorithm has merely revised an earlier routing decision:

Greedy without back edges gets stuck. Top: saturating the zigzag blocks every remaining forward path at value . Middle: the residual graph contains the reverse edge , opening the path . Bottom: pushing along it cancels the flow on and reaches the maximum value .

The augmentation lemma

Before trusting the algorithm we must prove that augmenting actually produces a better feasible flow. Augmenting along a path by amount produces a new function defined edge-by-edge:

So every augmentation strictly increases the value while keeping legal. The converse, which the min-cut proof will need, also holds: if is not maximum, must contain an augmenting path. Putting the two together gives the equivalence at the center of the theory

Ford-Fulkerson and Edmonds-Karp

The method is then simple: while an augmenting path exists, push flow along it.2

Algorithm 1:Ford-Fulkerson(G,s,t)\textsc{Ford-Fulkerson}(G, s, t) — augment until no path remains
  1. 1
    foreach edge (u,v)E(u, v) \in E do
  2. 2
    f(u,v)0f(u, v) \gets 0
  3. 3
    while there exists an augmenting path pp from ss to tt in GfG_f do
  4. 4
    cf(p)min{cf(u,v):(u,v) on p}c_f(p) \gets \min\set{c_f(u, v) : (u, v) \text{ on } p}
    bottleneck
  5. 5
    foreach edge (u,v)(u, v) on pp do
  6. 6
    f(u,v)f(u,v)+cf(p)f(u, v) \gets f(u, v) + c_f(p)
    push forward
  7. 7
    f(v,u)f(v,u)cf(p)f(v, u) \gets f(v, u) - c_f(p)
    cancel on reverse edge
  8. 8
    return ff

Running time rests on an invariant: if every capacity is an integer, , then all residual capacities and flow values stay integers throughout. Each augmentation then raises by at least , so there are at most iterations, each costing to find a path and push along it (here , ):

If additionally every capacity lies in , then , giving . This is pseudo-polynomial: fine for small capacities, but very slow when they are huge. The classic bad case has a middle edge of capacity between two paths of capacity ; a naïve solver alternately pushes one unit forward and one unit back, taking augmentations. (On irrational capacities the method may not even terminate.)

The Ford-Fulkerson bad case: a unit middle edge between two capacity- paths. A poor path choice alternately pushes one unit forward and back, taking augmentations.

The fix, due to , is to always pick the shortest augmenting path (fewest edges): find it with BFS in the residual graph.

Algorithm 2:Edmonds-Karp(G,s,t)\textsc{Edmonds-Karp}(G, s, t) — Ford-Fulkerson with BFS path choice
  1. 1
    foreach edge (u,v)E(u, v) \in E do
  2. 2
    f(u,v)0f(u, v) \gets 0
  3. 3
    while BFS finds a shortest path pp from ss to tt in GfG_f do
  4. 4
    augment ff along pp by its bottleneck cf(p)c_f(p)
  5. 5
    return ff

Why should the shortest path be any better? The analysis rests on a monotonicity property of BFS levels in the residual graph. Write for the number of edges on a shortest path in .

Counting iterations now follows from a charging argument on critical edges. In each augmentation, at least one residual edge on the path has equal to the bottleneck; that edge is critical and disappears from . For to become critical again, the reverse must first appear on some later shortest path, which requires at the first event and at the second. With the monotone lemma,

so 's level rises by at least between consecutive criticalities of . Levels live in (once the vertex is unreachable and the edge never reappears on a path), so each of the at most residual edges is critical times, giving augmentations in total. Each iteration is one BFS plus one path update, work, for a strongly polynomial bound independent of capacities:

For dense graphs this is far from the last word — Orlin's 2012 algorithm runs in , and you may cite max-flow as an black box — but is the version to know: two ideas (residual graph, BFS) and a clean proof.

ford_fulkerson.pypython
from collections.abc import Hashable
from typing import Optional, TypeVar

from flow_network import FlowEdge, FlowNetwork, FlowVertex

Label = TypeVar("Label", bound=Hashable)

def _find_augmenting_path(
  network: FlowNetwork[Label],
  source: FlowVertex[Label],
  sink: FlowVertex[Label],
) -> Optional[list[FlowEdge[Label]]]:
  """
    A source-to-sink path through edges with spare residual capacity,\n
    returned as its list of edges, or None if the sink is unreachable.\n
    Found by depth-first search over the residual graph.\n
  """
  visited: set[Label] = {source.label}
  path: list[FlowEdge[Label]] = []

  def explore(current: FlowVertex[Label]) -> bool:
    if current is sink:
      return True

    # try each unvisited neighbor with spare capacity, backtracking on dead ends.
    for edge in current.outgoing:
      neighbor: FlowVertex[Label] = edge.target
      if edge.residual_capacity() <= 0 or neighbor.label in visited:
        continue

      visited.add(neighbor.label)
      path.append(edge)
      if explore(neighbor):
        return True
      path.pop()

    return False

  return path if explore(source) else None

def ford_fulkerson(
  network: FlowNetwork[Label],
  source_label: Label,
  sink_label: Label,
) -> float:
  """
    The value of a maximum flow from `source_label` to `sink_label`.\n
    Mutates the network's edge flows in place to realize that flow, so\n
    the saturating assignment can be read off afterward (e.g. for min-cut\n
    or matching). Starts from the current flow; reset it first for a clean\n
    run.\n
  """
  # get-or-create: a terminal with no incident edge is a valid isolated
  # vertex carrying zero flow, not a missing key.
  source: FlowVertex[Label] = network.add_vertex(source_label)
  sink: FlowVertex[Label] = network.add_vertex(sink_label)
  max_flow: float = 0.0

  while True:
    path: Optional[list[FlowEdge[Label]]] = _find_augmenting_path(
      network, source, sink
    )
    if path is None:
      break

    # bottleneck: the least spare capacity along the path.
    bottleneck: float = min(edge.residual_capacity() for edge in path)
    for edge in path:
      edge.push(bottleneck)
    max_flow += bottleneck

  return max_flow
edmonds_karp.pypython
from collections import deque
from collections.abc import Hashable
from typing import Optional, TypeVar

from flow_network import FlowEdge, FlowNetwork, FlowVertex

Label = TypeVar("Label", bound=Hashable)

def _shortest_augmenting_path(
  network: FlowNetwork[Label],
  source: FlowVertex[Label],
  sink: FlowVertex[Label],
) -> Optional[list[FlowEdge[Label]]]:
  """
    A source-to-sink path with the fewest residual edges, returned as its\n
    list of edges, or None if the sink is unreachable. Found by BFS, so the\n
    first time the sink is reached the path to it is shortest.\n
  """
  came_from: dict[Label, FlowEdge[Label]] = {}
  visited: set[Label] = {source.label}
  queue: deque[FlowVertex[Label]] = deque([source])

  # bfs from the source; record the edge each vertex was first reached by.
  while queue:
    current: FlowVertex[Label] = queue.popleft()
    if current is sink:
      break

    for edge in current.outgoing:
      neighbor: FlowVertex[Label] = edge.target
      if edge.residual_capacity() > 0 and neighbor.label not in visited:
        visited.add(neighbor.label)
        came_from[neighbor.label] = edge
        queue.append(neighbor)

  if sink.label not in came_from:
    return None

  # walk the predecessor edges back from the sink, then reverse.
  path: list[FlowEdge[Label]] = []
  cursor: Label = sink.label
  while cursor != source.label:
    edge = came_from[cursor]
    path.append(edge)
    cursor = edge.source.label
  path.reverse()
  return path

def edmonds_karp(
  network: FlowNetwork[Label],
  source_label: Label,
  sink_label: Label,
) -> float:
  """
    The value of a maximum flow from `source_label` to `sink_label`,\n
    augmenting along BFS-shortest residual paths. Mutates the network's\n
    edge flows in place. Starts from the current flow; reset it first for\n
    a clean run.\n
  """
  # get-or-create: a terminal with no incident edge is a valid isolated
  # vertex carrying zero flow, not a missing key.
  source: FlowVertex[Label] = network.add_vertex(source_label)
  sink: FlowVertex[Label] = network.add_vertex(sink_label)
  max_flow: float = 0.0

  while True:
    path: Optional[list[FlowEdge[Label]]] = _shortest_augmenting_path(
      network, source, sink
    )
    if path is None:
      break

    bottleneck: float = min(edge.residual_capacity() for edge in path)
    for edge in path:
      edge.push(bottleneck)
    max_flow += bottleneck

  return max_flow

A complete run of Edmonds-Karp

Here is one full run, end to end. The network below has six vertices and seven edges (capacities on the edges); we run from the zero flow, scanning neighbors in alphabetical order so the BFS choices are reproducible. Each round we show the residual graph, the shortest augmenting path BFS finds (highlighted), and the bottleneck it pushes.

Round 1. With the residual graph is : every edge appears forward at full capacity. BFS layers the vertices and returns the length- path . Its residual capacities are , so the bottleneck is : push , and the value rises .

Round 2. Edge is now saturated and survives only as the reverse edge ; keeps forward capacity plus a reverse edge of capacity . BFS finds with residual capacities ; the bottleneck is . Push : the value rises , and both and saturate.

Rounds 1 and 2. Top: the initial residual graph (equal to ) with the first BFS path highlighted; its bottleneck is . Bottom: the residual graph after pushing 5, with the second path highlighted; its bottleneck is .

Round 3. The source's only remaining forward edge is . BFS finds with residual capacities (edge has left); the bottleneck is . Push : the value rises , and saturates.

Round 4 (halt). BFS from now reaches (forward capacity remains on ), then (via , capacity ), then (via the reverse edge ) — and stops. Every edge out of this set is saturated, so is unreachable and the algorithm halts with .

Rounds 3 and 4. Top: the residual graph after round 2, with the third path highlighted; its bottleneck is . Bottom: the final residual graph. The shaded vertices are exactly those reachable from ; is not among them, so no augmenting path remains.

The whole run in one table:

RoundAugmenting pathResidual capacitiesBottleneckValue
1
2
3
4none — unreachable

The final flow, edge by edge: , , , , , , . Every interior vertex balances (: in , out ; : in , out ; : in , out ; : in , out ), and . Is really the maximum? The halting condition says yes, but the certificate that proves it is a cut — the subject of the next lesson, where this same network returns.

This continues in Max-Flow Min-Cut and Applications. There we prove why the halting flow is optimal — the max-flow min-cut theorem — and apply the abstraction to bipartite matching and a catalog of modeling reductions.

Footnotes

  1. Erickson, Ch. 10 & 11 — Maximum Flows and Applications — the art of recognizing a problem as a flow problem.
  2. CLRS, Ch. 26 — Maximum Flow — the Ford-Fulkerson method augmenting along residual paths.
  3. CLRS, Ch. 26 — Maximum Flow — the Edmonds-Karp analysis via monotone shortest-path distances in the residual graph.
Practice

╌╌ END ╌╌