Skip to content

Commit bc9ccd9

Browse files
committed
feat(graphs): Add Hopcroft-Karp maximum bipartite matching algorithm
1 parent a71618f commit bc9ccd9

File tree

1 file changed

+196
-0
lines changed

1 file changed

+196
-0
lines changed

graphs/hopcroft_karp_matching.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""
2+
Author: Gowrawaram Karthik Koundinya (https://github.com/G26karthik)
3+
Description: Implementation of Hopcroft-Karp algorithm for finding maximum
4+
cardinality matching in bipartite graphs. Uses layered graph
5+
approach with BFS and DFS phases for O(E*sqrt(V)) complexity.
6+
7+
References:
8+
- https://en.wikipedia.org/wiki/Hopcroft%E2%80%93Karp_algorithm
9+
- Hopcroft, John E.; Karp, Richard M. (1973), "An n^5/2 algorithm for maximum
10+
matchings in bipartite graphs"
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections import deque
16+
17+
UNMATCHED = 0
18+
INF = float("inf")
19+
20+
21+
class BipartiteGraph:
22+
"""
23+
Bipartite graph for computing maximum cardinality matching
24+
using the Hopcroft-Karp algorithm.
25+
26+
The graph has two disjoint sets U and V with edges only between U and V nodes.
27+
"""
28+
29+
def __init__(
30+
self, n_u: int, n_v: int, adjacency_list: dict[int, list[int]]
31+
) -> None:
32+
"""
33+
Initialize the bipartite graph.
34+
35+
Args:
36+
n_u: Number of nodes in set U (1-indexed)
37+
n_v: Number of nodes in set V (1-indexed)
38+
adjacency_list: Maps U-nodes to their connected V-nodes
39+
40+
>>> graph = BipartiteGraph(3, 3, {1: [1], 2: [2], 3: [3]})
41+
>>> graph.n_u
42+
3
43+
>>> graph.n_v
44+
3
45+
"""
46+
self.n_u = n_u
47+
self.n_v = n_v
48+
self.adjacency_list = adjacency_list
49+
50+
# pair_u[u] = v means U-node u is matched to V-node v (0 if unmatched)
51+
self.pair_u = [UNMATCHED] * (n_u + 1)
52+
53+
# pair_v[v] = u means V-node v is matched to U-node u (0 if unmatched)
54+
self.pair_v = [UNMATCHED] * (n_v + 1)
55+
56+
# distance_layer[u] stores the BFS layer distance for U-node u
57+
self.distance_layer = [INF] * (n_u + 1)
58+
59+
def _breadth_first_search_phase(self) -> bool:
60+
"""
61+
Build layered graph using BFS to find shortest augmenting paths.
62+
63+
Returns:
64+
True if an augmenting path exists, False otherwise
65+
66+
>>> graph = BipartiteGraph(2, 2, {1: [1], 2: [2]})
67+
>>> graph._breadth_first_search_phase()
68+
True
69+
"""
70+
queue: deque[int] = deque()
71+
72+
# Initialize BFS: add all unmatched U-nodes to the queue with distance 0
73+
for u in range(1, self.n_u + 1):
74+
if self.pair_u[u] == UNMATCHED:
75+
self.distance_layer[u] = 0
76+
queue.append(u)
77+
else:
78+
self.distance_layer[u] = INF
79+
80+
# Distance to dummy unmatched node (used as sentinel)
81+
self.distance_layer[UNMATCHED] = INF
82+
83+
# BFS to build layered graph
84+
while queue:
85+
u = queue.popleft()
86+
87+
# Only continue if this U-node can lead to a shorter path
88+
if self.distance_layer[u] < self.distance_layer[UNMATCHED]:
89+
# Explore all V-neighbors of this U-node
90+
for v in self.adjacency_list.get(u, []):
91+
# Check the U-node that V is currently matched to
92+
u_matched_to_v = self.pair_v[v]
93+
94+
# If we haven't visited this matched U-node yet, add it to queue
95+
if self.distance_layer[u_matched_to_v] == INF:
96+
self.distance_layer[u_matched_to_v] = self.distance_layer[u] + 1
97+
queue.append(u_matched_to_v)
98+
99+
# Return True if we found at least one augmenting path
100+
# (i.e., an unmatched V-node is reachable)
101+
return self.distance_layer[UNMATCHED] != INF
102+
103+
def _depth_first_search_phase(self, node_u: int) -> bool:
104+
"""
105+
Find and augment along a shortest augmenting path using DFS.
106+
107+
Args:
108+
node_u: Current U-node in the DFS traversal
109+
110+
Returns:
111+
True if an augmenting path was found, False otherwise
112+
113+
>>> graph = BipartiteGraph(2, 2, {1: [1], 2: [2]})
114+
>>> graph._breadth_first_search_phase()
115+
True
116+
>>> graph._depth_first_search_phase(1)
117+
True
118+
"""
119+
# Base case: we've reached an unmatched node (augmenting path found)
120+
if node_u == UNMATCHED:
121+
return True
122+
123+
# Try all V-neighbors of this U-node
124+
for v in self.adjacency_list.get(node_u, []):
125+
u_matched_to_v = self.pair_v[v]
126+
127+
# Only follow edges that go to the next layer in the BFS tree
128+
if self.distance_layer[u_matched_to_v] == self.distance_layer[
129+
node_u
130+
] + 1 and self._depth_first_search_phase(u_matched_to_v):
131+
# Augment the matching: update both pair arrays
132+
self.pair_v[v] = node_u
133+
self.pair_u[node_u] = v
134+
return True
135+
136+
# No augmenting path found from this node; mark it as unreachable
137+
self.distance_layer[node_u] = INF
138+
return False
139+
140+
def max_cardinality_matching(self) -> int:
141+
"""
142+
Find maximum cardinality matching using Hopcroft-Karp algorithm.
143+
144+
Returns:
145+
Size of the maximum matching (number of matched edges)
146+
147+
>>> # Test Case: U={1,2,3}, V={1,2,3}. Edges: (1, 2), (2, 1),
148+
>>> # (2, 3), (3, 3). Max matching is 3.
149+
>>> adj_input = {1: [2], 2: [1, 3], 3: [3]}
150+
>>> graph_instance = BipartiteGraph(n_u=3, n_v=3, adjacency_list=adj_input)
151+
>>> graph_instance.max_cardinality_matching()
152+
3
153+
154+
>>> # Test Case: Complete bipartite graph K_{3,3}
155+
>>> adj_complete = {1: [1, 2, 3], 2: [1, 2, 3], 3: [1, 2, 3]}
156+
>>> graph_complete = BipartiteGraph(n_u=3, n_v=3, adjacency_list=adj_complete)
157+
>>> graph_complete.max_cardinality_matching()
158+
3
159+
160+
>>> # Test Case: No edges
161+
>>> adj_empty = {}
162+
>>> graph_empty = BipartiteGraph(n_u=3, n_v=3, adjacency_list=adj_empty)
163+
>>> graph_empty.max_cardinality_matching()
164+
0
165+
166+
>>> # Test Case: Single edge
167+
>>> adj_single = {1: [1]}
168+
>>> graph_single = BipartiteGraph(n_u=2, n_v=2, adjacency_list=adj_single)
169+
>>> graph_single.max_cardinality_matching()
170+
1
171+
172+
>>> # Test Case: Unbalanced graph
173+
>>> adj_unbalanced = {1: [1], 2: [1], 3: [2]}
174+
>>> graph_unbalanced = BipartiteGraph(
175+
... n_u=3, n_v=2, adjacency_list=adj_unbalanced
176+
... )
177+
>>> graph_unbalanced.max_cardinality_matching()
178+
2
179+
"""
180+
matching_size = 0
181+
182+
# Main loop: keep finding augmenting paths until none exist
183+
while self._breadth_first_search_phase():
184+
# Try to find augmenting paths from all unmatched U-nodes
185+
for u in range(1, self.n_u + 1):
186+
if self.pair_u[u] == UNMATCHED and self._depth_first_search_phase(u):
187+
# If DFS finds an augmenting path, increment the matching size
188+
matching_size += 1
189+
190+
return matching_size
191+
192+
193+
if __name__ == "__main__":
194+
import doctest
195+
196+
doctest.testmod()

0 commit comments

Comments
 (0)