Graphs/Max-Flow Min-Cut and Applications

Lesson 6.92,648 words

Max-Flow Min-Cut and Applications

Why is the flow found when no augmenting path remains actually optimal? The answer is a duality theorem: the maximum flow equals the minimum cut.

╌╌╌╌

This builds on Network Flow, which developed flow networks, the residual graph, and the augmenting-path algorithms. We pick up exactly where that lesson halted: with a flow whose residual graph has no path, and the question of why that flow is provably maximum.

The max-flow min-cut theorem

When does Ford-Fulkerson stop, and why is the result optimal? The answer is a duality theorem linking flow to cuts.1

The dual view is a minimum-cut problem: an adversary wants to cut a cheap set of edges so that no flow can get through. The two problems turn out to be the same problem.

A flow network with an - cut separating from , the crossing forward edges drawn dashed in red.

Because the hard direction shows the converse of the augmentation lemma, we get the promised three-way equivalence.

This is also why is correct: it halts exactly when has no path, which is precisely when is maximum. Better, the halting state yields the minimum cut for free: the proof's is just the set of vertices BFS reaches from in the final residual graph, so one extra traversal after the last augmentation outputs a minimum cut.

The min cut of the worked run

Return to the six-vertex network. Round 4's BFS reached exactly , so the theorem says the crossing edges form a minimum cut. Two edges cross forward: (capacity ) and (capacity ), so

matching the flow value exactly — the certificate that round 3's flow is maximum. Both boundary observations from the proof are visible: the two crossing edges are saturated (, ), and no edge crosses backward from into at all.

The maximum flow of the worked run, edges labeled flow over capacity, with the minimum cut (region ) found by the final BFS. The crossing edges and (dashed red) are saturated and their capacities sum to .

Two easy misreadings to avoid. First, the minimum cut is not all saturated edges: is saturated here yet lies entirely inside and crosses nothing. Saturation is necessary for a forward crossing edge of a minimum cut, not sufficient for membership. Second, cut capacity counts forward edges only; if some edge ran from back into , its capacity would not be charged (though a maximum flow must leave it empty).

min_cut.pypython
from __future__ import annotations

from collections import deque
from collections.abc import Hashable
from typing import Generic, NamedTuple, TypeVar

from edmonds_karp import edmonds_karp
from flow_network import FlowEdge, FlowNetwork, FlowVertex

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

class Cut(NamedTuple, Generic[Label]):
  """
    A minimum s-t cut: the source side `source_set`, the sink side\n
    `sink_set`, the saturated forward edges `crossing` from S to T, and\n
    the total `capacity` of those edges (equal to the maximum flow value).\n
  """
  source_set: set[Label]
  sink_set: set[Label]
  crossing: list[FlowEdge[Label]]
  capacity: float

def _reachable_in_residual(
  network: FlowNetwork[Label],
  source: FlowVertex[Label],
) -> set[Label]:
  """
    Every vertex label reachable from `source` along edges that still have\n
    spare residual capacity — the set S of the min cut.\n
  """
  reachable: set[Label] = {source.label}
  queue: deque[FlowVertex[Label]] = deque([source])

  # bfs along edges with spare residual capacity.
  while queue:
    current: FlowVertex[Label] = queue.popleft()
    for edge in current.outgoing:
      neighbor: FlowVertex[Label] = edge.target
      if edge.residual_capacity() > 0 and neighbor.label not in reachable:
        reachable.add(neighbor.label)
        queue.append(neighbor)

  return reachable

def min_cut(
  network: FlowNetwork[Label],
  source_label: Label,
  sink_label: Label,
) -> Cut[Label]:
  """
    The minimum s-t cut of `network`. Runs Edmonds-Karp to a maximum flow\n
    (mutating edge flows in place), then reads off the cut from the\n
    residual reachable set. The returned capacity equals the max-flow value.\n
  """
  network.reset_flow()
  edmonds_karp(network, source_label, sink_label)

  # edmonds_karp has already ensured the terminal exists; get-or-create
  # keeps this robust if called on a network with an isolated source.
  source: FlowVertex[Label] = network.add_vertex(source_label)
  source_set: set[Label] = _reachable_in_residual(network, source)
  sink_set: set[Label] = {
    vertex.label for vertex in network if vertex.label not in source_set
  }

  # forward edges leaving S for T are exactly the saturated cut edges.
  crossing: list[FlowEdge[Label]] = [
    edge
    for edge in network.edges()
    if edge.source.label in source_set and edge.target.label in sink_set
  ]
  capacity: float = sum(edge.capacity for edge in crossing)

  return Cut(
    source_set=source_set,
    sink_set=sink_set,
    crossing=crossing,
    capacity=capacity,
  )

Application: bipartite matching

The payoff of the flow abstraction is that other problems reduce to it.2 A useful rule of thumb: whenever a graph problem looks for paths or cycles under some per-edge or per-vertex budget constraint, reach for max-flow as a subroutine, and many non-graph problems (task assignment, base-station connection, workshop scheduling) yield to it too once you find the right network.

Consider bipartite matching: a set of applicants, a set of jobs, and an edge for each applicant qualified for a job (edge iff ). A matching pairs applicants to jobs with no one used twice; we want a maximum matching.

Build a flow network: add a source with a unit-capacity edge to every applicant, add a sink with a unit-capacity edge from every job, and orient each qualification edge from to with capacity .

Bipartite matching modeled as flow, with source , applicants, jobs, and sink joined by unit-capacity edges.

The reduction works because of an exact correspondence in both directions: if tasks can be assigned, those disjoint routes form a flow of value (easy); conversely, if the max-flow value is , then tasks can be assigned (less obvious, since it needs integrality).

The word some is doing work in the statement: fractional maximum flows also exist (split each unit half-and-half across two routes), but at least one integral optimum always exists, and the augmenting-path algorithms never create fractions they were not given. Here every capacity is , so the maximum flow is / on every edge. Its path-flow decomposition is a set of vertex-disjoint routes; reading off the middle edge of each gives a matching, and the value of the max flow equals the size of the maximum matching. So one run of solves bipartite matching.

For the network above, the integral max flow saturates three vertex-disjoint routes, shown in blue: , , . The flow value is , so the maximum matching has size — every applicant is placed:

The integral max flow of value (blue) decomposes into three vertex-disjoint routes; the middle edges are the maximum matching.

This is the template for many reductions (assignment, base-station connection, workshop scheduling, vertex-disjoint paths), all of which become build a network, compute max flow, read off an integral solution.

bipartite_matching.pypython
from collections.abc import Hashable, Iterable
from typing import TypeVar

from edmonds_karp import edmonds_karp
from flow_network import FlowEdge, FlowNetwork

Applicant = TypeVar("Applicant", bound=Hashable)
Job = TypeVar("Job", bound=Hashable)

# network labels are tagged tuples so the two sides (and the synthetic
# terminals) never collide even when applicants and jobs share raw values.
_Label = tuple[Hashable, ...]
_SOURCE: _Label = ("__source__",)
_SINK: _Label = ("__sink__",)

def maximum_bipartite_matching(
  qualifications: Iterable[tuple[Applicant, Job]],
) -> dict[Applicant, Job]:
  """
    A maximum matching of applicants to jobs given the allowed\n
    `qualifications` (applicant, job) pairs, returned as a dict mapping each\n
    matched applicant to its assigned job. No applicant or job appears\n
    twice; the dict's size is the maximum matching size.\n
    Applicant and job labels are tagged ("L", x) / ("R", y) inside the\n
    network so the two sides never collide even if they share raw values.\n
  """
  edges: list[tuple[Applicant, Job]] = list(qualifications)

  # collect each side's distinct labels in first-seen order.
  seen_applicants: set[Applicant] = set()
  seen_jobs: set[Job] = set()
  applicants: list[Applicant] = []
  jobs: list[Job] = []

  for applicant, job in edges:
    if applicant not in seen_applicants:
      seen_applicants.add(applicant)
      applicants.append(applicant)
    if job not in seen_jobs:
      seen_jobs.add(job)
      jobs.append(job)

  network: FlowNetwork[_Label] = FlowNetwork()
  middle_edges: list[tuple[Applicant, Job, FlowEdge[_Label]]] = []

  # unit edges: source -> applicant, job -> sink, applicant -> job per pair.
  for applicant in applicants:
    network.add_edge(_SOURCE, ("L", applicant), 1.0)
  for job in jobs:
    network.add_edge(("R", job), _SINK, 1.0)

  for applicant, job in edges:
    forward: FlowEdge[_Label] = network.add_edge(
      ("L", applicant), ("R", job), 1.0
    )
    middle_edges.append((applicant, job, forward))

  if applicants and jobs:
    edmonds_karp(network, _SOURCE, _SINK)

  # a saturated middle edge (flow 1) is a matched applicant-job pair.
  matching: dict[Applicant, Job] = {}
  for applicant, job, edge in middle_edges:
    if edge.flow > 0.5 and applicant not in matching:
      matching[applicant] = job
  return matching

Modeling tricks and pitfalls

Real problems rarely arrive in the exact shape the definitions demand. A small kit of standard transformations, plus the failure modes worth knowing:

  • Multiple sources or sinks. Add a supersource with an edge of capacity (effectively unlimited) to each real source, and symmetrically a supersink. Max flow in the new network equals the total max flow of the old one.
  • Vertex capacities. If a vertex may carry at most units, split it into joined by an edge of capacity ; route all former in-edges into and all out-edges out of . Vertex-disjoint path problems use this with .
  • Undirected edges. Replace of capacity with directed edges and , each of capacity , then apply the anti-parallel splitting trick if the representation requires it.
  • Anti-parallel pairs. When both and appear, subdivide one of them through a dummy vertex so residual reverse edges stay unambiguous.

The failure modes:

  • Irrational capacities break raw Ford-Fulkerson. With arbitrary path choice the method is only guaranteed to terminate when capacities are integers (or rationals, which scale to integers). On irrational capacities there are networks where it runs forever, and worse: the flow values converge to a limit strictly below the maximum, so even run it long enough fails. is immune; its bound never mentions capacities.
  • Pseudo-polynomial blowup. is fine when capacities are small and disastrous when they are large; the two-path network with the unit middle edge realizes the worst case. If capacities can be big, insist on the BFS rule.
  • Forgetting integrality when reading off answers. Reductions like matching need an integral flow to decode; the integrality theorem guarantees the augmenting-path algorithms deliver one, but a fractional solution from some other solver (say, a linear-programming relaxation) would need rounding first.

As defaults: is the implementation to write, (Orlin) is the bound to quote for a black-box subroutine, and for unit-capacity networks such as bipartite matching specialized bounds are far better than the generic ones.

Faster max-flow

is the version to understand, but it is nowhere near the fastest known. Its bound has been beaten repeatedly, and the ideas behind the faster methods are worth a paragraph each.

Dinic's algorithm (1970). Dinic's insight is to stop augmenting one path at a time.3 From the current flow, build the level graph by one BFS from — keep only residual edges that go from level to level — then push a blocking flow through it (a flow that saturates at least one edge on every remaining path in the level graph). A single blocking flow can be found in with DFS, and each one strictly increases the shortest-path distance , so there are at most phases. That gives overall — already better than Edmonds-Karp on dense graphs, and on the unit-capacity networks that model bipartite matching, which is the bound the Hopcroft-Karp matching algorithm realizes directly.

Dinic's level graph: one BFS layers the residual vertices by distance from ; a blocking flow is pushed using only the forward edges (solid) between consecutive layers. Cross- and back-edges (dashed) are ignored this phase.

Push-relabel (Goldberg-Tarjan, 1988). Rather than maintaining a valid flow and augmenting along whole paths, push-relabel maintains a preflow (conservation may be violated by excess piling up at interior vertices) and a height label on each vertex.4 It repeatedly pushes excess downhill from a higher vertex to a lower neighbor with residual room, and relabels (raises the height of) a vertex with excess but no downhill neighbor. When no vertex except has excess, the preflow is a maximum flow. The generic rule runs in ; the highest-label and FIFO selection rules improve this to and , and in practice push-relabel is often the fastest general-purpose method. It is the algorithm most industrial max-flow codes actually implement.

The current frontier. For decades the record for sparse graphs was Orlin's algorithm (2013), which is the bound to quote when you use max-flow as a black-box subroutine.5 In 2022 Chen, Kyng, Liu, Peng, Probst Gutenberg, and Sachdeva announced a max-flow algorithm running in almost-linear time , resolving a question open since Ford and Fulkerson by routing flow through fast solvers for the underlying convex program.6 It is a landmark theoretical result, not something you would code by hand — but it settles the asymptotics: max-flow is, up to sub-polynomial factors, as cheap as reading the graph.

The practical takeaway is unchanged. Write when you need something correct and simple; reach for push-relabel or a library routine when max-flow sits on the hot path; and quote (Orlin) or (Chen et al.) when you only need the abstraction's cost, not its code.

Takeaways

  • A flow network routes flow from to under capacity and conservation () constraints; the value is what we maximize.
  • The residual graph adds reverse edges (capacity ) that let us undo flow; the augmentation lemma proves pushing along any path of yields a feasible flow of value .
  • augments until has no path; with integer capacities it runs in (pseudo-polynomial), and on irrational capacities it may fail to terminate at all.
  • 's BFS choice makes it , capacity-independent: BFS distances never decrease, so each edge is critical times and augmentations suffice.
  • The max-flow min-cut theorem (max flow min cut) gives a three-way equivalence: is maximum has no augmenting path for some cut. The hard direction builds the cut from the set reachable from in .
  • By the integrality theorem, an all-integer network has an integral max flow that decomposes into path flows; bipartite matching is the canonical unit-capacity instance among flow's many applications.

Footnotes

  1. CLRS, Ch. 26 — Maximum Flow — the max-flow min-cut theorem equating maximum flow with minimum cut.
  2. Skiena, §6 — Weighted Graph Algorithms — modeling problems such as bipartite matching as max-flow instances.
  3. Dinic, E. A. (1970), Algorithm for solution of a problem of maximum flow in networks with power estimation, Soviet Math. Doklady 11, 1277–1280 — blocking flows on the level graph, .
  4. Goldberg, A. V. & Tarjan, R. E. (1988), A new approach to the maximum-flow problem, Journal of the ACM 35(4), 921–940 — the push-relabel method.
  5. Orlin, J. B. (2013), Max flows in time, or better, Proc. STOC 2013, 765–774 — max-flow in on sparse graphs.
  6. Chen, Kyng, Liu, Peng, Probst Gutenberg & Sachdeva (2022), Maximum Flow and Minimum-Cost Flow in Almost-Linear Time, Proc. FOCS 2022 — max-flow in time.
Practice

╌╌ END ╌╌