|
| 1 | +"""Hopcroft-Karp algorithm for finding maximum cardinality matching in bipartite graphs. |
| 2 | +
|
| 3 | +Reference: |
| 4 | + https://en.wikipedia.org/wiki/Hopcroft%E2%80%93Karp_algorithm |
| 5 | +
|
| 6 | +The Hopcroft-Karp algorithm finds a maximum cardinality matching in an unweighted |
| 7 | +bipartite graph in O(|E| * sqrt(|V|)) time. |
| 8 | +
|
| 9 | +Key Concepts and Conditions: |
| 10 | +1. Bipartite Condition: |
| 11 | + A graph G = (U union V, E) is bipartite if its vertices can be partitioned into |
| 12 | + two disjoint sets U (left partition) and V (right partition) such that every |
| 13 | + edge connects a vertex in U to a vertex in V. No edges may exist between two |
| 14 | + vertices within the same partition (U intersect V = empty set). Vertices cannot |
| 15 | + be None. |
| 16 | +
|
| 17 | +2. Matching Condition: |
| 18 | + A matching M is a subset of edges such that no two edges share a common vertex. |
| 19 | + A vertex is 'free' (unmatched) if it is not incident to any edge in M. |
| 20 | +
|
| 21 | +3. Alternating and Augmenting Paths: |
| 22 | + - Alternating path: A path whose edges alternate between unmatched edges |
| 23 | + (not in M) and matched edges (in M). |
| 24 | + - Augmenting path: An alternating path that starts and ends at distinct free |
| 25 | + vertices. |
| 26 | + - Berge's Lemma: A matching is of maximum cardinality if and only if no |
| 27 | + augmenting paths exist. |
| 28 | +
|
| 29 | +4. Hopcroft-Karp Layering and Augmentation Conditions: |
| 30 | + Instead of searching for augmenting paths one-by-one (O(|V| * |E|)), Hopcroft-Karp |
| 31 | + operates in phases: |
| 32 | + - BFS Phase (Layering): Simultaneously searches from all free vertices in U to |
| 33 | + find the length of the shortest augmenting paths. It builds a layered DAG of |
| 34 | + alternating levels. If no free vertex in V is reachable, the algorithm terminates. |
| 35 | + - DFS Phase (Augmentation): Discovers a maximal set of vertex-disjoint augmenting |
| 36 | + paths of the shortest length found by BFS. It only traverses edges satisfying: |
| 37 | + distance_map[matched_left] == distance_map[curr_left] + 1. |
| 38 | + - Symmetric Difference: Matching edges along each augmenting path are flipped |
| 39 | + (unmatched becomes matched, matched becomes unmatched). |
| 40 | + - Iterative DFS: The DFS phase is implemented iteratively using an explicit stack |
| 41 | + to prevent RecursionError on graphs with large alternating path diameters. |
| 42 | +
|
| 43 | +Complexity: |
| 44 | + Time Complexity: O(|E| * sqrt(|V|)) |
| 45 | + Space Complexity: O(|V| + |E|) |
| 46 | +""" |
| 47 | + |
| 48 | +from __future__ import annotations |
| 49 | + |
| 50 | +import math |
| 51 | +from collections import deque |
| 52 | + |
| 53 | +_NIL = object() |
| 54 | + |
| 55 | + |
| 56 | +class HopcroftKarp[T]: |
| 57 | + """Class implementing the Hopcroft-Karp maximum bipartite matching algorithm. |
| 58 | +
|
| 59 | + >>> hk = HopcroftKarp({"u1": ["v1", "v2"], "u2": ["v1"], "u3": ["v2", "v3"]}) |
| 60 | + >>> hk.maximum_matching() |
| 61 | + {'u1': 'v2', 'u2': 'v1', 'u3': 'v3'} |
| 62 | + """ |
| 63 | + |
| 64 | + def __init__(self, graph: dict[T, list[T]]) -> None: |
| 65 | + """Initialize bipartite partitions and match pairing dictionaries. |
| 66 | +
|
| 67 | + Raises: |
| 68 | + ValueError: If partitions overlap or if any vertex is None. |
| 69 | +
|
| 70 | + >>> hk = HopcroftKarp({"u1": ["v1"]}) |
| 71 | + >>> hk.left_vertices |
| 72 | + ['u1'] |
| 73 | + >>> hk.right_vertices |
| 74 | + ['v1'] |
| 75 | + >>> HopcroftKarp({"A": ["A"]}) |
| 76 | + Traceback (most recent call last): |
| 77 | + ... |
| 78 | + ValueError: Partitions must be disjoint: found vertices in both sets: ['A'] |
| 79 | + >>> HopcroftKarp({"u1": [None]}) |
| 80 | + Traceback (most recent call last): |
| 81 | + ... |
| 82 | + ValueError: Vertices cannot be None |
| 83 | + """ |
| 84 | + self.graph = graph |
| 85 | + self.left_vertices = list(graph.keys()) |
| 86 | + self.right_vertices = sorted( |
| 87 | + { |
| 88 | + right_vertex |
| 89 | + for neighbors in graph.values() |
| 90 | + for right_vertex in neighbors |
| 91 | + }, |
| 92 | + key=repr, |
| 93 | + ) |
| 94 | + |
| 95 | + if any(vertex is None for vertex in self.left_vertices) or any( |
| 96 | + vertex is None for vertex in self.right_vertices |
| 97 | + ): |
| 98 | + msg = "Vertices cannot be None" |
| 99 | + raise ValueError(msg) |
| 100 | + |
| 101 | + overlap = set(self.left_vertices) & set(self.right_vertices) |
| 102 | + if overlap: |
| 103 | + msg = ( |
| 104 | + f"Partitions must be disjoint: found vertices in both sets: " |
| 105 | + f"{sorted(overlap, key=repr)}" |
| 106 | + ) |
| 107 | + raise ValueError(msg) |
| 108 | + |
| 109 | + # pair_left[u] stores matched vertex in V for u in U (or _NIL if free) |
| 110 | + self.pair_left: dict[T, T | object] = dict.fromkeys(self.left_vertices, _NIL) |
| 111 | + # pair_right[v] stores matched vertex in U for v in V (or _NIL if free) |
| 112 | + self.pair_right: dict[T, T | object] = dict.fromkeys(self.right_vertices, _NIL) |
| 113 | + # distance_map stores the BFS level from free vertices in U |
| 114 | + self.distance_map: dict[T | object, float] = {} |
| 115 | + |
| 116 | + def breadth_first_search(self) -> bool: |
| 117 | + """BFS Phase: Layer the graph and find shortest augmenting path length. |
| 118 | +
|
| 119 | + Returns: |
| 120 | + True if at least one augmenting path to a free vertex in V exists, |
| 121 | + False otherwise (termination condition). |
| 122 | +
|
| 123 | + >>> hk = HopcroftKarp({"u1": ["v1"]}) |
| 124 | + >>> hk.breadth_first_search() |
| 125 | + True |
| 126 | + >>> hk.pair_left["u1"] = "v1" |
| 127 | + >>> hk.pair_right["v1"] = "u1" |
| 128 | + >>> hk.breadth_first_search() |
| 129 | + False |
| 130 | + """ |
| 131 | + queue: deque[T] = deque() |
| 132 | + |
| 133 | + # Enqueue all free vertices in the left partition at level 0 |
| 134 | + for left_vertex in self.left_vertices: |
| 135 | + if self.pair_left[left_vertex] is _NIL: |
| 136 | + self.distance_map[left_vertex] = 0.0 |
| 137 | + queue.append(left_vertex) |
| 138 | + else: |
| 139 | + self.distance_map[left_vertex] = math.inf |
| 140 | + |
| 141 | + # distance_map[_NIL] represents distance to a free vertex in right partition |
| 142 | + self.distance_map[_NIL] = math.inf |
| 143 | + |
| 144 | + while queue: |
| 145 | + left_vertex = queue.popleft() |
| 146 | + if self.distance_map[left_vertex] < self.distance_map[_NIL]: |
| 147 | + for right_vertex in self.graph[left_vertex]: |
| 148 | + matched_left = self.pair_right[right_vertex] |
| 149 | + if self.distance_map.get(matched_left, math.inf) == math.inf: |
| 150 | + self.distance_map[matched_left] = ( |
| 151 | + self.distance_map[left_vertex] + 1.0 |
| 152 | + ) |
| 153 | + if matched_left is not _NIL: |
| 154 | + queue.append(matched_left) # type: ignore[arg-type] |
| 155 | + |
| 156 | + return self.distance_map[_NIL] != math.inf |
| 157 | + |
| 158 | + def depth_first_search(self, start_left: T) -> bool: |
| 159 | + """DFS Phase: Find and augment along shortest augmenting paths iteratively. |
| 160 | +
|
| 161 | + Implemented iteratively with an explicit stack to prevent RecursionError |
| 162 | + on graphs with deep alternating paths (diameter > 1000). |
| 163 | +
|
| 164 | + Parameters: |
| 165 | + start_left: The free vertex in the left partition to start the search from. |
| 166 | +
|
| 167 | + Returns: |
| 168 | + True if an augmenting path was found and augmented, False otherwise. |
| 169 | +
|
| 170 | + >>> hk = HopcroftKarp({"u1": ["v1"]}) |
| 171 | + >>> _ = hk.breadth_first_search() |
| 172 | + >>> hk.depth_first_search("u1") |
| 173 | + True |
| 174 | + >>> hk.pair_left["u1"] |
| 175 | + 'v1' |
| 176 | + >>> hk.depth_first_search("u1") |
| 177 | + False |
| 178 | + """ |
| 179 | + stack: list[T] = [start_left] |
| 180 | + neighbor_indices: list[int] = [0] |
| 181 | + path: list[tuple[T, T]] = [] |
| 182 | + |
| 183 | + while stack: |
| 184 | + curr_left = stack[-1] |
| 185 | + curr_index = neighbor_indices[-1] |
| 186 | + neighbors = self.graph[curr_left] |
| 187 | + |
| 188 | + found_next = False |
| 189 | + for idx in range(curr_index, len(neighbors)): |
| 190 | + right_vertex = neighbors[idx] |
| 191 | + matched_left = self.pair_right[right_vertex] |
| 192 | + |
| 193 | + # Augmentation Condition: Only step along shortest layer paths |
| 194 | + if ( |
| 195 | + self.distance_map.get(matched_left, math.inf) |
| 196 | + == self.distance_map[curr_left] + 1.0 |
| 197 | + ): |
| 198 | + neighbor_indices[-1] = idx + 1 |
| 199 | + path.append((curr_left, right_vertex)) |
| 200 | + |
| 201 | + if matched_left is _NIL: |
| 202 | + # Reached a free right vertex: augment matching along path |
| 203 | + for path_left, path_right in path: |
| 204 | + self.pair_right[path_right] = path_left |
| 205 | + self.pair_left[path_left] = path_right |
| 206 | + return True |
| 207 | + |
| 208 | + stack.append(matched_left) # type: ignore[arg-type] |
| 209 | + neighbor_indices.append(0) |
| 210 | + found_next = True |
| 211 | + break |
| 212 | + |
| 213 | + if not found_next: |
| 214 | + # Dead end: prune curr_left from this phase |
| 215 | + self.distance_map[curr_left] = math.inf |
| 216 | + stack.pop() |
| 217 | + neighbor_indices.pop() |
| 218 | + if path: |
| 219 | + path.pop() |
| 220 | + |
| 221 | + return False |
| 222 | + |
| 223 | + def maximum_matching(self) -> dict[T, T]: |
| 224 | + """Compute and return the maximum cardinality matching. |
| 225 | +
|
| 226 | + >>> hk = HopcroftKarp({"u1": ["v1"], "u2": ["v1"]}) |
| 227 | + >>> hk.maximum_matching() |
| 228 | + {'u1': 'v1'} |
| 229 | + """ |
| 230 | + while self.breadth_first_search(): |
| 231 | + for left_vertex in self.left_vertices: |
| 232 | + if self.pair_left[left_vertex] is _NIL: |
| 233 | + self.depth_first_search(left_vertex) |
| 234 | + |
| 235 | + return { |
| 236 | + left_vertex: matched_right # type: ignore[misc] |
| 237 | + for left_vertex, matched_right in self.pair_left.items() |
| 238 | + if matched_right is not _NIL |
| 239 | + } |
| 240 | + |
| 241 | + |
| 242 | +def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]: |
| 243 | + """Find a maximum cardinality matching in a bipartite graph using Hopcroft-Karp. |
| 244 | +
|
| 245 | + Parameters: |
| 246 | + graph: An adjacency list mapping each vertex in the left partition (U) to |
| 247 | + a list of adjacent vertices in the right partition (V). The two |
| 248 | + partitions must be disjoint, and vertices cannot be None. |
| 249 | +
|
| 250 | + Returns: |
| 251 | + A dictionary representing the matching, mapping each matched vertex in |
| 252 | + the left partition to its matched partner in the right partition. |
| 253 | +
|
| 254 | + Raises: |
| 255 | + ValueError: If any vertex appears in both partitions or if any vertex is None. |
| 256 | +
|
| 257 | + Examples: |
| 258 | + >>> # Standard bipartite matching |
| 259 | + >>> graph = {"u1": ["v1", "v2"], "u2": ["v1"], "u3": ["v2", "v3"]} |
| 260 | + >>> hopcroft_karp(graph) |
| 261 | + {'u1': 'v2', 'u2': 'v1', 'u3': 'v3'} |
| 262 | +
|
| 263 | + >>> # Empty graph condition |
| 264 | + >>> hopcroft_karp({}) |
| 265 | + {} |
| 266 | +
|
| 267 | + >>> # Isolated vertices (no incident edges) |
| 268 | + >>> hopcroft_karp({"u1": []}) |
| 269 | + {} |
| 270 | +
|
| 271 | + >>> # Competing vertices (more left vertices than right vertices) |
| 272 | + >>> hopcroft_karp({"u1": ["v1"], "u2": ["v1"]}) |
| 273 | + {'u1': 'v1'} |
| 274 | +
|
| 275 | + >>> # Bipartite cycle (6 vertices) |
| 276 | + >>> cycle_graph = { |
| 277 | + ... "u1": ["v1", "v2"], |
| 278 | + ... "u2": ["v2", "v3"], |
| 279 | + ... "u3": ["v3", "v1"], |
| 280 | + ... } |
| 281 | + >>> hopcroft_karp(cycle_graph) |
| 282 | + {'u1': 'v1', 'u2': 'v2', 'u3': 'v3'} |
| 283 | +
|
| 284 | + >>> # Error condition: Overlapping partitions (not a valid bipartite graph) |
| 285 | + >>> hopcroft_karp({"A": ["A"]}) |
| 286 | + Traceback (most recent call last): |
| 287 | + ... |
| 288 | + ValueError: Partitions must be disjoint: found vertices in both sets: ['A'] |
| 289 | +
|
| 290 | + >>> # Error condition: None vertex |
| 291 | + >>> hopcroft_karp({"u": [None]}) |
| 292 | + Traceback (most recent call last): |
| 293 | + ... |
| 294 | + ValueError: Vertices cannot be None |
| 295 | + """ |
| 296 | + return HopcroftKarp(graph).maximum_matching() |
| 297 | + |
| 298 | + |
| 299 | +def test_hopcroft_karp() -> None: |
| 300 | + """Pytest test function to verify maximum bipartite matching functionality. |
| 301 | +
|
| 302 | + >>> test_hopcroft_karp() |
| 303 | + """ |
| 304 | + assert hopcroft_karp({"u1": ["v1", "v2"], "u2": ["v1"], "u3": ["v2", "v3"]}) == { |
| 305 | + "u1": "v2", |
| 306 | + "u2": "v1", |
| 307 | + "u3": "v3", |
| 308 | + } |
| 309 | + assert hopcroft_karp({}) == {} |
| 310 | + assert hopcroft_karp({"u1": []}) == {} |
| 311 | + assert hopcroft_karp({"u1": ["v1"], "u2": ["v1"]}) == {"u1": "v1"} |
| 312 | + assert hopcroft_karp( |
| 313 | + {"u1": ["v1", "v2"], "u2": ["v2", "v3"], "u3": ["v3", "v1"]} |
| 314 | + ) == {"u1": "v1", "u2": "v2", "u3": "v3"} |
| 315 | + |
| 316 | + # Test deep alternating path to ensure no RecursionError occurs |
| 317 | + chain_length = 1500 |
| 318 | + chain_graph = {f"u{i}": [f"v{i}", f"v{i + 1}"] for i in range(chain_length)} |
| 319 | + assert len(hopcroft_karp(chain_graph)) == chain_length |
| 320 | + |
| 321 | + |
| 322 | +if __name__ == "__main__": |
| 323 | + import doctest |
| 324 | + |
| 325 | + doctest.testmod() |
0 commit comments