个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
二叉树的遍历与二叉搜索树:把递归、迭代、还原树一次讲透
文章目录
- 二叉树的遍历与二叉搜索树:把递归、迭代、还原树一次讲透
-
- 一、先把结构定死
- 二、三种遍历,差的只是"哪一次经过时输出"
- 三、把递归改成迭代:用显式栈自己维护"经过"
- 四、已知两种遍历,怎么把树还原回来
- 五、二叉搜索树:让"查找"有了方向
- [六、复杂度与退化:为什么裸 BST 不够用](#六、复杂度与退化:为什么裸 BST 不够用)
- 七、收个尾
刚开始学二叉树的时候,我卡住的不是语法,而是脑子里没有一个统一的模型。
前序、中序、后序,三个名字背得滚瓜烂熟,可一旦题目换成"已知前序和中序,还原这棵树",手就停了;等到二叉搜索树的删除节点,三种情况更是每次都现推。后来我发现,问题出在我把"遍历"当成了三个独立的知识点去记,而它其实只是同一件事的三个观察窗口。
这篇文章就用一棵树走到底:遍历、还原、BST 的查找插入删除,全部围绕下面这棵树展开。把它吃透,比刷十道不相干的题有用。
一、先把结构定死
go
type TreeNode struct {
Val int
Left, Right *TreeNode
}
就这三行。树是非线性结构,但节点本身只记三样东西:自己的值、左孩子、右孩子。空树用 nil 表示。
这里有个新手常犯的混淆:完全二叉树可以用数组存(堆就是这么干的),但普通二叉树不行。
堆用数组是因为它必须"完全",下标 i 的左右孩子固定在 2i+1 和 2i+2,没有任何浪费。而二叉搜索树的形状由插入顺序决定,中间可能空出一大片,硬塞进数组就是拿空间换一个并不存在的规律。所以我们一律用指针链起来。
文章里统一用这棵树,它恰好还是一棵合法的二叉搜索树:
text
8
/ \
3 10
/ \ \
1 6 14
/ \ /
4 7 13
二、三种遍历,差的只是"哪一次经过时输出"
先看递归写法,三份代码骨架完全一样:
go
func preorder(n *TreeNode, out *[]int) {
if n == nil {
return
}
*out = append(*out, n.Val) // 第 1 次经过:根
preorder(n.Left, out)
preorder(n.Right, out)
}
func inorder(n *TreeNode, out *[]int) {
if n == nil {
return
}
inorder(n.Left, out)
*out = append(*out, n.Val) // 第 2 次经过
inorder(n.Right, out)
}
func postorder(n *TreeNode, out *[]int) {
if n == nil {
return
}
postorder(n.Left, out)
postorder(n.Right, out)
*out = append(*out, n.Val) // 第 3 次经过
}
如果只把它们当成"要背的三个顺序",那确实容易乱。换成递归序 的视角就清楚了:递归过程中,每一个节点都会被经过三次------刚进来一次、左子树回来一次、右子树回来一次。
- 第 1 次经过时打印 → 前序(根左右)
- 第 2 次经过时打印 → 中序(左根右)
- 第 3 次经过时打印 → 后序(左右根)
打印语句所在的位置,就是"第几次经过"。三份代码只有那一行的位置不同,这不是巧合,是本质。
套进上面那棵树:
| 遍历方式 | 序列 |
|---|---|
| 前序 | 8, 3, 1, 6, 4, 7, 10, 14, 13 |
| 中序 | 1, 3, 4, 6, 7, 8, 10, 13, 14 |
| 后序 | 1, 4, 7, 6, 3, 13, 14, 10, 8 |
注意中序那一行:1 3 4 6 7 8 10 13 14,正好是升序。这不是这棵树的运气,而是二叉搜索树的定义决定的,后面第六节会用到。
三、把递归改成迭代:用显式栈自己维护"经过"
递归跑得爽,但有两类场景必须换迭代:
- 深度太大。斜树有 10 万个节点,递归深度就是 10 万,C/C++/Java 的线程栈撑不住,会栈溢出。Go 的 goroutine 栈能自动扩容,不容易炸,但深度太大照样是隐患。
- 面试官就说"不许用递归"。
写迭代的关键是想清楚:递归帮你干了什么? 它把"待处理的节点"存在了调用栈里。那迭代就自己开一个栈,把这个活儿接管过来。
前序 :出栈即访问,然后把右孩子、左孩子依次压栈。
go
func preorderIter(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
stack := []*TreeNode{root}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
res = append(res, n.Val)
if n.Right != nil {
stack = append(stack, n.Right) // 先压右
}
if n.Left != nil {
stack = append(stack, n.Left) // 后压左,所以左先出栈
}
}
return res
}
压栈顺序反过来写,是因为栈是后进先出。想先访问左子树,就得让左孩子后压。
中序:一路向左压栈,压不动了就弹一个出来访问,然后转向它的右子树。
go
func inorderIter(root *TreeNode) []int {
var res []int
stack := []*TreeNode{}
cur := root
for cur != nil || len(stack) > 0 {
for cur != nil { // 一路向左
stack = append(stack, cur)
cur = cur.Left
}
cur = stack[len(stack)-1] // 左边到头的那个节点,该"第 2 次经过"了
stack = stack[:len(stack)-1]
res = append(res, cur.Val)
cur = cur.Right // 左子树处理完,转向右子树
}
return res
}
这段是三种迭代里最容易写错的,建议记一个判断标准:内层 for 只管左,外层循环负责"弹栈 + 转右",两者不要混。
后序:有个很省事的技巧------先按"根 → 右 → 左"的顺序遍历,最后把结果整个反转,就得到"左 → 右 → 根"。
go
func postorderIter(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
stack := []*TreeNode{root}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
res = append(res, n.Val) // 此刻拿到的是 根 → 右 → 左
if n.Left != nil {
stack = append(stack, n.Left)
}
if n.Right != nil {
stack = append(stack, n.Right)
}
}
for i, j := 0, len(res)-1; i < j; i, j = i+1, j-1 {
res[i], res[j] = res[j], res[i]
}
return res
}
比起"用一个指针记录上次访问的节点"那种严格模拟递归的写法,这个反转法好写也好懂,代价只是多一次 O(n) 的反转,复杂度没变。
层序遍历(广度优先)是另一套路子,用队列而不是栈:
go
func levelOrder(root *TreeNode) [][]int {
if root == nil {
return nil
}
var out [][]int
queue := []*TreeNode{root}
for len(queue) > 0 {
size := len(queue) // 关键:先锁定这一层的节点个数
level := make([]int, 0, size)
for i := 0; i < size; i++ {
n := queue[0]
queue = queue[1:]
level = append(level, n.Val)
if n.Left != nil {
queue = append(queue, n.Left)
}
if n.Right != nil {
queue = append(queue, n.Right)
}
}
out = append(out, level)
}
return out
}
size 那一行是分层的关键。不锁住它,你就分不清队列里哪些是当前层、哪些是下一层,"按层分组"立刻失效。
小提醒:queue = queue[1:] 这种写法在切片底层数组上会一直往前挪,元素本身不会被回收。数据量大的时候改成 head 下标更稳妥:
go
head := 0
for head < len(queue) {
n := queue[head]
head++
// ...
}
四、已知两种遍历,怎么把树还原回来
这是"遍历"最实用的一次反向使用,也是面试高频。
规律只有一条:某种遍历里"最先/最后出现"的那个节点,就是根。
- 前序:第一个是根
- 后序:最后一个是根
- 中序:根在中间,左边全是左子树,右边全是右子树
前序 + 中序的流程:
- 前序第一个是根,在这棵树里是
8; - 去中序里找
8,左边1 3 4 6 7是左子树,右边10 13 14是右子树; - 左子树有 5 个节点,那么前序里根后面的 5 个就是左子树的前序;
- 左右分别递归。
用代码实现时,别每层都去扫一遍中序找根的位置------那会退化。先用 map 把"值 → 中序下标"缓存起来,整棵树就回到 O(n):
go
func buildTree(preorder, inorder []int) *TreeNode {
idx := make(map[int]int, len(inorder))
for i, v := range inorder {
idx[v] = i
}
var build func(preLo, preHi, inLo, inHi int) *TreeNode
build = func(preLo, preHi, inLo, inHi int) *TreeNode {
if preLo > preHi {
return nil
}
rootVal := preorder[preLo] // 前序第一个就是根
pos := idx[rootVal] // 它在中序里的位置
leftSize := pos - inLo // 左子树有多少个节点
return &TreeNode{
Val: rootVal,
Left: build(preLo+1, preLo+leftSize, inLo, pos-1),
Right: build(preLo+leftSize+1, preHi, pos+1, inHi),
}
}
return build(0, len(preorder)-1, 0, len(inorder)-1)
}
中序 + 后序 同理,只是根要从后序的末尾取:
go
rootVal := postorder[postHi]
// 左子树:后序 [postLo, postLo+leftSize-1]
// 右子树:后序 [postLo+leftSize, postHi-1]
那前序 + 后序为什么不行? 这两个序列都能定位根,但都没法告诉你"根后面的节点里,哪些属于左子树"。看个反例:
text
树 A:1 只有左孩子 2 树 B:1 只有右孩子 2
A 的前序: 1, 2 B 的前序: 1, 2
A 的后序: 2, 1 B 的后序: 2, 1
两棵完全不同的树,前序和后序一模一样。所以没有中序,就没有"分界线"。
记法很简单:中序是唯一能切分左右子树的序列,所以它必须出现。 已知"中序 + 任意另一种"才能唯一确定一棵树。
五、二叉搜索树:让"查找"有了方向
二叉搜索树(BST)在普通二叉树上加了一条约束:
任意节点的左子树中所有节点 都小于它,右子树中所有节点都大于它。
请特别注意"所有"两个字。它约束的不是直接孩子,而是整棵子树。这一点决定了验证 BST 时最容易踩的坑(见本节最后)。
有了这条约束,查找就有了方向感------每比较一次,就能扔掉一半:
go
func search(root *TreeNode, v int) *TreeNode {
cur := root
for cur != nil {
switch {
case v < cur.Val:
cur = cur.Left
case v > cur.Val:
cur = cur.Right
default:
return cur
}
}
return nil
}
这就是二分查找在树上的形态,时间复杂度是树高 O(h)。树平衡时 h = log n。
插入 的思路是:先按查找的路线走到底,挂上去。要注意 BST 通常不允许重复值,且需要用一个 parent 记录上一站,否则走到 nil 时就丢了父节点:
go
func insert(root *TreeNode, v int) *TreeNode {
if root == nil {
return &TreeNode{Val: v}
}
cur, parent := root, root
for cur != nil {
if v == cur.Val {
return root // 已存在,不插入
}
parent = cur
if v < cur.Val {
cur = cur.Left
} else {
cur = cur.Right
}
}
if v < parent.Val {
parent.Left = &TreeNode{Val: v}
} else {
parent.Right = &TreeNode{Val: v}
}
return root
}
删除是三种操作里唯一需要分情况的:
- 叶子节点:直接删;
- 只有一个孩子:让孩子顶替自己的位置;
- 有两个孩子 :找中序后继(右子树里最小的节点,也就是右子树一路向左到底)来接替自己的值,然后回到情况 1 或 2 把它删掉。
go
func deleteNode(n *TreeNode, v int) *TreeNode {
if n == nil {
return nil
}
switch {
case v < n.Val:
n.Left = deleteNode(n.Left, v)
case v > n.Val:
n.Right = deleteNode(n.Right, v)
default:
if n.Left == nil { // 情况 1、2:右孩子顶上(nil 也行)
return n.Right
}
if n.Right == nil { // 情况 1、2:左孩子顶上
return n.Left
}
succ := n.Right // 情况 3:找中序后继
for succ.Left != nil {
succ = succ.Left
}
n.Val = succ.Val // 值替换
n.Right = deleteNode(n.Right, succ.Val) // 再去右子树里删掉那个后继
}
return n
}
为什么找"右子树最小"?因为它刚好是大于待删节点的所有值里最小的那个,把它放上来,左边依然全小、右边依然全大,BST 的性质保住了。左子树最大节点(前驱)同样可以,二选一。
验证 BST 的坑 :很多人上来就写"检查每个节点是否大于左孩子、小于右孩子",这个判断是错的。看 LeetCode 上的经典反例 [5,1,6,null,null,3,7]:根 5,右孩子 6,而 6 的左孩子是 3。局部看 3 < 6 完全合法,可 3 待的是根 5 的右子树,它必须大于 5,所以整棵树非法。
正确做法有两条路:
go
// 写法一:递归时把「上下界」往下传
func isValidBST(n *TreeNode, lo, hi *int) bool {
if n == nil {
return true
}
if lo != nil && n.Val <= *lo {
return false
}
if hi != nil && n.Val >= *hi {
return false
}
return isValidBST(n.Left, lo, &n.Val) && isValidBST(n.Right, &n.Val, hi)
}
go
// 写法二:利用「中序遍历必然严格递增」,只记住前一个值
func isValidBST2(root *TreeNode) bool {
var prev *int
var walk func(*TreeNode) bool
walk = func(n *TreeNode) bool {
if n == nil {
return true
}
if !walk(n.Left) {
return false
}
if prev != nil && n.Val <= *prev {
return false
}
v := n.Val
prev = &v
if !walk(n.Right) {
return false
}
return true
}
return walk(root)
}
写法二更值得记住:"BST 的中序遍历是升序" 这句话本身就是一把万能钥匙------验证合法性只是它的一个用法,"求第 k 小的元素"也是中序走到第 k 个就返回,"求最小/最大值"则是一路向左/向右到底。
六、复杂度与退化:为什么裸 BST 不够用
BST 的查找、插入、删除都沿着一条根到叶的路径走,所以复杂度是 O(h):
| 结构 | 查找 | 插入 | 删除 | 说明 |
|---|---|---|---|---|
| 普通数组 | O(n) | O(n) | O(n) | 无序,只能扫 |
| 有序数组 | O(log n) | O(n) | O(n) | 查找快,但插入要搬元素 |
| 二叉搜索树(平衡) | O(log n) | O(log n) | O(log n) | 理想情况 |
| 二叉搜索树(退化) | O(n) | O(n) | O(n) | 退化成链表 |
| 平衡树(AVL / 红黑树) | O(log n) | O(log n) | O(log n) | 靠旋转维持平衡 |
问题出在最后两行之间:BST 的形状完全由插入顺序决定 。按 10, 20, 30, 40 顺序插入,得到的不是树,而是一条往右甩的链表------这时 h = n,所有操作退化成 O(n),和扫数组没区别。
所以生产环境里几乎没人直接用裸 BST。Java 的 TreeMap、C++ 的 std::map 用的都是红黑树,通过插入和删除后的旋转,把树高摁在 O(log n)。
顺带说一个 Go 的实际情况:Go 标准库没有内置的有序 map。需要有序查找时,常见做法是:
- 读多写少:
sort.Search在有序切片上二分,缓存友好,实际很快; - 读写都频繁:引入第三方 B 树实现(如
google/btree),或者自己维护平衡树。
选之前先想清楚读写比例,不要为了"看起来高级"直接上红黑树。
七、收个尾
回头看,这篇文章其实只讲了一个模型和它的两个用途:
- 递归序:每个节点被经过三次,打印发生在第几次,就叫第几种遍历------这是"如何访问";
- BST 的约束:左子树全小、右子树全大,让查找每步扔一半------这是"如何组织",而它反过来又让中序遍历变成升序。
遍历和 BST 不是两块知识,它们咬合在同一个递归结构上。下次再遇到"还原树"或"删除节点",先问自己两句话:"根在哪?"、"左子树和右子树的分界线在哪?"------大部分题目的入口就出来了。