(e1)先序遍历

官方信息技术老师·11 页·深入(追求细节与边界)·0 次浏览·3 天前
二叉树递归迭代DFS

(e1)先序遍历

递归思路、迭代写法、边界用例一次打通

按 空格/→ 演示下一步

1 / 11 页

全部页面点击任意一页,跳回舞台从这页播放

二叉树递归迭代DFS

(e1)先序遍历

递归思路、迭代写法、边界用例一次打通

1第 1 页 · (e1)先序遍历

转化策略

先序遍历讲的是「按什么顺序访问节点」。但要把一整棵树真正落成一段可存储、可传输、可还原的序列,光有顺序还不够——我们需要一套完整的转化策略。

转化目标
把非线性树压成线性序列,便于存储与传输
递归分解
根节点 + 左子树 + 右子树三段,各自独立处理
访问次序
根 → 左 → 右,三段各自再按此序递归
空节点标记
用占位符(如 #)标记 null,否则无法唯一还原
典型应用
树的序列化/反序列化、字符串表示、跨语言传输
拆一盒嵌套礼盒对应 →把树转化为序列

先记外盒(根),再拆左右小盒(子树);空盒要打标记,否则装不回去

preorder(T)=[root(T)]preorder(left(T))preorder(right(T))\text{preorder}(T) = [\text{root}(T)] \oplus \text{preorder}(\text{left}(T)) \oplus \text{preorder}(\text{right}(T))
2第 2 页 · 转化策略

遍历规则

上一页我们讨论了把树压成序列的转化策略。现在回到规则本身:为什么「根→左→右」这个顺序被叫「先」序?

访问顺序
先访问根节点,再进入左子树,最后进入右子树
递归定义
对每个子树都重复同一顺序,直到子树为空
空树终止
遇到 null 节点立即返回,是递归的天然出口
典型应用
树的深拷贝、序列化保存、按序还原原树都依赖此顺序
走访一栋建筑对应 →先序遍历

先看大厅(根),再进左边房间(左子树),最后进右边(右子树);每进一个新空间,规则都重走一遍

前序(T)=访(v)前序(TL)前序(TR)    (T)\text{前序}(T)=\text{访}(v)\,\cup\,\text{前序}(T_L)\,\cup\,\text{前序}(T_R)\;\;(T\neq\varnothing)
3第 3 页 · 遍历规则

递归实现

上一页我们看清了先序遍历的路线——根、左、右。怎么让程序自己按这条路线走下去?递归就够了。

递归定义
函数自己调用自己,把大问题拆成同形态的小问题
终止条件
遇到空节点(null)就返回,否则会无限递归下去
调用即访问
处理根的代码写在递归调用之前,先序顺序天然成立
栈由系统管
调用栈自动记录返回点,程序员无需手动维护
拆俄罗斯套娃对应 →递归调用

大娃套小娃,直到空娃;大调用嵌小调用,直到空节点

4第 4 页 · 递归实现

迭代实现(1)

上一页我们用递归写了先序遍历——三行就完事。但递归调用栈是系统隐式管的,看不见也控不住。树太深就会爆栈,想暂停或并行也无从下手。这页我们自己造一个栈来替代它。

显式栈替代调用栈
用我们手动维护的栈结构,模拟递归时系统维护的调用栈
先压右、再压左
栈是后进先出,要让左子先出,就得把右子先压到栈底
入栈即访问
节点出栈那一刻立刻访问写入结果,而非压栈时延后处理
栈空即终止
当栈被弹空时,所有节点都已遍历完,循环结束
抽屉里摞文件对应 →显式栈压栈顺序

想让左边文件先抽到,就把它放在最上面——右边的必须压在下面

while (!stack.empty()): pop \tovisit \topush(right) \topush(left)\text{while (!stack.empty()): pop \to visit \to push(right) \to push(left)}
5第 5 页 · 迭代实现(1)

实例

递归和迭代都给了框架,但只看代码还是会绕。这一页拿一棵具体的二叉树,把「根 → 左 → 右」落到真实节点上,再看这种顺序能用来做什么。

示例树
7 节点的不平衡二叉树:根为 1,左子树含 2/4/5,右子树含 3/6/7
访问序列
按根→左→右依次输出:1, 2, 4, 5, 3, 6, 7
典型应用
树的深拷贝、前缀表达式(波兰式)、序列化与反序列化
依次打开嵌套文件夹对应 →先序遍历

进文件夹先看当前内容(根),再从左到右打开子文件夹(左→右),全部展开后才退出

6第 6 页 · 实例

新思路

看完了实例,你会觉得先序遍历只是树的专属动作。但把视角抬高一层你发现:核心不是树,而是「根先于子 + 递归骨架」。这一页聊聊它如何升维,套到更多题目上。

根先于子
先处理当前节点再处理子节点,这个顺序不变——所有先序变体都围绕它展开。
三步拆解
递归每层拆成「进入处理→递归子树→返回清理」三步,这就是回溯的通用骨架。
跨问题域
子集、排列、组合、括号生成看似无关,本质都是先序遍历在序列上的展开。
推广到图
把「左右子树」换成「邻接节点」,二叉树先序就推广为多叉树/图的 DFS。
流水线工位对应 →先序遍历骨架

不论什么零件,「先本工位→再下一工位」的顺序不变,换的只是每站的具体操作。

7第 7 页 · 新思路

新构思

看完了实例,你可能想问:递归和栈都要 O(h) 空间,能不能更省?Morris 遍历的答案是借树自己的空指针,把「回头的路」写在树里。

空指针借桥
把叶子节点的右空指针临时指向根,遍历完左子树再拆掉
前驱定位
对当前节点,沿左子树一路向右找到最右节点,即为前驱
到达即访问
前序特征:第一次到达节点立即输出,不等左子树走完
剪断回溯
发现前驱右指针临时指向自己时,立刻把这条链剪断
O(1) 空间
除若干指针变量外,不依赖栈也不依赖递归调用栈
借图书馆的书对应 →Morris 借空指针

书看完必须放回原位,临时指针走完左子树也要拆掉

8第 8 页 · 新构思

迭代实现(2)

迭代实现(1)用栈手动模拟——三种遍历要写三个版本,差别只在压栈顺序。今天换个思路:每个节点入栈两次,把「路过」和「真正处理」分两步做,同一份代码就能产出三种遍历。

双入栈标记
每个节点进栈两次,区分「路过」与「真正处理」
首次弹出
只把左右孩子按规则压栈,不输出节点值
再次弹出
此时才把节点值输出,真正完成「处理」
切换压栈序
改变左右孩子的压入顺序,就能切换前/中/后序
读文章先列提纲再细读对应 →双入栈遍历

首次过节点是列提纲(只压孩子);再次过节点是真读(输出)

9第 9 页 · 迭代实现(2)

本节要点

  • 先序遍历的判据是'根先于子树',与左右子树的相对顺序无关
  • 迭代用栈模拟递归:先压右再压左,弹出顺序才是真正的访问顺序
  • 递归与迭代遍历同一棵树的序列完全相同,只是控制流表达不同
  • 前/中/后序的区别仅在根的访问时机,对同一棵树三者序列互不相同
延伸主题:中序与后序遍历的对比层序遍历(队列实现)莫里斯遍历(O(1) 空间)
10第 10 页 · 本节要点

课后思考

先独立写下思路,再展开参考答案,比较自己的推理路径。

1为什么先序遍历必须先处理根节点,再处理左右子树?若交换先后会怎样?

参考答案根是当前子树的入口。先处理根能留下“已访问边界”,供栈回溯;若先处理子树,根的信息容易被覆盖或难以确定回溯位置。

2把递归实现改写为迭代实现时,怎样设计栈,才能保证与先序遍历完全同序?

参考答案可把“递归调用尚未结束”视为栈中任务。弹出一个任务时,先完成根,再按逆序压入右、左任务;后压入的左任务会先执行。

3把二叉树推广为多叉树后,“根先于后代”仍成立吗?遍历顺序应如何描述?

参考答案“根先于后代”仍成立。每棵树先访问根,再按预先约定的次序访问各子树;若不约定子树次序,同一棵树可能对应多种遍历结果。

11第 11 页 · 课后思考