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