|
11 | 11 | A graph G = (U union V, E) is bipartite if its vertices can be partitioned into |
12 | 12 | two disjoint sets U (left partition) and V (right partition) such that every |
13 | 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). |
| 14 | + vertices within the same partition (U intersect V = empty set). Vertices cannot |
| 15 | + be None. |
15 | 16 |
|
16 | 17 | 2. Matching Condition: |
17 | 18 | A matching M is a subset of edges such that no two edges share a common vertex. |
|
33 | 34 | alternating levels. If no free vertex in V is reachable, the algorithm terminates. |
34 | 35 | - DFS Phase (Augmentation): Discovers a maximal set of vertex-disjoint augmenting |
35 | 36 | paths of the shortest length found by BFS. It only traverses edges satisfying: |
36 | | - distance_map[matched_left] == distance_map[left_vertex] + 1. |
| 37 | + distance_map[matched_left] == distance_map[curr_left] + 1. |
37 | 38 | - Symmetric Difference: Matching edges along each augmenting path are flipped |
38 | 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. |
39 | 42 |
|
40 | 43 | Complexity: |
41 | 44 | Time Complexity: O(|E| * sqrt(|V|)) |
|
47 | 50 | import math |
48 | 51 | from collections import deque |
49 | 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 | + |
50 | 241 |
|
51 | 242 | def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]: |
52 | 243 | """Find a maximum cardinality matching in a bipartite graph using Hopcroft-Karp. |
53 | 244 |
|
54 | 245 | Parameters: |
55 | 246 | graph: An adjacency list mapping each vertex in the left partition (U) to |
56 | 247 | a list of adjacent vertices in the right partition (V). The two |
57 | | - partitions must be disjoint. |
| 248 | + partitions must be disjoint, and vertices cannot be None. |
58 | 249 |
|
59 | 250 | Returns: |
60 | 251 | A dictionary representing the matching, mapping each matched vertex in |
61 | 252 | the left partition to its matched partner in the right partition. |
62 | 253 |
|
63 | 254 | Raises: |
64 | | - ValueError: If any vertex appears in both the left and right partitions, |
65 | | - violating the disjoint bipartite partition condition. |
| 255 | + ValueError: If any vertex appears in both partitions or if any vertex is None. |
66 | 256 |
|
67 | 257 | Examples: |
68 | 258 | >>> # Standard bipartite matching |
@@ -96,105 +286,37 @@ def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]: |
96 | 286 | Traceback (most recent call last): |
97 | 287 | ... |
98 | 288 | ValueError: Partitions must be disjoint: found vertices in both sets: ['A'] |
99 | | - """ |
100 | | - left_vertices = list(graph.keys()) |
101 | | - right_vertices = sorted( |
102 | | - {right_vertex for neighbors in graph.values() for right_vertex in neighbors}, |
103 | | - key=repr, |
104 | | - ) |
105 | | - |
106 | | - # Condition Check: Partitions U and V must be disjoint |
107 | | - overlap = set(left_vertices) & set(right_vertices) |
108 | | - if overlap: |
109 | | - msg = ( |
110 | | - f"Partitions must be disjoint: found vertices in both sets: " |
111 | | - f"{sorted(overlap, key=repr)}" |
112 | | - ) |
113 | | - raise ValueError(msg) |
114 | | - |
115 | | - # pair_left[u] stores the vertex in V matched to u in U (or None if free) |
116 | | - pair_left: dict[T, T | None] = dict.fromkeys(left_vertices) |
117 | | - # pair_right[v] stores the vertex in U matched to v in V (or None if free) |
118 | | - pair_right: dict[T, T | None] = dict.fromkeys(right_vertices) |
119 | | - # distance_map stores the BFS level/distance from free vertices in U |
120 | | - distance_map: dict[T | None, float] = {} |
121 | | - |
122 | | - def breadth_first_search() -> bool: |
123 | | - """BFS Phase: Layer the graph and find shortest augmenting path length. |
124 | 289 |
|
125 | | - Returns: |
126 | | - True if at least one augmenting path to a free vertex in V exists, |
127 | | - False otherwise (termination condition). |
128 | | - """ |
129 | | - queue: deque[T] = deque() |
130 | | - |
131 | | - # Initialize BFS from all free vertices in the left partition U |
132 | | - for left_vertex in left_vertices: |
133 | | - if pair_left[left_vertex] is None: |
134 | | - distance_map[left_vertex] = 0.0 |
135 | | - queue.append(left_vertex) |
136 | | - else: |
137 | | - distance_map[left_vertex] = math.inf |
138 | | - |
139 | | - # distance_map[None] represents distance to a free vertex in right partition V |
140 | | - distance_map[None] = math.inf |
141 | | - |
142 | | - while queue: |
143 | | - left_vertex = queue.popleft() |
144 | | - |
145 | | - # Only explore while distance is strictly less than shortest augmenting path |
146 | | - if distance_map[left_vertex] < distance_map[None]: |
147 | | - for right_vertex in graph[left_vertex]: |
148 | | - matched_left = pair_right[right_vertex] |
149 | | - |
150 | | - # If matched_left has not been visited in this BFS phase |
151 | | - if distance_map.get(matched_left, math.inf) == math.inf: |
152 | | - distance_map[matched_left] = distance_map[left_vertex] + 1.0 |
153 | | - if matched_left is not None: |
154 | | - queue.append(matched_left) |
| 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() |
155 | 297 |
|
156 | | - # Termination condition: True if an augmenting path was found, False otherwise |
157 | | - return distance_map[None] != math.inf |
158 | 298 |
|
159 | | - def depth_first_search(left_vertex: T | None) -> bool: |
160 | | - """DFS Phase: Find vertex-disjoint augmenting paths along shortest layers. |
| 299 | +def test_hopcroft_karp() -> None: |
| 300 | + """Pytest test function to verify maximum bipartite matching functionality. |
161 | 301 |
|
162 | | - Returns: |
163 | | - True if an augmenting path was successfully found and augmented, |
164 | | - False otherwise. |
165 | | - """ |
166 | | - if left_vertex is not None: |
167 | | - for right_vertex in graph[left_vertex]: |
168 | | - matched_left = pair_right[right_vertex] |
169 | | - |
170 | | - # Augmentation Condition: Only step forward along the layered DAG |
171 | | - if distance_map.get(matched_left, math.inf) == distance_map[ |
172 | | - left_vertex |
173 | | - ] + 1.0 and depth_first_search(matched_left): |
174 | | - # Augment the path by flipping matched/unmatched edges |
175 | | - pair_right[right_vertex] = left_vertex |
176 | | - pair_left[left_vertex] = right_vertex |
177 | | - return True |
178 | | - |
179 | | - # If no augmenting path can proceed through left_vertex, prune it |
180 | | - distance_map[left_vertex] = math.inf |
181 | | - return False |
182 | | - |
183 | | - # Base case: reached a free vertex in V (represented by None) |
184 | | - return True |
185 | | - |
186 | | - # Main Loop: Alternate BFS layering and DFS augmentations |
187 | | - while breadth_first_search(): |
188 | | - for left_vertex in left_vertices: |
189 | | - if pair_left[left_vertex] is None: |
190 | | - depth_first_search(left_vertex) |
191 | | - |
192 | | - # Return only the matched pairs from left partition U -> right partition V |
193 | | - return { |
194 | | - left_vertex: matched_right |
195 | | - for left_vertex, matched_right in pair_left.items() |
196 | | - if matched_right is not None |
| 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", |
197 | 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 |
198 | 320 |
|
199 | 321 |
|
200 | 322 | if __name__ == "__main__": |
|
0 commit comments