Google

编码面试:如何用 Hopcroft–Karp 求二分图最大匹配?

编程题困难
Offer.cc 编辑团队发布 更新

题干

给定左侧 n 个对象、右侧 m 个对象及 E 条可行边,每个对象最多参与一次匹配。请输出最大匹配数,并解释为什么不能只用贪心。

题干与适用场景

例如把题目分配给程序员:左侧是题目,右侧是程序员,边表示至少有一个共同标签。每条边最多选一次,目标是最大化分配数量。PracHub 的公开面经把这类资格分配建模为二分图匹配,并继续追问边生成、分布式协调和流式变更;本题聚焦单机编码部分。

面试官考察点

  • 能否区分“任意可行匹配”“极大匹配”和“最大基数匹配”。
  • 能否用增广路证明匹配可继续变大,并维护匹配不变量。
  • 能否解释 BFS 分层与 DFS 找到一组最短、点不相交的增广路。
  • 能否给出 O((V+E)√V) 的最坏时间、O(V+E) 的存储,并说明适用边界。

回答前需要澄清的问题

  • 目标是最大数量,还是有权重、优先级或公平约束?
  • n、m、E 的上限是多少,输入是否已经是二分图且边无重复?
  • 只需返回数量,还是要返回每条配对及未匹配对象?
  • 图是静态批处理,还是会有增删边和在线延迟要求?

30 秒回答框架

“我把两类对象作为左右顶点,资格关系作为边。维护 pair_leftpair_right,每轮 BFS 从未匹配左点建立只经过匹配边交替的层次图,DFS 在层次图中寻找一组最短增广路;沿路翻转边后匹配数增加。没有增广路时由增广路定理得到最大匹配。复杂度是 O((V+E)√V),小图也可以用更简单的逐点 DFS。”

分步骤深入解答

第一步:建立图与不变量

只把真实可行的边放入邻接表。pair_left[u]pair_right[v] 必须互相指向,或同时为 -1。一次翻转只改变增广路上的边,因此每个顶点仍至多有一个匹配边。

第二步:BFS 建立层次

从所有未匹配左点同时出发。沿未匹配边到右点,再沿已匹配边回到左点,记录最短层数。只保留能到达未匹配右点的最短层,避免 DFS 在同一轮探索更长的无效路径。

第三步:DFS 成批增广

对每个未匹配左点 DFS。访问到未匹配右点就成功;访问到已匹配右点时,只递归其匹配的左点,并要求层数严格增加。每个左点保存当前邻接游标,失败后把距离置为无效,避免本轮反复扫描。

第四步:证明终止与正确性

一条增广路的未匹配边比匹配边多一条,沿路异或会让基数增加一。若 BFS 找不到任何增广路,增广路定理说明当前匹配已是最大基数匹配;算法每轮至少增加一条边,因此必然终止。

第五步:复杂度与取舍

Hopcroft–Karp 的最坏时间为 O((V+E)√V),额外空间为 O(V),图本身占 O(V+E)。Princeton 的实现还维护最小顶点覆盖;本题只返回匹配,不需要那部分。若图很小,逐个左点 DFS 的实现更短,但最坏可达 O(VE)。权重目标应改用 Hungarian 或最小费用流,不能继续声称本算法解决它。

可运行的 Python 实现

python
from collections import deque


def hopcroft_karp(left_size, right_size, edges):
    adj = [[] for _ in range(left_size)]
    for left, right in edges:
        adj[left].append(right)

    pair_left = [-1] * left_size
    pair_right = [-1] * right_size
    distance = [-1] * left_size

    def bfs():
        queue = deque()
        for left in range(left_size):
            if pair_left[left] == -1:
                distance[left] = 0
                queue.append(left)
            else:
                distance[left] = -1
        found = False
        while queue:
            left = queue.popleft()
            for right in adj[left]:
                mate = pair_right[right]
                if mate == -1:
                    found = True
                elif distance[mate] == -1:
                    distance[mate] = distance[left] + 1
                    queue.append(mate)
        return found

    def dfs(left, next_edge):
        while next_edge[left] < len(adj[left]):
            right = adj[left][next_edge[left]]
            next_edge[left] += 1
            mate = pair_right[right]
            if mate == -1 or (
                distance[mate] == distance[left] + 1
                and dfs(mate, next_edge)
            ):
                pair_left[left] = right
                pair_right[right] = left
                return True
        distance[left] = -1
        return False

    matching = 0
    while bfs():
        next_edge = [0] * left_size
        for left in range(left_size):
            if pair_left[left] == -1 and dfs(left, next_edge):
                matching += 1
    return matching, pair_left

高质量示范回答

“先确认目标是最大基数而非加权匹配,并把资格关系作为二分图边。两个配对数组保证双向一致。BFS 从全部未匹配左点构造最短增广路的层次,DFS 用当前边游标在层次图里找尽可能多的点不相交路径,找到后翻转路径边。没有增广路时由增广路定理结束。实现使用 O((V+E)√V) 时间和 O(V+E) 存储;若规模小可用简单 DFS,若有权重则换 Hungarian 或最小费用流。”

常见错误

  • 把贪心结果叫作最大匹配;极大匹配可能远小于最大匹配。
  • 只保存一侧的配对,翻转增广路后产生重复占用。
  • BFS 找到任意可达路径就停止,破坏最短层次的批处理条件。
  • 忘记当前边游标,DFS 在同一轮反复扫描失败边。
  • 把 O((V+E)√V) 误写成适用于一般图、加权匹配或动态更新的保证。

追问及应对

如何生成资格边而不比较所有 n×m 对?

为标签建立倒排索引:先把右侧对象按标签分桶,再对每个左对象合并相关桶并去重。边数仍可能很大,应报告 E、热点标签和内存预算。

为什么“没有增广路”就能停止?

增广路每次会让匹配数增加一;增广路定理说明存在更大匹配当且仅当存在增广路。因此 BFS 无法发现它时,当前解已达到最大基数。

如果需求改成带权偏好怎么办?

Hopcroft–Karp 只优化边数,不能表达权重。可改成 Hungarian 或最小费用最大流,并重新说明复杂度、整数权重范围和不可行时的降级策略。

图持续增删时如何处理?

单机批算法适合重算。在线场景可从受影响顶点局部寻找增广路,但要明确延迟、重排次数和暂时非最优的服务契约。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具