一、引言

“一笔画”是很多人童年的第一个图论问题:能不能不重复地走遍图形上的每一条线?在信息学竞赛里,它对应一个体系完整、模板性极强的考点——欧拉路径与欧拉回路。从柯尼斯堡七桥问题,到信奥备考中的图论基础,再到 DNA 片段拼接、邮递员路线规划,Hierholzer 算法都是绕不开的核心。

本文用一道原创题讲透:怎样判定一张图能否一笔画,怎样用线性时间把那条路线构造出来。配 C++ / Python 双版代码、考点拆解、复杂度与易错点,以及进阶方向。

二、题目 / 项目目标

【原创题】校园快递巡逻路线

某校区有 n 个路口(编号 1 ~ n)和 m 条双向小路。巡逻车希望设计一条路线:从某个路口出发,恰好走遍每条小路一次(路口可以重复经过)。

  • 输入:第一行 n m;接下来 m 行每行 u v,表示一条连接路口 u、v 的双向小路(可能重边、自环)。
  • 输出:若存在这样的路线,先输出一行 Yes,再输出一行顶点序列(空格分隔,要求字典序最小);若不存在,输出一行 No 并简要说明原因(奇度顶点个数不对 / 图不连通)。

数据范围:1 ≤ n ≤ 10^5,1 ≤ m ≤ 2×10^5。

这道题的本质就是判定并构造无向图的欧拉路径 / 欧拉回路,并要求字典序最小——正是竞赛里最常见的“一笔画”模板形态。

三、核心考点

  1. 定义辨析:欧拉路径经过每条边恰好一次(点可重复),起点终点可不同;欧拉回路是起点终点重合的欧拉路径。关键词是“边”,不是“点”。
  2. 无向图判定:所有“有度”顶点连通,且奇度顶点个数为 0(回路)或 2(通路)。
  3. 有向图判定:基图(忽略方向)连通;回路要求每个顶点入度 = 出度;通路要求恰有一个顶点出度 = 入度 + 1(起点)、一个顶点入度 = 出度 + 1(终点),其余入度 = 出度。
  4. Hierholzer 思想:DFS 一路走,无路可走时回溯,把当前点压入路径;所有边走完才记录,最后反转得到答案。
  5. 复杂度:用边编号 + 访问标记(或 ptr 指针优化)可达 O(V + E) 时间与 O(V + E) 空间。
  6. 字典序最小构造:邻接表排序,每次优先选编号最小的邻居,得到的顶点序列即为字典序最小。

四、解法 / 拆解

4.1 先判定,再构造

第一步,统计每个顶点的度数(自环贡献 2 度)。无向图里数奇度顶点个数 odd:

  • odd == 0:存在欧拉回路,起点任取一个有度的点;
  • odd == 2:存在欧拉通路,起点必须是那两个奇度点之一(取编号较小者,利于字典序);
  • 其他情况:直接 No。

第二步,连通性检查(只看度数不为 0 的点)。题目若明说“保证连通”可省,但实战务必查,否则会漏判“图分成几块、每块都满足度数条件”的假阳性。

4.2 Hierholzer 算法

核心三步:选起点 → 沿未走边 DFS,走一条删一条 → 回溯时把点加入答案,最后反转。

“删边”保证每条边只走一次;回溯才记录保证拼出来的是合法回路(先找小环并入主路径)。递归版直观,迭代 + ptr 数组版本可避免 O(deg) 删除开销与递归爆栈。

4.3 Python 解法(字典序最小,递归版)

import sys

def solve() -> None:
    data = sys.stdin.read().strip().split()
    if not data:
        return
    it = iter(data)
    n = int(next(it)); m = int(next(it))

    # 邻接表:存邻居,排序后每次取最小编号 => 字典序最小
    adj = [[] for _ in range(n + 1)]
    deg = [0] * (n + 1)
    for _ in range(m):
        u = int(next(it)); v = int(next(it))
        adj[u].append(v); adj[v].append(u)
        deg[u] += 1; deg[v] += 1
    for i in range(1, n + 1):
        adj[i].sort()

    # 奇度顶点
    odd = [i for i in range(1, n + 1) if deg[i] % 2 == 1]

    # 连通性:只看有度的点
    def connected() -> bool:
        start = next((i for i in range(1, n + 1) if deg[i] > 0), -1)
        if start == -1:
            return True
        seen = [False] * (n + 1)
        st = [start]; seen[start] = True
        while st:
            x = st.pop()
            for y in adj[x]:
                if not seen[y]:
                    seen[y] = True; st.append(y)
        return all(seen[i] or deg[i] == 0 for i in range(1, n + 1))

    if len(odd) not in (0, 2):
        print("No(奇度顶点个数不是 0 或 2)"); return
    if not connected():
        print("No(图不连通)"); return

    # 起点:最小奇度点;否则最小有度点
    src = odd[0] if odd else next(i for i in range(1, n + 1) if deg[i] > 0)
    path: list[int] = []

    def dfs(u: int) -> None:
        while adj[u]:
            v = adj[u].pop(0)          # 取最小邻居
            adj[v].remove(u)           # 双向删除该边
            dfs(v)
        path.append(u)                # 回溯时才记录

    dfs(src)
    path.reverse()

    print("Yes")
    print(" ".join(map(str, path)))

if __name__ == "__main__":
    solve()

4.4 C++ 解法(字典序最小,递归版)

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int n, m;
vector<vector<int>> adj;
vector<int> deg;
vector<int> path;

void dfs(int u) {
    while (!adj[u].empty()) {
        int v = adj[u][0];
        adj[u].erase(adj[u].begin());                 // 删 u->v
        auto it = find(adj[v].begin(), adj[v].end(), u);
        if (it != adj[v].end()) adj[v].erase(it);     // 删反向边 v->u
        dfs(v);
    }
    path.push_back(u);                                // 回溯记录
}

int main() {
    ios::sync_with_stdio(false); cin.tie(nullptr);
    if (!(cin >> n >> m)) return 0;
    adj.assign(n + 1, {}); deg.assign(n + 1, 0);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
        deg[u]++; deg[v]++;
    }
    for (int i = 1; i <= n; i++) sort(adj[i].begin(), adj[i].end());

    int odd = 0, src = -1;
    for (int i = 1; i <= n; i++)
        if (deg[i] % 2) { odd++; if (src == -1) src = i; }

    // 连通性检查
    int first = -1;
    for (int i = 1; i <= n; i++) if (deg[i] > 0) { first = i; break; }
    if (first != -1) {
        vector<bool> vis(n + 1, false);
        vector<int> stk = {first}; vis[first] = true;
        while (!stk.empty()) {
            int x = stk.back(); stk.pop_back();
            for (int y : adj[x]) if (!vis[y]) { vis[y] = true; stk.push_back(y); }
        }
        for (int i = 1; i <= n; i++)
            if (deg[i] > 0 && !vis[i]) { cout << "No(图不连通)\n"; return 0; }
    }
    if (odd != 0 && odd != 2) { cout << "No(奇度顶点个数不是 0 或 2)\n"; return 0; }
    if (src == -1)
        for (int i = 1; i <= n; i++) if (deg[i] > 0) { src = i; break; }

    dfs(src);
    reverse(path.begin(), path.end());
    cout << "Yes\n";
    for (size_t i = 0; i < path.size(); i++)
        cout << path[i] << (i + 1 == path.size() ? "" : " ");
    cout << "\n";
    return 0;
}

4.5 样例

输入:

6 8
1 2
2 3
3 1
3 4
4 5
5 6
6 4
2 4

各点度数:1:2, 2:3, 3:3, 4:4, 5:2, 6:2,奇度顶点为 {2, 3}(共 2 个),且图连通,存在从 2 到 3 的欧拉通路。程序输出(字典序最小顶点序列):

Yes
2 1 3 2 4 5 6 4 3

可自行验证:序列相邻点之间恰好对应输入的 7 条边,每条边出现一次。

五、进阶

  • 有向图版本:度数改用入度 ind、出度 outd;通路要求恰一个 outd-ind==1(起点)、一个 ind-outd==1(终点);边只需单向删除。
  • ptr 指针优化:给每个顶点的邻接表维护“下一条待试边”下标,避免 erase/remove 的 O(deg) 开销,整体严格 O(V + E),且可改成迭代版防递归爆栈(数据 m 到 10^5 时必须用)。
  • Fleury 算法对比:更早的 Fleury 每次“非到万不得已不走桥”,需判桥,复杂度更高;竞赛几乎一律用 Hierholzer。
  • 真实应用:DNA 序列拼接(k-mer overlap 图求欧拉路径)、七桥问题、中国邮递员问题(欧拉回路的加权扩展)。
  • 练习推荐:信息学奥赛一本通 1341「一笔画问题」、洛谷 P2731「骑马修栅栏」、洛谷 P1127「词链」。

六、小结与互动

欧拉回路 / 一笔画的套路非常固定:先按度数判存在性(无向奇度 0/2,有向出入度相等或差 1),再查连通性,最后用 Hierholzer 边走边删、回溯记录并反转。把“判定三步走”和“删边 + 反转”两个命门记牢,这类题基本秒杀。

你在刷题时还遇到过哪些“看着像一笔画、实则要绕坑”的题?欢迎在评论区留言,我们下一期可以聊聊有向图 + 字典序最小的综合变形。如果本文对你有帮助,记得点赞收藏,备考路上一起进阶!


📚 免费少儿编程资料(夸克网盘领取)

以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:

  1. 全国青少年信息素养大赛复赛集训题目Python&C++.docx
    https://pan.quark.cn/s/93995d3cb150
  2. 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
    https://pan.quark.cn/s/da97b5dbf75d
  3. Python背记手册.pdf
    https://pan.quark.cn/s/7568ae9ca92b
  4. Python课程
    https://pan.quark.cn/s/a94bf02d00c6
  5. 2024信息素养大赛图形化复赛集训题答案3-9
    https://pan.quark.cn/s/6ccab7ec3cbc
  6. 2025年03月份电子学会考级真题
    https://pan.quark.cn/s/4403c4228912
  7. 2025全国青少年信息素养大赛赛项说明
    https://pan.quark.cn/s/d9d0df4a9f29
  8. 青少儿信息素养大赛编程资料
    https://pan.quark.cn/s/4ab6bd83be8a

资料持续更新,关注获取最新分享。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐