一、边的执念 vs 点的执念:重新认识“一笔画”
在图论学习的前半段,我们绝大多数时候都在和“节点”打交道:
- 算最短路(Dijkstra/SPFA),我们关心从源点到各个节点的最少花费;
- 算连通块(DFS/BFS),我们给每个节点打上
vis[u] = true,防止同一个点被重复踩踏; - 找哈密顿路径(Hamiltonian Path),要求每个节点恰好访问一次(这是个经典的 NP-Hard 难题)。
但在“欧拉路径”(Eulerian Path)的世界里,规则被彻底颠覆了:
核心铁律:图里的每一条边都必须走过,且每条边恰好只能走一次;而节点则完全不受约束,可以被反复经过成百上千次!
直观例子:
考虑一个由三条边组成的三角形 1—2、2—3、3—1。
我们从节点 1 出发,沿着边走 1 → 2 → 3 → 1:
- 节点 1 被访问了两次;
- 但三条边各走了一次,不多也不少。 这就是一条完美的欧拉回路(起终点重合的闭合欧拉路径)。
如果我们在节点 1 外面再接上一条“尾巴” 1—4,那么路线可以安排为 4 → 1 → 2 → 3 → 1。
此时所有边依然被恰好走了一次,但起点是 4、终点是 1。这是一条不闭合的开放欧拉路径。
坐标与长度的物理常识:
一条包含
二、无向图的存在性:进出守恒与连通陷阱
要想一笔画完所有的边,首先得回答一个根本问题:一张图到底在什么条件下才“可能”存在欧拉路径?
1. 物理视角的出入平衡(度数条件)
想象我们是路线中的一个过客,正在途经某个普通的中间节点
- 我们必须顺着某条边进入
; - 随后必须顺着另一条没走过的边离开
。
每一次途经,都会在节点
| 场景 | 奇度点的物理意义 | 结论 |
|---|---|---|
| 欧拉回路(闭合) | 起点也是终点,起飞消耗 1 度,最后降落补齐 1 度。全图所有点的进出完全对称。 | 奇度点的数量必须恰好为 0。 |
| 欧拉路径(开路径) | 起点多了一次“只出不进”(少配对 1 次),终点多了一次“只进不出”(多配对 1 次)。 | 奇度点的数量必须恰好为 2。路线必须从其中一个奇度点出发,在另一个奇度点结束。 |
如果一张无向图里出现了 4 个或更多奇度点,别挣扎了,神仙也无法用一笔画完。
2. 致命盲区:度数对了,就一定能走完吗?
反例警示: 画两个互不相连的三角形。 左边三角形的点度数全为 2,右边三角形的点度数也全为 2。 全图奇度点数量为 0,完全符合度数条件!但你能一笔画完它们吗?根本不可能,因为你走完了左边的三角形,根本跨不到右边去!
判定铁律:除了奇度点数量为 0 或 2 外,所有度数非零的节点必须处在同一个连通块内!
为什么只要求“度数非零的节点”连通? 因为孤立点上面根本没有需要被走过的边。若节点 1 孤立,而 2、3、4 构成三角形,我们从 2 出发走完三条边就已经圆满完成了任务,完全没有必要去“强行拜访”孤立点 1。
三、有向图的平衡:流水必须对账
在有向图中,边带上了箭头,方向不能逆转。
1. 出度与入度的精准对账
- 中间节点:每流进一次(
in++),就必须流出一次(out++),因此in[u] == out[u]。 - 欧拉回路:所有节点的出度必须完全等于入度(
out[u] == in[u])。 - 开放欧拉路径:
- 唯一的起点:只出不进一次,满足
out[u] - in[u] == 1; - 唯一的终点:只进不出一次,满足
in[u] - out[u] == 1; - 其余所有节点:收支平衡,
out[u] == in[u]。
- 唯一的起点:只出不进一次,满足
2. 弱连通性:为什么不要求强连通?
很多同学在有向图判断连通性时会下意识地求“强连通分量”,这是完全错误的!
考虑一条最简单的有向链:1 → 2 → 3 → 4。
它显然存在欧拉路径,但终点 4 根本无法回到起点 1,原图显然不是强连通的。
因此,有向图的连通性检查只需做到弱连通(Weakly Connected)即可:
把所有的有向边暂时看作无向边,所有度数非零(in[u] + out[u] > 0)的节点必须处在同一个无向连通块中。
只要出入度配平且弱连通,图中的所有有向环和路径就能自然拼合成一条大路径。
四、重边与自环:给每条边发一张“身份证”
在图论竞赛中,重边和自环是让粗心选手爆零的最隐蔽杀手。
1. 为什么不能用两维数组 vis[u][v]?
如果节点 1 和 2 之间有两根并行的导线(重边),如果你用 vis[u][v] = true 记录访问,当你走过第一条边时,第二条边直接被无辜地“株连封杀”了!
两节点之间端点虽然相同,但它们在物理上是两条完全独立不同的边。
2. 自环对度数的物理贡献
如果节点 1 上挂着一个自环(自己连向自己):
- 它是一条边,走过它只需要消耗 1 步;
- 但在无向图中,它的一端连接节点 1,另一端也连接节点 1,因此它给节点 1 的度数贡献整整是 +2!
边编号机制(Edge ID):
我们必须给读入的每一条边赋予一个唯一的整数编号 id(从 id。只要其中一个方向被使用了,就立即打上标记 used[id] = true。
例如:节点 1 和 2 之间有两条重边(编号 0 和 1),节点 1 上有一个自环(编号 2)。
节点 1 的度数为

五、构造灵魂:Hierholzer 算法与“死胡同哲学”
知道了什么时候有解,那具体怎么把这条路径输出出来?
1. 贪心走法的致命死穴:提前走进死胡同
假设我们手里有一张图:由一个大三角形 1—2—3—1 外挂一条死胡同尾巴 1—4 组成。
奇度点是 4 和 1,假设我们从 1 出发。
如果我们盲目贪心往前走:一出门不小心走进了 1 → 4。
到了 4 号点,四面碰壁无路可走!而大三角形 1—2—3—1 却完全被抛弃在身后,成了烂尾工程。
如果每次走错都去回溯撤销,算法复杂度会指数级暴涨。怎么破?
2. Hierholzer 算法的逆向哲学:撞了南墙再记录
Hierholzer 算法的核心魔法在于:不要在第一次踏入节点时记录它,而是在一个节点“无路可走(所有出边都已消耗殆尽)”时,才将它压入答案序列!
我们重新推演刚才的例子:
- 从 1 出发,走入 4;
- 此时在 4 号点,发现 4 的所有边都用光了(撞了南墙,无路可走)。
此时把 4 记录到答案末尾:
ans = [4]; - 程序退回 1 号点,发现 1 号点竟然还有未走过的边(大三角形的边)!
- 继续探索三角形,走过
1 → 2 → 3 → 1; - 回溯时,节点依次无路可走,被依次加入答案:
ans = [4, 1, 3, 2, 1]; - 遍历结束,将
ans整体翻转(reverse),得到:[1, 2, 3, 1, 4]!
看到了吗?那个最早被我们不小心走错、提前撞墙的死胡同节点 4,在逆序记录并翻转之后,被极其精妙地推到了整条路线的绝对终点! 这就是 Hierholzer 算法的“死胡同拼接法”:各个局部的回路,无论何时被探索,都会在退栈时严丝合缝地拼接在一起。
3. 灵魂指针:当前弧优化 cur
在遍历邻接表时,很多新手会写出这样的代码:
for(auto e : g[u]) {
if(!used[e.second]) { ... }
}
严重警告:这是导致超时的头号元凶!
如果一个节点度数很大,你每次退回它时,都从下标 0 开始重新扫描跳过已经访问过的边,在完全图或稠密图上,总扫描次数会瞬间退化到
解法:当前弧优化指针 cur[u]
为每个节点维护一个指针 cur[u],记录当前节点 cur[u]++ 单调向前推,永不回头!
这保证了全图的每条邻接表边最多被检查常数次,将时间牢牢锁定在
4. 显式栈防御:彻底告别深链爆栈
当图是一条拥有 Runtime Error (Stack Overflow) 爆栈!
因此,在严肃的竞赛中,我们必须用手写 vector 显式栈模拟遍历。
六、完整程序一:无向图欧拉路径
1. 题目契约与输入输出规范
- 输入格式:第一行两个整数
( , )。接下来 行,每行两个整数 ,表示一条无向边。允许重边、自环与孤立点。 - 输出格式:若无解输出
No;若有解,输出一行由空格隔开的个整数,表示节点行走序列。若 ,约定输出单个节点 1。不强制要求字典序。 - 复杂度:时间复杂度
,空间复杂度 。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=200005, M=500005;
// g[u] 存储 pair: {邻接点 v, 边编号 id}
vector<pair<int,int>> g[N];
int cur[N];
bool used[M], seen[N];
vector<int> st, ans, odd;
int n, m;
void solve(){
cin>>n>>m;
for(int id=0;id<m;id++){
int u,v;
cin>>u>>v;
g[u].push_back({v,id});
g[v].push_back({u,id});
}
// 1. 统计度数与确定潜在起点
int s=1;
for(int u=1;u<=n;u++){
if(!g[u].empty()) s=u; // 找到任意一个有边的点作为默认连通起点
if(g[u].size()%2!=0) odd.push_back(u); // 奇度点
}
// 奇度点数量只能是 0(回路)或 2(开路径)
if(!odd.empty() && odd.size()!=2){
cout<<"No\n";
return;
}
if(odd.size()==2) s=odd[0]; // 如果存在奇度点,必须从奇度点起跑
// 2. 连通性检查:BFS 验证所有非零度点是否在同一个连通块内
queue<int> q;
q.push(s);
seen[s]=true;
while(!q.empty()){
int u=q.front();
q.pop();
for(auto& e:g[u]){
int v=e.first;
if(!seen[v]){
seen[v]=true;
q.push(v);
}
}
}
for(int u=1;u<=n;u++){
if(!g[u].empty() && !seen[u]){
cout<<"No\n"; // 存在包含边的节点未连通
return;
}
}
// 3. 显式栈 Hierholzer 算法,结合当前弧优化 cur[u]
st.push_back(s);
int cnt=0;
while(!st.empty()){
int u=st.back();
// 跳过已经使用过的边
while(cur[u]<g[u].size() && used[g[u][cur[u]].second]) cur[u]++;
if(cur[u]==g[u].size()){
// u 已经无路可走,撞到南墙,记录进 ans 并出栈
ans.push_back(u);
st.pop_back();
}else{
// 沿着当前弧向前推进一步
auto e=g[u][cur[u]++];
used[e.second]=true;
cnt++;
st.push_back(e.first);
}
}
// 4. 双重防线核验:使用边数与节点数必须完全吻合
if(cnt!=m || ans.size()!=m+1){
cout<<"No\n";
return;
}
// 逆序输出,将死胡同还原至真实终点
reverse(ans.begin(), ans.end());
for(int i=0;i<ans.size();i++){
cout<<ans[i]<<(i+1==ans.size()?'\n':' ');
}
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
七、完整程序二:有向图欧拉路径
1. 题目契约与输入输出规范
- 输入格式:第一行两个整数
( , )。接下来 行,每行两个整数 ,表示一条有向边 。 - 输出格式:若无解输出
No;若有解输出个整数表示路径序列。若 ,约定输出 1。 - 复杂度:时间复杂度
,空间复杂度 。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=200005;
vector<int> g[N], weak[N];
int in_deg[N], out_deg[N], cur[N];
bool seen[N];
vector<int> st, ans;
int n, m;
void solve(){
cin>>n>>m;
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
// 弱连通辅助无向图
weak[u].push_back(v);
weak[v].push_back(u);
out_deg[u]++;
in_deg[v]++;
}
// 1. 出入度平衡对账
int s=1, starts=0, ends=0;
for(int u=1;u<=n;u++){
if(out_deg[u]>0) s=u; // 找到默认有出边的起点
}
for(int u=1;u<=n;u++){
int d=out_deg[u]-in_deg[u];
if(d==1){
starts++;
s=u; // 唯一的出发点
}else if(d==-1){
ends++; // 唯一的到达点
}else if(d!=0){
cout<<"No\n"; // 偏差超过 1,绝不可能走完
return;
}
}
// 要么起终点各 1 个,要么全是 0(回路)
if(starts!=ends || starts>1){
cout<<"No\n";
return;
}
// 2. 弱连通性检查(在 weak 图上 BFS)
queue<int> q;
q.push(s);
seen[s]=true;
while(!q.empty()){
int u=q.front();
q.pop();
for(int v:weak[u]){
if(!seen[v]){
seen[v]=true;
q.push(v);
}
}
}
for(int u=1;u<=n;u++){
if((in_deg[u]+out_deg[u]>0) && !seen[u]){
cout<<"No\n";
return;
}
}
// 3. 显式栈 Hierholzer 算法
st.push_back(s);
int cnt=0;
while(!st.empty()){
int u=st.back();
if(cur[u]==g[u].size()){
// u 的所有出边走完,压入答案并退栈
ans.push_back(u);
st.pop_back();
}else{
// 有向边单向流动,无需全局 used 标记,直接推进指针
int v=g[u][cur[u]++];
cnt++;
st.push_back(v);
}
}
// 4. 终局核验与倒序还原
if(cnt!=m || ans.size()!=m+1){
cout<<"No\n";
return;
}
reverse(ans.begin(), ans.end());
for(int i=0;i<ans.size();i++){
cout<<ans[i]<<(i+1==ans.size()?'\n':' ');
}
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
八、进阶建模魔法:把“碎片拼接”降维成有向欧拉路径
场景痛点:
考场上经常出现“单词接龙”这类题目:给你 "abc", "cde", "efa"),要求你把它们首尾相接,拼成一个连续的序列。
很多同学的第一反应是:把单词当成节点,单词 A 的尾巴如果等于单词 B 的头,就连一条
降维打击:点边反转 既然欧拉路径的定义是“每条边恰好走一次”,我们为什么不把单词当作边呢?
- 节点:提取单词的“接口”作为节点(例如首字母为起点节点,尾字母为终点节点)。
- 边:单词本身作为一条从“首字母节点”指向“尾字母节点”的有向边。
例如三个单词 "ab", "bc", "cd":
节点是 a, b, c, d。边是 a->b(代表 "ab"),b->c(代表 "bc"),c->d(代表 "cd")。
题目瞬间变成了:在 26 个字母节点构成的图中,找一条恰好经过这
1. 核心小结:字典序输出的三大严谨条件
当这类拼接题加上“字典序最小”的条件时,绝不是简单写个 sort 就完事,必须同时锁死三个条件:
- 排序条件:若要求节点序列最小,按目标节点的编号排序;若要求边(单词)序列最小,必须按边自身的权值或内容排序。
- 起点约束:有向开放路径的起点唯一;无向开放路径有两个奇度点可选,求最小节点序列时取编号较小的奇度点。若是闭合回路,必须手动寻找满足条件的最小非零度节点作为起点。
- 输出对象:记录答案时,分清楚压入栈的到底是被踩到的“节点”,还是带你过来的“边”。
2. 完整程序三:单词接龙与边序列字典序
本程序先检查度数,连通性则靠第九节的最终使用边数检查兜底:必须走满全部单词,不能只拼出其中一个连通块。
// 独立题目:自拟单词接龙验证题 (Word Dominoes)
// 输入范围:第一行 N (1 <= N <= 100000)。接下来 N 行,每行一个全小写字母组成的单词。
// 要求:输出所有单词首尾相接拼接而成的、且边序列字典序最小的合法序列。无解输出 No。
// 样例输入:
// 4
// aac
// cba
// abc
// cde
// 样例对应输出(路径为 a->c->a->c->e):
// aac cba abc cde
#include<bits/stdc++.h>
#define int long long
using namespace std;
struct Edge {
int v; // 目标节点
string word; // 边携带的单词
// 排序条件:为了使得最后生成的序列字典序最小,出边按单词字典序升序排序
bool operator<(const Edge& o) const {
return word < o.word;
}
};
const int N = 26; // 26个小写字母节点
vector<Edge> g[N];
int in_deg[N], out_deg[N], cur[N];
vector<string> ans;
int n;
void solve(){
cin >> n;
for(int i = 0; i < n; i++){
string s;
cin >> s;
int u = s.front() - 'a';
int v = s.back() - 'a';
g[u].push_back({v, s});
out_deg[u]++;
in_deg[v]++;
}
// 1. 严格锁死排序条件:要求边序列字典序最小,对出边按单词本身排序
for(int i = 0; i < N; i++){
if(!g[i].empty()) sort(g[i].begin(), g[i].end());
}
// 2. 寻找起点(严格的起点约束)
int s = -1, starts = 0, ends = 0;
for(int i = 0; i < N; i++){
if(out_deg[i] > 0 && s == -1) s = i; // 欧拉回路的默认最小非零度起点
}
for(int i = 0; i < N; i++){
int d = out_deg[i] - in_deg[i];
if(d == 1){
starts++;
s = i; // 绑定到唯一满足出度比入度大 1 的起点
}else if(d == -1){
ends++;
}else if(d != 0){
cout << "No\n";
return;
}
}
if(!((starts == 1 && ends == 1) || (starts == 0 && ends == 0))){
cout << "No\n";
return;
}
// 3. Hierholzer 算法(精准控制输出对象)
// 栈内存放 pair: {当前节点 u, 走到 u 所用的单词}
vector<pair<int, string>> st;
st.push_back({s, ""});
int cnt = 0;
while(!st.empty()){
int u = st.back().first;
if(cur[u] == g[u].size()){
// 撞南墙出栈时,把【带我们来到这个节点】的单词记入答案
string edge_word = st.back().second;
if(edge_word != "") ans.push_back(edge_word);
st.pop_back();
}else{
auto e = g[u][cur[u]++];
cnt++;
st.push_back({e.v, e.word});
}
}
if(cnt != n){
cout << "No\n";
return;
}
reverse(ans.begin(), ans.end());
for(int i = 0; i < ans.size(); i++){
cout << ans[i] << (i + 1 == ans.size() ? '\n' : ' ');
}
}
signed main(){
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
九、终局的“铁壁防线”:为什么要检查最终使用边数?
在上面所有的模板代码中,你都会看到最后有一个极其固定的判断:
if(cnt != m) { cout << "No\n"; return; }
// 若输出节点序列则判 ans.size() != m + 1
物理意义:
有些同学会问,既然我们前面已经查过度数,甚至用 BFS 查过连通性,为什么最后还要核对一次总边数?
因为这是成本最低、最不可逾越的“铁壁防线”。
在复杂的图论题中,你可能会遇到图由两个互不连通、但各自度数都完美平衡的回路构成;或者在寻找起跑点时,你不小心选了一个度数合法但其实处于游离小连通块里的孤立点。此时 Hierholzer 算法依然能顺滑运行,但它只会“走完局部回路就停下”。最后核对入栈的实际边数 cnt 是否等于全图总边数 m,能一针见血地抓出所有漏走的边。它是防止你莫名其妙 WA 掉测试点的最后一道保险。
十、欧拉回路的“切环成链”与密码锁建模(选学)
场景:如果整张图是一个完美的欧拉回路(所有点度数完美平衡,奇度点数量为 0),我们跑出来的答案在物理上是一个闭合的圆环。但题目通常要求我们输出一个线性的数组或序列。
切开回路: 既然是首尾相接的完美圆环,那么从环上的任何一个点出发,都能顺着一笔画完所有的边。因此,如果题目只要任意解,跑完回路直接把它当作线性链条输出即可(这就是“切环成链”的本质)。
硬核实战:De Bruijn 序列(环形密码锁问题)
假设有一个 3 位数的密码锁,你想通过输入一串最短的连续字符串,利用滑动窗口的特性,把 000 到 999 这 1000 种密码组合全部试一遍。如何构造这个最短的字符串?
- 节点分配:长度为 2 的字符串(如
"00","01"..."99"),共 100 个节点。 - 边分配:长度为 3 的字符串(即我们想试的组合)。节点
"01"尾部加上一个字符'2',可以滑动转移到节点"12"。我们就连一条有向边"01" -> "12"。 这样连出的图,每个节点的入度和出度都是 10,完美构成了一个全连通的有向欧拉回路!跑一遍 Hierholzer 得到环,随便切开成链,得到的就是大名鼎鼎的 De Bruijn 序列。这正是欧拉回路降维解决滑动窗口问题的顶级美学。
实际输出时,先写起点的两位,再依次追加每条边的末位,共
十一、考场避坑指南与变式破题
在竞赛中,出题人常常会在基础欧拉路径上附加各种严苛限制。掌握以下三项破题心法,能让你在考场上彻底封堵失分点:
1. 字典序最小化(常考变体)
如果题目要求:“输出字典序最小的节点序列”(如洛谷 P7771)。
- 出边按目标节点升序排序,起点按第八节第 1 小节选取;前面的通用程序不要求字典序,默认起点循环不能原样照搬。
- 有向图可先写
int s=1; for(int u=1;u<=n;u++) if(out_deg[u]>0){s=u;break;},再让度数检查覆盖为开放路径的唯一起点;无向回路同样找第一个非零度点,开放路径则选较小奇点。
2. 边编号输出模式
有些题(如带有重边时)要求输出走过的边编号序列而非节点序列。
- 破题策略:
在栈元素中同时记录“走入当前节点所经过的边编号
edge_id”,在节点退出撞南墙时,将edge_id存入答案数组(起点初入栈时edge_id设为 -1,最后跳过即可)。
3. 渐进式实战练习题单
第一阶段:模板硬实力打底
- 洛谷 P7771 【模板】欧拉路径
- 训练指引:有向图字典序最小欧拉路径。重点训练出边
sort排序以及回路情况下起点的贪心选取。
- 训练指引:有向图字典序最小欧拉路径。重点训练出边
第二阶段:经典建模转化 2. 洛谷 P2731 [USACO3.3] 骑马修栅栏 Riding the Fences
- 训练指引:无向图字典序最小欧拉路径。图可能存在重边与自环,注意点编号范围很小但可能不连续,严格使用排序邻接表和度数奇偶判定。
- 洛谷 P1341 无序字母对
- 训练指引:把每个字母看成节点,每个双字母对看成一条无向边。题目本质是求无向图字典序最小欧拉路径,是字符串与图论结合的绝佳小品题。