From e648751c35332b2e4fc8959a117099e913220423 Mon Sep 17 00:00:00 2001 From: Clear20-22 Date: Sat, 12 Sep 2026 16:44:10 +0600 Subject: [PATCH 1/2] Add Hopcroft-Karp algorithm for maximum bipartite matching --- graphs/hopcroft_karp.py | 203 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 graphs/hopcroft_karp.py diff --git a/graphs/hopcroft_karp.py b/graphs/hopcroft_karp.py new file mode 100644 index 000000000000..c68be18818f6 --- /dev/null +++ b/graphs/hopcroft_karp.py @@ -0,0 +1,203 @@ +"""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). + +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[left_vertex] + 1. + - Symmetric Difference: Matching edges along each augmenting path are flipped + (unmatched becomes matched, matched becomes unmatched). + +Complexity: + Time Complexity: O(|E| * sqrt(|V|)) + Space Complexity: O(|V| + |E|) +""" + +from __future__ import annotations + +import math +from collections import deque + + +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. + + 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 the left and right partitions, + violating the disjoint bipartite partition condition. + + 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'] + """ + left_vertices = list(graph.keys()) + right_vertices = sorted( + {right_vertex for neighbors in graph.values() for right_vertex in neighbors}, + key=repr, + ) + + # Condition Check: Partitions U and V must be disjoint + overlap = set(left_vertices) & set(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 the vertex in V matched to u in U (or None if free) + pair_left: dict[T, T | None] = dict.fromkeys(left_vertices) + # pair_right[v] stores the vertex in U matched to v in V (or None if free) + pair_right: dict[T, T | None] = dict.fromkeys(right_vertices) + # distance_map stores the BFS level/distance from free vertices in U + distance_map: dict[T | None, float] = {} + + def breadth_first_search() -> 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). + """ + queue: deque[T] = deque() + + # Initialize BFS from all free vertices in the left partition U + for left_vertex in left_vertices: + if pair_left[left_vertex] is None: + distance_map[left_vertex] = 0.0 + queue.append(left_vertex) + else: + distance_map[left_vertex] = math.inf + + # distance_map[None] represents distance to a free vertex in right partition V + distance_map[None] = math.inf + + while queue: + left_vertex = queue.popleft() + + # Only explore while distance is strictly less than shortest augmenting path + if distance_map[left_vertex] < distance_map[None]: + for right_vertex in graph[left_vertex]: + matched_left = pair_right[right_vertex] + + # If matched_left has not been visited in this BFS phase + if distance_map.get(matched_left, math.inf) == math.inf: + distance_map[matched_left] = distance_map[left_vertex] + 1.0 + if matched_left is not None: + queue.append(matched_left) + + # Termination condition: True if an augmenting path was found, False otherwise + return distance_map[None] != math.inf + + def depth_first_search(left_vertex: T | None) -> bool: + """DFS Phase: Find vertex-disjoint augmenting paths along shortest layers. + + Returns: + True if an augmenting path was successfully found and augmented, + False otherwise. + """ + if left_vertex is not None: + for right_vertex in graph[left_vertex]: + matched_left = pair_right[right_vertex] + + # Augmentation Condition: Only step forward along the layered DAG + if distance_map.get(matched_left, math.inf) == distance_map[ + left_vertex + ] + 1.0 and depth_first_search(matched_left): + # Augment the path by flipping matched/unmatched edges + pair_right[right_vertex] = left_vertex + pair_left[left_vertex] = right_vertex + return True + + # If no augmenting path can proceed through left_vertex, prune it + distance_map[left_vertex] = math.inf + return False + + # Base case: reached a free vertex in V (represented by None) + return True + + # Main Loop: Alternate BFS layering and DFS augmentations + while breadth_first_search(): + for left_vertex in left_vertices: + if pair_left[left_vertex] is None: + depth_first_search(left_vertex) + + # Return only the matched pairs from left partition U -> right partition V + return { + left_vertex: matched_right + for left_vertex, matched_right in pair_left.items() + if matched_right is not None + } + + +if __name__ == "__main__": + import doctest + + doctest.testmod() From 4e08678acac6c045eec1ba069dc101930dcfc682 Mon Sep 17 00:00:00 2001 From: Clear20-22 Date: Sat, 12 Sep 2026 16:55:25 +0600 Subject: [PATCH 2/2] refactor(graphs): address Copilot and keeper reviews with private sentinel, iterative DFS, and tests --- graphs/hopcroft_karp.py | 320 +++++++++++++++++++++++++++------------- 1 file changed, 221 insertions(+), 99 deletions(-) diff --git a/graphs/hopcroft_karp.py b/graphs/hopcroft_karp.py index c68be18818f6..a6131f4f94ad 100644 --- a/graphs/hopcroft_karp.py +++ b/graphs/hopcroft_karp.py @@ -11,7 +11,8 @@ 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 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. @@ -33,9 +34,11 @@ 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[left_vertex] + 1. + 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|)) @@ -47,6 +50,194 @@ 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. @@ -54,15 +245,14 @@ def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]: 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. + 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 the left and right partitions, - violating the disjoint bipartite partition condition. + ValueError: If any vertex appears in both partitions or if any vertex is None. Examples: >>> # Standard bipartite matching @@ -96,105 +286,37 @@ def hopcroft_karp[T](graph: dict[T, list[T]]) -> dict[T, T]: Traceback (most recent call last): ... ValueError: Partitions must be disjoint: found vertices in both sets: ['A'] - """ - left_vertices = list(graph.keys()) - right_vertices = sorted( - {right_vertex for neighbors in graph.values() for right_vertex in neighbors}, - key=repr, - ) - - # Condition Check: Partitions U and V must be disjoint - overlap = set(left_vertices) & set(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 the vertex in V matched to u in U (or None if free) - pair_left: dict[T, T | None] = dict.fromkeys(left_vertices) - # pair_right[v] stores the vertex in U matched to v in V (or None if free) - pair_right: dict[T, T | None] = dict.fromkeys(right_vertices) - # distance_map stores the BFS level/distance from free vertices in U - distance_map: dict[T | None, float] = {} - - def breadth_first_search() -> 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). - """ - queue: deque[T] = deque() - - # Initialize BFS from all free vertices in the left partition U - for left_vertex in left_vertices: - if pair_left[left_vertex] is None: - distance_map[left_vertex] = 0.0 - queue.append(left_vertex) - else: - distance_map[left_vertex] = math.inf - - # distance_map[None] represents distance to a free vertex in right partition V - distance_map[None] = math.inf - - while queue: - left_vertex = queue.popleft() - - # Only explore while distance is strictly less than shortest augmenting path - if distance_map[left_vertex] < distance_map[None]: - for right_vertex in graph[left_vertex]: - matched_left = pair_right[right_vertex] - - # If matched_left has not been visited in this BFS phase - if distance_map.get(matched_left, math.inf) == math.inf: - distance_map[matched_left] = distance_map[left_vertex] + 1.0 - if matched_left is not None: - queue.append(matched_left) + >>> # Error condition: None vertex + >>> hopcroft_karp({"u": [None]}) + Traceback (most recent call last): + ... + ValueError: Vertices cannot be None + """ + return HopcroftKarp(graph).maximum_matching() - # Termination condition: True if an augmenting path was found, False otherwise - return distance_map[None] != math.inf - def depth_first_search(left_vertex: T | None) -> bool: - """DFS Phase: Find vertex-disjoint augmenting paths along shortest layers. +def test_hopcroft_karp() -> None: + """Pytest test function to verify maximum bipartite matching functionality. - Returns: - True if an augmenting path was successfully found and augmented, - False otherwise. - """ - if left_vertex is not None: - for right_vertex in graph[left_vertex]: - matched_left = pair_right[right_vertex] - - # Augmentation Condition: Only step forward along the layered DAG - if distance_map.get(matched_left, math.inf) == distance_map[ - left_vertex - ] + 1.0 and depth_first_search(matched_left): - # Augment the path by flipping matched/unmatched edges - pair_right[right_vertex] = left_vertex - pair_left[left_vertex] = right_vertex - return True - - # If no augmenting path can proceed through left_vertex, prune it - distance_map[left_vertex] = math.inf - return False - - # Base case: reached a free vertex in V (represented by None) - return True - - # Main Loop: Alternate BFS layering and DFS augmentations - while breadth_first_search(): - for left_vertex in left_vertices: - if pair_left[left_vertex] is None: - depth_first_search(left_vertex) - - # Return only the matched pairs from left partition U -> right partition V - return { - left_vertex: matched_right - for left_vertex, matched_right in pair_left.items() - if matched_right is not None + >>> 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__":