Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 62 additions & 307 deletions graphs/hopcroft_karp.py
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 7 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP035)

graphs/hopcroft_karp.py:7:1: UP035 `typing.List` is deprecated, use `list` instead

Check failure on line 7 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP035)

graphs/hopcroft_karp.py:7:1: UP035 `typing.Dict` is deprecated, use `dict` instead

_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:

Check failure on line 25 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP006)

graphs/hopcroft_karp.py:25:41: UP006 Use `list` instead of `List` for type annotation help: Replace with `list`

Check failure on line 25 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP006)

graphs/hopcroft_karp.py:25:31: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`
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}

Check failure on line 27 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (C420)

graphs/hopcroft_karp.py:27:49: C420 Unnecessary dict comprehension for iterable; use `dict.fromkeys` instead help: Replace with `dict.fromkeys(iterable, value)`)

Check failure on line 27 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP045)

graphs/hopcroft_karp.py:27:32: UP045 Use `X | None` for type annotations help: Convert to `X | None`

Check failure on line 27 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP006)

graphs/hopcroft_karp.py:27:22: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`
self.pair_v: Dict[int, Optional[int]] = {}

Check failure on line 28 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP045)

graphs/hopcroft_karp.py:28:32: UP045 Use `X | None` for type annotations help: Convert to `X | None`

Check failure on line 28 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP006)

graphs/hopcroft_karp.py:28:22: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`
for neighbors in graph.values():
for v in neighbors:
self.pair_v[v] = None
self.dist: Dict[Optional[int], int] = {}

Check failure on line 32 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (UP006)

graphs/hopcroft_karp.py:32:20: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`

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")

Check failure on line 41 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ty

ty (invalid-assignment)

graphs/hopcroft_karp.py:41:17: invalid-assignment: Invalid subscript assignment with key of type `int` and value of type `float*` on object of type `dict[int | None, int]` graphs/hopcroft_karp.py:41:32: Expected value of type `int`, got `float*`
self.dist[None] = float("inf")

Check failure on line 42 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ty

ty (invalid-assignment)

graphs/hopcroft_karp.py:42:9: invalid-assignment: Invalid subscript assignment with key of type `None` and value of type `float*` on object of type `dict[int | None, int]` graphs/hopcroft_karp.py:42:27: Expected value of type `int`, got `float*`

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")

Check failure on line 64 in graphs/hopcroft_karp.py

View workflow job for this annotation

GitHub Actions / ty

ty (invalid-assignment)

graphs/hopcroft_karp.py:64:13: invalid-assignment: Invalid subscript assignment with key of type `int` and value of type `float*` on object of type `dict[int | None, int]` graphs/hopcroft_karp.py:64:28: Expected value of type `int`, got `float*`
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__":
Expand Down
Loading