
先说个我当年刷题的真实感受遇到“能不能把某些东西分成两组”“让所有有冲突的人不在同一侧”这类题目很多人第一反应就是贪心或者直接爆搜。但事实上这类题十有八九是在问同一个问题——这个图是不是二分图。图论里的二分图判定看起来只是一个上色的基础操作实际上它贯穿了从竞赛到面试再到业务系统的一大片场景。它解决的不只是“能不能分”更是“怎么分最优”的基础。这篇文章我会从二分图的定义和判定原理讲起手把手拆解BFS/DFS染色判定的代码细节再延伸到最大匹配、最小点覆盖、最大独立集这些组合优化里的经典变式最后用一道“分组且人数尽量均衡”的实战题把从建模到编码的全过程走一遍。我踩过的坑、调试时的经验也会一并写出来。适合正在准备算法面试的人、打比赛的同学以及工作中偶尔要处理“冲突分组”问题的工程朋友。1. 二分图到底在说什么从一个染色的“脑筋急转弯”理解1.1 二分图的定义不是在画图是在涂色二分图的标准定义是一个无向图 G (V, E)如果顶点集 V 可以划分成两个互不相交的子集 X 和 Y使得每一条边 e (u, v) 的两个端点一个在 X 里另一个在 Y 里那么这个图就叫二分图也叫二部图。这个定义可能有点干换个涂色的说法就清楚了给每个顶点涂黑色或白色要求每条边的两个端点颜色必须不同。如果你能做到那这个图就是二分图如果做不到那它就不是。判断二分图的过程本质上就是一个给全图顶点“染色”的过程这也是“二分染色判定法”这个名字的由来。这里的“二分”不是说图长成两堆的模样而是指顶点天然可以分成两个互补的阵营。就好比一场舞会上的配对关系把“男生”和“女生”看作两个集合所有的舞伴关系都发生在跨集合之间同一个集合内部是没有任何边的。这种结构在现实中大量存在比如学生与课程之间的选课关系、任务与工人之间的分配关系、广告位与投放需求之间的匹配关系。你要做的不是去画一张复杂的图而是训练自己一眼看出“这类关系本质上就是二分图”。1.2 无奇环判定二分图的隐藏密码二分图有一个非常漂亮的等价条件一个无向图是二分图当且仅当它不包含奇数长度的环。这个结论我用很朴素的方式理解。假设有一个环顶点数如果是偶数你可以按照“黑、白、黑、白”交替的方式给环染色首尾正好不同一切正常。但如果环的长度是奇数比如三个顶点形成一个三角形你从顶点1染黑色顶点2染白色顶点3再染黑色绕回顶点1的时候发现“黑色”和“黑色”撞上了矛盾。这个矛盾不是偶然它是一条路径交替染色后首尾关系因为环长为奇数而发生冲突的必然结果。反过来说只要图里找不到奇环这个图就一定能成功染色。这个结论在竞赛里的应用极其广泛。很多题表面上在问“是否存在矛盾”本质就是在问“图中是否存在奇环”。比如一群人之间有一些矛盾关系要求把矛盾双方分开如果矛盾关系围成了一个三角形那三个人两两之间都有矛盾却只能分成两组怎么分都会有一对矛盾残留。用染色法跑一遍在三角形这种结构上就会直接检测出冲突。1.3 为什么现实中到处都是二分图很多人觉得二分图是纯理论概念其实不是。只要一个场景里的实体能明确分成两类而且关系只发生在“跨类”之间那就是二分图。我记得自己在做项目时接到过一个任务分配系统每个任务单只能分配给具备对应技能类型的人人和任务之间构成一个庞大的匹配关系这种图天然就是二分图。再举几个常见例子课程表里学生和班级的关系、数据库ER模型里“订单”和“商品”的多对多关联、棋盘上一个格子向上下左右相邻格子移动的关系相邻黑白格子交替分布这些都是二分图。更重要的是这种结构一旦被识别出来你就可以直接套用二分图最成熟的算法体系包括最大匹配、最小点覆盖、最大独立集。它们能直接回答“最多能撮合多少对”“最少要选多少个点才能盖住所有边”“最多能保留多少个互不干扰的节点”这些问题。2. 二分图判定染色法的思路与代码2.1 为什么不用暴力分组我第一次遇到二分图判定问题时第一反应是枚举顶点分组看看哪种分组能让所有边都跨集合。这个思路在顶点数小于等于15的时候还能跑一跑n稍大一点就指数爆炸根本没有可行性。染色法的巧妙之处在于它把“所有顶点分组”的搜索问题转化成了“逐点涂色并用边约束验证”的线性问题。你不需要去尝试每一种分组只需要从一个顶点开始强制给它染一种颜色然后顺着边把相邻顶点的颜色一个一个确定下来。这个确定的过程非常像多米诺骨牌起点的颜色定了它的所有邻居颜色就都定了每个邻居确定后又继续影响下一层邻居。如果这个过程走到某一步发现一个已经染过色的邻居颜色必须和我们冲突那整张牌的链条就断了图不是二分图。因为每条边只被访问一次每个顶点也基本只被处理一次整个时间复杂度是 O(V E)。这个复杂度在图上是最理想的线性级别这也是染色法成为二分图判定标准做法的根本原因。2.2 BFS染色判定的完整步骤BFS染色是我最推荐的入门方案代码清晰也不会像DFS那样有递归爆栈的风险。整个流程分三步建立邻接表初始化所有顶点的颜色为 -1表示未染色我们使用 0 和 1 两种颜色。对每个顶点做检查如果它还没被染色就以它为起点执行一次 BFS 染色。BFS 过程中每次取出队首顶点 u遍历它所有邻居 v。如果 v 未染色就把 v 染成 color[u] ^ 1即相反颜色并入队如果 v 已经染色且 color[v] color[u]就说明矛盾直接判定不是二分图。这里最容易被忽略的是第二步的“对每个顶点做检查”。很多人以为从一个起点出发BFS能覆盖全部顶点但图不一定是连通的。如果图由多个连通分量组成你只从其中一个分量出发其余分量根本没被处理。后面我会专门展开这个坑。2.3 非连通图的坑一个起点走不完整个图有一次做数据竞赛的模拟题写完判定代码测试时自己的样例全过交上去却有测试点失败。当时查了很久最后发现题目里有一对根本没有任何边的孤立点我的代码只从第一个顶点开始染色另一个孤立点完全没进入处理逻辑。细分问题后意识到非连通图的每个连通分量其实都需要独立选择起点因为每个分量可以随意选择初始颜色互不影响。解决办法就是在主函数里加一层循环遍历所有顶点只要还没染色就以它为起点跑一次BFS。这个循环并不影响复杂度因为每个顶点只会被启动一次总复杂度依然是 O(V E)。但少了这一步你的判定就会漏掉整片整片的区域这是二分图判定里最低级、也最容易踩的坑。2.4 DFS染色与递归栈的问题DFS染色和BFS染色的核心思路完全一样只是把“队列”换成了“递归”。代码往往更短很多人面试时喜欢用递归版因为它看起来优雅。但递归版有一个致命隐患当图的顶点数达到几十万而且图是一条长链时递归深度会突破默认栈限制。Python里最常见的表现就是 RecursionErrorC则是栈溢出、程序直接崩掉。我个人的建议是除非你明确知道数据量很小否则优先用BFS。如果一定要写DFS可以先用 sys.setrecursionlimit 把递归深度调高但这也只是在Python里的事C还是老老实实用BFS或者手写迭代栈吧。真到了大规模图的场景BFS队列的方案几乎总是最稳的。3. 从判定到应用二分图的经典组合优化3.1 最大匹配从增广路想到“置换座位”只判定二分图其实只是入场券二分图真正强大的地方在于很多组合优化难题在这个结构上都能高效求解。其中最核心的概念是匹配匹配是一组边任意两条边没有公共顶点。最大匹配就是让这组边的数量尽可能多。最大匹配的求解核心是增广路这个概念我当初理解了很久直到用了一个比喻才算通透假设你在一场相亲活动里坐了一排座位已经撮合了一些人。这时又来了一位参与者他想参与进来但和他有缘分的座位已经有人了。这时候你不能直接把别人赶走而是要去看看那个人能不能换到隔壁空着的座位。如果沿着这条“有人坐、没人坐、有人坐、没人坐”的座位链一直找下去找到一个空座那么整条链上的人依次移动一格新来的人就成功加入了。这条座位链在算法里的名字就叫增广路。增广路是匹配理论的核心一个匹配是最大匹配当且仅当图中不存在增广路。所以所有找最大匹配的算法本质上都在反复做同一件事——寻找增广路找到了就翻转把匹配数加一直到找不到为止。3.2 匈牙利算法的完整实现与复杂度匈牙利算法就是把上面这种“坐座位”的思路落地。它逐个处理左侧每一个顶点尝试为该顶点找到一个匹配的右侧顶点。如果目标右侧顶点空闲直接配对如果它已经有主了递归地让那个“主人”尝试换一个位置。这整个过程中用到一个 vis 数组标记本轮尝试中有哪些右侧顶点已经被访问过防止递归死循环。C风格的核心代码如下// adj[u] 存放左侧点 u 能连到的右侧点编号 vectorint adj[N]; int matchR[N]; // 右侧点匹配的左侧点-1 表示未匹配 bool dfs(int u, int vis[]) { for (int v : adj[u]) { if (vis[v]) continue; vis[v] 1; if (matchR[v] -1 || dfs(matchR[v], vis)) { matchR[v] u; return true; } } return false; } // 主流程 int hungarian(int leftN) { memset(matchR, -1, sizeof(matchR)); int res 0; for (int u 0; u leftN; u) { int vis[MAXV] {0}; if (dfs(u, vis)) res; } return res; }代码本身不长但有几个细节容易出错matchR[i] 存的是右侧点 i 当前匹配的左侧点编号递归到 dfs(matchR[v], vis) 时是在替原来的左侧点重新找位置而不是直接换右侧点。另外每次主循环都要重置 vis因为每一轮的递归都是一次独立的“抢座尝试”。时间复杂度方面最坏情况是 O(VE)因为每个左侧点都可能进行一整轮的DFS搜索。如果 V 在 500 以内这个算法基本无脑跑如果到了几万规模你就得考虑 Hopcroft-Karp 算法了它用 BFS 分层、再用 DFS 多路增广复杂度可以降到 O(E sqrt(V))。不过实际比赛里匈牙利算法因为常数小、实现简单仍然是使用频率最高的方案。3.3 三大转换最小点覆盖、最大独立集、最小路径覆盖二分图之所以在算法题里无处不在还因为它连着三个很容易互相转换的经典问题而且转换关系都是漂亮的等式。第一个是 Kőnig 定理二分图的最小点覆盖大小等于最大匹配大小。最小点覆盖指选最少的点使每条边至少有一个端点被选中。这个问题在普通图上是NP难问题但在二分图上它就等于最大匹配直接用匈牙利算法算出匹配数就能解决。我记得当时知道这个结论时第一反应是不敢相信后来反复构造数据验证确实成立。第二个是最大独立集最大独立集大小等于顶点总数减去最大匹配大小在二分图中成立。这里的独立集是指选出最多的点使得它们之间没有任何边。它与最小点覆盖互为补集不选覆盖点的剩余点必然两两无边。第三个是DAG有向无环图的最小路径覆盖先把每个点拆成入点和出点构造二分图然后答案等于顶点数减去最大匹配数。这个转化我在刷题时见过不下十次比如“最少需要几个工作人员才能覆盖所有任务链”拆点建图是核心步骤。可以说只要你能把一个实际场景抽象成“一类实体对另一类实体的配对关系”那么这三板斧就能帮你解决一大票看起来完全不同的问题。4. 实战演练一道从读题到AC的完整题解4.1 题目分成两组矛盾互斥并尽量均衡我拿一道自己改编过的经典题举例它有二分图判定、非连通分量处理、还有背包调度正好把前面各个知识点串起来。题目大意有 n 个人编号 0 到 n - 1给出 m 对矛盾关系 (u, v)表示这两个人不能分到同一个组。现在要把所有人分成两个小组问是否可行。如果可行输出一种分组方案并且要求两个小组的人数尽量接近人数差的绝对值最小。这个题拆开看就是两问第一问是判定能不能把它染成二分图第二问是在所有合法的染色方案里找出人数最均衡的那一个。注意第二问并不是“随便找一种染色就能得到最均衡方案”因为每个连通分量内部有两种颜色两种颜色分别对应“进A组”和“进B组”选择哪个方向是可以整体翻转的这就是一个典型的组合优化问题。4.2 建模与判定连通分量里的颜色翻转建模非常直接每个人是一个顶点每对矛盾关系是一条边能成功染色等价于分组可行。BFS染色时我们把每个连通分量内部两种颜色的人数分别统计出来记为 (cnt0, cnt1)。整个图被分成若干个连通分量后每个分量相当于一个“二元物品包”你可以选择把 cnt0 个人放进A组、cnt1 个人放进B组或者整体翻转把 cnt1 个人放进A组、cnt0 个人放进B组。不同分量之间的选择互不影响因为分量之间没有边连接。这里的关键认知是BFS染色时每一种颜色并不是固定对等于A组或B组而是可以在每个连通分量内独立决定翻转方向的。很多人在这一步犯了迷糊以为染成0的人必须在A组、染成1的人必须在B组结果错过了最优解。4.3 背包调平把每个连通块“塞”进两组当我们把每个连通分量都化简成一个二选一的物品后问题就变成了给定若干组二元数对 (a_i, b_i)每个数对选择其中一个放进A组另一个自然放进B组求A组人数最接近 n / 2 的方案。这是一个典型的0-1背包变种只不过每个物品有两个选项。我写Python实现的时候是这样处理的from collections import deque def solve(n, edges): g [[] for _ in range(n)] for u, v in edges: g[u].append(v) g[v].append(u) color [-1] * n comps [] ok True for i in range(n): if color[i] ! -1: continue color[i] 0 q deque([i]) cnt [0, 0] while q: u q.popleft() cnt[color[u]] 1 for v in g[u]: if color[v] -1: color[v] color[u] ^ 1 q.append(v) elif color[v] color[u]: ok False if not ok: return NO comps.append(cnt) total n half total // 2 dp [False] * (half 1) dp[0] True for a, b in comps: ndp [False] * (half 1) for s in range(half 1): if not dp[s]: continue if s a half: ndp[s a] True if s b half: ndp[s b] True dp ndp best 0 for s in range(half, -1, -1): if dp[s]: best s break return fYES {best} {n - best}这段代码里的 dp[s] 表示A组能否达到正好 s 人。每处理一个连通分量就生成新的状态数组 ndp从旧状态转移而来。这个数组长度只有 n/2空间开销很小。最后从 half 向下找第一个可达到的人数就是A组能最接近半数的人选。如果要输出具体哪些人进了A组只需要在转移时用一个 choice[s] 记录当前状态选的是 a 还是 b然后从最终状态沿路径回溯即可。4.4 完整验证与扩展思考拿一个简单例子验证假设 n 6矛盾关系是 (0,1)、(2,3)、(4,5)。每个连通分量都是两个点cnt 都是 (1,1)。背包跑完后每个分量选 a 或 bA组人数可以是 0、2、4、6最接近 3 的是 2 或 4所以输出 “YES 2 4”比如0、2进A组其余进B组合法且均衡。这个例子看似平淡但它展示了把“染色结果”和“DP调平”组合起来的能力。很多类似题比如“给一些互斥课程排到两个学期且学分尽量均衡”本质上就是这道题的换皮。只要你能识别出“冲突边 两组分配”的结构整个解法可以照搬。5. 常见问题与排查技巧实录5.1 自环和重边会不会影响判定自环表示一个顶点和它自己有一条边这在普通图里看起来很奇怪但实际场景中可能对应“某人和自己有矛盾”这种无效数据。染色法遇到自环时因为 u 和 v 是同一个点判断 color[v] color[u] 会直接发现矛盾结果就是判定失败这是正确的。但如果你写代码时用了 color[u] ^ 1 这种先入队再判断的逻辑可能会把自环处理成“把自己重新入队”的奇怪行为所以要确保在入队前检查是否已染色并判断颜色冲突。重边一般不影响二分图判定因为两条平行边不会改变奇环的存在性。但它会影响遍历效率尤其是数据量大的时候邻接表里重复的边会拖慢BFS。刷题时如果输入保证无重边不用处理如果没保证建图时用一个 unordered_set 或其他方式去个重能省下不少时间。5.2 为什么我的判定漏了非连通图这是我见过最多的问题我自己也中过招。检查清单如下主循环是否遍历了所有 n 个顶点每个没染色的顶点是否都能作为一次起点如果答案是否那就修正它。判断标准也很简单构造一个包含多个连通分量的数据比如两个人之间没有边跑你的代码看它能不能正确输出 YES。如果它输出了错误答案基本可以确定就是非连通图处理漏了。另外还要检查BFS内部是每入队一个顶点就立即染色还是出队时染色。如果出队时才染色某个顶点可能因为多种路径被重复入队比如相邻两个顶点在它们入队前已被另一条路径染过色就会导致状态覆盖所以成熟的写法都是入队前完成染色判断和赋值。5.3 大图的性能隐患当 n 超过 10 万m 超过 20 万时图论代码的性能就会变得敏感。首先是邻接表要尽量用数组形式而不是大量动态分配Python里可以用 list of listC里 vector 足够但如果追求极限性能可用前向星。其次是颜色数组初始化一定要用循环赋值而不是按需填避免重复清零。然后是递归深度这个问题前面已经强调过了大图务必使用 BFS。还有一点容易被忽略隔离点也要参与背包。你没看错如果一个顶点没有任何边它本身就是一个独立连通分量cnt 是 (1,0)。它在染色判定里不需要处理太多逻辑但在背包调平阶段它也是一个可以自由选择去A组还是B组的变量。如果你在求“分组人数均衡”的题里漏掉隔离点的背包贡献结果就会偏。5.4 调试二分图题的3个建议第一自己写一个随机数据生成器n 从 10 到 1000 随机生成边然后和你认为正确的暴力算法对比结果。暴力做法是枚举每个连通分量内的两种颜色分配对每个分量做DFS后统计分布规模小完全可以跑。第二自测时多用星形图、链式图、完全二分图、奇环图四类结构它们能覆盖二分图判断的绝大多数边界问题。第三如果答案不对先别盯着匹配算法查先回到染色判定你能确认每一组输出的分组方案里任意一条边的两端确实不同色吗如果不能问题大概率出在染色环节而不是后续的DP。个人经验与一点补充我做了这么多年图论题最大的感受是二分图题里“建图建模”往往比背算法模板难得多也重要得多。很多人看到一道题觉得像二分图却不知道怎么把现实关系转成顶点和边其实就是缺少练习。我的建议是每次遇到“两类实体配对”的题目先画一画两类实体分别是什么、边代表什么关系关系是否只发生在跨类之间。如果是那二分图的整套工具就可以放心地用起来。最后再分享一个考试和面试都能用的小技巧当你不太确定题目是不是二分图时先看数据范围。如果 n 很小比如 20 以内你可以用指数级方法验证模型是否正确如果 n 在 500 到 1000 的区间二分图的各类算法正好是标准复杂度。这种范围估算能力往往比盲目套模板更能帮你稳定地拿分。