从一笔画问题入门图论:欧拉路径判定与Hierholzer算法详解
1. 项目概述从一笔画问题看信奥赛的图论入门最近在辅导南沙这边准备信息学奥赛信奥赛的学生时我发现很多孩子在学到图论这一块就有点卡壳了。图论听起来高大上但入门其实有非常经典的“敲门砖”比如《信息学奥赛一本通》里的这道1341【例题】一笔画问题。这不仅是书上的一道例题更是理解图论中“欧拉路径/回路”核心思想的绝佳起点。很多学生初次接触时只会死记“存在欧拉回路的充要条件是所有顶点度数为偶数”但为什么怎么用代码去找出这条路径遇到不连通图怎么办这些才是比赛和实际解题中的关键。在我看来这道题的价值在于它用一个非常生活化的问题能不能一笔画出一个图形引出了图论中“度”、“连通性”、“DFS遍历顺序”等多个核心概念。对于正在学习C和算法竞赛的同学们来说吃透这道题就相当于拿到了打开图论世界大门的钥匙。它考察的不仅仅是背诵结论更是将数学定理转化为可靠代码的工程能力。接下来我就结合多年的教学和解题经验把这道题的“里里外外”拆解清楚从思路分析、代码实现到调试技巧给你讲个明明白白。2. 问题本质与数学模型构建2.1 什么是一笔画问题我们小时候可能在趣味数学里玩过给定一个图形要求笔不离开纸、每条边只画一次能否将这个图形一笔画出这就是著名的一笔画问题也叫欧拉路径问题。在信息学的语境下我们需要将这个问题进行数学抽象。图形被抽象为图Graph图形的交点抽象为顶点Vertex图形中的线条抽象为边Edge。问题就转化为在一个给定的无向图中是否存在一条路径经过每条边恰好一次如果这条路径的起点和终点是同一个点那么它被称为欧拉回路如果起点和终点不同则被称为欧拉路径。2.2 核心定理欧拉路径的存在性判定这是解题的理论基石必须理解透彻不能只记结论。欧拉回路存在的充要条件一个无向图存在欧拉回路当且仅当该图是连通图并且图中所有顶点的度数Degree均为偶数。为什么可以直观想象如果你从一个点出发要最终回到这个点那么每次“进入”一个点必然需要一次“离开”。对于路径中间的任何一个点进入和离开总是成对出现的因此经过该点的边数即度数必定是偶数。起点和终点是同一个点它同样满足“进入”和“离开”成对。欧拉路径存在的充要条件一个无向图存在欧拉路径但不是回路当且仅当该图是连通图并且图中恰好有两个顶点的度数为奇数其余顶点度数均为偶数。这两个奇度顶点分别是路径的起点和终点。为什么对于起点你只“离开”不“进入”或第一次是离开所以度数为奇对于终点你只“进入”不“离开”所以度数也为奇。路径中间的点进入和离开依然成对度数为偶。注意“连通图”这个前提至关重要很多学生写完代码发现答案错误就是因为忽略了图可能不连通的情况。一个图即使所有点度数都是偶数但如果它被分成几个互不连接的部分那也绝对不可能一笔画。2.3 问题输入输出的具体分析根据《一本通》的题目描述我们需要处理以下逻辑输入图的顶点数N边数M以及接下来的M行每行两个整数表示一条边连接的两个顶点。处理统计每个顶点的度数。检查图的连通性通常使用DFS或BFS。根据度数统计结果和连通性判断判断属于哪一种情况欧拉回路、欧拉路径、或都不是。输出如果存在欧拉回路则输出回路通常以顶点编号序列表示如果存在欧拉路径则输出路径如果都不存在则输出相应提示。在例题中输出要求可能更侧重于判定“能否一笔画”但作为完整的算法学习我们必须掌握求解具体路径的方法。3. 算法设计与核心代码解析3.1 整体算法框架解决这个问题的标准算法是Fleury算法或Hierholzer算法。Fleury算法思路直接但效率较低在竞赛中更常用的是Hierholzer算法它可以在O(M)的时间复杂度内求出欧拉路径。这里我们详细讲解 Hierholzer 算法。算法步骤如下数据存储使用邻接表存储图为了方便在遍历中删除或标记已访问的边通常使用vectormultisetint或vectorstackint但更常用的是用vectorvectorint配合一个记录边索引访问状态的数组或者直接使用map或unordered_map来记录两点间剩余的可走边数。对于稀疏图使用邻接表比邻接矩阵更省空间。预处理判断计算每个顶点的度数。使用DFS/BFS判断图的连通性仅需判断有边的顶点所在的连通块是否包含了所有有边的顶点。统计度数为奇数的顶点个数oddCnt。根据定理判断如果oddCnt 0存在欧拉回路可从任意点开始通常从1号点或第一个有边的点开始。如果oddCnt 2存在欧拉路径必须从其中一个奇度点开始。否则无法一笔画。Hierholzer算法核心DFS从确定的起点start开始进行深度优先搜索DFS。在DFS函数中对于当前节点u尝试走一条尚未访问的边(u, v)并将这条边标记为已访问或从邻接表中删除然后递归地深入节点v。当节点u的所有边都被访问完后将u压入一个答案栈或路径数组。递归返回后最终将栈中的元素依次弹出得到的序列就是一条逆序的欧拉路径。将其反转或直接逆序输出即为正确的顺序。这个“后序压栈”的操作是算法的精髓。它保证了当一条“岔路”走到底之后能正确地回溯并拼接回主路径。3.2 关键代码实现与逐行解读以下是用C实现的代码框架包含了详细的注释#include iostream #include vector #include algorithm #include stack #include cstring // for memset using namespace std; const int MAXN 1005; // 根据题目数据范围设定 const int MAXM 100005; vectorint g[MAXN]; // 邻接表存图 vectorint path; // 存储最终的欧拉路径 int degree[MAXN]; // 存储每个顶点的度数 bool visited[MAXN]; // 用于BFS/DFS检查连通性 int edgeUsed[MAXN][MAXN]; // 记录两点之间的边被使用过的次数适用于无向图 // 检查连通性BFS或DFS均可 void dfs_connect(int u) { visited[u] true; for (int v : g[u]) { if (!visited[v]) { dfs_connect(v); } } } // Hierholzer算法的DFS过程 void hierholzer(int u) { // 遍历当前节点u的所有邻接点 for (int i 0; i g[u].size(); i) { int v g[u][i]; // 如果边(u, v)还未被使用 if (edgeUsed[u][v] 0) { // 无向图两条有向边都要标记 edgeUsed[u][v]--; edgeUsed[v][u]--; // 递归深入 hierholzer(v); } } // 当前节点的所有边都已访问将其加入路径 // 注意这里是后序加入所以最终路径是逆序的 path.push_back(u); } int main() { int n, m; cin n m; // 初始化 memset(degree, 0, sizeof(degree)); memset(edgeUsed, 0, sizeof(edgeUsed)); for (int i 1; i n; i) g[i].clear(); // 读入图构建邻接表并统计度数 for (int i 0; i m; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); // 无向图 degree[u]; degree[v]; edgeUsed[u][v]; // 记录边数支持重边 edgeUsed[v][u]; } // --- 步骤1检查连通性 --- memset(visited, false, sizeof(visited)); int start 1; // 找到一个有边的点作为连通性检查的起点 while (start n g[start].empty()) start; if (start n) { dfs_connect(start); } // 判断所有有边的点是否都被访问过 bool isConnected true; for (int i 1; i n; i) { if (!g[i].empty() !visited[i]) { isConnected false; break; } } if (!isConnected) { cout No Euler Path or Circuit exists. (Graph is disconnected) endl; return 0; } // --- 步骤2统计奇度顶点个数确定起点 --- int oddCnt 0; vectorint oddVertices; for (int i 1; i n; i) { if (degree[i] % 2 1) { oddCnt; oddVertices.push_back(i); } } int startNode 1; if (oddCnt 0) { // 欧拉回路从任意点开始这里选择第一个有边的点 while (startNode n g[startNode].empty()) startNode; if (startNode n) startNode 1; // 图没有边的情况 cout Euler Circuit Exists. Starting from node startNode endl; } else if (oddCnt 2) { // 欧拉路径必须从奇度点开始。通常选择编号较小的一个作为起点。 startNode min(oddVertices[0], oddVertices[1]); cout Euler Path Exists. Starting from odd-degree node startNode endl; } else { cout No Euler Path or Circuit exists. (Number of odd-degree vertices is oddCnt ) endl; return 0; } // --- 步骤3使用Hierholzer算法寻找路径 --- path.clear(); hierholzer(startNode); // 由于是后序压栈path中存储的是逆序路径需要反转 reverse(path.begin(), path.end()); // --- 步骤4输出路径 --- cout The Euler Path/Circuit is: ; for (int i 0; i path.size(); i) { if (i 0) cout ; cout path[i]; } cout endl; return 0; }代码关键点解读edgeUsed数组这是一个关键技巧。因为我们需要在遍历中标记边是否已访问对于无向图边(u,v)和(v,u)是同一条。我们用edgeUsed[u][v]记录剩余可用的边数。每次走过一条边就将对应两个方向的计数减1。这比从邻接表中物理删除元素更简单且能正确处理重边。hierholzer函数中的递归递归调用hierholzer(v)发生在标记边之后、当前节点入栈之前。这确保了深度优先探索到底再回溯记录节点完美符合“一笔画”的走法。路径反转因为节点是在所有边访问完后才加入path的所以最终path是终点到起点的顺序。输出前需要reverse。3.3 数据结构选择的权衡邻接表 vs 邻接矩阵顶点数N较大时比如超过1000必须使用邻接表vectorint g[MAXN]否则邻接矩阵的O(N^2)空间开销无法承受。边标记方法除了上述的edgeUsed二维数组如果图非常稀疏且顶点编号很大可以使用mappairint,int, int来记录边。另一种常见方法是使用邻接表存储边的索引并用一个vis[]数组标记每条边是否访问过。edgeUsed数组的方法在顶点数不超过1000时实现最简单。路径存储使用vectorint配合reverse或者直接用stackint然后依次弹出输出都是可以的。vector更便于后续操作如果需要。4. 调试技巧与常见问题实录在实际编码和教学过程中学生们会遇到各种各样的问题。下面我总结几个最常见的“坑”和解决方法。4.1 常见错误与排查清单问题现象可能原因排查与解决方法输出结果漏点或顺序明显不对1.连通性判断错误图本身不连通但代码默认连通。2.起点选择错误存在欧拉路径时没有从奇度点开始。3.边标记逻辑错误edgeUsed数组更新有误导致边被重复使用或提前认为无边可走。1. 在判断部分后先输出isConnected和oddCnt的值验证预处理是否正确。2. 确保在oddCnt2时startNode确实是其中一个奇度点。3. 在hierholzer函数中关键位置打印调试信息如cout “从 “ u ” 走到 “ v endl;观察遍历顺序。程序递归深度过大导致栈溢出图的边数M很大例如超过10^5递归的hierholzer函数可能导致系统调用栈溢出。1.改用迭代栈模拟递归。这是解决此类问题的标准优化。用一个栈stackint来手动模拟递归过程可以避免递归深度限制。2. 检查编译器栈空间设置竞赛环境通常默认较小。运行超时 (TLE)1.算法复杂度退化在hierholzer函数中每次都用for循环从头扫描邻接表如果边已删除/标记会进行大量无效扫描。2.使用了低效的数据结构如用邻接矩阵存大图。1.使用“当前弧优化”为每个节点维护一个迭代器如vectorint::iterator指向下一个待检查的边避免重复扫描已访问的边。这是竞赛代码中非常常见的优化。2. 确认使用邻接表。存在重边时结果错误edgeUsed数组只被当作布尔标记使用当有重边时一条边被标记后另一条相同的边也无法使用。将edgeUsed数组定义为int类型初始值为两点间的边数。每次走过一条边就减1判断条件为edgeUsed[u][v] 0。这样就能正确处理重边。输出路径的顶点数不是 M1理论上遍历M条边会经过M1个顶点。如果输出顶点数不对说明有的边没走到或者顶点被重复输出。检查edgeUsed数组的更新逻辑确保每条边都被严格标记一次。检查递归结束条件确保每个节点的所有边都被尝试。4.2 迭代版Hierholzer算法实现为了避免递归栈溢出并提升一些性能我们可以实现迭代版本的算法。这需要手动用一个栈来模拟递归调用栈的过程。void hierholzer_iterative(int start) { stackint stk; vectorint circuit; stk.push(start); int cur start; while (!stk.empty()) { if (!g[cur].empty()) { // 如果当前节点还有未走的边 stk.push(cur); int next g[cur].back(); // 从邻接表末尾取一条边方便删除 g[cur].pop_back(); // 删除边 (cur, next) // 在无向图中也需要删除反向边 (next, cur) // 这里需要在自己的邻接表中找到并删除对应边 for (auto it g[next].begin(); it ! g[next].end(); it) { if (*it cur) { g[next].erase(it); break; } } cur next; } else { // 当前节点无边可走加入路径 circuit.push_back(cur); cur stk.top(); stk.pop(); } } // 此时circuit存储的是欧拉路径顺序是起点-...-终点注意分析 // 实际上上述算法得到的circuit是逆序的需要reverse reverse(circuit.begin(), circuit.end()); // 将circuit赋值给全局的path path circuit; }注意迭代版本中直接修改了邻接表g删除边所以这份代码不能和之前判断连通性的DFS共用同一个g除非事先拷贝一份。这是工程实现上的一个细节在竞赛中为了节省时间我们有时会牺牲一些代码的清晰度来换取速度。4.3 关于“当前弧优化”的补充在递归版的hierholzer函数中我们使用for (int i 0; i g[u].size(); i)来遍历。即使边(u, g[u][i])已经被标记使用下一次调用hierholzer(u)时i又会从0开始重新扫描那些已经无效的边。当图规模很大时这是低效的。优化方法是使用引用或指针来记录当前检查到了哪条边vectorint g[MAXN]; vectorint::iterator it[MAXN]; // 每个节点的当前弧迭代器 void dfs_hierholzer(int u) { for (auto i it[u]; i ! g[u].end(); ) { int v *i; // ... 判断边是否可用 ... if (edgeUsed[u][v] 0) { edgeUsed[u][v]--; edgeUsed[v][u]--; dfs_hierholzer(v); // 注意递归返回后i可能已经失效因为vector可能扩容 // 更安全的方法是使用索引而非迭代器或者在递归前保存值 } else { i; // 只有边不可用时才移动迭代器 } } path.push_back(u); } // 在主函数中初始化 for(int i1; in; i) it[i] g[i].begin();实现当前弧优化需要小心处理迭代器失效问题在竞赛中如果时间不紧张朴素的循环方法通常也足够通过。但这绝对是一个值得掌握的高级技巧尤其是在解决网络流等更复杂的问题时。5. 从例题到竞赛拓展与变式吃透这道例题不仅仅是AC一道题。在信奥赛和更广泛的算法学习中它关联着多个重要的知识点和变式问题。5.1 相关变式问题有向图的欧拉路径判定条件略有不同。存在有向欧拉回路的充要条件是所有顶点入度等于出度且基图忽略方向后的图弱连通。存在有向欧拉路径的充要条件是有一个顶点出度比入度大1起点有一个顶点入度比出度大1终点其余顶点入度等于出度且基图弱连通。算法核心Hierholzer完全适用只需调整度数的统计和起点的选择。输出字典序最小的路径这是常见的附加要求。为了满足字典序最小我们需要在存储图时就将每个节点的邻接表按顶点编号排序。这样在hierholzer函数中总是优先走编号小的顶点最终得到的路径字典序就是最小的。这体现了“贪心”思想在算法中的应用。混合图既有有向边又有无向边的欧拉回路这是一个更难的问题通常需要用到网络流建模来为无向边定向使其满足有向图欧拉回路的度数条件。这已经超出了NOIP/省赛的范畴但在更高级别的竞赛中可能出现。5.2 在图论学习中的承上启下作用承上它巩固了图的存储邻接表、遍历DFS/BFS、顶点度数等基础概念。启下它是学习图论通路问题的起点。欧拉路径关注“边”的遍历而与之相对的哈密顿路径则关注“点”的遍历这是一个NP难问题。同时理解DFS在寻找路径中的应用为后续学习拓扑排序、强连通分量Kosaraju/Tarjan等算法打下了坚实的基础。Hierholzer算法中“后序压栈”的思想也与Tarjan算法求SCC时有异曲同工之妙。5.3 对初学者的核心建议理解优先于背诵一定要自己画几个简单的图比如三角形、五角星、“日”字格手动模拟算法的执行过程尤其是path栈是如何形成的。理解“为什么后序加入就是逆序路径”比记住代码更重要。从暴力到优化先实现一个正确但可能低效的版本比如用邻接矩阵、不优化递归。通过这个版本理解整个流程并通过小数据测试。然后再逐步引入邻接表、迭代栈、当前弧优化等高级技巧。重视调试利用IDE的调试功能或者使用最朴素的cout打印关键变量oddCnt,isConnected, 递归时的u,v。调试是解决算法问题不可或缺的能力。触类旁通做完这道题可以去尝试《一本通》或在线评测系统如洛谷、AcWing上的其他欧拉路径问题比如要求输出具体方案、字典序最小等巩固所学。一笔画问题就像一把精巧的钥匙它本身不复杂但却能打开图论中一扇重要的大门。解决它的过程几乎涵盖了基础图论算法从建模、定理应用、算法实现到调试优化的全流程。希望这篇长文不仅能帮你通过这道例题更能让你体会到算法学习的乐趣和其中严谨的思维之美。在信奥赛的路上把每一个这样的基础问题砸实后面的路才会越走越宽。