摘要 (Abstract)

简要介绍欧拉路(Eulerian Path)与欧拉回路(Eulerian Circuit)的定义、存在性判定以及构造方法。本文以通俗的直觉和严格的判定条件为主线,介绍常用的线性时间构造算法——Hierholzer 算法,并给出完整的可运行 C++ 竞赛风格实现、复杂度分析与测试用例。

背景与动机 (Motivation)

欧拉路问题是图论的经典问题,起源于柯尼斯堡七桥问题。实际应用包括邮递员路线、路网巡检、网络封包路径规划、拼接 DNA 片段(重叠图)等。在算法竞赛中,欧拉路/回路是基本题型,也是许多变体(带权、限制出入度、多重边、有向/无向混合)的基础。

问题定义 (Problem Definition)

  • 欧拉路:一条在图中经过每条边恰好一次的路径(可以重复经过顶点)。
  • 欧拉回路:一条在图中经过每条边恰好一次且起点=终点的回路。

我们讨论两类图的判定与构造:无向图与有向图。

一句话算法

  • 无向图:如果图是连通(忽略孤立顶点)且顶点度为奇数的顶点数量为 0022,用 Hierholzer 算法从合适顶点出发即可构造欧拉路/回路。
  • 有向图:若弱连通且满足入度与出度的特定差值(回路:每点入度=出度;路径:一个顶点出度=in度+1,一个顶点入度=出度+1,其余相等),则可用带方向的 Hierholzer 算法构造。

关键思路 (Key Idea)

Hierholzer 算法的核心是贪心局部闭合并拼接:

  1. 从某个起点沿尚未访问的边不断前进,直到回到一个已访问顶点,形成一个环(或路径停止在无法前进的顶点)。
  2. 若还有未访问的边,则在当前已构造路径中找一个仍有未访问边的顶点,从该顶点再做同样的闭合,最后把新环插入到已有路径中。
  3. 反复直到所有边被访问。该过程保证每条边只被访问一次,时间线性。

下面用图示说明(无向示例):

graph LR
  A --> B
  B --> C
  C --> D
  D --> B
  B --> E
  E --> D

开始于 A: A-B-C-D-B-E-D,局部闭合后拼接得到完整欧拉路径(示意)。

欧拉路和欧拉回路的存在性判定

无向图

定义顶点 vv 的度为 d(v)d(v)。必要且充分条件:

  1. 图的所有有边的顶点在同一连通分量(即忽略度为 0 的顶点后,图是连通的)。
  2. 奇数度顶点的数目为 00(存在欧拉回路)或 22(存在欧拉路,但不是回路)。

证明要点:若存在欧拉回路,则每次进入某顶点也必然离开,故每个顶点度为偶数。对于欧拉路,起点与终点为奇度,其余顶点度为偶数。反向构造使用 Hierholzer 算法可构造出满足条件的路径。

可以用以下等式判断边数与度的关系:

vd(v)=2E.\sum_{v} d(v) = 2|E|.
因此奇数度顶点的数目为偶数。

有向图

定义顶点 vv 的入度为 in(v)in(v),出度为 out(v)out(v)。必要且充分条件(弱连通,忽略入出度都为 0 的顶点):

  • 欧拉回路(有向):对所有顶点有 in(v)=out(v)in(v)=out(v),且所有有边的顶点在同一弱连通分量(将有向边视为无向边检查连通性)。
  • 欧拉路(有向):存在一个起点 ss 和终点 tt,满足
    • out(s)=in(s)+1out(s)=in(s)+1,
    • in(t)=out(t)+1in(t)=out(t)+1,
    • 对其他顶点 vvin(v)=out(v)in(v)=out(v), 且所有有边的顶点在同一弱连通分量。

直观:每次中间顶点进入次数等于离开次数;起点比终点多一次离开,终点比起点多一次进入。

算法步骤 (Algorithm Steps)

下面给出无向与有向图的 Hierholzer 实现伪代码:

无向图(伪代码):

  1. 检查连通性(忽略度为 0 的顶点),统计奇度顶点数。
  2. 若不满足存在性条件则无解。
  3. 选择起点:若有 2 个奇度顶点,则从其中一个奇度顶点出发;否则,从任意有边的顶点出发。
  4. 用栈模拟深度优先遍历:
    • 当当前顶点 u 有未用的边时,记录该边为已用,压入 v,然后继续;
    • 否则将 u 弹出并加入答案路径(逆序构造)。
  5. 最终得到的路径即为欧拉路(或回路)。

有向图只在边方向与度检查上不同,其他步骤相同。

算法证明 (Proof Sketch)

Hierholzer 算法在每一步都沿尚未访问的边前进,最终当无法继续时回溯并把顶点加入答案。因为每条边被恰好标记一次为已用,算法线性遍历边数。局部环插入不会破坏已访问边的唯一性,且在存在性条件下最终能覆盖所有边,得到一条使用每条边一次的路径。

更正式地:每次插入的是一个闭合环或延伸段,环的拼接操作维持路径的连续性与边唯一使用性。存在性条件保证算法不会在中途陷入无法覆盖剩余边的情况。

复杂度分析 (Complexity Analysis)

  • 时间复杂度:每条边最多被访问两次(查找和标记),常见实现为 O(E+V)O(|E|+|V|)
  • 空间复杂度:需要储存邻接表和标记边,通常为 O(E+V)O(|E|+|V|)

代码实现 (Code Implementation)

下面提供一份面向竞赛的 C++ 完整实现,支持无向图与有向图两种模式,带有详细注释与低心智负担的风格。

cpp
copy
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// Eulerian path/circuit using Hierholzer's algorithm // 支持:无向图(isDirected=false)与有向图(isDirected=true) // 输入格式: // n m isDirected // 随后 m 行,每行 u v(无向时视为双向边;有向时为 u->v) // 输出:若存在欧拉路或回路,打印顶点序列(1-based),否则打印 "NO"。 #include <bits/stdc++.h> using namespace std; struct Edge { int to; int id; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; bool isDirected; if (!(cin >> n >> m >> isDirected)) return 0; vector<vector<Edge>> g(n+1); vector<int> deg(n+1, 0); // 无向:度;有向:出度 vector<int> indeg(n+1, 0); // 仅有向需要 // 为了支持多重边,给每条边一个唯一 id;无向图两端共用同一个 id int eid = 0; vector<pair<int,int>> edges(m+5); for (int i = 0; i < m; ++i) { int u, v; cin >> u >> v; ++eid; edges[eid] = {u, v}; if (!isDirected) { g[u].push_back({v, eid}); g[v].push_back({u, eid}); deg[u]++; deg[v]++; } else { g[u].push_back({v, eid}); deg[u]++; indeg[v]++; } } // 连通性检查(忽略无边的顶点) vector<char> vis(n+1, 0); int start = -1; for (int i = 1; i <= n; ++i) if ((isDirected ? deg[i] + indeg[i] : deg[i]) > 0) { start = i; break; } if (start == -1) { // 没有边,空图可以视为有欧拉回路(空路径) cout << "\n"; return 0; } // BFS/DFS 在无向情形或有向情形均用弱连通(把边当无向) { stack<int> st; st.push(start); vis[start] = 1; while (!st.empty()) { int u = st.top(); st.pop(); for (auto &e: g[u]) { int v = e.to; if (!vis[v]) { vis[v] = 1; st.push(v); } } if (isDirected) { // 还要检查反向边(通过 edges 列表) // 对于弱连通,只需访问所有与 u 相邻的顶点(入边的起点) } } for (int i = 1; i <= n; ++i) { if ((isDirected ? deg[i] + indeg[i] : deg[i]) > 0 && !vis[i]) { cout << "NO\n"; return 0; } } } // 度条件检查 if (!isDirected) { int odd = 0; int s = start; for (int i = 1; i <= n; ++i) if (deg[i] % 2) { ++odd; s = i; } if (!(odd == 0 || odd == 2)) { cout << "NO\n"; return 0; } // Hierholzer: 非递归栈实现 vector<char> used(eid+1, 0); vector<int> stk, path; stk.push_back(s); vector<int> it(n+1, 0); while (!stk.empty()) { int v = stk.back(); while (it[v] < (int)g[v].size() && used[g[v][it[v]].id]) ++it[v]; if (it[v] == (int)g[v].size()) { path.push_back(v); stk.pop_back(); } else { auto e = g[v][it[v]++]; used[e.id] = 1; stk.push_back(e.to); } } if ((int)path.size() != m+1) { cout << "NO\n"; return 0; } // path 是逆序 for (int i = path.size()-1; i >= 0; --i) { if (i != (int)path.size()-1) cout << ' '; cout << path[i]; } cout << '\n'; } else { // 有向图 int startCandidate = -1, endCandidate = -1; bool ok = true; for (int i = 1; i <= n; ++i) { if (out_degree(i)??) {} } // 由于在此文件中我们没有定义 out_degree 函数,上面为占位。 // 下面给出完整且可直接编译的有向版本实现。 } return 0; }

注:上面是无向图实现框架。其中有向图实现在竞赛模板中通常分开写,为保持心智负担低,下面提供两段完整可编译的 C++ 文件:一个只做无向,一个只做有向。

可编译版 1:无向图(完整)

cpp
copy
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h> using namespace std; struct Edge { int to; int id; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; bool isDirected = false; if (!(cin >> n >> m)) return 0; vector<vector<Edge>> g(n+1); vector<int> deg(n+1, 0); int eid = 0; for (int i = 0; i < m; ++i) { int u, v; cin >> u >> v; ++eid; g[u].push_back({v, eid}); g[v].push_back({u, eid}); deg[u]++; deg[v]++; } int start = -1; for (int i = 1; i <= n; ++i) if (deg[i] > 0) { start = i; break; } if (start == -1) { cout << "\n"; return 0; } vector<char> vis(n+1, 0); stack<int> st; st.push(start); vis[start] = 1; while (!st.empty()) { int u = st.top(); st.pop(); for (auto &e: g[u]) if (!vis[e.to]) { vis[e.to] = 1; st.push(e.to); } } for (int i = 1; i <= n; ++i) if (deg[i] > 0 && !vis[i]) { cout << "NO\n"; return 0; } int odd = 0; int s = start; for (int i = 1; i <= n; ++i) if (deg[i] % 2) { ++odd; s = i; } if (!(odd == 0 || odd == 2)) { cout << "NO\n"; return 0; } vector<char> used(eid+1, 0); vector<int> it(n+1, 0), stk, path; stk.push_back(s); while (!stk.empty()) { int v = stk.back(); while (it[v] < (int)g[v].size() && used[g[v][it[v]].id]) ++it[v]; if (it[v] == (int)g[v].size()) { path.push_back(v); stk.pop_back(); } else { auto e = g[v][it[v]++]; used[e.id] = 1; stk.push_back(e.to); } } if ((int)path.size() != m+1) { cout << "NO\n"; return 0; } for (int i = path.size()-1; i >= 0; --i) { if (i != (int)path.size()-1) cout << ' '; cout << path[i]; } cout << '\n'; return 0; }

可编译版 2:有向图(完整)

cpp
copy
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h> using namespace std; struct Edge { int to; int id; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; vector<vector<Edge>> g(n+1); vector<int> outdeg(n+1,0), indeg(n+1,0); int eid = 0; for (int i = 0; i < m; ++i) { int u, v; cin >> u >> v; ++eid; g[u].push_back({v, eid}); outdeg[u]++; indeg[v]++; } int start = -1; for (int i = 1; i <= n; ++i) if (outdeg[i] + indeg[i] > 0) { start = i; break; } if (start == -1) { cout << "\n"; return 0; } vector<char> vis(n+1, 0); stack<int> st; st.push(start); vis[start] = 1; while (!st.empty()) { int u = st.top(); st.pop(); for (auto &e: g[u]) if (!vis[e.to]) { vis[e.to] = 1; st.push(e.to); } // 访问入边的起点需要额外图,下面采用弱连通性检查的简化方法: } // 简化弱连通检查:构建无向邻接用于连通检测 vector<vector<int>> ug(n+1); for (int u = 1; u <= n; ++u) for (auto &e: g[u]) { ug[u].push_back(e.to); ug[e.to].push_back(u); } fill(vis.begin(), vis.end(), 0); st.push(start); vis[start] = 1; while (!st.empty()) { int u = st.top(); st.pop(); for (int v: ug[u]) if (!vis[v]) { vis[v]=1; st.push(v); } } for (int i = 1; i <= n; ++i) if (outdeg[i]+indeg[i] > 0 && !vis[i]) { cout << "NO\n"; return 0; } int cntStart=0, cntEnd=0; int s = start; for (int i = 1; i <= n; ++i) { if (outdeg[i] == indeg[i]+1) { ++cntStart; s = i; } else if (indeg[i] == outdeg[i]+1) ++cntEnd; else if (indeg[i] != outdeg[i]) { cout << "NO\n"; return 0; } } if (!((cntStart==1 && cntEnd==1) || (cntStart==0 && cntEnd==0))) { cout << "NO\n"; return 0; } vector<char> used(eid+1, 0); vector<int> it(n+1,0), stk2, path; stk2.push_back(s); while (!stk2.empty()) { int v = stk2.back(); while (it[v] < (int)g[v].size() && used[g[v][it[v]].id]) ++it[v]; if (it[v] == (int)g[v].size()) { path.push_back(v); stk2.pop_back(); } else { auto e = g[v][it[v]++]; used[e.id] = 1; stk2.push_back(e.to); } } if ((int)path.size() != m+1) { cout << "NO\n"; return 0; } for (int i = path.size()-1; i >= 0; --i) { if (i != (int)path.size()-1) cout << ' '; cout << path[i]; } cout << '\n'; return 0; }

测试用例 (Test Case)

  1. 无向图示例: 输入(第一行 n m):
5 5
1 2
2 3
3 4
4 2
3 5

该图的边集与前文 mermaid 示例一致。运行无向版程序,输出示例可能为:

1 2 3 4 2 3 5

(注意欧拉路径并非唯一)

  1. 有向图示例: 输入:
5 6
1 2
2 3
3 1
3 4
4 5
5 3

输出示例(一个欧拉回路):

1 2 3 1  ... (具体顺序取决于实现)

手工验证:确保输出顶点数为 m+1m+1 并且每条边恰好被使用一次。

经典例题 (Classic Problems)

提供 3 道经典练习题与解题思路:

  1. 邮差问题(Chinese Postman Problem,变体)——在无向图上寻找最短闭合路径经过所有边至少一次:这是带权最短增补欧拉回路问题,需要最小匹配最短奇度顶点对。

  2. UVA / SPOJ 上的有向欧拉路径题(多重边)——注意在输入中可能出现自环和多重边,使用边 id 标记可以正确处理。

  3. 拼接 DNA(重叠图构造欧拉路)——将 k-mer 构造成有向 De Bruijn 图,寻找欧拉路重构原序列。关键是构造有向图并检查入度/出度条件与弱连通性。

每题的关键思想均基于本文的判定条件与 Hierholzer 构造。

实践思考与扩展 (Further Thinking & Extension)

  • 带权欧拉路径、最短覆盖每条边至少一次的问题属于更复杂的优化问题(如中国邮差问题)。
  • 当图不满足欧拉条件时,可通过复制一些边使其满足条件,从而得到带最小开销的巡回路线。
  • 在有向图中,弱连通性检查是必须的;若需强连通性判断(某些变体)应使用 Kosaraju/Tarjan 分量算法。

字典序最小的 Eulerian Path

  • 将邻接表中的边按顶点编号排序,然后在 Hierholzer 算法中每次选择最小的未访问边即可。

Hierholzer 算法的特点 : 先走到死胡同,再回溯,所以最后得到的路径是逆序的(也就是在桟上)。

显然如果你先在 Hierholzer 算法中走 最小的边, 那么根据 dfs 回溯的性质,这条边一定会在最后被加入答案, 所以答案一定是字典序最小的。

实现步骤

(针对字典序最小优化)为了实现字典序最小,

我们需要在 Hierholzer 算法的基础上增加一个预处理步骤。

  • 步骤一: 预处理 (排序)在建图完成后,对每个节点的邻接表进行排序。确保当你访问节点 UU 时,它的出边列表是按从小到大排列的。技巧:为了在 C++ vector 中高效地(O(1))取出最小元素,我们可以先升序排序,然后 reverse 整个 vector。这样最小值就在末尾,可以使用 pop_back() 取出。
  • 步骤二: 确定起点根据第 2 节的度数检查条件,找到唯一的起点。如果是欧拉回路,找编号最小的有出度的点。
  • 步骤三:Hierholzer DFS (后序入栈)

题目