Graphs/Bipartite Matching

Lesson 6.144,977 words

Bipartite Matching

Pairing applicants to jobs, students to slots, files to disks: all are maximum bipartite matching. We solve it combinatorially with augmenting paths (Kuhn's algorithm, O(VE)O(VE)), speed it up to O(EV)O(E\sqrt V) with Hopcroft–Karp, and uncover the structure behind it — König's theorem (max matching equals min vertex cover) and Hall's marriage theorem (a perfect matching exists iff every set has enough neighbors).

╌╌╌╌

A great many assignment problems share one shape: two disjoint groups, a list of which pairs are compatible, and the wish to pair off as many as possible with no one used twice. Applicants and jobs, students and exam seats, taxis and riders, files and disk blocks — each is a bipartite graph, and pair off as many as possible is maximum bipartite matching. The previous lesson showed this falls out of max-flow as a unit-capacity special case. This lesson takes matching on its own terms: a direct combinatorial algorithm, a faster one, and the two theorems that explain why the greedy-looking augmenting trick works.

The problem

We restrict to bipartite graphs: splits into two sides and with every edge running between them (, never within a side). The bipartite structure is what makes the problem tractable; matching in general graphs is also polynomial but needs Edmonds' far subtler blossom algorithm, which we set aside.

bipartite_graph.pypython
from collections.abc import Hashable
from typing import Generic, TypeVar

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

class BipartiteVertex(Generic[Label]):
  """
    One vertex of a bipartite graph: its label and which side it sits on.\n
    `is_left` is True for an L-vertex, False for an R-vertex. A left vertex\n
    records its right-side neighbors in `neighbors`; right vertices keep the\n
    mirror list so either side can be iterated.\n
  """

  def __init__(self, label: Label, is_left: bool) -> None:
    self.label: Label = label
    self.is_left: bool = is_left
    self.neighbors: list[BipartiteVertex[Label]] = []

  def __repr__(self) -> str:
    side: str = "L" if self.is_left else "R"
    return f"BipartiteVertex({self.label!r}, {side})"

class BipartiteGraph(Generic[Label]):
  """
    A bipartite graph over a left side L and a right side R.\n
    Add vertices with `add_left` / `add_right` (or let `add_edge` create them)\n
    and connect a left label to a right label with `add_edge`. The two sides\n
    are stored in separate label maps, so the same label may appear once on\n
    each side.\n
  """

  def __init__(self) -> None:
    self._left: dict[Label, BipartiteVertex[Label]] = {}
    self._right: dict[Label, BipartiteVertex[Label]] = {}

  def add_left(self, label: Label) -> BipartiteVertex[Label]:
    """
      Return the left vertex for `label`, creating it if absent.\n
    """
    vertex = self._left.get(label)
    if vertex is None:
      vertex = BipartiteVertex(label, is_left=True)
      self._left[label] = vertex
    return vertex

  def add_right(self, label: Label) -> BipartiteVertex[Label]:
    """
      Return the right vertex for `label`, creating it if absent.\n
    """
    vertex = self._right.get(label)
    if vertex is None:
      vertex = BipartiteVertex(label, is_left=False)
      self._right[label] = vertex
    return vertex

  def add_edge(self, left_label: Label, right_label: Label) -> None:
    """
      Connect a left label to a right label, creating either vertex as\n
      needed. Duplicate edges are ignored so adjacency lists stay simple.\n
    """
    left_vertex: BipartiteVertex[Label] = self.add_left(left_label)
    right_vertex: BipartiteVertex[Label] = self.add_right(right_label)
    if right_vertex not in left_vertex.neighbors:
      left_vertex.neighbors.append(right_vertex)
      right_vertex.neighbors.append(left_vertex)

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

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

  @property
  def left_vertices(self) -> list[BipartiteVertex[Label]]:
    """
      Every left vertex, in insertion order.\n
    """
    return list(self._left.values())

  @property
  def right_vertices(self) -> list[BipartiteVertex[Label]]:
    """
      Every right vertex, in insertion order.\n
    """
    return list(self._right.values())

  @property
  def left_labels(self) -> list[Label]:
    """
      Every left label, in insertion order.\n
    """
    return list(self._left)

  @property
  def right_labels(self) -> list[Label]:
    """
      Every right label, in insertion order.\n
    """
    return list(self._right)
A bipartite graph (left side , right side ); the three green edges , , form a matching of size , leaving and free (no incident matched edge).

Augmenting paths

Every matching algorithm is built on the augmenting path, the same idea as in flow but specialized to alternate on and off the matching.

An augmenting path has odd length: free, unmatched edge, matched edge, unmatched edge, , unmatched edge, free. It carries one more unmatched edge than matched edge. So if we flip — delete its matched edges from and add its unmatched ones — we get a new matching with exactly one more edge, and it is still a valid matching because the only vertices whose status changed were the two formerly-free endpoints. This flip is the matching analogue of pushing flow along a residual path.

An augmenting path with free endpoints (blue-outlined): green edges are matched, gray unmatched. Flipping the path grows the matching from size (left) to size (right).

The whole theory rests on one fact: the absence of augmenting paths certifies optimality.

This is the soundness/completeness split from the foundations. Soundness: when the algorithm stops (no augmenting path found), Berge's theorem guarantees the matching really is maximum — an optimal verdict is never wrong. Completeness: as long as the matching is suboptimal, an augmenting path exists to be found, so the algorithm never halts early. Berge's theorem turns local non-improvability into global optimality.

A trace on one graph

Take a fixed graph on and with edges . Start with the empty matching and process left vertices in order, finding one augmenting path each time. The first two are trivial single edges, because their targets are free; the third forces a genuine re-route.

  • : the edge reaches a free right vertex. Augment; .
  • : 's only neighbor is taken by . Try to re-route : its other neighbor is free, so the path augments. Flip it: , size .
  • : neighbor is taken by ; 's alternate is taken by ; has no other neighbor, so that branch dead-ends. Back at , try its other neighbor — free. Augment ; has size .
  • : neighbor is taken by ; 's alternate is taken by ; 's alternate is taken by ; dead-ends. Back at , its other neighbor is free. Augment ; has size .

Every left vertex is matched, so is perfect and certainly maximum. The trace below shows the matching after each augmentation; augment 2 rewires an existing edge rather than merely adding one.

Four augmentations building a perfect matching. Each panel shows the matching (green) after processing in turn. Augment 2 re-routes: slides from to so can take . Free vertices are blue-outlined; gray edges are unmatched.

Kuhn's algorithm

Berge's theorem suggests the algorithm immediately: repeatedly find an augmenting path and flip it, until none remains. In a bipartite graph the search is especially clean. Try to match each left vertex in turn. From , run a DFS that only ever takes alternating steps: step to a neighbor ; if is free, we have found the end of an augmenting path — match and return; if is already matched to some , recursively try to re-route to a different free partner, and if that succeeds, is freed for . This is the Hungarian / Kuhn augmenting-path method.1

Algorithm 1:Kuhn(G,L,R)\textsc{Kuhn}(G, L, R) — maximum bipartite matching by augmenting paths
  1. 1
    foreach vRv \in R do match[v]nil\text{match}[v] \gets \textsc{nil}
    who vv is matched to
  2. 2
    size0\text{size} \gets 0
  3. 3
    foreach uLu \in L do
  4. 4
    reset seen[]false\text{seen}[\,\cdot\,] \gets \textsc{false} for all vRv \in R
  5. 5
    if TryKuhn(u)\textsc{TryKuhn}(u) then sizesize+1\text{size} \gets \text{size} + 1
  6. 6
    return size\text{size}
    and match[]\text{match}[\cdot] holds the matching
  7. 7
  8. 8
    // try to match (or re-route) uu along an alternating path
  9. 9
    function TryKuhn(u)\textsc{TryKuhn}(u):
  10. 10
    foreach vadj(u)v \in \text{adj}(u) do
  11. 11
    if not seen[v]\text{seen}[v] then
  12. 12
    seen[v]true\text{seen}[v] \gets \textsc{true}
  13. 13
    // vv free, or its partner can step aside
  14. 14
    if match[v]=nil\text{match}[v] = \textsc{nil} or TryKuhn(match[v])\textsc{TryKuhn}(\text{match}[v]) then
  15. 15
    match[v]u\text{match}[v] \gets u
  16. 16
    return true\textsc{true}
  17. 17
    return false\textsc{false}
Kuhn's DFS re-routing, read as a zig-zag alternating walk : wants , taken by (green matched edge); the recursion sends to its free alternate , liberating for . Dashed blue arrows trace the search; after the flip the matching is and .

In practice Kuhn is much faster than the bound: greedily seeding the matching first (match each to any free neighbor before running the loop) and iterating from the higher-degree side both cut the constant sharply. For dense graphs can be , which is why the next algorithm matters.

kuhn_matching.pypython
from collections.abc import Hashable
from typing import Generic, Optional, TypeVar

from bipartite_graph import BipartiteGraph, BipartiteVertex

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

class Matching(Generic[Label]):
  """
    A matching in a bipartite graph, paired off in both directions.\n
    `partner_of_left[left_label]` is the right label it is matched to (absent\n
    if free), and `partner_of_right[right_label]` is the mirror map. The graph\n
    is kept so structural queries (Konig's vertex cover) can reuse it.\n
  """

  def __init__(self, graph: BipartiteGraph[Label]) -> None:
    self.graph: BipartiteGraph[Label] = graph
    self.partner_of_left: dict[Label, Label] = {}
    self.partner_of_right: dict[Label, Label] = {}

  def pair(self, left_label: Label, right_label: Label) -> None:
    """
      Record `left_label`-`right_label` as a matched edge, both ways.\n
    """
    self.partner_of_left[left_label] = right_label
    self.partner_of_right[right_label] = left_label

  @property
  def size(self) -> int:
    """
      The number of matched edges.\n
    """
    return len(self.partner_of_left)

  def edges(self) -> list[tuple[Label, Label]]:
    """
      The matched edges as (left_label, right_label) pairs, left order.\n
    """
    return list(self.partner_of_left.items())

def kuhn_matching(graph: BipartiteGraph[Label]) -> Matching[Label]:
  """
    A maximum matching of `graph` by Kuhn's augmenting-path search.\n
  """
  matching: Matching[Label] = Matching(graph)

  def try_augment(
    left_vertex: BipartiteVertex[Label],
    seen: set[Label],
  ) -> bool:
    """
      Search for an augmenting path out of `left_vertex`, flipping it on\n
      success. `seen` holds the right labels already explored this attempt.\n
    """
    for right_vertex in left_vertex.neighbors:
      right_label: Label = right_vertex.label
      if right_label in seen:
        continue
      seen.add(right_label)

      # the right vertex is free, or its partner can step aside for us.
      current_partner: Optional[Label] = matching.partner_of_right.get(
        right_label
      )
      if current_partner is None or try_augment(
        graph.left_vertex(current_partner), seen
      ):
        matching.pair(left_vertex.label, right_label)
        return True
    return False

  # grow the matching by one augmenting search per left vertex.
  for left_vertex in graph.left_vertices:
    seen: set[Label] = set()
    try_augment(left_vertex, seen)

  return matching

Hopcroft–Karp

Kuhn finds one augmenting path per phase. Hopcroft–Karp finds a maximal set of shortest, vertex-disjoint augmenting paths per phase, all at once, and augments along all of them simultaneously. The speedup mirrors Edmonds–Karp's BFS choice for flow: forcing shortest augmenting paths makes the shortest length strictly increase across phases, capping the number of phases.2

Each phase has two steps:

  • BFS from all free left vertices simultaneously, layering the graph by alternating distance, and stopping at the first layer that contains a free right vertex. This finds the length of the shortest augmenting paths.
  • DFS to greedily extract a maximal set of vertex-disjoint augmenting paths of that exact length , flipping each as it is found.
Algorithm 2:HopcroftKarp(G,L,R)\textsc{HopcroftKarp}(G, L, R) — shortest augmenting paths in phases
  1. 1
    foreach uLu \in L do matchL[u]nil\text{matchL}[u] \gets \textsc{nil}
  2. 2
    foreach vRv \in R do matchR[v]nil\text{matchR}[v] \gets \textsc{nil}
  3. 3
    size0\text{size} \gets 0
  4. 4
    while Bfs()\textsc{Bfs}() finds at least one augmenting path do
    one phase
  5. 5
    foreach free uLu \in L do
  6. 6
    if Dfs(u)\textsc{Dfs}(u) then sizesize+1\text{size} \gets \text{size} + 1
  7. 7
    return size\text{size}
  8. 8
  9. 9
    // layer by alternating distance; return true if a free right vertex is reached
  10. 10
    function Bfs()\textsc{Bfs}():
  11. 11
    QQ \gets empty queue
  12. 12
    foreach uLu \in L do
  13. 13
    if matchL[u]=nil\text{matchL}[u] = \textsc{nil} then dist[u]0\text{dist}[u] \gets 0; enqueue uu
  14. 14
    else dist[u]\text{dist}[u] \gets \infty
  15. 15
    foundfalse\text{found} \gets \textsc{false}
  16. 16
    while QQ nonempty do
  17. 17
    uu \gets dequeue
  18. 18
    foreach vadj(u)v \in \text{adj}(u) do
  19. 19
    wmatchR[v]w \gets \text{matchR}[v]
    left vertex across the matched edge
  20. 20
    if w=nilw = \textsc{nil} then foundtrue\text{found} \gets \textsc{true}
    reached a free right vertex
  21. 21
    else if dist[w]=\text{dist}[w] = \infty then dist[w]dist[u]+1\text{dist}[w] \gets \text{dist}[u] + 1; enqueue ww
  22. 22
    return found\text{found}
  23. 23
  24. 24
    // extend one shortest augmenting path from uu, respecting BFS layers
  25. 25
    function Dfs(u)\textsc{Dfs}(u):
  26. 26
    foreach vadj(u)v \in \text{adj}(u) do
  27. 27
    wmatchR[v]w \gets \text{matchR}[v]
  28. 28
    if w=nilw = \textsc{nil} or (dist[w]=dist[u]+1\text{dist}[w] = \text{dist}[u] + 1 and Dfs(w)\textsc{Dfs}(w)) then
  29. 29
    matchL[u]v\text{matchL}[u] \gets v; matchR[v]u\text{matchR}[v] \gets u
    flip this edge
  30. 30
    return true\textsc{true}
  31. 31
    dist[u]\text{dist}[u] \gets \infty
    dead end; never revisit
  32. 32
    return false\textsc{false}

For dense bipartite graphs this is a real gain over Kuhn's , and Hopcroft–Karp is the standard choice when is large. It is also Dinic's algorithm specialized to unit-capacity bipartite networks — the bound is Dinic's on such networks.

hopcroft_karp.pypython
from collections import deque
from collections.abc import Hashable
from math import inf
from typing import Optional, TypeVar

from bipartite_graph import BipartiteGraph, BipartiteVertex
from kuhn_matching import Matching

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

def hopcroft_karp(graph: BipartiteGraph[Label]) -> Matching[Label]:
  """
    A maximum matching of `graph` by shortest augmenting paths in phases.\n
  """
  matching: Matching[Label] = Matching(graph)

  # alternating distance to each left vertex within the current phase;
  # inf means unreached, so it cannot start a path this phase.
  distance: dict[Label, float] = {}

  def build_layers() -> bool:
    """
      BFS from every free left vertex, layering left vertices by alternating\n
      distance. Returns True if some augmenting path (a free right vertex)\n
      is reachable, meaning the phase has work to do.\n
    """
    # free left vertices start at distance 0; matched ones start unreached.
    queue: deque[BipartiteVertex[Label]] = deque()
    for left_vertex in graph.left_vertices:
      if left_vertex.label not in matching.partner_of_left:
        distance[left_vertex.label] = 0
        queue.append(left_vertex)
      else:
        distance[left_vertex.label] = inf

    # walk the layers, recording the first free right vertex reached.
    found: bool = False
    while queue:
      left_vertex = queue.popleft()
      for right_vertex in left_vertex.neighbors:
        across: Optional[Label] = matching.partner_of_right.get(
          right_vertex.label
        )
        if across is None:
          # a free right vertex: an augmenting path ends here.
          found = True
        elif distance[across] == inf:
          distance[across] = distance[left_vertex.label] + 1
          queue.append(graph.left_vertex(across))
    return found

  def extend_path(left_vertex: BipartiteVertex[Label]) -> bool:
    """
      DFS one shortest augmenting path out of `left_vertex`, respecting the\n
      BFS layers, and flip its edges on success.\n
    """
    for right_vertex in left_vertex.neighbors:
      across: Optional[Label] = matching.partner_of_right.get(
        right_vertex.label
      )
      reaches_free: bool = across is None
      steps_forward: bool = (
        across is not None
        and distance[across] == distance[left_vertex.label] + 1
        and extend_path(graph.left_vertex(across))
      )
      if reaches_free or steps_forward:
        matching.pair(left_vertex.label, right_vertex.label)
        return True

    # dead end: bar this vertex from the rest of the phase.
    distance[left_vertex.label] = inf
    return False

  # each phase: layer, then flip every disjoint shortest path found.
  while build_layers():
    for left_vertex in graph.left_vertices:
      if left_vertex.label not in matching.partner_of_left:
        extend_path(left_vertex)

  return matching

König's theorem

Augmenting paths solve the matching problem; the next two theorems explain its structure, and both are good interview material in their own right. The first links matching to its natural dual, the vertex cover.3

Any matching and any vertex cover are tied by an easy inequality: a vertex cover must contain at least one endpoint of every matched edge, and those endpoints are all distinct (matching edges are disjoint), so always — this is weak duality, exactly as was for flow. König's theorem says that in bipartite graphs the bound is tight.

The construction doubles as an algorithm for the minimum vertex cover. Run Kuhn or Hopcroft–Karp, then one alternating-reachability BFS from the free left vertices computes and reads off . So minimum vertex cover — NP-hard in general graphs — is polynomial on bipartite graphs, solved through matching. By complementation, is a maximum independent set, also polynomial here.

konig_vertex_cover.pypython
from collections import deque
from collections.abc import Hashable
from typing import Generic, Optional, TypeVar

from bipartite_graph import BipartiteGraph, BipartiteVertex
from kuhn_matching import Matching, kuhn_matching

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

class Cover(Generic[Label]):
  """
    A minimum vertex cover of a bipartite graph, split by side.\n
    `left` and `right` hold the cover labels on each side; `independent_set`\n
    is the complement, a maximum independent set. By Konig's theorem the total\n
    cover size equals the maximum matching size.\n
  """

  def __init__(
    self,
    left: set[Label],
    right: set[Label],
    independent_left: set[Label],
    independent_right: set[Label],
  ) -> None:
    self.left: set[Label] = left
    self.right: set[Label] = right
    self.independent_left: set[Label] = independent_left
    self.independent_right: set[Label] = independent_right

  @property
  def size(self) -> int:
    """
      The number of vertices in the cover.\n
    """
    return len(self.left) + len(self.right)

  def covers(self, left_label: Label, right_label: Label) -> bool:
    """
      Whether the edge (left_label, right_label) has a cover endpoint.\n
    """
    return left_label in self.left or right_label in self.right

def _alternating_reachable(
  graph: BipartiteGraph[Label],
  matching: Matching[Label],
) -> tuple[set[Label], set[Label]]:
  """
    The set Z of vertices reachable from the free left vertices by alternating\n
    paths, returned as (reachable left labels, reachable right labels).\n
    Starting free and unmatched-first, a left vertex steps to a right neighbor\n
    along an unmatched edge, and a reached right vertex steps back to its\n
    partner along the matched edge.\n
  """
  reachable_left: set[Label] = set()
  reachable_right: set[Label] = set()
  queue: deque[BipartiteVertex[Label]] = deque()

  # seed the search at the free (unmatched) left vertices.
  for left_vertex in graph.left_vertices:
    if left_vertex.label not in matching.partner_of_left:
      reachable_left.add(left_vertex.label)
      queue.append(left_vertex)

  while queue:
    left_vertex = queue.popleft()
    partner: Optional[Label] = matching.partner_of_left.get(left_vertex.label)

    for right_vertex in left_vertex.neighbors:
      # cross only unmatched edges, and only to fresh right vertices.
      if right_vertex.label == partner:
        continue
      if right_vertex.label in reachable_right:
        continue
      reachable_right.add(right_vertex.label)

      # then step back along the matched edge, if any.
      next_left: Optional[Label] = matching.partner_of_right.get(
        right_vertex.label
      )
      if next_left is not None and next_left not in reachable_left:
        reachable_left.add(next_left)
        queue.append(graph.left_vertex(next_left))

  return reachable_left, reachable_right

def minimum_vertex_cover(graph: BipartiteGraph[Label]) -> Cover[Label]:
  """
    A minimum vertex cover of `graph`, with its complementary maximum\n
    independent set, built from a maximum matching via Konig's construction.\n
  """
  matching: Matching[Label] = kuhn_matching(graph)
  reachable_left, reachable_right = _alternating_reachable(graph, matching)

  all_left: set[Label] = set(graph.left_labels)
  all_right: set[Label] = set(graph.right_labels)

  # C = (L \ Z) union (R intersect Z).
  cover_left: set[Label] = all_left - reachable_left
  cover_right: set[Label] = all_right & reachable_right

  # the complement on each side is the maximum independent set.
  independent_left: set[Label] = all_left - cover_left
  independent_right: set[Label] = all_right - cover_right

  return Cover(cover_left, cover_right, independent_left, independent_right)
König duality: the maximum matching (green) has size ; the minimum vertex cover (blue-filled) also has size , one cover vertex per matched edge.

Hall's marriage theorem

The second structural result answers a different question: not how large is the matching, but when does it saturate one whole side. Picture as people and as the partners they would accept; we want everyone in married. The obstruction is obvious once stated: if some set of people collectively know fewer than acceptable partners, they cannot all be matched. Hall's theorem says this single obstruction is the only one. Write for the set of neighbors of (every -vertex adjacent to some vertex of ).

Hall's condition violated: the set (blue-filled) has only two distinct neighbors (red-outlined), so and no matching can saturate .

Hall's theorem is the existence companion to König's optimization statement; the proof above derives one directly from the other. A corollary handles the symmetric, fully-balanced case:

This corollary explains why a -regular bipartite graph's edges decompose into perfect matchings (peel one off, repeat), which underlies, e.g., scheduling conflict-free rounds.

hall_condition.pypython
from collections.abc import Hashable
from itertools import combinations
from typing import Optional, TypeVar

from bipartite_graph import BipartiteGraph
from kuhn_matching import kuhn_matching
from konig_vertex_cover import minimum_vertex_cover

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

def neighbors_of(
  graph: BipartiteGraph[Label],
  left_labels: set[Label],
) -> set[Label]:
  """
    N(S): the right labels adjacent to some vertex of `left_labels`.\n
  """
  # union the right neighbors over every chosen left vertex.
  result: set[Label] = set()
  for left_label in left_labels:
    for right_vertex in graph.left_vertex(left_label).neighbors:
      result.add(right_vertex.label)

  return result

def saturates_left(graph: BipartiteGraph[Label]) -> bool:
  """
    Whether some matching saturates every left vertex of `graph`.\n
    Decided by a maximum matching: L is saturable iff the matching size\n
    reaches |L|. This is the efficient face of Hall's theorem.\n
  """
  return kuhn_matching(graph).size == len(graph.left_labels)

def hall_violator(graph: BipartiteGraph[Label]) -> Optional[set[Label]]:
  """
    A witness set S subset of L with |N(S)| < |S| if the marriage condition\n
    fails, else None. The witness is built from Konig's minimum vertex cover:\n
    when L is not saturable the cover has size < |L|, and S = L \\ C_L\n
    violates Hall's condition, since every neighbor of S lies in C_R.\n
  """
  cover = minimum_vertex_cover(graph)
  if cover.size == len(graph.left_labels):
    return None

  # S = L \ C_L; its neighborhood is contained in C_R, which is smaller.
  witness: set[Label] = set(graph.left_labels) - cover.left
  return witness

def hall_violator_brute_force(
  graph: BipartiteGraph[Label],
) -> Optional[set[Label]]:
  """
    The same witness by exhaustive subset search — the reference for tests.\n
    Returns any S subset of L with |N(S)| < |S|, or None if none exists.\n
  """
  left_labels: list[Label] = graph.left_labels
  for size in range(1, len(left_labels) + 1):
    for chosen in combinations(left_labels, size):
      subset: set[Label] = set(chosen)
      if len(neighbors_of(graph, subset)) < len(subset):
        return subset
  return None

def is_regular(graph: BipartiteGraph[Label]) -> Optional[int]:
  """
    The common degree `d` if `graph` is d-regular (both sides, every vertex\n
    of degree d) and non-empty, else None. A d-regular bipartite graph with\n
    d >= 1 always has a perfect matching, by Hall's corollary.\n
  """
  # collect every vertex degree across both sides.
  degrees: set[int] = set()
  for left_vertex in graph.left_vertices:
    degrees.add(len(left_vertex.neighbors))
  for right_vertex in graph.right_vertices:
    degrees.add(len(right_vertex.neighbors))

  # regular iff a single shared degree, and that degree is nonzero.
  if len(degrees) != 1:
    return None
  degree: int = next(iter(degrees))
  return degree if degree != 0 else None

The reduction to max-flow

The combinatorial view and the flow view describe the same object. The max-flow reduction makes this explicit, and it is worth carrying out once in full because it explains where the augmenting-path machinery comes from.

Given a bipartite graph , build a flow network :

  • add a source and a sink ;
  • for each , an edge of capacity ;
  • for each original edge , a directed edge of capacity (direction to );
  • for each , an edge of capacity .

Every capacity is . An integer flow of value then corresponds exactly to a matching of size : the unit capacity on lets at most one middle edge out of carry flow, and the unit capacity on lets at most one into , so the saturated middle edges form a set of disjoint pairs — a matching. Conversely a matching of size routes units. Because all capacities are integral, the integrality theorem for max-flow guarantees an integer maximum flow exists, so max-flow value maximum matching size.

Bipartite matching as unit-capacity max-flow. Source feeds each left vertex (capacity ), original edges point to (capacity ), each right vertex drains to sink (capacity ). Green marks one saturated - path; the three saturated middle edges are the matching. Every edge has capacity .

An augmenting path in this network alternates forward edges (unused) with backward residual edges (used) — and on the middle layer that is precisely an alternating path in : a forward is an unmatched edge, a backward is a matched edge traversed in reverse. Kuhn's DFS is the flow algorithm with the and plumbing stripped away, and Hopcroft–Karp is Dinic on this same network. The two views are the same algorithm in different notation.

Why not just call max-flow?

Matching reduces to flow, but the dedicated combinatorial algorithms are still worth having, for several reasons.

  • Simplicity and constants. Kuhn is a dozen lines of DFS with no residual bookkeeping, no source/sink scaffolding, and tiny constants — the practical default for the moderate sizes seen in contests and interviews.
  • The right asymptotics. Hopcroft–Karp's matches the best general bound for this problem and beats a black-box Edmonds–Karp ( is far worse here); you get the specialized bound without invoking Dinic by hand.
  • Structure, not just a number. König and Hall fall straight out of the augmenting-path picture, yielding minimum vertex cover, maximum independent set, and saturation certificates — outputs the flow value alone does not give.

The flow reduction remains the right tool the moment the problem stops being pure matching: capacities other than (a job hiring several applicants), costs on edges (minimum-cost assignment), or many-to-one constraints. Matching is the special case; flow is the generalization for anything beyond it.

Weighted, general, and online matching

Unweighted bipartite matching is the entry point to a larger theory; three generalizations are worth knowing by name.

Weighted matching — the assignment problem. Put a cost on each edge and ask for the cheapest perfect matching: this is the assignment problem, solved by the Hungarian algorithm (Kuhn 1955, from Kőnig's and Egerváry's ideas) in .4 It maintains dual variables (potentials) on the vertices and augments along shortest alternating paths — structurally the same augmenting-path idea, now weighted, and equivalent to min-cost max-flow specialized to unit capacities. It is the standard method for optimal task-to-worker, sensor-to-target, and tracking-association assignments.

General graphs — Edmonds' blossoms. Drop the bipartite restriction and the augmenting-path method breaks, because an odd cycle can hide an augmenting path that alternating BFS never finds. Edmonds' blossom algorithm (1965) repairs this by contracting each odd cycle (a blossom) into a single vertex, searching the smaller graph, then lifting the result back — and in doing so gave the field its working definition of efficient as polynomial time.5 Micali and Vazirani later pushed general matching to , matching the bipartite bound.

Why general matching is harder. The odd cycle (a blossom) hides an augmenting path that alternating search misses; Edmonds' algorithm contracts it to a single vertex, matches the smaller graph, then expands.

Online and streaming. When one side arrives over time and must be matched irrevocably on arrival — ad impressions to advertisers, riders to drivers — no algorithm can match the offline optimum. The RANKING algorithm (Karp, Vazirani, Vazirani 1990) fixes a random priority over the offline side and greedily matches each arrival to its highest-priority free neighbor, achieving a competitive ratio of , which is provably optimal for online bipartite matching.6 This result launched the whole field of online matching that underlies modern ad-allocation systems.

All three build on the augmenting path from this lesson: weighted matching augments with costs, general matching augments through contracted blossoms, and online matching gives up augmentation entirely for a randomized greedy rule.

Takeaways

  • A matching is a set of pairwise-disjoint edges; in a bipartite graph we want a maximum one, and the whole theory turns on the augmenting path (alternating, free-to-free), whose flip grows by one.
  • Berge's theorem: is maximum iff it has no augmenting path — the soundness (stops only at optimum) and completeness (always improvable until optimum) of every augmenting-path algorithm.
  • Kuhn's augmenting-path DFS runs in ; Hopcroft–Karp batches shortest, disjoint augmenting paths per phase for , the unit-capacity specialization of Dinic.
  • König's theorem: in bipartite graphs max matching min vertex cover, giving a polynomial minimum vertex cover (NP-hard in general) and, by complement, maximum independent set.
  • Hall's marriage theorem: is fully matchable iff for all ; it yields perfect matchings for -regular bipartite graphs and is König's dual face.

Footnotes

  1. CLRS, Ch. 26 — Maximum Flow: bipartite matching as unit-capacity flow, and the augmenting-path method underlying Kuhn's algorithm.
  2. Erickson, Ch. 11 — Applications of Maximum Flow: matching, vertex cover, and the shortest-augmenting-path speedup behind Hopcroft–Karp / Dinic.
  3. Skiena, §6 — Weighted Graph Algorithms: bipartite matching in practice, and the matching / vertex-cover duality (König).
  4. Kuhn, H. W. (1955), The Hungarian method for the assignment problem, Naval Research Logistics Quarterly 2, 83–97 — minimum-cost perfect matching via vertex potentials.
  5. Edmonds, J. (1965), Paths, trees, and flowers, Canadian Journal of Mathematics 17, 449–467 — the blossom algorithm for maximum matching in general graphs.
  6. Karp, R. M., Vazirani, U. V. & Vazirani, V. V. (1990), An optimal algorithm for on-line bipartite matching, Proc. STOC 1990, 352–358 — the RANKING algorithm and its competitive ratio.
Practice

╌╌ END ╌╌