Level 3 · 图论课堂实验台

欧拉路 DFS:递归如何自动拼环

结合“双环共享点 2”的例子,看懂任务暂停、回来继续、无边封口,以及为什么答案必须反转。

一、先回答:DFS 到底在做什么

一句话结论

先处理边,后记录点

dfs(u) 的任务不是“第一次访问点 u”,而是“把 u 能接上的未使用边全部处理完”。

最容易误解

递归调用后,原任务没有消失

dfs(u) 调用 dfs(v) 时只是暂停。等 dfs(v) 完成,程序会回到原来的 dfs(u),继续找其他边。

DFS 版本一句话总结

对于节点 u,遍历它所有尚未使用的边;沿每条边递归 dfs。所有边处理完后,把 u 放进答案数组,最后反转答案。

判断的是“边是否使用”,不是“点是否访问”
先标记边,再执行 dfs(v)
push_back(u) 放在所有边遍历完以后

二、它和普通 DFS 有什么不同

普通连通性 DFS

目标通常是每个点访问一次,因此维护 visited[u]

欧拉路 DFS

点可以重复经过,不能重复的是边,因此维护 used[边号]

1取出一条未使用边 (u,v)
2标记这条原边已经使用
3调用 dfs(v),原任务暂停
4无边可走时执行 answer.push_back(u)

三、互动演示:两个环怎样自动拼起来

步骤0 / 14
当前动作准备出发
调用栈深度1
倒序 answer
两个三角形在点 2 处相连 左环为 1、2、3,右环为 2、4、5。互动步骤会高亮当前使用的边。 左环先走 回来后接右环 1 2 3 4 5
从点 1 开始。演示固定先走左环,目的是观察:回到共享点 2 后,原来的 dfs(2) 还会继续工作。

四、把动画翻译成代码

向下走

dfs(e.to);

沿边进入新点。当前函数留在调用栈里,等子任务完成后继续执行自己的 while

向上封口

answer.push_back(u);

它必须写在 while 外面:只有所有可用边都处理完,点 u 在最终路线中的位置才确定。

最先封口的是终点,所以 answer 是“终点到起点”;反转后才是“起点到终点”。

五、手推一次关键状态

时刻调用栈倒序 answer真正发生的事
走入左环dfs(1), dfs(2), dfs(3), dfs(1)原来的 dfs(2) 暂停着,没有结束。
左环封口dfs(1), dfs(2)1, 3回到 dfs(2),继续执行它的 while。
接上右环dfs(1), dfs(2), dfs(4), dfs(5), dfs(2)1, 3共享点 2 发现还有边 2—4,于是自动拼入右环。
全部封口1, 3, 2, 5, 4, 2, 1这是逆序结果。
最后反转1 → 2 → 4 → 5 → 2 → 3 → 1

六、自己选答案

问题 1

为什么不能使用 visited[u] 禁止再次进入点 u?

问题 2

answer.push_back(u) 应该放在哪里?

问题 3

为什么最后必须反转 answer?

七、C++14 递归教学版

EulerianGraph_recursive.cpp
#include <bits/stdc++.h>
using namespace std;

struct Edge {
    int to;
    int id;
};

vector<vector<Edge> > graph;
vector<char> used;
vector<int> answer;

void dfs(int u) {
    while (!graph[u].empty()) {
        Edge e = graph[u].back();
        graph[u].pop_back();

        // 无向边在邻接表中存了两次,但只能使用一次
        if (used[e.id]) {
            continue;
        }

        used[e.id] = true;
        dfs(e.to);          // 有边:递归向前走
    }

    answer.push_back(u);     // 无边:回退时才记点
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    graph.assign(n + 1, vector<Edge>());
    used.assign(m, false);
    vector<int> degree(n + 1, 0);

    for (int id = 0; id < m; ++id) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back({v, id});
        graph[v].push_back({u, id});
        ++degree[u];
        ++degree[v];
    }

    int start = -1;
    int oddCount = 0;

    for (int u = 1; u <= n; ++u) {
        if (degree[u] % 2 == 1) {
            ++oddCount;
            if (start == -1) start = u;
        }
    }

    if (oddCount != 0 && oddCount != 2) {
        cout << "No\n";
        return 0;
    }

    // 欧拉回路:选择任意一个真正有边的点
    if (start == -1) {
        for (int u = 1; u <= n; ++u) {
            if (degree[u] > 0) {
                start = u;
                break;
            }
        }
    }

    // 没有边时,这里约定输出点 1
    if (m == 0) {
        cout << 1 << '\n';
        return 0;
    }

    dfs(start);

    // 没走完所有边,说明有边的部分不连通
    if ((int)answer.size() != m + 1) {
        cout << "No\n";
        return 0;
    }

    reverse(answer.begin(), answer.end());

    for (int i = 0; i < (int)answer.size(); ++i) {
        if (i > 0) cout << ' ';
        cout << answer[i];
    }
    cout << '\n';
    return 0;
}
used[e.id]
维护“哪一条原边已经使用”,正确处理无向边和重边。
graph[u].pop_back()
每个邻接项只取出一次,因此不反复扫描;注意它会修改邻接表。
answer.size() == m + 1
验收所有边是否真的进入同一条欧拉路线。

八、复杂度与常见错误

复杂度

不要求字典序时,每个邻接项只被弹出一次,时间复杂度为 O(V+E),空间复杂度为 O(V+E)

递归深度最坏达到 O(E);边数很大时应改用显式栈版本。

易错点
  • visited[u] 禁止重复经过点。
  • 无向边的两个邻接项没有共享同一个边号。
  • answer.push_back(u) 放进 while
  • 忘记反转,或没有检查答案长度是否为 E+1

九、认知定位

建模对象顶点与必须恰好使用一次的边
算法范式DFS 回溯与 Hierholzer 拼环
维护信息剩余邻边、used、调用栈、answer
压缩信息不枚举所有走边顺序,自动拼接局部环
学生易卡点把它误当成“每个点一次”的普通 DFS