图论

欧拉路径

度数判定、边编号与逆序构造

11个章节
查看本篇目录一、边的执念 vs 点的执念:重新认识“一笔画”二、无向图的存在性:进出守恒与连通陷阱1. 物理视角的出入平衡(度数条件)2. 致命盲区:度数对了,就一定能走完吗?三、有向图的平衡:流水必须对账1. 出度与入度的精准对账2. 弱连通性:为什么不要求强连通?四、重边与自环:给每条边发一张“身份证”1. 为什么不能用两维数组 vis[u][v]?2. 自环对度数的物理贡献五、构造灵魂:Hierholzer 算法与“死胡同哲学”1. 贪心走法的致命死穴:提前走进死胡同2. Hierholzer 算法的逆向哲学:撞了南墙再记录3. 灵魂指针:当前弧优化 cur4. 显式栈防御:彻底告别深链爆栈六、完整程序一:无向图欧拉路径1. 题目契约与输入输出规范七、完整程序二:有向图欧拉路径1. 题目契约与输入输出规范八、进阶建模魔法:把“碎片拼接”降维成有向欧拉路径1. 核心小结:字典序输出的三大严谨条件2. 完整程序三:单词接龙与边序列字典序九、终局的“铁壁防线”:为什么要检查最终使用边数?十、欧拉回路的“切环成链”与密码锁建模(选学)十一、考场避坑指南与变式破题1. 字典序最小化(常考变体)2. 边编号输出模式3. 渐进式实战练习题单

一、边的执念 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。这是一条不闭合的开放欧拉路径。

坐标与长度的物理常识: 一条包含 mm 条边的欧拉路线,输出的节点访问序列必定恰好包含 m+1m+1 项。 千万不要把欧拉路径的代码和点遍历混淆,把边标记错写成点标记,那将是灾难性的逻辑崩溃。


二、无向图的存在性:进出守恒与连通陷阱

要想一笔画完所有的边,首先得回答一个根本问题:一张图到底在什么条件下才“可能”存在欧拉路径?

1. 物理视角的出入平衡(度数条件)

想象我们是路线中的一个过客,正在途经某个普通的中间节点 uu:

  • 我们必须顺着某条边进入 uu;
  • 随后必须顺着另一条没走过的边离开 uu。

每一次途经,都会在节点 uu 上消耗掉一对边(2 度)。因此,只要这个节点不是全流程的“出发点”或“终结点”,它的度数(连接的边数)就必须是偶数!

场景 奇度点的物理意义 结论
欧拉回路(闭合) 起点也是终点,起飞消耗 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(从 00 到 m−1m-1)。 在无向图中,一条边存入邻接表两次(u→vu \to v 与 v→uv \to u),但它们共享同一个 id。只要其中一个方向被使用了,就立即打上标记 used[id] = true。

例如:节点 1 和 2 之间有两条重边(编号 0 和 1),节点 1 上有一个自环(编号 2)。 节点 1 的度数为 1+1+2=41+1+2 = 4(偶数),节点 2 的度数为 2(偶数)。 我们可以走出合法路径 1→1→2→11 \to 1 \to 2 \to 1,恰好把编号 0、1、2 这三条边各消耗一次!

两条1—2重边分别用id0和id1,1上的自环用id2;路线1→1→2→1恰好使用三个边编号一次,自环贡献度数2。


五、构造灵魂:Hierholzer 算法与“死胡同哲学”

知道了什么时候有解,那具体怎么把这条路径输出出来?

1. 贪心走法的致命死穴:提前走进死胡同

假设我们手里有一张图:由一个大三角形 1—2—3—1 外挂一条死胡同尾巴 1—4 组成。 奇度点是 4 和 1,假设我们从 1 出发。

如果我们盲目贪心往前走:一出门不小心走进了 1 → 4。 到了 4 号点,四面碰壁无路可走!而大三角形 1—2—3—1 却完全被抛弃在身后,成了烂尾工程。

如果每次走错都去回溯撤销,算法复杂度会指数级暴涨。怎么破?

2. Hierholzer 算法的逆向哲学:撞了南墙再记录

Hierholzer 算法的核心魔法在于:不要在第一次踏入节点时记录它,而是在一个节点“无路可走(所有出边都已消耗殆尽)”时,才将它压入答案序列!

我们重新推演刚才的例子:

  1. 从 1 出发,走入 4;
  2. 此时在 4 号点,发现 4 的所有边都用光了(撞了南墙,无路可走)。 此时把 4 记录到答案末尾:ans = [4];
  3. 程序退回 1 号点,发现 1 号点竟然还有未走过的边(大三角形的边)!
  4. 继续探索三角形,走过 1 → 2 → 3 → 1;
  5. 回溯时,节点依次无路可走,被依次加入答案:ans = [4, 1, 3, 2, 1];
  6. 遍历结束,将 ans 整体翻转(reverse),得到: [1, 2, 3, 1, 4]!

看到了吗?那个最早被我们不小心走错、提前撞墙的死胡同节点 4,在逆序记录并翻转之后,被极其精妙地推到了整条路线的绝对终点! 这就是 Hierholzer 算法的“死胡同拼接法”:各个局部的回路,无论何时被探索,都会在退栈时严丝合缝地拼接在一起。

3. 灵魂指针:当前弧优化 cur

在遍历邻接表时,很多新手会写出这样的代码:

C++
for(auto e : g[u]) {
    if(!used[e.second]) { ... }
}

严重警告:这是导致超时的头号元凶! 如果一个节点度数很大,你每次退回它时,都从下标 0 开始重新扫描跳过已经访问过的边,在完全图或稠密图上,总扫描次数会瞬间退化到 O(m2)O(m^2)!

解法:当前弧优化指针 cur[u] 为每个节点维护一个指针 cur[u],记录当前节点 uu 正在考察哪一条邻接边。 每当一条边被使用或被判定为废边,cur[u]++ 单调向前推,永不回头! 这保证了全图的每条邻接表边最多被检查常数次,将时间牢牢锁定在 O(n+m)O(n + m)。

4. 显式栈防御:彻底告别深链爆栈

当图是一条拥有 5×1055 \times 10^5 条边的超长链时,常规的系统递归 DFS 会导致函数调用栈深度达到 5050 万层,在任何评测机上都会直接 Runtime Error (Stack Overflow) 爆栈! 因此,在严肃的竞赛中,我们必须用手写 vector 显式栈模拟遍历。


六、完整程序一:无向图欧拉路径

1. 题目契约与输入输出规范

  • 输入格式:第一行两个整数 n,mn, m(1≤n≤2000001 \le n \le 200000,0≤m≤5000000 \le m \le 500000)。接下来 mm 行,每行两个整数 u,vu, v,表示一条无向边。允许重边、自环与孤立点。
  • 输出格式:若无解输出 No;若有解,输出一行由空格隔开的 m+1m+1 个整数,表示节点行走序列。若 m=0m=0,约定输出单个节点 1。不强制要求字典序。
  • 复杂度:时间复杂度 O(n+m)O(n + m),空间复杂度 O(n+m)O(n + m)。
C++
#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. 题目契约与输入输出规范

  • 输入格式:第一行两个整数 n,mn, m(1≤n≤2000001 \le n \le 200000,0≤m≤5000000 \le m \le 500000)。接下来 mm 行,每行两个整数 u,vu, v,表示一条有向边 u→vu \to v。
  • 输出格式:若无解输出 No;若有解输出 m+1m+1 个整数表示路径序列。若 m=0m=0,约定输出 1。
  • 复杂度:时间复杂度 O(n+m)O(n + m),空间复杂度 O(n+m)O(n + m)。
C++
#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;
}

八、进阶建模魔法:把“碎片拼接”降维成有向欧拉路径

场景痛点: 考场上经常出现“单词接龙”这类题目:给你 nn 个单词(如 "abc", "cde", "efa"),要求你把它们首尾相接,拼成一个连续的序列。 很多同学的第一反应是:把单词当成节点,单词 A 的尾巴如果等于单词 B 的头,就连一条 A→BA \to B 的边。 这听起来很自然,但题目往往要求“每个单词恰好使用一次”,这就意味着你需要找到一条访问每个节点恰好一次的哈密顿路径(Hamiltonian Path)!这是经典的 NP-Hard 问题,当 n=105n=10^5 时,写 DFS 必然严重超时。

降维打击:点边反转 既然欧拉路径的定义是“每条边恰好走一次”,我们为什么不把单词当作边呢?

  • 节点:提取单词的“接口”作为节点(例如首字母为起点节点,尾字母为终点节点)。
  • 边:单词本身作为一条从“首字母节点”指向“尾字母节点”的有向边。

例如三个单词 "ab", "bc", "cd": 节点是 a, b, c, d。边是 a->b(代表 "ab"),b->c(代表 "bc"),c->d(代表 "cd")。 题目瞬间变成了:在 26 个字母节点构成的图中,找一条恰好经过这 nn 条边的有向欧拉路径。复杂度暴降为 O(N)O(N)!

1. 核心小结:字典序输出的三大严谨条件

当这类拼接题加上“字典序最小”的条件时,绝不是简单写个 sort 就完事,必须同时锁死三个条件:

  • 排序条件:若要求节点序列最小,按目标节点的编号排序;若要求边(单词)序列最小,必须按边自身的权值或内容排序。
  • 起点约束:有向开放路径的起点唯一;无向开放路径有两个奇度点可选,求最小节点序列时取编号较小的奇度点。若是闭合回路,必须手动寻找满足条件的最小非零度节点作为起点。
  • 输出对象:记录答案时,分清楚压入栈的到底是被踩到的“节点”,还是带你过来的“边”。

2. 完整程序三:单词接龙与边序列字典序

本程序先检查度数,连通性则靠第九节的最终使用边数检查兜底:必须走满全部单词,不能只拼出其中一个连通块。

C++
// 独立题目:自拟单词接龙验证题 (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;
}

九、终局的“铁壁防线”:为什么要检查最终使用边数?

在上面所有的模板代码中,你都会看到最后有一个极其固定的判断:

C++
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 序列。这正是欧拉回路降维解决滑动窗口问题的顶级美学。

实际输出时,先写起点的两位,再依次追加每条边的末位,共 2+1000=10022+1000=1002 位,恰好形成 1000 个长度为 3 的窗口。

十一、考场避坑指南与变式破题

在竞赛中,出题人常常会在基础欧拉路径上附加各种严苛限制。掌握以下三项破题心法,能让你在考场上彻底封堵失分点:

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. 渐进式实战练习题单

第一阶段:模板硬实力打底

  1. 洛谷 P7771 【模板】欧拉路径
    • 训练指引:有向图字典序最小欧拉路径。重点训练出边 sort 排序以及回路情况下起点的贪心选取。

第二阶段:经典建模转化 2. 洛谷 P2731 [USACO3.3] 骑马修栅栏 Riding the Fences

  • 训练指引:无向图字典序最小欧拉路径。图可能存在重边与自环,注意点编号范围很小但可能不连续,严格使用排序邻接表和度数奇偶判定。
  1. 洛谷 P1341 无序字母对
    • 训练指引:把每个字母看成节点,每个双字母对看成一条无向边。题目本质是求无向图字典序最小欧拉路径,是字符串与图论结合的绝佳小品题。
搜索全部54篇讲义的标题、目录与正文
点击结果进入讲义Esc 关闭