1. 从一道面试题说起:为什么二分图值得单独写一篇
如果你刷过算法题或者准备过大厂面试,大概率见过这类问题:“给定一个无向图,判断能否用两种颜色给所有顶点染色,使得任意一条边的两个端点颜色不同。”第一次看到这个题目时,我其实没太当回事,觉得不就是遍历一遍图嘛。直到后来在真实项目里遇到排课冲突检测、地图涂色、任务分配这类需求,才发现这个看似简单的模型背后藏着一整套精妙的理论,而且它的应用面远比想象中广。
所谓二分图,学术定义很简洁:如果一个无向图的顶点集合可以被划分为两个互不相交的子集A和B,并且图中的每条边都恰好连接A中的一个顶点和B中的一个顶点,那么这个图就是二分图。用人话说,就是你能把图中所有节点分成左右两拨,边只存在于左边和右边之间,左边内部、右边内部都没有边相连。判断一个图是不是二分图,最经典的算法就是染色法,这也是本篇文章真正要展开讲的东西。
这篇文章会从定义出发,把染色法的原理、BFS/DFS两种实现、复杂度分析、常见误区和典型应用完整过一遍,最后附上可直接用的代码模板和我在实际调试中踩过的坑。适合刚学图论的入门者,也适合面试前想快速系统过一遍二分图知识的同学。
2. 染色法的核心思想:用“矛盾”来判定结构
2.1 为什么两种颜色就够了
先想一个问题:为什么判断二分图只需要两种颜色,而不是三种、四种?答案藏在二分图的定义里。既然要求所有边都跨集合,那么沿着任意一条路径走,每经过一条边,节点所属的集合就必须切换一次。从起点出发,走奇数条边到达的节点必然和起点在同一个集合,走偶数条边到达的节点必然在另一个集合。所以整个图的信息完全由起点决定,用两种颜色标记就足够区分所有节点了。
这个“沿着边走、颜色交替变化”的观察,就是染色法的理论基础。实际操作时,我们从任意一个未被访问的节点出发,给它染上颜色0,然后遍历它的所有邻居,全部染成颜色1;再遍历这些邻居的邻居,染回颜色0……如果整个过程中出现矛盾,也就是某个节点已经被染成了颜色0,但当前遍历发现它应该被染成颜色1,那么图就不可能是二分图。矛盾出现的那一刻,其实就等价于找到了一个奇数长度的环。原因是只有奇环才会让路径长度奇偶性冲突,导致同一个节点被要求染上不同颜色。这个结论非常关键,它给出了一个直观判据:一个图是二分图,当且仅当图中不存在长度为奇数的环。
2.2 从环的角度理解二分图的边界情况
把“无奇环”作为判据后,很多边界情况就清楚了。比如一棵树,任意两个节点之间只有唯一一条简单路径,根本不存在环,所以树天然是二分图。一个没有边的空图当然也是二分图,因为你可以把所有节点随便分成两组,约束条件为空。
再看一个有趣的例子:三角形,三个节点两两相连。它显然是奇数环,所以不是二分图。但是五角星形状的图呢?如果把五个节点放在外圈,中心节点分别与五个外圈节点相连,外圈节点之间没有边,那么中心节点是一拨,五个外圈节点是一拨,这就是个标准的二分图,星形结构天然二分。
再比如网格图。你把棋盘按坐标的奇偶性染色,横纵坐标之和为偶数的格子染白,为奇数的格子染黑,那么任意一步移动都会改变奇偶性,相邻格子颜色必然不同。这就是为什么国际象棋棋盘上的马走日、象走斜这类问题,经常能转换成二分图模型。我在实际做题时养成了一个习惯:先画图,找找有没有三角形的子结构。一旦看到三角形或者任意奇数长度的环,就可以直接判断不是二分图,连染色都不用跑。
2.3 染色法的本质是遍历
从算法角度看,染色法并不神秘,本质就是一次完整的图遍历,无论是深度优先还是广度优先。遍历过程中维护每个节点的颜色状态,遇到未染色的邻居就赋相反颜色,遇到已染色的邻居就检查颜色是否冲突。因为图可能不连通,所以外层还需要遍历所有节点,保证每个连通分量都被处理到。
这也意味着染色法的时间复杂度是O(V+E),空间复杂度O(V)。对一个图做一次标准遍历就能得出结论,这是非常高效的算法。在很多在线判题系统里,二分图判定题目的数据范围常常达到10的5次方甚至10的6次方个节点,染色法都能轻松应对,不用担心效率问题。
3. 从零手写染色法:BFS与DFS两种实现细节
3.1 基于BFS的染色实现
我第一版写的染色判定代码用的是BFS,原因很实际:防止递归深度过大导致栈溢出。尤其是当数据规模达到几十万节点、图退化成一条链时,DFS递归很容易爆栈。BFS用队列实现,天然没有这个风险。
下面是一份完整的C++实现,我加了详细注释:
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; vector<int> adj[MAXN]; // 邻接表存图 int color[MAXN]; // 颜色数组,-1表示未染色,0和1表示两种颜色 int n, m; // n个节点,m条边 bool bfs(int start) { queue<int> q; q.push(start); color[start] = 0; while (!q.empty()) { int u = q.front(); q.pop(); for (int v : adj[u]) { if (color[v] == -1) { // 未染色,染成相反颜色 color[v] = color[u] ^ 1; q.push(v); } else if (color[v] == color[u]) { // 已染色但颜色相同,说明存在奇环,不是二分图 return false; } } } return true; } int main() { cin >> n >> m; memset(color, -1, sizeof(color)); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); // 无向图必须双向建边 } bool isBipartite = true; for (int i = 1; i <= n; i++) { if (color[i] == -1) { if (!bfs(i)) { isBipartite = false; break; } } } cout << (isBipartite ? "YES" : "NO") << endl; return 0; }注意这里有个新手最容易踩的坑:图可能不连通。如果你只从1号节点开始BFS,另一个连通分量里可能存在奇数环,但永远遍历不到。所以必须在外层循环遍历所有节点,发现未染色的节点就作为新起点启动一次BFS。用color数组同时承担visited数组的职责,color[v] == -1就表示未访问过,这样省去额外开一个布尔数组的开销。
3.2 基于DFS的递归实现
DFS版本代码更短,理解起来也更直观,适合数据规模较小、递归深度可控的场景:
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; vector<int> adj[MAXN]; int color[MAXN]; int n, m; bool dfs(int u, int c) { color[u] = c; for (int v : adj[u]) { if (color[v] == -1) { // 递归给邻居染相反颜色 if (!dfs(v, c ^ 1)) return false; } else if (color[v] == c) { // 发现相邻节点颜色相同,矛盾 return false; } } return true; } int main() { cin >> n >> m; memset(color, -1, sizeof(color)); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); } bool isBipartite = true; for (int i = 1; i <= n; i++) { if (color[i] == -1) { if (!dfs(i, 0)) { isBipartite = false; break; } } } cout << (isBipartite ? "YES" : "NO") << endl; return 0; }这段代码有个细节值得注意:dfs对每个节点只执行一次染色,但会对每条边检查两次。为什么每个节点只染一次就够?因为如果第一次染色成功,说明这个连通分量内没有矛盾;如果失败,说明这个连通分量不是二分图。一个节点已经染过色就没必要再次作为起点调用dfs,否则会重复遍历很多遍,效果等同于没剪枝。
3.3 两种实现的取舍建议
我的经验是:小规模图用DFS,因为代码简洁;大规模图用BFS,因为不依赖系统栈。如果你是在OJ上做题,把握不准数据范围,直接写BFS版最稳妥。还有一个折中方案是用vector模拟栈做迭代式DFS,但可读性差一些,实际收益也不大。
性能上两种方式都是O(V+E),差别主要在常数和递归开销。实测在10的6次方条边的图上,BFS通常会比递归DFS快10%到20%,原因是递归调用的函数栈开销较大。如果边数几百万甚至上千万,这个差距就不能忽略了。
提示:用递归DFS时,如果图是一条很长的链,比如10万个节点首尾相接,递归深度会达到10万层。在C++默认栈大小下,这很容易导致栈溢出。遇到这种情况,要么改成BFS,要么在编译选项里显式扩大栈空间。
4. 不只是判断:二分图能解决哪几类实际问题
4.1 最大匹配问题:从相亲配对到任务分配
判断完二分图之后,最经典的实际问题就是最大匹配。想象一个场景:有N个候选人和M个岗位,每个候选人能胜任若干个岗位,每个岗位只能安排一个人,问最多能安排多少人上岗。把候选人和岗位各作为二分图的一侧,能胜任的关系作为边,问题就变成求二分图最大匹配。
匈牙利算法就是专门解决这个问题的,核心思想是“让位”。如果某个岗位已经分配给了别人,但当前候选人也能胜任,我们不是直接拒绝,而是尝试让占用岗位的那个人换个岗位。这种递归的“协商”机制,配合visit标记防止无限循环,能在O(VE)时间内求出最大匹配。实际面试中,很多公司会把这类问题包装成“会议房间分配”“课程时间冲突”等业务场景来考察。
我参与过一个在线教育平台的项目,需要把课程切片分配给审核老师,每位老师擅长不同学科,每个切片只能分配给一位老师,目标是最小化未分配数量。这个需求本质上就是二分图最大匹配,我用匈牙利算法在半小时内完成了原型,运行速度完全满足业务需求。遇到这类场景,先把实体分成两组,再抽象出边的关系,问题就清晰了。
4.2 最小点覆盖与最大独立集
二分图有一组非常漂亮的等价关系:最小点覆盖数等于最大匹配数,也就是著名的Kőnig定理;同时最大独立集的大小等于节点总数减去最大匹配数。这个结论在面试中经常考,但很多学习者只是背结论,不明白推理过程。
概念上,点覆盖就是选一组顶点,使得每条边都至少有一个端点被选中。独立集则是选一组顶点,使得这组顶点之间没有边相连。在网络流或资源调度的场景中,最小点覆盖可以用来解决“用最少的监控设备覆盖所有道路”的问题,最大独立集则可以用来解决“最多能同时安排多少个互不冲突的任务”的问题。
以任务安排为例:有若干个任务,部分任务之间因为资源竞争不能同时执行。把任务作为顶点,冲突关系作为边,最大独立集就是能同时执行的最大任务数。如果冲突关系天然把任务分成两组(比如A组任务和B组任务之间才可能冲突),那这就是个二分图,直接用Kőnig定理就能高效求解。
4.3 棋盘覆盖与多米诺骨牌
另一个有趣的经典应用是棋盘覆盖问题。给你一个m行n列的棋盘,其中某些格子有障碍物,问能否用若干个1x2的多米诺骨牌覆盖所有无障碍物的格子,且不重叠、不越界。
解决思路很巧:把棋盘格子按照坐标奇偶性染成黑白两色,那么任意一个1x2的骨牌必然同时覆盖一个黑格和一个白格。把黑格和白格分别作为二分图的两侧,相邻(上下左右)的黑白格之间连边,问题就转化为:是否存在一个匹配,能覆盖所有非障碍格子。这个问题在“是否可以用数量较少的骨牌铺满特定区域”的变体中也很常见。
类似的思路还出现在一些拼图类游戏题里,比如“给定一组L形三格骨牌,能否覆盖指定区域”。只要图形内部的相邻关系能自然形成二分结构,就可以尝试用匹配的思路求解。
4.4 判定二分图在排课系统中的应用
排课系统是我接触过最典型的二分图实际应用。学校有多个班级、多位老师,每位老师负责某些班级的某些课程,同一时间同一个老师不能上两门课,同一个班级也不能同时上两门课。把所有课次作为节点,如果两节课次不能同时安排(要么同老师,要么同班级),就在它们之间连一条边。如果这个冲突图是二分图,说明可以用两个时间段把所有课次全部排完;如果不是,就需要引入第三个时间段。
你可能会问,为什么冲突图会在某些场景下是二分图?假设只考虑同一老师不同班级之间的冲突,这些课次天然按老师分成若干组,同组内部两两冲突,但不同组的课次之间没有边,这恰好构成一个多部图而非二分图。但如果限制条件是“每个老师只教一个特定年级”,那么课次可以根据年级分组,冲突只发生在不同年级之间,就退化成二分图。用染色法可以快速判断最少需要几个时段,这是排课软件中非常实用的前置工具。
5. 调试记录:我踩过的5个染色法相关的坑
5.1 双向建边缺失导致“误判为二分图”
有一次我在本地测试一个图,明明画出来有个三角形,程序却输出YES。检查了半天,发现建图时只添加了u->v的邻接关系,漏了v->u。染色过程中从u遍历不到v对应的反向边,三角形的一条边被忽略了,奇环被“隐藏”了,程序自然得出错误结论。无向图必须双向建边,这是所有图论题的入门铁律,但越基础的错误越容易在细节中复发。
5.2 外层循环起点选择不当
另一个常见错误是只从1号节点启动一次BFS或DFS。对于不连通图,后续连通分量可能包含奇数环,但因为起点没覆盖到,程序错误地输出YES。正确做法是遍历所有节点,遇到未染色节点就启动一次染色过程。这里有个小优化:只要任何一个连通分量染色失败,就可以提前终止循环,不用继续检查后面的分量。
5.3 递归深度爆栈
处理一条链式的图时,DFS递归深度等于节点数。节点数到10万级,C++默认栈就爆了,程序直接段错误。这个坑在OJ上很隐蔽,本地测试小数据一切正常,提交就Runtime Error。我的解决方案是把递归DFS改成BFS,或者在main函数开头用栈扩容指令。BFS版的队列实现完全不受递归深度限制,这也是我最终推荐BFS的原因。
5.4 颜色初始值用0导致判断失误
如果color数组初始值设为0,而两种颜色也定义为0和1,就会出现未访问节点和颜色0节点无法区分的情况。染色逻辑判断color[v] == -1时会把全部已染成颜色0的节点当作未访问,导致重复染色和错误判断。常见修复有两种:颜色值用0和1,初始值用-1;或者初始值用0,颜色值用1和2。我用的是第一种,因为-1的表意更清晰。
5.5 自环和重边的处理
自环看起来只是一个节点自己连自己,但它本质上是一个长度为1的环,必然是奇数环。任何包含自环的图都不是二分图。重边则不会改变二分性判定,因为两条边连接的是同一对节点,颜色检查会执行两次,结果一致,不影响正确性。遇到自环,判断逻辑会在检查color[v] == color[u]时发现v和u是同一个节点,颜色一定相同,直接返回false。代码无需额外处理,但心里要有数。
6. 染色法在真实业务中的一个项目复盘
6.1 项目背景:仓库拣货冲突检测
前阵子接到一个仓库管理系统的需求。仓库里有多个拣货员,每个拣货员同时只能执行一个拣货任务,但不同任务的拣货路线可能在通道上重叠。如果两个任务经过同一条狭窄通道,就不能同时执行,否则会互相阻塞。系统需要在任务下发前判断,给定一批任务,能否用两个时间窗口全部安排好。
这本质上是个判定问题:每个任务是顶点,通道重叠的任务之间连边,判断这张冲突图是否为二分图。如果是,说明两个时间窗口足够;如果不是,就需要引入更多窗口或人工协调。这个场景很典型,因为任务的冲突关系天然由物理通道决定,而通道可以看作多个任务的共享资源,直接映射成图上的边关系。
6.2 建模细节与实现选择
我先梳理了任务的路径数据,把每个任务经过的通道集合提取出来。两个任务如果有共同通道,就建立一条边。这里有个小优化:如果某条通道被超过两个任务共享,那这些任务两两之间都有边,会形成一个团或近似团,染色法会立即发现冲突,很多情况甚至不需要完整建图就能提前终止判断。
实现上我选用了BFS染色,节点数大约2万,边数高峰期能到80万。BFS在毫秒级别跑完,完全满足实时判断的需求。核心代码和上面贴的模板基本一致,只是邻接表换成了vector<vector >,节点下标从0开始。最初的版本用递归DFS实现,数据量小的时候没问题,后来压力测试发现深路径场景会栈溢出,果断改成BFS。
6.3 数据实验与阈值结论
我在验证时构造了几组数据:第一组是1000个节点、5000条边的随机图,染色耗时不到1毫秒;第二组是20000个节点、800000条边的稠密图,BFS耗时大约20毫秒;第三组特意构造了一条20000节点的链,BFS依然秒过,但DFS直接栈溢出。实验结果基本验证了我的预判:稀疏图效率极高,稠密图也不会有性能瓶颈,核心风险永远在递归深度上。
这个项目整体做下来,最大的体会是:很多看似复杂的业务系统,底层需求就是图论里几个经典问题。二分图染色作为其中最基础的判定工具,能帮你快速筛掉一批“用两个时段就能解决”的简单冲突,为后续更复杂的调度算法留出空间。
7. 常见问题速查表
我把排查经验整理成一张速查表,方便大家对照检查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 有三角形却输出YES | 建图漏了反向边 | 无向图必须双向建边 |
| 不连通图判断错误 | 只从一个起点开始染色 | 遍历所有未染色节点,逐个作为起点 |
| 程序段错误 | 递归深度过大 | 改为BFS实现,或扩容栈 |
| 染色结果混乱 | 颜色初始值和颜色值冲突 | 初始值用-1,颜色值用0和1 |
| 自环导致误判 | 未处理自环 | 染色检查时会自动判错,无需额外代码 |
| 大数据超时 | 重复遍历已染色节点 | 外层循环用color数组判断是否访问过 |
这些坑我基本都踩过一遍。即便现在写这类代码,我也会先画一个小样例手工跑一遍,确认建图正确再上数据量大的测试,这个小习惯帮我省了不少调试时间。
8. 一些补充的思考:染色法的扩展与边界
染色法虽然叫“染色”,但它本质上是一个约束满足问题:给定每个节点的取值集合,边的两个端点取值必须不同。二分图允许每个节点有两种取值,但如果有K种颜色,问题就从二分图判定变成了K染色问题。K染色是NP完全问题,复杂度远高于二分图判定。二分图之所以有高效算法,正是因为它只需要两种颜色,而这个约束足够强,结构上的奇环判据让判定变得简单直接。
另一个值得思考的方向是带权图。如果每条边带有一个冲突代价,我们不仅要判定是否是二分图,还想知道最少需要删除多少条边才能让图变成二分图。这个问题叫“删除边使图二分”,同样是NP难问题。但在二分图上进行最大匹配的加权扩展,比如“最小权最大匹配”,却有多项式算法,比如KM算法。有时候边界条件稍微变化,问题难度就会天差地别。
在工业界实际应用时,很少会直接遇到一个“裸的二分图”,更多情况是数据本身有噪声,需要你先做数据清洗和抽象,把实体分成两组,再判断边的关系是否符合二分结构。如果发现不是二分图,不要急着用更复杂的算法,先看看抽象是否正确,也许只是某个边加错了。
如果你对二分图的下一步感兴趣,最值得学的是匈牙利算法和Kőnig定理的证明。两者吃透之后,你会对图论里匹配、覆盖、独立集这几类问题有整体性的理解,这种感觉非常美妙。我个人也是从二分图染色这一步开始,才真正把图论从“会写模板”推向“理解底层逻辑”的。