CC++ & Algorithm

欧拉路径与欧拉回路

较难2
语言版本:C++
概述:欧拉路径就是“一笔画”的路径,从一个点出发,不重复地走完所有边,最后回到起点就是回路。

一笔画里的数学秘密:欧拉路径与欧拉回路

你有没有玩过“一笔画”游戏?在纸上画一个图形,笔尖不能离开纸,而且每条边只能画一次,最后看看能不能把图形全部画完。这个看似简单的游戏背后,藏着一个著名的数学问题——柯尼斯堡七桥问题。数学家欧拉在1736年解决了这个问题,从此诞生了图论中的“欧拉路径”和“欧拉回路”。

简单来说:

  • 欧拉路径:从某个顶点出发,沿着边走,每条边恰好经过一次,最后停在某个顶点。就像在游乐场里,你走过每一条滑梯,但每座滑梯只玩一次。
  • 欧拉回路:如果这条路径最后能回到起点,就是欧拉回路。相当于你从家出发,逛完所有景点,最后又回到家里。

超过200年后的今天,这个原理被用在很多地方:快递员规划最短路线(中国邮路问题)、电路板设计、DNA片段拼接……学会它,你就能用代码让计算机自动“一笔画”。


什么时候才能“一笔画”?——判定条件

不是所有图形都能一笔画成。判定条件与每个顶点连接的边数(叫做度数)有关。

无向图(边没有方向)

  • 所有顶点度数都是偶数 → 存在欧拉回路(从任意一点出发都能回到起点)。
    比如一个正方形,四个顶点都有2条边(偶数),绕着走一圈就能回到起点。
  • 恰好两个顶点度数是奇数 → 存在欧拉路径(必须从其中一个奇度顶点出发,在另一个奇度顶点结束)。
    比如“日”字形,中间两个顶点有3条边(奇数),左右两个顶点有2条边(偶数)。你只能从左下角出发,走到右下角结束。
  • 其他情况:奇数度顶点个数超过2个 → 无法一笔画成。

举个生活中的例子:你和朋友玩“校园巡逻游戏”,每条走廊必须走一次且不重复。把每个路口看作顶点,走廊看作边。如果所有路口连接的走廊数都是偶数,你可以从校门口出发,最后回到校门口;如果只有两个路口是奇数,你只能从其中一个奇数路口进去,从另一个吐出来。

有向图(边有方向,比如单行道)

  • 所有顶点入度等于出度 → 存在欧拉回路。
  • 恰好一个顶点出度比入度大1(起点),另一个入度比出度大1(终点),其余顶点入度=出度 → 存在欧拉路径。

想象一下:你设计一个迷宫,每条路是单向的。如果你希望从一个入口进去,不重复地走过所有路,最后从出口出来,就要满足上面的条件。


如何用代码找出欧拉路径?——Hierholzer算法

既然知道了什么样的图可以一笔画,那怎么让计算机自动找出这条路呢?最经典的算法叫Hierholzer算法,它的核心思想是:用深度优先搜索(DFS)一条道走到黑,遇到死胡同就回头,并把走过的顶点记录下来

算法步骤(以无向图为例):

  1. 从任意一个顶点开始(如果是欧拉路径,从奇度顶点开始;如果是回路,可以任意选)。
  2. 沿着未走过的边一直向前走,每走一条边就标记为已用。
  3. 如果当前顶点没有未走过的边了,就把这个顶点压入一个结果栈,然后回退。
  4. 重复直到所有边都被走过。最后结果栈里装的顶点序列就是欧拉路径或回路(注意顺序要反转)。

为什么这个算法有效?
因为欧拉图里每条边都会恰好被走一次,当我们走到一个“死胡同”时,说明这个顶点在图里其实是个“终点”或“起点”,把它先记下来,回头再补上前面的部分。就像拼图,先拼出局部环,再把它们首尾相连。

下面的代码展示了如何用邻接表和访问标记实现

#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,奇数),所以运行结果可能不完整或错误。我们需要在代码里先检查度数,或者构造一个正确的欧拉图。下面会给出修正后的完整示例。


新手容易踩的坑

  1. 忘记检查度数:直接运行算法,结果路径可能漏边或者死循环。一定要先判断图是否满足欧拉路径/回路条件。
  2. 边标记混乱:无向图要双向标记(visitedEdge[u][v]visitedEdge[v][u]都设为true),有向图只需标记一个方向。
  3. 输出顺序:Hierholzer算法得到的结果是倒序的,输出前需要反转,或者用栈来收集后直接pop输出。
  4. 起点选择错误:欧拉路径必须从奇度顶点出发(或指定起点);欧拉回路可以任选。如果从错误起点开始,算法可能会多走回头路但无法形成完整回路。
  5. 忽略多重边:如果两个顶点之间有多条平行边,标记时应该用计数(每条边一个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算法),它们在竞赛中经常与欧拉路径一起出现。


现在,你不妨亲手画一个简单的图形,试着用纸笔模拟算法,再运行代码验证。你会发现,“一笔画”真的可以用数学和代码完美解决!

例题精讲

1单选题

无向图存在欧拉回路的充要条件是?

A所有顶点度数均为偶数
B所有顶点度数均为奇数
C恰有两个顶点度数为奇数
D所有顶点度数均不为0
2判断题

有向图存在欧拉回路当且仅当每个顶点的入度等于出度。

3填空题
以下代码判断无向图是否存在欧拉路径(部分),请在___处填入正确代码。
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;
}