Skip to content

Commit 4e08678

Browse files
committed
refactor(graphs): address Copilot and keeper reviews with private sentinel, iterative DFS, and tests
1 parent e648751 commit 4e08678

1 file changed

Lines changed: 221 additions & 99 deletions

File tree

graphs/hopcroft_karp.py

Lines changed: 221 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
A graph G = (U union V, E) is bipartite if its vertices can be partitioned into
1212
two disjoint sets U (left partition) and V (right partition) such that every
1313
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.
1516
1617
2. Matching Condition:
1718
A matching M is a subset of edges such that no two edges share a common vertex.
@@ -33,9 +34,11 @@
3334
alternating levels. If no free vertex in V is reachable, the algorithm terminates.
3435
- DFS Phase (Augmentation): Discovers a maximal set of vertex-disjoint augmenting
3536
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.
3738
- Symmetric Difference: Matching edges along each augmenting path are flipped
3839
(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.
3942
4043
Complexity:
4144
Time Complexity: O(|E| * sqrt(|V|))
@@ -47,22 +50,209 @@
4750
import math
4851
from collections import deque
4952

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+
50241

51242
def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]:
52243
"""Find a maximum cardinality matching in a bipartite graph using Hopcroft-Karp.
53244
54245
Parameters:
55246
graph: An adjacency list mapping each vertex in the left partition (U) to
56247
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.
58249
59250
Returns:
60251
A dictionary representing the matching, mapping each matched vertex in
61252
the left partition to its matched partner in the right partition.
62253
63254
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.
66256
67257
Examples:
68258
>>> # Standard bipartite matching
@@ -96,105 +286,37 @@ def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]:
96286
Traceback (most recent call last):
97287
...
98288
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.
124289
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()
155297

156-
# Termination condition: True if an augmenting path was found, False otherwise
157-
return distance_map[None] != math.inf
158298

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.
161301
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",
197308
}
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
198320

199321

200322
if __name__ == "__main__":

0 commit comments

Comments
 (0)