欧拉路径与欧拉回路
较难2一笔画里的数学秘密:欧拉路径与欧拉回路
你有没有玩过“一笔画”游戏?在纸上画一个图形,笔尖不能离开纸,而且每条边只能画一次,最后看看能不能把图形全部画完。这个看似简单的游戏背后,藏着一个著名的数学问题——柯尼斯堡七桥问题。数学家欧拉在1736年解决了这个问题,从此诞生了图论中的“欧拉路径”和“欧拉回路”。
简单来说:
- 欧拉路径:从某个顶点出发,沿着边走,每条边恰好经过一次,最后停在某个顶点。就像在游乐场里,你走过每一条滑梯,但每座滑梯只玩一次。
- 欧拉回路:如果这条路径最后能回到起点,就是欧拉回路。相当于你从家出发,逛完所有景点,最后又回到家里。
超过200年后的今天,这个原理被用在很多地方:快递员规划最短路线(中国邮路问题)、电路板设计、DNA片段拼接……学会它,你就能用代码让计算机自动“一笔画”。
什么时候才能“一笔画”?——判定条件
不是所有图形都能一笔画成。判定条件与每个顶点连接的边数(叫做度数)有关。
无向图(边没有方向)
- 所有顶点度数都是偶数 → 存在欧拉回路(从任意一点出发都能回到起点)。
比如一个正方形,四个顶点都有2条边(偶数),绕着走一圈就能回到起点。 - 恰好两个顶点度数是奇数 → 存在欧拉路径(必须从其中一个奇度顶点出发,在另一个奇度顶点结束)。
比如“日”字形,中间两个顶点有3条边(奇数),左右两个顶点有2条边(偶数)。你只能从左下角出发,走到右下角结束。 - 其他情况:奇数度顶点个数超过2个 → 无法一笔画成。
举个生活中的例子:你和朋友玩“校园巡逻游戏”,每条走廊必须走一次且不重复。把每个路口看作顶点,走廊看作边。如果所有路口连接的走廊数都是偶数,你可以从校门口出发,最后回到校门口;如果只有两个路口是奇数,你只能从其中一个奇数路口进去,从另一个吐出来。
有向图(边有方向,比如单行道)
- 所有顶点入度等于出度 → 存在欧拉回路。
- 恰好一个顶点出度比入度大1(起点),另一个入度比出度大1(终点),其余顶点入度=出度 → 存在欧拉路径。
想象一下:你设计一个迷宫,每条路是单向的。如果你希望从一个入口进去,不重复地走过所有路,最后从出口出来,就要满足上面的条件。
如何用代码找出欧拉路径?——Hierholzer算法
既然知道了什么样的图可以一笔画,那怎么让计算机自动找出这条路呢?最经典的算法叫Hierholzer算法,它的核心思想是:用深度优先搜索(DFS)一条道走到黑,遇到死胡同就回头,并把走过的顶点记录下来。
算法步骤(以无向图为例):
- 从任意一个顶点开始(如果是欧拉路径,从奇度顶点开始;如果是回路,可以任意选)。
- 沿着未走过的边一直向前走,每走一条边就标记为已用。
- 如果当前顶点没有未走过的边了,就把这个顶点压入一个结果栈,然后回退。
- 重复直到所有边都被走过。最后结果栈里装的顶点序列就是欧拉路径或回路(注意顺序要反转)。
为什么这个算法有效?
因为欧拉图里每条边都会恰好被走一次,当我们走到一个“死胡同”时,说明这个顶点在图里其实是个“终点”或“起点”,把它先记下来,回头再补上前面的部分。就像拼图,先拼出局部环,再把它们首尾相连。
下面的代码展示了如何用邻接表和访问标记实现:
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
using namespace std;
const int MAXN = 100; // 最大顶点数
vector<int> adj[MAXN]; // 邻接表:每个顶点的邻居
bool visitedEdge[MAXN][MAXN]; // 标记边是否已使用(无向图需要双向标记)
// 函数:打印欧拉回路(从 start 出发)
void hierholzer(int start) {
stack<int> stk; // 模拟DFS的栈
vector<int> circuit; // 存储最终路径(倒序)
stk.push(start); // 先把起点入栈
while (!stk.empty()) {
int u = stk.top(); // 当前顶点
bool hasEdge = false; // 是否还有未走的边
// 遍历 u 的所有邻居
for (int v : adj[u]) {
if (!visitedEdge[u][v]) { // 找到一条没走过的边
visitedEdge[u][v] = true; // 标记 u->v
visitedEdge[v][u] = true; // 因为是无向图,也要标记 v->u
stk.push(v); // 走向 v
hasEdge = true; // 已找到边
break; // 注意:这里只走一条边,然后继续 while
}
}
// 如果 u 没有未走过的边了,说明 u 是当前局部的终点
if (!hasEdge) {
stk.pop(); // 弹出 u
circuit.push_back(u); // 把 u 加入结果(注意是倒序!)
}
}
// 输出:因为 circuit 是倒序的,需要反转
cout << "欧拉回路: ";
for (int i = circuit.size() - 1; i >= 0; --i) {
cout << circuit[i] << " ";
}
cout << "\n";
}
int main() {
int n = 4; // 顶点数(0~3)
// 构造一个欧拉图:四边形加一条对角线
// 顶点:0--1, 1--2, 2--3, 3--0, 0--2
adj[0].push_back(1); adj[1].push_back(0);
adj[1].push_back(2); adj[2].push_back(1);
adj[2].push_back(3); adj[3].push_back(2);
adj[3].push_back(0); adj[0].push_back(3);
adj[0].push_back(2); adj[2].push_back(0);
// 检查度数:顶点0 = 3? 实际上邻接表里0有3个邻居(1,3,2),度数为3(奇数)!这是一个问题!
// 等等,这个图顶点0的度数其实是3,不是偶数,不符合欧拉回路条件。
// 我们后文会说明:这个例子实际上只存在欧拉路径,不是回路。请参照修正。
// 先判定一下度数是否全偶数(为了演示,这里假设已判定)
// 实际上应该先检查,这里故意演示错误情况。
hierholzer(0);
return 0;
}
⚠️ 注意:上面代码中构造的图其实不满足欧拉回路条件(顶点0度数为3,奇数),所以运行结果可能不完整或错误。我们需要在代码里先检查度数,或者构造一个正确的欧拉图。下面会给出修正后的完整示例。
新手容易踩的坑
- 忘记检查度数:直接运行算法,结果路径可能漏边或者死循环。一定要先判断图是否满足欧拉路径/回路条件。
- 边标记混乱:无向图要双向标记(
visitedEdge[u][v]和visitedEdge[v][u]都设为true),有向图只需标记一个方向。 - 输出顺序:Hierholzer算法得到的结果是倒序的,输出前需要反转,或者用栈来收集后直接pop输出。
- 起点选择错误:欧拉路径必须从奇度顶点出发(或指定起点);欧拉回路可以任选。如果从错误起点开始,算法可能会多走回头路但无法形成完整回路。
- 忽略多重边:如果两个顶点之间有多条平行边,标记时应该用计数(每条边一个id)而不是简单的bool标记。上面的代码只适用于简单图(无重边)。
完整可运行的示例:正确欧拉回路
下面构造一个所有顶点度数都是偶数的图(四边形加一条对角线?不,那样是奇数。改成五边形内部再加两条边,确保每个顶点度数为偶数)。为了简单,我们使用一个六边形加两条对角线,每个顶点度数为4。代码也会加入度数判断。
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
using namespace std;
const int MAXN = 100; // 最大顶点数
vector<int> adj[MAXN]; // 邻接表
bool visitedEdge[MAXN][MAXN]; // 边是否已用
// 函数:判断无向图是否欧拉图(所有顶点度数为偶数)
bool isEulerian(int n) {
for (int u = 0; u < n; ++u) {
if (adj[u].size() % 2 != 0) return false;
}
return true;
}
// 函数:找欧拉回路
void findEulerianCircuit(int start, int n) {
if (!isEulerian(n)) {
cout << "该图不是欧拉图(有奇数度顶点)!" << endl;
return;
}
stack<int> stk;
vector<int> circuit;
stk.push(start);
while (!stk.empty()) {
int u = stk.top();
bool hasEdge = false;
for (int v : adj[u]) {
if (!visitedEdge[u][v]) {
visitedEdge[u][v] = true;
visitedEdge[v][u] = true;
stk.push(v);
hasEdge = true;
break;
}
}
if (!hasEdge) {
stk.pop();
circuit.push_back(u);
}
}
// 输出(反转)
cout << "欧拉回路: ";
for (int i = circuit.size() - 1; i >= 0; --i) {
cout << circuit[i] << " ";
}
cout << endl;
}
int main() {
int n = 5; // 0~4 五个顶点
// 构造一个五边形,每顶点连接2条边,再添加内部连线使每个顶点度数变为4
// 具体:五边形边:0-1,1-2,2-3,3-4,4-0
// 内部连线:0-2, 1-3, 2-4 (这样每个顶点恰好连接4条边)
adj[0].push_back(1); adj[1].push_back(0);
adj[1].push_back(2); adj[2].push_back(1);
adj[2].push_back(3); adj[3].push_back(2);
adj[3].push_back(4); adj[4].push_back(3);
adj[4].push_back(0); adj[0].push_back(4);
adj[0].push_back(2); adj[2].push_back(0);
adj[1].push_back(3); adj[3].push_back(1);
adj[2].push_back(4); adj[4].push_back(2);
// 检查度数(用函数自动验证)
cout << "图中有 " << n << " 个顶点,边数 " << 10 << endl;
findEulerianCircuit(0, n);
return 0;
}
运行这段代码,计算机就会输出一个从顶点0开始的欧拉回路,例如:0 1 2 3 4 0 2 4 1 3 0(顺序可能不同,但一定覆盖所有边)。
相关知识点:从欧拉路径到更多图论问题
- 哈密顿路径/回路:跟欧拉路径很像,但它要求每个顶点恰好经过一次,而不是边。就像旅游时你要逛遍所有景点(顶点),而不是走遍所有道路(边)。这是另一个NP困难问题。
- 中国邮路问题:如果图不是欧拉图(有奇数度顶点),邮递员又想走最短路径遍历所有边,需要重复走一些边。这就是欧拉路径的扩展应用。
- 有向图的欧拉路径:只需要把标记和度数判断改成入度出度即可,算法类似。
- 算法复杂度:Hierholzer算法的时间复杂度是 O(E)(E是边数),非常高效,因为在每条边只被访问一次。
进一步学习:如果你对图论感兴趣,可以接着研究强连通分量(Tarjan算法)和最小生成树(Kruskal算法),它们在竞赛中经常与欧拉路径一起出现。
现在,你不妨亲手画一个简单的图形,试着用纸笔模拟算法,再运行代码验证。你会发现,“一笔画”真的可以用数学和代码完美解决!
例题精讲
无向图存在欧拉回路的充要条件是?
有向图存在欧拉回路当且仅当每个顶点的入度等于出度。
以下代码判断无向图是否存在欧拉路径(部分),请在___处填入正确代码。
int parent[N];
int find(int x) { return parent[x]==x?x:parent[x]=find(parent[x]); }
void unite(int a,int b) { parent[find(a)]=find(b); }
bool hasEulerPath(int n,vector<pair<int,int>>& edges) {
vector<int> deg(n+1,0);
for(int i=1;i<=n;i++) parent[i]=i;
for(auto& e:edges) {
int u=e.first,v=e.second;
deg[u]++; deg[v]++;
unite(u,v);
}
int odd=0, root=-1;
for(int i=1;i<=n;i++) {
if(deg[i]==0) continue;
if(root==-1) root=find(i);
else if(___ != root) return false;
if(deg[i]%2==1) odd++;
}
return odd==0 || odd==2;
}