建议在正式学习欧拉路径前 5--7 天吃完。今天不做 OJ 题;我们只把“每条边走一次”这件事看懂,免得一上来就被
Hierholzer这个名字唬住。
学完后,你能:
只需要:无向图、邻接表、栈,以及“每条边有两个端点”。先不要查答案,拿纸画一画。
今天不要先在脑中想“DFS”。拿纸画点、把每条边编号成一张卡;每走一条边,就把那张卡翻面。你的纸上账本固定有四栏:
| 对象 | 状态 | 本轮动作 | 必须不变的事 |
|---|---|---|---|
| 路口与道路 | 当前站在哪、哪些边已用 | 选一张相连的未用边走过去 | 一张道路卡最多翻一次 |
| 正在形成的路线 | 路线首尾、尚未收尾的部分 | 继续走或把尾巴收起来 | 已收好的部分不应再需要新边 |
先写下预测,再看折叠答案。今天的目标不是记住 Hierholzer 的拼写,而是能回答:为什么“走过点”不够,为什么“无路可走”反而是好消息?
画三角形 $A-B-C-A$。若任务是“每个点至少到一次”,从 $A$ 走到 $B$ 再到 $C$ 就可以停;若任务是“每条边恰好一次”,此时边 $C-A$ 还躺在地上。请在三条边旁分别写 已用/未用,不要只给点打访问标记。
停顿 0:普通 DFS 遇到已访问的 $A$ 时通常会怎样做?为什么这会误伤边 $C-A$?
画边:A-B, B-C, C-A, C-D, D-E。先从 $A$ 出发,并尽量“看到哪条未用边就走哪条”:
A -> B -> C -> D -> E
现在手上还有哪张边卡?当前站在哪里?还能回去拿它吗?
停顿 1:别立即改路线。请写出所有点的度数,并圈出奇数。你认为这条欧拉路径的两个端点被允许是谁?
对一条路线内部的点,每次沿边进入,都要沿另一条未用边离开。请给下表补上“未配对的动作”:
| 点的位置 | 进入次数与离开次数的关系 | 允许的度数奇偶 |
|---|---|---|
| 中间点 | ||
| 起点(非回路) | ||
| 终点(非回路) | ||
| 回路中的所有点 |
重新画两个共用 $A$ 的三角形。先只绕左三角形得到 A→B→C→A。此时路线已经闭合,但右三角形的边还未用。请在这条路线中圈出一个还能出发的点,并试着把右三角形的回路写进原路线的哪个空隙。
停顿 3:如果你把新回路直接接在路线末尾,会得到一条连续路线吗?为什么必须“插入”而不是“追加”?
现在给框架命名之前,先写下需要记住什么:
current / stack:还没有收尾的一段行走
used_edge[id]:每张道路卡是否已翻面
ptr[u]:u 的邻接表已经翻到哪里
answer:已经确认应放在最终路线尾部的点
当栈顶还有未用边,就继续向下走;当它没有未用边,这个点以后不可能再接新道路,才可以从栈顶弹出、收进 answer。这不是“回溯撤销边”,而是终于知道一段路线的结尾应该放在哪里。
想象每条边是一条必须巡查一次的小路。最暴力的想法是:站在当前点,枚举下一条尚未巡查的路;走不通就撤销,换一条。若有很多岔路,选择序列会像排列一样迅速爆炸。邮递员还没出门,搜索树已经开始长蘑菇了。
看这张图:两个三角形共用 $A$。从 $A$ 先绕完左三角形,仍然回到 $A$;右三角形并没有消失,只是应该被接到已有路线中间。
关键转变是:不猜整条路线,而是维护一条已经闭合、可以继续插入小回路的路线。
从路线内部的点 $X$ 看:每次进来都要出去,所以已使用的关联边成对出现。只有起点允许多一次离开,终点允许多一次进入。因此无向图中:
停顿:一条自环给一个点贡献几度?请在纸上画“进”和“出”各一次。
从某个还有未用边的点出发,一直沿未用边走。若每个点的剩余度数仍是偶数,就不会在别处“只进不能出”;最后会回到出发点,得到一个小回路。若它的起点在旧路线里,把这个小回路插在该点的位置,旧路线和新回路都没有丢边,也没有重边。
这就是 Hierholzer 算法的发明思路:先走到无路可走,再把完成的尾巴反向收进答案。栈恰好替我们记住“回到哪里插”。
设 used[e] 表示边 e 是否已经领过巡查单,ptr[u] 表示邻接表中已经检查到的位置。每条边只会被 ptr 跳过一次、被标记一次。
# start 是奇度点之一;若没有奇度点,任取有边的点
stack = [start]
answer = [] # 这里收集的是倒序路线
while stack is not empty:
u = stack.top()
advance ptr[u] past used edges
if u still has an unused edge e = (u, v):
used[e] = true
stack.push(v) # 先继续把一条小回路走深
else:
answer.append(stack.pop())
return reverse(answer)
对边编号:0:A-B, 1:B-C, 2:C-A, 3:A-D, 4:D-E, 5:E-A,从 $A$ 出发。请填写前六次“向下走”的栈;之后不要急着反转,先预测谁最先进入 answer。
| 动作 | 走的边 | 栈(底到顶) | 倒序 answer |
|---|---|---|---|
| 开始 | — | A |
[] |
| 向下走 | 0 | ||
| 向下走 | 1 | ||
| 向下走 | 2 | ||
| 向下走 | 3 | ||
| 向下走 | 4 | ||
| 向下走 | 5 | ||
| 无边可走,弹栈 | — |
answer 的一个点被加入时,说明从它出发的所有未用边都已经处理完;它正好该排在剩余路线的末尾。倒过来以后,每相邻两个点对应一条已走边。
算法维护两个不变量:
answer 的顶点之后不再需要插入新的边。每次前进恰新增一条边;每次回退只收集已经走完的部分。若所有非孤立点连通且奇度数为 0 或 2,最终会用尽全部边。反向答案的长度应为 $E+1$;这是很实用的验收器:少了说明漏边,多了说明某条边重复使用。
时间复杂度是 $O(V+E)$,空间是 $O(V+E)$。它比回溯快的原因不是“栈很神奇”,而是它完全不撤销已确定的边:每条边只有一次职业生涯。
used[u][v]:重边会互相误伤,自环也会变得尴尬。edge_count + 1。正式课会进一步处理有向边、字典序和建模。ptr[u] 是防止反复扫描陈旧边的关键。没有它,表面上像 $O(E)$ 的代码可能变成不停翻旧抽屉。运行 从散步到欧拉回路.py。直接回车使用“双三角形”预设;程序会:
试着把一条边删掉,再预测奇度点会怎样变化。不要害怕把图弄坏:程序不会给图记仇。
有向图把“度数偶偶配对”换成“入度与出度配对”:内部点要求 in = out,两个端点分别允许相差 1。若任务要求访问每个点一次,则它已经是另一个世界的哈密顿问题,不能把今天的度数结论搬过去。
正式课会把“词尾接词首”“给边定向”等题面翻译成边的使用问题;零食课先帮你练出识别信号:题目反复强调“每条关系/道路/相邻窗口恰好一次”时,先想到欧拉,而不是对点做普通 DFS。
answer 为什么要反转?