Skip to content

Commit 9c8a3c6

Browse files
authored
Add Hopcroft-Karp algorithm for maximum bipartite matching (#15293)
* Add Hopcroft-Karp algorithm for maximum bipartite matching * refactor(graphs): address Copilot and keeper reviews with private sentinel, iterative DFS, and tests
1 parent febcf13 commit 9c8a3c6

1 file changed

Lines changed: 325 additions & 0 deletions

File tree

graphs/hopcroft_karp.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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

Comments
 (0)