diff --git a/graphs/hopcroft_karp.py b/graphs/hopcroft_karp.py index a6131f4f94ad..4c11bd26eac6 100644 --- a/graphs/hopcroft_karp.py +++ b/graphs/hopcroft_karp.py @@ -1,322 +1,77 @@ -"""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|) +""" +Hopcroft-Karp Algorithm for Maximum Cardinality Matching in Bipartite Graphs. +Time Complexity: O(E * sqrt(V)) """ -from __future__ import annotations - -import math from collections import deque +from typing import Dict, List, Optional -_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'} +class HopcroftKarp: + """ + Computes maximum bipartite matching using the Hopcroft-Karp algorithm. + + >>> graph = { + ... 1: [5, 6], + ... 2: [5], + ... 3: [6, 7], + ... 4: [8] + ... } + >>> hk = HopcroftKarp(graph) + >>> hk.max_matching() + 4 """ - 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 - """ + def __init__(self, graph: Dict[int, List[int]]) -> 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) + self.pair_u: Dict[int, Optional[int]] = {u: None for u in graph} + self.pair_v: Dict[int, Optional[int]] = {} + for neighbors in graph.values(): + for v in neighbors: + self.pair_v[v] = None + self.dist: Dict[Optional[int], int] = {} + + def _bfs(self) -> bool: + queue: deque[int] = deque() + for u in self.graph: + if self.pair_u[u] is None: + self.dist[u] = 0 + queue.append(u) 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 + self.dist[u] = float("inf") + self.dist[None] = float("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 + u = queue.popleft() + if self.dist[u] < self.dist[None]: + for v in self.graph.get(u, []): + next_u = self.pair_v[v] + if self.dist.get(next_u, float("inf")) == float("inf"): + self.dist[next_u] = self.dist[u] + 1 + if next_u is not None: + queue.append(next_u) + return self.dist[None] != float("inf") + + def _dfs(self, u: Optional[int]) -> bool: + if u is not None: + for v in self.graph.get(u, []): + next_u = self.pair_v[v] + if self.dist.get(next_u, float("inf")) == self.dist[u] + 1: + if self._dfs(next_u): + self.pair_v[v] = u + self.pair_u[u] = v 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 + self.dist[u] = float("inf") + return False + return True + + def max_matching(self) -> int: + matching = 0 + while self._bfs(): + for u in self.graph: + if self.pair_u[u] is None and self._dfs(u): + matching += 1 + return matching if __name__ == "__main__":