从二分图判定到完全二分图:算法竞赛中的图论建模与DFS染色法实践

发布时间:2026/7/22 6:10:25

从二分图判定到完全二分图:算法竞赛中的图论建模与DFS染色法实践 1. 项目概述与核心需求解析最近在刷信奥信息学奥林匹克的题目碰到了这道P13099 [FJCPC 2025] VERTeX。一看是FJCPC福建省大学生程序设计竞赛2025年的题目就知道这题肯定不简单不是那种一眼就能看出解法的水题。题目名字叫“VERTeX”直译是“顶点”在算法竞赛里但凡跟图论沾边的尤其是这种看起来像缩写或者双关语的标题往往意味着你需要深入理解问题的图论模型。我花了大概一个下午的时间从读题、建模、推导到最终用C实现和调试算是把这道题啃下来了。这里就把完整的解题思路、代码实现过程以及我踩过的几个坑分享出来希望能给同样在刷题路上前进的朋友们一些参考。这道题的核心是要求我们根据一组特定的输入规则判断一个无向图是否满足某种“顶点”性质。具体来说题目会给出图的顶点数、边数以及每条边连接的两个顶点。我们需要判断这个图是否是一个“VERTeX”图。根据题目描述和测试样例分析所谓的“VERTeX”图实际上指的是一个完全二分图。如果你对图论不熟可以这么理解想象有一群人和一堆任务每个人只能和特定的某些任务配对形成边并且配对关系要“满”到某种程度。在完全二分图中我们把所有顶点分成两个不相交的集合X和YX中的每一个顶点都与Y中的每一个顶点有边相连而X内部或Y内部的顶点之间没有边。题目就是让我们判断给定的图是不是这种结构。那么为什么判断完全二分图会成为一道竞赛题呢因为它考察了你对图的基本存储、遍历DFS/BFS、以及二分图判定算法的掌握同时还需要一点逻辑推导能力来处理输入和输出格式。这对于正在准备信奥或CPC竞赛的同学来说是一个很好的综合练习涵盖了图论的基础和算法实现的基本功。2. 算法思路与图论模型建立拿到题目后第一步永远是彻底理解题意和数据范围。题目输入格式通常是第一行两个整数n和m分别代表顶点数和边数。接下来的m行每行两个整数u和v代表一条边。输出是简单的“YES”或“NO”。n和m的范围是关键它决定了你能用什么复杂度的算法。虽然题目原文没给但根据FJCPC的常规难度和“VERTeX”这个性质n在10^5量级、m在10^5量级是很有可能的这意味着你需要一个O(nm)的线性算法。2.1 为什么是二分图判定题目要求判断是否为完全二分图。我们分两步走第一步先判断它是不是一个二分图。这是基础。如果一个图连二分图都不是那它绝对不可能是完全二分图。二分图的定义是可以将所有顶点分成两个集合使得同一集合内的顶点之间没有边。判定二分图的标准算法是染色法也称二着色法使用DFS或BFS遍历图尝试用两种颜色比如1和2给顶点染色规则是相邻顶点必须染不同颜色。如果在染色过程中发现某个相邻顶点已经被染成了和自己相同的颜色则说明存在奇环该图不是二分图。第二步如果是二分图再判断它是否“完全”。假设我们通过染色法得到了两个集合X和Y。完全二分图要求X中的每个顶点都与Y中的每个顶点有边。换句话说边数必须等于 |X| * |Y|。同时还要确保X内部和Y内部确实没有边这一步在染色法过程中已经保证了。所以整个算法的框架就清晰了读入数据建立邻接表存储图对于稀疏图邻接表是唯一选择。使用DFS/BFS进行二分图染色并统计两个集合的大小cnt1和cnt2。如果染色失败输出“NO”。如果染色成功计算 cnt1 * cnt2看是否等于给定的边数m。相等则输出“YES”否则输出“NO”。2.2 邻接表存储与数据结构选择对于顶点数n可能达到10^5的图我们绝对不能使用邻接矩阵空间复杂度O(n^2)会爆内存。必须使用邻接表。在C中最常用的是vectorvectorint adj(n1)。这里有个细节顶点编号通常从1开始所以我们分配n1的大小下标0的位置空着不用。#include iostream #include vector using namespace std; int main() { int n, m; cin n m; vectorvectorint adj(n 1); // 邻接表 for (int i 0; i m; i) { int u, v; cin u v; adj[u].push_back(v); adj[v].push_back(u); // 无向图需要添加两条边 } // ... 后续处理 }这里有一个注意事项在添加边的时候务必记得无向图要添加两次adj[u].push_back(v)和adj[v].push_back(u)。这是新手很容易忘记的一点会导致图结构错误后续遍历出问题。2.3 染色法的具体实现与细节染色法可以用DFS或BFS实现。我个人更喜欢用DFS写递归的形式比较简洁。我们需要一个color数组来记录每个顶点的颜色0表示未访问1和2表示两种颜色。同时我们可以用两个变量cnt1和cnt2在DFS过程中顺便统计两个集合的大小。DFS函数的思路是对于当前顶点u尝试给它染上色c。然后遍历它的所有邻居v如果邻居v还没染色就递归调用DFS尝试染上另一种颜色3-c因为颜色是1和23-123-21这个技巧很常用。如果递归调用返回false说明下游发现了冲突直接返回false。如果邻居v已经染色并且颜色和u相同说明冲突直接返回false。vectorint color; // 全局或引用传递 vectorvectorint adj; // 邻接表 long long cnt1, cnt2; // 统计集合大小用long long防止乘法溢出 bool dfs(int u, int c) { color[u] c; if (c 1) cnt1; else cnt2; for (int v : adj[u]) { if (color[v] 0) { // 未访问 if (!dfs(v, 3 - c)) return false; } else if (color[v] c) { // 冲突 return false; } } return true; }这里有几个关键点和易错点图可能不连通题目没有说图一定是连通的。因此我们需要对每一个未访问的顶点color[i] 0都发起一次DFS。如果任意一次DFS返回false整个图就不是二分图。集合大小统计cnt1和cnt2必须在每次启动一个新的连通分量DFS前清零吗不我们需要的是整个图两个集合的总大小。因此cnt1和cnt2应该定义为全局变量或在main函数中定义然后在每次DFS过程中累加。但是注意如果图有多个连通分量每个连通分量自己内部会形成一种二分图划分但不同连通分量之间的颜色是独立的。一个连通分量里被染成颜色1的点和另一个连通分量里染成颜色1的点并不属于题目要求的同一个集合X。这是本题最大的思维陷阱3. 核心陷阱多连通分量与“完全”性的关系这是理解本题算法的难点也是我调试时踩的最大的坑。我们来回想完全二分图的定义所有顶点分成两个集合X和YX中任一点与Y中任一点都有边。如果图有多个连通分量会发生什么假设有两个互不连通的连通分量A和B。每个连通分量自身可能都是一个完全二分图。但是对于整个图来说来自分量A的某个顶点属于它的“X集”和来自分量B的某个顶点属于它的“Y集”之间并没有边。这违反了“X中所有点与Y中所有点都有边”的定义。因此一个真正的完全二分图必须是连通图它只能有一个连通分量。这个推论极大地简化了问题我们不需要处理多个连通分量下cnt1和cnt2的复杂累加。我们只需要在染色成功后判断整个图是否连通并且边数m是否等于cnt1 * cnt2。如何判断连通在DFS/BFS之后检查是否所有顶点的color都不为0即可。因为我们的染色遍历会走遍当前连通分量所有点如果图是连通的那么一次DFS就应该访问所有n个顶点。因此算法修正如下读图。任选一个起点比如1开始DFS染色并统计cnt1和cnt2。检查DFS返回值是否为false若是则输出“NO”。检查是否所有顶点都被染色了即color[i] ! 0对所有i从1到n成立。若有顶点未被访问说明图不连通输出“NO”。计算cnt1 * cnt2与m比较。相等则输出“YES”否则输出“NO”。4. C代码实现与逐行解析结合以上分析我们可以写出完整、健壮的代码。下面我给出代码并加上详细注释。#include iostream #include vector using namespace std; vectorvectorint adj; // 邻接表 vectorint color; // 颜色数组0未访问1颜色A2颜色B long long cnt1, cnt2; // 统计两种颜色的顶点数 // DFS染色函数 // u: 当前顶点 // c: 要染的颜色 (1 或 2) // 返回值: true表示染色成功false表示发现冲突 bool dfs(int u, int c) { color[u] c; // 给当前顶点染色 // 根据颜色统计集合大小 if (c 1) { cnt1; } else { cnt2; } // 遍历所有邻居 for (int v : adj[u]) { if (color[v] 0) { // 邻居未访问递归染相反颜色 if (!dfs(v, 3 - c)) { return false; // 如果下游失败直接返回失败 } } else if (color[v] c) { // 邻居已访问且颜色相同冲突 return false; } // 邻居已访问且颜色不同是符合要求的继续 } return true; // 所有邻居处理完毕成功 } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); // 这两行是加速C输入输出对付大数据量必备 int n, m; cin n m; // 初始化顶点编号从1开始 adj.resize(n 1); color.assign(n 1, 0); // 读入边构建无向图邻接表 for (int i 0; i m; i) { int u, v; cin u v; adj[u].push_back(v); adj[v].push_back(u); // 无向边双向添加 } // 初始化计数器 cnt1 0; cnt2 0; // 从顶点1开始进行二分图染色判定 // 如果图是连通的一次DFS就能访问所有点 bool isBipartite dfs(1, 1); // 判定条件1: 必须是二分图 if (!isBipartite) { cout NO\n; return 0; } // 判定条件2: 必须是连通图 // 检查是否所有顶点都被访问过color不为0 for (int i 1; i n; i) { if (color[i] 0) { cout NO\n; return 0; } } // 判定条件3: 边数必须等于 |X| * |Y| // 注意乘法可能很大用long long if (1LL * cnt1 * cnt2 m) { cout YES\n; } else { cout NO\n; } return 0; }4.1 代码关键点解析输入输出加速ios::sync_with_stdio(false);和cin.tie(nullptr);是竞赛中处理大量输入输出的标准操作可以显著提升速度。注意使用了之后就不要混用C风格的scanf/printf和cin/cout了。邻接表初始化adj.resize(n1)和color.assign(n1, 0)是安全的初始化方式。vectorint(n1, 0)也可以。DFS的启动我们从顶点1开始染色并假设染颜色1。如果图是连通的那么这次调用会遍历所有顶点。连通性检查for循环检查color[1..n]是否全非零。这是判断连通性的简洁方法。也可以用一个visited数组但这里我们复用color数组即可。乘法溢出cnt1和cnt2是long long类型但m是int。计算cnt1 * cnt2时即使它们本身是long long但直接与int类型的m比较C会进行自动类型转换通常没问题。显式地写成1LL * cnt1 * cnt2是一个好习惯强调了这是长整型乘法防止潜在的溢出风险虽然本题n最大10^5时乘积最大约2.5e9在int范围内但养成好习惯很重要。5. 测试用例分析与调试心得理论正确不代表代码正确一定要用测试用例验证。我们可以设计几组数据用例1简单的完全二分图输入 4 4 1 3 1 4 2 3 2 4顶点集X{1,2}, Y{3,4}。边数2*24。图连通且是二分图。预期输出YES。用例2是二分图但不是完全二分图输入 4 3 1 3 1 4 2 3X{1,2}, Y{3,4}但边2-4缺失。边数3 4。预期输出NO。用例3不是二分图存在奇环输入 3 3 1 2 2 3 1 3这是一个三角形3个顶点的环是奇环不是二分图。预期输出NO。用例4不连通图输入 5 4 1 2 2 3 4 5 5 4? (更正应为 4 5但边重复我们换一个)更正为5 2 1 2 4 5有两个连通分量{1,2}和{4,5}虽然每个分量自身可以视为完全二分图比如{1},{2}和{4},{5}但整体不连通。预期输出NO。用例5单点或空图输入 1 0只有一个顶点没有边。它可以被看作一个集合大小为1另一个集合大小为0的完全二分图吗通常在图论定义中完全二分图K_{1,0}是允许的一个孤立点。我们的算法从1染色cnt11, cnt20。图连通只有一个点。边数m0乘积cnt1*cnt20。预期输出YES。算法能正确处理。在调试时我最初版本就忽略了连通性检查导致对多连通分量的图给出了错误判断。另一个容易忽略的点是当图只有一个顶点时DFS染色是成功的连通性检查也通过因为只有一个点自然被访问了乘积比较也正确这很好。6. 算法复杂度与优化探讨时间复杂度O(n m)。我们使用邻接表存储图DFS遍历了所有顶点和所有边各一次。检查连通性的循环是O(n)。所以总体是线性的对于n, m 10^5的数据规模完全没问题。空间复杂度O(n m)。邻接表存储了所有边color数组用了O(n)。有没有优化空间对于这道题上述解法已经是最优了。但我们可以讨论一些变体如果用BFS实现染色可以避免递归深度过深可能导致的栈溢出虽然对于10^5的数据递归DFS通常没问题除非是链状图深度达到10^5。BFS使用队列是迭代过程没有栈溢出风险。并查集也可以用来判定二分图吗可以但思路不同。一种叫做“扩展域”或“拆点”的并查集方法也能在近似线性的时间内判定二分图但代码不如染色法直观。染色法对于此题是更自然的选择。7. 总结与举一反三解决这道“VERTeX”题我们经历了一个完整的算法解题流程问题抽象完全二分图 - 模型分解二分图判定完全性检验 - 算法选择DFS染色法 - 细节剖析连通性 - 代码实现 - 测试验证。它巩固了几个核心知识点二分图的定义与判定染色法这是基础图论算法必须熟练掌握DFS和BFS两种写法。完全二分图的性质边数等于两分部顶点数的乘积并且图必须是连通的。图的存储与遍历邻接表的使用以及如何处理无向图。编程细节防止溢出、输入输出加速、递归函数设计。举一反三如果题目变一下问的是“最大完全二分子图”或者“至少需要添加多少条边才能使其变成完全二分图”那难度就上了一个台阶可能需要用到更复杂的图论算法或动态规划。但万变不离其宗对基本概念和基本算法的深刻理解是解决所有变体问题的基础。最后在信奥和CPC的刷题路上这种需要细心推导边界条件和隐藏性质如本题的连通性的题目非常多。我的经验是在写出初步解法后一定要在脑子里过几组极端和特殊的测试数据比如n1, m0n2, m1不连通图多环图等这往往能帮你发现思维漏洞写出鲁棒性更强的代码。这道“VERTeX”题就是一个很好的训练希望我的拆解对你有所帮助。

相关新闻