图论建模与二分图判定:从CCPC赛题看DFS/BFS算法实战

📅 2026/7/25 5:58:41
图论建模与二分图判定:从CCPC赛题看DFS/BFS算法实战
1. 项目概述从一道赛题看图的算法实战最近在带学生备赛刷到一道挺有意思的题目——P10048 “[CCPC 2023 北京市赛] 图”。这题名起得直白就叫“图”但内容可一点都不简单它考察的是对图论基础概念的深刻理解以及将问题转化为图论模型的能力。很多刚接触信奥信息学奥林匹克的同学一看到“图”就觉得是最短路或者最小生成树但这道题恰恰跳出了这个定式思维它更像是一个“图论建模”的思维体操。题目本身描述可能不长但如何从一段文字描述中抽丝剥茧构建出正确的图模型才是真正的难点这也是省赛级别题目常见的风格。这道题适合已经掌握C基础语法、学过邻接矩阵和邻接表这两种基本存储方式并了解深度优先搜索DFS和广度优先搜索BFS的同学进行拔高训练。它不要求你掌握特别高深的算法但要求你对“图”这个结构本身有灵活的应用能力。接下来我就结合这道题和大家深入聊聊如何用C实现图的相关算法并拆解这类问题的通用解决思路。我们会从题目分析、模型构建、存储选择、算法设计到代码实现和调试完整地走一遍。2. 核心思路拆解问题本质与图论建模拿到题目第一步永远不是急着写代码而是彻底理解问题并尝试用自己熟悉的语言不一定是编程语言可以是自然语言或图形重新描述它。对于P10048我们首先需要解析其核心需求。通常这类赛题会给出一个关于若干元素及其之间关系的描述。例如元素可能是人、任务、城市关系可能是“认识”、“冲突”、“先后顺序”、“连通”等。题目的问题往往是是否存在某种满足条件的安排最多/最少能有多少个最小的代价是多少“图”在这里就是一个绝佳的抽象工具我们把元素抽象成顶点Vertex把元素之间的关系抽象成边Edge。2.1 识别顶点与边这是建模最关键的一步。以一道经典改编题为例“有N个人和M对敌对关系请问能否将所有人分成两组使得每组内部没有敌对关系” 这里顶点显然就是“N个人”。而“M对敌对关系”就是连接顶点的边。这样我们就把一个生活问题转化为了一个图论问题给定一个无向图能否将其顶点进行二染色比如分成红蓝两组使得任意一条边连接的两个顶点颜色不同这就是著名的二分图判定问题。P10048的具体描述需要你仔细阅读但方法论是通用的。你需要问自己题目中的“东西”是什么它们之间的“关系”是什么这个关系是双向的还是单向的回答清楚这几个问题图的模型就呼之欲出了。关系是双向的如朋友、冲突就是无向图关系是单向的如A赢了B、A必须排在B之前就是有向图。2.2 确定图的性质与算法目标模型建好接下来要确定图的性质和我们需要解决的问题。图的类型是无向图还是有向图是稀疏图边数远少于顶点数的平方还是稠密图算法目标题目是要求我们判断属性如是否为二分图、是否有环还是进行遍历如连通块计数或是寻找路径最短路亦或是进行计算如满足某条件的顶点对数对于二分图判定这类问题目标非常明确遍历整个图尝试进行染色如果过程中发现矛盾即一条边连接的两个顶点被染成了相同颜色则判定失败。这通常通过DFS或BFS遍历来实现。2.3 选择合适的数据结构存储图这是C实现中非常实际的一步选择直接影响代码的效率和编写的便捷性。主要有两种主流方式邻接矩阵用一个二维数组g[N][N]存储g[i][j] 1表示顶点i到j有一条边。对于无向图矩阵是对称的。优点直观检查两点间是否有边非常快O(1)。缺点空间复杂度高为 O(N²)。对于顶点数N很大比如10^5而边数M较少的稀疏图会造成巨大的内存浪费通常不可行。适用场景顶点数较少一般N ≤ 1000的稠密图。邻接表这是最常用、最通用的存储方式。为每个顶点维护一个链表在C中常用vectorint来模拟链表中存储与该顶点直接相连的所有邻居顶点。优点空间复杂度为 O(NM)与实际的顶点数和边数成正比非常适合稀疏图。遍历某个顶点的所有邻居也非常高效。缺点判断任意两点i和j之间是否有边需要遍历i的邻居链表最坏情况O(N)。适用场景绝大多数情况尤其是顶点数多、边数相对较少的竞赛题目。注意在信奥赛题中由于数据规模通常较大N和M可达10^5量级邻接表用vector实现是绝对的首选。P10048也极大概率需要使用邻接表。3. 算法设计与实现以二分图判定为例假设P10048经分析后核心是二分图判定问题。我们来详细走一遍用C实现DFS染色的全过程。这里会包含大量实际编码中的细节和技巧。3.1 数据结构定义与输入处理首先我们定义全局的数据结构。由于图的顶点通常从1开始编号为了方便我们的数组会开得比最大顶点数稍大一些。#include iostream #include vector using namespace std; const int MAXN 100010; // 根据题目数据范围设定通常比最大N大一些 vectorint graph[MAXN]; // 邻接表graph[i]存储顶点i的所有邻居 int color[MAXN]; // 染色数组0表示未染色1和2表示两种颜色 int n, m; // n个顶点m条边接下来是输入处理。这是非常模式化的部分但要注意无向图边的添加方式。bool input() { // 这里假设题目输入格式为第一行n, m接下来m行每行两个整数u, v表示一条边。 if (!(cin n m)) return false; // 处理多组数据输入结束的情况 // 初始化图非常重要特别是处理多组数据时必须清空。 for (int i 1; i n; i) { graph[i].clear(); } for (int i 0; i m; i) { int u, v; cin u v; // 无向图边需要添加两次 graph[u].push_back(v); graph[v].push_back(u); } return true; }实操心得graph[i].clear()这步在初始化或处理多组数据时至关重要。我曾因为忘记清空导致上一组数据残留的边混入下一组调试了半小时才找到这个隐蔽的Bug。对于color数组我们可以在每次DFS前用memset或循环归零但更常见的做法是在DFS函数内部判断是否访问过。3.2 DFS染色核心函数这是算法的核心。我们从一个未染色的顶点开始将其染成颜色1然后递归地遍历它的所有邻居。对于每个邻居如果未染色就将其染成与当前顶点相反的颜色1-2, 2-1并继续递归。如果已染色则检查其颜色是否与当前顶点相反。如果不是说明发现冲突整张图不是二分图。// 从顶点u开始进行DFS染色当前顶点u的颜色是c // 返回值从u开始的子图是否是二分图 bool dfs(int u, int c) { color[u] c; // 将顶点u染成颜色c int nextColor (c 1) ? 2 : 1; // 邻居应该染的相反颜色 // 遍历u的所有邻居顶点v for (int i 0; i graph[u].size(); i) { int v graph[u][i]; if (color[v] 0) { // 邻居v未染色 // 递归染色如果递归返回false则直接向上传递失败信号 if (!dfs(v, nextColor)) { return false; } } else if (color[v] c) { // 邻居v已染色且颜色与u相同冲突 return false; } // 另一种情况邻居v已染色且颜色与u相反是符合要求的继续检查其他邻居 } return true; // u的所有邻居都检查完毕没有冲突 }为什么参数是(int u, int c)u是当前要处理的顶点c是这个顶点“应该被染的颜色”。这个颜色是由它的父顶点决定的。在顶层调用时我们传入一个初始颜色通常是1。这种“传入状态”的递归设计非常清晰。3.3 主逻辑与遍历入口图可能不是连通图即由多个连通分量组成。因此我们需要检查每一个连通分量是否都是二分图。bool isBipartite() { // 初始化颜色数组0表示未访问/未染色 // 使用循环赋值比memset更安全避免字节操作可能的问题虽然这里用int没问题 for (int i 1; i n; i) { color[i] 0; } for (int i 1; i n; i) { if (color[i] 0) { // 找到一个未染色的顶点说明是一个新连通分量的起点 // 从这个点开始染色初始颜色设为1 if (!dfs(i, 1)) { // 如果这个连通分量染色失败整张图就不是二分图 return false; } } } // 所有连通分量都染色成功 return true; } int main() { while (input()) { // 处理多组数据 if (isBipartite()) { cout YES endl; // 是二分图满足题目分组要求 } else { cout NO endl; // 不是二分图无法满足要求 } } return 0; }关键点解析主函数中的for (int i 1; i n; i)循环确保了即使图不连通每个顶点也都会被检查到。color[i]0是发现新连通分量的标志。4. 代码实现的深度优化与细节探讨上面的代码已经可以解决基础问题。但在竞赛中我们需要考虑效率、鲁棒性和代码的简洁性。下面分享几个进阶技巧。4.1 邻接表遍历的优化写法使用C11的范围for循环range-based for loop可以让遍历邻居的代码更简洁、更不易出错。bool dfs(int u, int c) { color[u] c; int nextColor 3 - c; // 一个小技巧如果c是13-12如果c是23-21。 for (int v : graph[u]) { // 更清晰的遍历方式 if (color[v] 0) { if (!dfs(v, nextColor)) return false; } else if (color[v] c) { return false; } } return true; }3-c这个技巧比三元运算符? :在思维上更直接。范围for循环避免了手动管理下标i和调用graph[u].size()减少了出错概率。4.2 使用BFS实现染色除了DFSBFS广度优先搜索同样可以用于二分图判定。BFS使用队列是非递归的对于深度很大的图可以避免递归栈溢出的风险。#include queue bool bfs(int start) { queueint q; q.push(start); color[start] 1; // 起始点染颜色1 while (!q.empty()) { int u q.front(); q.pop(); int nextColor 3 - color[u]; for (int v : graph[u]) { if (color[v] 0) { color[v] nextColor; q.push(v); } else if (color[v] color[u]) { return false; } } } return true; } bool isBipartiteBFS() { for (int i 1; i n; i) color[i] 0; for (int i 1; i n; i) { if (color[i] 0) { if (!bfs(i)) return false; } } return true; }BFS的思路是“层层推进”。它和DFS在判断二分图这个问题上是等价的时间复杂度都是O(NM)。你可以根据个人习惯或题目特性选择。4.3 处理大规模输入的效率问题当顶点数N非常大时比如10^5即使使用邻接表一些细微的操作也可能成为瓶颈。cin/cout与scanf/printf在需要读入大量数据10^5量级以上时C语言的scanf和printf通常比cin/cout快。可以在代码开头加上ios::sync_with_stdio(false); cin.tie(0);来关闭C流与C标准流的同步从而大幅提升cin/cout的速度使其接近scanf。但注意一旦加了这句就不要混用cin/cout和scanf/printf。vector的reserve方法如果你能预估每个顶点大致的邻居数可以在建图前使用graph[i].reserve(estimated_size)来预留内存空间减少push_back时动态扩容的开销。不过这在竞赛中通常不是必须的vector的自动扩容机制已经足够高效。5. 常见问题排查与调试技巧实录即便思路清晰实际编码和调试中也会遇到各种问题。下面是我和学生们在刷这类图论题目时踩过的一些“坑”和解决方法。5.1 多组数据初始化不全这是最最常见的错误没有之一。症状第一组数据结果正确从第二组开始结果随机错误。原因只清空了graph邻接表但忘记了清空color等全局状态数组或者反过来。解决方案养成编写init()函数的习惯在每组数据开始处理时显式地重置所有用到的全局数据结构。void init() { for (int i 1; i n; i) { graph[i].clear(); color[i] 0; // 或其他标记数组 } // 其他需要重置的变量... }在input()函数中或读取n, m后立即调用init()。5.2 递归深度过大导致栈溢出症状程序在某个大数据点运行时异常终止如Segmentation Fault但小数据正常。原因图是一条长长的链例如100000个顶点连成一条线使用DFS递归会递归100000层超出系统默认的栈空间限制。解决方案改用BFSBFS使用显式的队列不存在递归栈溢出问题。手动设置栈空间竞赛环境不一定允许有些OJ支持编译指令。写非递归DFS使用栈模拟递归过程但代码较复杂。对于二分图判定直接换用BFS是最简单有效的。5.3 顶点编号从0开始还是从1开始问题题目有时顶点编号从0开始有时从1开始。我们的数组通常从下标0开始。解决统一转换到从1开始处理是最稳妥的。在读入边(u, v)后执行u; v;这样所有顶点在数组中的下标范围就是[1, n]与我们的循环for(int i1; in; i)完美匹配。这能避免大量的边界条件判断错误。5.4 自环和重边的处理自环一条边连接同一个顶点。在二分图判定中如果存在自环那么这个顶点必须同时和自己颜色不同这是不可能的所以只要存在自环就一定不是二分图。需要在建图时或遍历前进行特判。重边两条相同的边。对于邻接表存储重边不影响DFS/BFS染色的正确性因为算法逻辑是检查“颜色是否相同”重边只会重复检查一次结果不变。但重边会占用额外空间。如果题目明确说“无重边”我们可以使用set或建图时检查来去重但通常用vector直接存即可除非空间特别紧张。针对自环的特判增强代码bool input() { // ... 读取n, m init(); bool hasSelfLoop false; for (int i 0; i m; i) { int u, v; cin u v; if (u v) { hasSelfLoop true; // 记录存在自环 // 即使有自环边也需要正常添加吗对于染色算法添加自环会导致dfs中自己发现自己颜色相同直接返回false。 // 但为了逻辑清晰我们可以选择不添加因为我们已经知道结果了。 // 这里我们选择仍然添加让dfs过程去发现矛盾。 } graph[u].push_back(v); graph[v].push_back(u); // 如果是无向图 } // 如果题目要求输出具体分组自环需要提前判断。 // 如果只是判断YES/NO可以交给dfs处理。 return true; } // 在isBipartite函数中可以先判断hasSelfLoop快速返回false。5.5 调试技巧小数据画图模拟当程序结果不对时最有效的方法不是盯着代码看而是构造一个小的测试用例比如n4, m3。在纸上画出对应的图。用笔和纸模拟你的算法DFS或BFS一步步写出color数组的变化。再让程序跑这个测试用例在关键位置如dfs函数入口、发现冲突时打印日志对比你的手动模拟和程序实际执行过程。使用IDE如VSCode或编辑器的调试功能单步跟踪观察变量值。例如在dfs函数里加一句调试输出bool dfs(int u, int c) { cout dfs: u u , set color c endl; // 调试输出 color[u] c; // ... 其余代码 }这能帮你清晰地看到递归的路径和染色顺序快速定位逻辑错误。6. 从P10048延伸图论问题的通用解题框架通过这道题我们可以总结出一个解决图论建模问题的通用框架这个框架能应用到很多题目上。第一步问题抽象与建模仔细读题明确“顶点”是什么“边”代表什么关系。判断是有向图还是无向图。思考图可能具有的性质是否需要考虑权重边是否有特殊属性。第二步数据结构与算法选型存储根据顶点数N和边数M决定使用邻接矩阵N小还是邻接表N大通用。算法根据问题目标选择。判断连通性、染色、简单遍历 -DFS/BFS单源最短路径边权非负-Dijkstra单源最短路径有负权边-Bellman-Ford / SPFA所有点对最短路径 -Floyd最小生成树 -Kruskal / Prim拓扑排序 -基于BFS的Kahn算法 / DFS强连通分量 -Kosaraju / Tarjan第三步核心函数实现将选定的算法如DFS染色、BFS层次遍历封装成清晰的函数。注意递归边界条件、访问标记的设置与检查。第四步主逻辑整合处理图可能不连通的情况循环检查所有未访问顶点。整合输入输出处理多组数据记得初始化。第五步测试与调试用样例测试。构造边界用例测试n0, n1, m0 极大图链状图星型图。使用打印日志或调试器排查问题。回到P10048 “[CCPC 2023 北京市赛] 图”这道题它很可能就是这样一个流程的完美演练。题目描述会引导你建立某个模型可能是二分图也可能是检查奇环、甚至是更复杂的约束满足问题然后你需要运用上述的框架去解决它。真正的难点在于第一步——如何从题目文字中精准地抽象出图模型。这需要大量的练习和积累。刷题时不要满足于ACAccept。AC之后去看看别人的题解学习不同的建模视角和更优美的代码实现。尝试用BFS再写一遍或者思考如果数据范围变大N10^6该如何优化。把这些思考和尝试记录下来才是刷题提升的关键。图论是信奥和算法竞赛的基石之一把这部分基础打扎实后面遇到更复杂的网络流、树上问题、图论动态规划时你才会更有底气。