做了这么多年图相关的业务,我越来越觉得二分图这个概念比很多人想象中更重要。面试时候问到的“如何给一群人分配任务”“如何做相亲匹配”“如何排班”这类问题,表面上是业务题,底子里全是二分图。最近团队里有人拿二分图重构了一套资源分配逻辑,效果立竿见影,我借这个机会把二分图的原理、判定、匹配和建模思路完整梳理一遍,给需要的人一个能直接用的参考。
1. 先从直觉理解二分图
1.1 一张图什么时候算二分图
二分图的定义说起来很简单:一幅无向图的所有顶点可以被划分成两个互不相交的集合 U 和 V,并且图里每一条边的两个端点,一个在 U 中,一个在 V 中。也就是说,集合内部不存在边,所有边都跨越两个集合。
拿几组数据举例:
- 一个班级里有男生和女生,只有男女之间有“认识”这种关系,男生之间、女生之间都不存在这条关系,把这个关系画成图,就是二分图。
- 一组任务和一组工人,每个工人只能做其中某些任务,任务和任务之间没有连接,工人和工人之间也没有连接,任务的连接只发生在“工人—任务”这条边上,这也是二分图。
- 一场相亲活动中,男嘉宾和女嘉宾之间互相有好感的关系,同样构成二分图。
这类图的本质是“两类不同对象之间的二元关系”。它不是数学课本里才有的抽象概念,而是大量真实匹配类业务的原型。理解了这句话,后面所有的算法就有了落地的土壤。
1.2 为什么不是所有图都能二分
这里要强调一个关键性质:不是所有无向图都能划分成这样的两个集合。最典型的反例是奇环,也就是长度为奇数的环。
比如三个顶点围成一个三角形,每个顶点都和其他两个顶点相连。尝试着把顶点分成两个集合,并且保证每条边的两端不在同一集合,你会发现怎么分都失败:第一个顶点放 U,第二个顶点放 V,第三个顶点既不能放 U(因为与第二个顶点相连)也不能放 V(因为与第一个顶点相连)。所以包含奇环的图,一定不是二分图。
反过来,不含奇环的图一定是二分图。这个定理是二分图判定算法的理论基础,所有染色法的正确性都建立在这句话上。理解这个定理的人,看染色代码不会觉得它是一个“trick”,而是能清楚地知道每一步在干什么。
2. 判定二分图:染色法的原理与实操
2.1 染色法为什么能判定二分图
染色法的思路非常直接:从一个顶点出发,给这个顶点染成颜色0,然后通过边遍历它的邻居,邻居全部染成颜色1。继续遍历邻居的邻居,染回颜色0。遍历过程中如果发现某个顶点已经被染色,而且染色结果和“期望颜色”冲突,说明这条边的两端落在了同一集合里,图就一定不是二分图。
为什么这套简单的逻辑能工作?因为它本质上是在对图做“广度优先或者深度优先的传播”,把集合归属关系通过边逐层传递出去。只要图中不存在矛盾,每个顶点的颜色就唯一确定了集合归属;一旦出现矛盾,等价于找到了一个奇环。
举个例子,十字形的二分图,从一个中心顶点开始,染成0,四个邻居都染成1,再没有更多顶点,判定成功。三角形就不同,从任意顶点染0,两个邻居染1,但这两个邻居之间有一条边,这条边的两端颜色相同,于是矛盾立刻暴露。
2.2 邻接表上的染色实现
实际工程里图通常以邻接表存储。下面这是我最常用的 BFS 染色模板,它比 DFS 版本更容易处理大规模图,也不容易因为递归深度过深而栈溢出。
function isBipartite(graph) { const n = graph.length; const color = new Array(n).fill(-1); for (let i = 0; i < n; i++) { if (color[i] !== -1) continue; color[i] = 0; const queue = [i]; while (queue.length) { const cur = queue.shift(); for (const next of graph[cur]) { if (color[next] === -1) { color[next] = color[cur] ^ 1; queue.push(next); } else if (color[next] === color[cur]) { return false; } } } } return true; }有人会问为什么要循环每个顶点,直接用 BFS 从 0 出发不行吗?关键在于图不一定是连通图。一个不连通的图由多个连通分量组成,每个分量都要单独染色,所以外层的 for 循环必不可少。这也是我在实际排查中遇到的第一类问题:只从单个起点染色,漏掉了其他连通分量,导致误判。
这段代码的复杂度是 O(V + E),对于大多数业务图来说完全是线性可控的,这也是染色法实用性很强的根本原因。
2.3 判定环节的两个坑
第一,图可能以“非连通图”的形式出现,我在上面已经提到了。处理办法是外层遍历每个未染色的顶点,而不是只跑一次 BFS。
第二,图可能是稀疏图或是邻接矩阵,两者在时间复杂度上的差异很大。如果遇到的是稠密图,邻接矩阵访问更快,但存储成本高;如果是稀疏图,邻接表更省内存。工程中需要在空间和时间之间做取舍,不要无脑套模板。
第三,如果实际业务里面向的数据是“有向关系”,比如“A 喜欢 B”但“B 不一定喜欢 A”,二分图建模就需要特别小心。有些场景可以把有向边直接当作无向边处理,但前提是这个关系语义允许双向解释;如果不能,就需要重新建模,而不是强行套用二分图判定。
3. 最大匹配:匈牙利算法的不传之秘
3.1 匹配相关概念一次说清楚
二分图确定之后,最核心的问题是匹配。匹配是图中一组边,要求这些边没有公共顶点。也就是说,一个工人只能被分配到一项任务,一个任务也只能被一个工人认领,这组“工人—任务”配对关系就是一个匹配。
- 最大匹配:边数最多的匹配。
- 完美匹配:所有顶点都有匹配边,左右两边顶点数相等时才会存在。
- 增广路径:从一个未匹配顶点出发,经过非匹配边、匹配边交替,最后到达另一个未匹配顶点的路径。把这条路径上的匹配边和非匹配边互换,匹配数就能增加1。
最大匹配算法的核心线索就是增广路径。从一个未匹配顶点出发,不断寻找增广路径,每找到一条,匹配大小就增加1。当图中不存在增广路径时,当前匹配就是最大匹配。这个定理叫 Berge 定理,是整个匹配算法的基石。
3.2 匈牙利算法的执行过程
匈牙利算法是求二分图最大匹配最经典的方法,思路简单容易记忆,非常推荐作为第一手实现。
从左侧集合 U 中的每个未匹配顶点出发,做一次深度优先搜索。搜索过程中,对于当前顶点 u,遍历所有邻接的右侧顶点 v:
- 如果 v 尚未匹配,直接让 u 和 v 配对,找到一条增广路径,匹配成功。
- 如果 v 已经匹配给了某个左侧顶点 matchedV,就尝试递归地为 matchedV 寻找新的右侧顶点。如果 matchedV 能换到另一个未匹配顶点,那么当前 u 就可以“抢”到 v,匹配数加1。
这一个“抢”的动作就是匈牙利算法的精髓。它在 O(VE) 时间内解决最大匹配问题,实际使用中往往远快于理论复杂度,尤其是稀疏图上表现很好。我处理过上百万节点级别的匹配任务,匈牙利算法的表现依然在可接受范围内。
3.3 DFS 版匈牙利实现
直接贴一个经典实现,配合注释阅读。
def hungarian(graph_u, u_match, v_match, visited, u): for v in graph_u[u]: if not visited[v]: visited[v] = True if v_match[v] == -1 or hungarian(graph_u, u_match, v_match, visited, v_match[v]): v_match[v] = u u_match[u] = v return True return False def max_match(n, m, graph_u): u_match = [-1] * n v_match = [-1] * m result = 0 for u in range(n): visited = [False] * m if hungarian(graph_u, u_match, v_match, visited, u): result += 1 return result, u_match, v_match这里的graph_u[u]是左侧顶点 u 能连接的右侧顶点集合,u_match[u]记录了 u 匹配的右侧顶点,v_match[v]记录了 v 匹配的左侧顶点。visited数组的作用相当关键,它保证每次搜索不会重复遍历同一个右侧顶点,避免递归陷入死循环。
每次从新的 u 出发都要重置 visited,这也是很多初学者容易忽略的点。如果你不重置,后面的搜索会误以为某个右侧顶点已被访问而跳过,导致匹配数计算错误。
3.4 匈牙利算法的实际性能优化
大规模场景下,直接用上述模板有时候会偏慢,因为每个 u 都要完整搜索一次。我常用的优化手段有几种:
- 优先处理度数较小的左侧顶点,往往能快速找到匹配,减少递归深度。
- 在递归内部先遍历未被匹配的右侧顶点,提升首次命中率。
- 如果右侧顶点数量远大于左侧,可以将左侧作为搜索起点,减少外层循环次数。
更极致的情况下,可以使用 Hopcroft-Karp 算法,它利用 BFS 分层和 DFS 同时寻找多条增广路,把复杂度降低到 O(E sqrt(V))。但实际工程项目里匈牙利算法已经足够好用了,算法不是越复杂越好,而是越匹配业务规模越好。
4. 二分图建模实战:从业务问题到图模型
4.1 任务分配场景
最常见也最容易建模的业务场景是任务分配。假设有 n 个工程师和 m 个需求,每个工程师能处理若干需求。现在要最大化同时开工的需求数量,这就是一个不折不扣的二分图最大匹配问题。
建模时,工程师作为左侧节点,需求作为右侧节点,能处理的对应关系作为边。跑一遍匈牙利算法,得到的结果就是同时开工的最优数量。我在负责调度系统的时候,就是利用这个模型把同时处理任务的能力提升到一个新的段位。
需要提醒的一点是,这里每条边的权重默认相同。如果需求有不同的优先级或者紧急程度,标准的二分图最大匹配就不够了,需要升级成带权二分图最大权匹配。这个问题在后面的进阶部分会讲到。
4.2 相亲配对与推荐系统场景
这种建模我之前还不太相信会用在业务里,直到做了一个交友类产品的计算任务才发现,二分图在匹配推荐领域的用处非常大。
假设聚会现场有 n 个男生和 m 个女生,每个男生都有一个心动名单,每个女生也有一个心动名单。想让互相心动的人尽可能多地坐在一桌,这就是二分图最大匹配的直观映射。男生集合 U,女生集合 V,互相心动则连一条边,求最大匹配即可。
但要注意,现实推荐系统往往是“单向喜欢”的。展示给用户的可能是一个多对多的推荐候选列表,而不是单纯求最大匹配。这类场景下二分图的作用通常是兜底筛选,后面还会叠加满意度分值、多样性约束等逻辑。我的经验是:把二分图匹配看成“硬约束条件最优解”,把业务上的品质因素放在匹配之后再做二次调优,这样既保证基本盘,又不阻塞业务策略。
4.3 排班与资源调度场景
排班问题经常被大家忽略,其实它的底层也是二分图。
举个例子,一周七天需要护士值班,假设有若干护士,每天需要一人,且每个护士有可值班的天数限制。把护士放在左侧,日期放在右侧,可值班关系连边。求解最大匹配,就可以判断是否能做到每天都有合适的护士值班。如果左侧人数和右侧日期数恰好相等,且最大匹配数等于日期数,那么存在一套完整排班方案。
一个更复杂的现实场景是“多班次排班”,这时候一个右侧节点往往需要对应多个护士,普通的匹配就不再适用。解决办法是把一个班次拆成多个节点,或者升级为多重图匹配问题。我的经验是,先用二分图匹配做一次可行性判断,再去寻找具体可行方案,能少走很多弯路。
4.4 哪些问题天然适合二分图建模
判断一个业务问题能否用二分图解决,不妨问自己三个问题:
- 参与方是否能划分成两个互不相交的集合?
- 关系是否只在不同集合之间发生,集合内部没有关系?
- 需求是不是在“一对一的配对”意义上成立的?
三个问题都回答“是”,就可以尝试用二分图建模。反之,如果同一集合内部也有关联,或者需要一对多分配,二分图就需要改造甚至换用网络流模型。很多同学在咨询我时,一开始就把问题描述得很复杂,但拆开后会发现,真正困难的部分往往只是关系建模,算法本身反而很简单。
5. 进阶方向:带权匹配、覆盖与独立集
5.1 最大权匹配与 KM 算法的引入
普通最大匹配只关心“配对数最大化”,但现实业务里往往每条配对关系的价值不一样。
例如房产中介要把经纪人推荐给客户,不同的经纪人擅长不同区域,成交概率差异很大。这时候直接求最大匹配不一定是利润最大化的方案,改成最大权匹配才更有意义。带权二分图最大权匹配有一类经典算法,通常被大家称为 KM 算法。它的核心思路是通过顶标和相等子图,逐步扩大相等子图中的匹配,直到找到最优匹配。
实现 KM 算法时有一个容易出错的细节是顶标的调整过程。简单说,每次调整顶标时要找到当前可行顶标条件下仍无法匹配的顶点,然后缩小顶标差,让新的边进入相等子图。这个细节一旦出错,算法就会得到错误结果。我会在写业务代码时把顶标变化过程打印出来,做小规模数据验证,确认无误后再放开到完整数据上。
不过 KM 算法对数据规模比较敏感,当 n 达到数千甚至更大时,开销会明显增加。工业界常采用“势函数 + Dijkstra”的优化版本,把复杂度降到一个更理想的量级。如果业务数据量太大,也可以使用最小费用最大流的模型,把权重视为费用,用网络流求解。
5.2 最小点覆盖与最大独立集的关系
二分图有一个很有意思的性质:最大匹配数等于最小点覆盖数。最小点覆盖是“用最少的顶点覆盖所有边”。这个等式被称为 König 定理,它把最大匹配问题和覆盖问题联系在了一起。
实际应用场景里,这个性质常用于防冲突问题。比如在一张二分关系图中,想用最少的检查点覆盖所有关系路径,那最小点覆盖就是答案。又比如安全风控里面,想用最少的策略拦截所有风险关联,也可以利用这个模型。
由最小点覆盖还能直接推出最大独立集:最大独立集大小等于顶点总数减去最小点覆盖大小。独立集的含义是选择尽量多的顶点,使它们之间没有边。这在很多“互斥选择”的业务逻辑里可以发挥作用。例如安排两个互不冲突的团队方案时,最大独立集可以帮助选出可并行的组成单元。
5.3 实际项目里如何选择匹配方案
面对一个具体业务,我把选择路径归纳成一张简单的决策表:
| 业务需求 | 推荐算法 | 复杂度 | 说明 |
|---|---|---|---|
| 只求最多配对数,不考虑权重 | 匈牙利算法 | O(VE) | 最简单,最常用 |
| 配对数优先,但边有权重 | 最大流/费用流模型 | 取决于网络 | 通用可靠 |
| 权重大于一切,必须保证最大权重 | KM 算法(含优化版) | O(n^3) 左右 | 小规模数据可跑 |
| 图规模极大,匹配数极多 | Hopcroft-Karp | O(E sqrt(V)) | 大规模二分图首选 |
| 涉及容量需求,还需要多次匹配 | 网络流 | 可视作二分图推广 | 类似物流调度 |
这里给初学者的建议是:优先使用匈牙利算法。绝大多数业务场景,数据量不会夸张到需要 HK 或 KM 优化版的级别,而匈牙利算法的理解成本和维护成本都是最低的。先把简单方案跑通,再根据瓶颈决定是否升级。
6. 常见问题与排查技巧实录
6.1 图的存储方式和输入格式
使用二分图算法前,第一步是建图。训练题和实际项目最大区别就在输入格式上:题目的输入往往是 n、m 和若干边,而工程项目中的输入经常是平面上万个对象的关系记录。
我把建图过程中常见问题整理成一个速查表:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 匹配数始终为 0 | 边没有正确加入邻接表 | 检查输入格式,打印邻接表 |
| 判定结果不稳定 | 图不是连通图,只从一个点染色 | 外层遍历全部顶点 |
| 匈牙利递归超时 | 图太稠密 / visited 未重置 | 重置 visited,优化遍历顺序 |
| 递归栈溢出 | 图规模大,DFS 深度过深 | 改用 BFS 染色或非递归 DFS |
| 结果和预期不一致 | 左右集合编号搞混 | 明确左右编号规则,用调试数据验证 |
建图时特别要注意“顶点编号从 0 开始还是从 1 开始”,很多 bug 都出在这个细节上。我的习惯是统一把外部输入的编号转换到 0 基,同时在算法入口处打印一次顶点数量和边数量,尽早暴露问题。
6.2 匈牙利算法搜索顺序带来的差异
同一条增广路径,如果搜索顺序不同,匹配方案也会不同,但匹配总数应该相同。如果发现两次运行结果不一样,大概率是 visited 没按每次搜索重置,或者是递归过程中修改了 shared 状态。
另一个常见问题是为了优化在递归开头加了全局匹配表的修改,结果把原本还能挽救的增广路径给破坏了。我的建议是先把最朴素的版本跑通,再做优化。朴素版本好处是逻辑一目了然,遇到问题容易定位,等数据量大了再逐层优化。
6.3 判定模块与匹配模块的边界
在业务系统中,二分图判定通常只是前置校验,匹配才是核心。我见过有人把两个算法混在一个方法里写,导致后期维护困难。建议是拆成两个清晰函数:isBipartite负责判定,maxMatch负责匹配。这样单元测试也好写,后续换算法也方便。
6.4 复杂度与性能的预期管理
二分图算法在数据量小时表现都很不错,但数据量上来之后要留意复杂度的增长。匈牙利算法的理论复杂度 O(VE) 在极端情况下可能到亿级别,虽然实际远远小于这个规模,但不能想当然。
我的经验值是:稀疏图上 V 达到十万级别,匈牙利算法跑完大概几十毫秒到几百毫秒;稠密图上如果 V 过万,就要考虑换 Hopcroft-Karp 或者改用启发式预处理。实操时先跑一组小规模数据评估耗时,再决定是否优化,而不是一上来就上最复杂的算法。
7. 二分图思维在工程中的延伸
7.1 把匹配思维用在接口幂等设计上
二分图匹配看起来像是纯算法问题,但它的“一对一配对”思维在很多工程设计中都能迁移。比如接口幂等设计里,请求 ID 和业务操作需要严格一一对应,设计核对表时其实也可以借鉴二分图的匹配思想,确保不重不漏。
7.2 组件依赖分析中的二分结构
做系统架构设计时,我经常发现模块之间的依赖可以划分成上下游两个集合。上游模块只依赖下游模块,下游模块不反向依赖上游模块。这种分层架构天然构成二分图。用染色法检查架构依赖中是否存在环,等于是在用二分图思路规避循环依赖的坏味道。
虽然没有直接调用二分图算法,但架构评审时我会带上染色法的思维方式:如果依赖图中找不到奇环,那它至少可以被拆成清晰的分层结构。这比单纯的说“不能循环依赖”更有说服力,也更可操作。
7.3 推荐系统里的一对一互斥约束
不少推荐系统中有“同一商品不能出现在同一次推荐结果的不同位置”这类的互斥约束。在做候选集后处理时,这类约束可以用二分图匹配做拦截:商品集合与位置集合分别作为左右节点,能投放的位置关系作为边。先求最大匹配,超出匹配数量的候选直接过滤。这个思路实现起来也不复杂,却能让推荐结果在硬约束层面保持稳定。
8. 写在最后的一点心得
二分图最迷人的地方在于,它用一个非常简单的模型,抓住了大量现实匹配问题的本质。无论是判定的染色法,还是最大化配对的匈牙利算法,实现门槛都不高,但里面蕴藏的建模思维却能在工程里反复复用。
我个人在实际项目里最喜欢做的事,是先用手画出这个关系图,再决定用什么算法。你会发现很多看起来混乱的业务,画完二分图瞬间就清晰了:左边是什么,右边是什么,边意味着什么,目标函数又是什么。这一步想清楚了,代码反而只是体力活。
最后再分享一个小技巧:如果你不确定自己的匹配模型对不对,拿一个火柴盒大小的样例手动模拟匈牙利算法,把匹配、增广路径、换配过程都写出来。很多时候,bug 不是算法写错了,而是模型本身建错了。二分图算法不会出错,会出错的是建模的人。