二叉树的遍历与二叉搜索树:把递归、迭代、还原树一次讲透

个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <

二叉树的遍历与二叉搜索树:把递归、迭代、还原树一次讲透

文章目录

刚开始学二叉树的时候,我卡住的不是语法,而是脑子里没有一个统一的模型。

前序、中序、后序,三个名字背得滚瓜烂熟,可一旦题目换成"已知前序和中序,还原这棵树",手就停了;等到二叉搜索树的删除节点,三种情况更是每次都现推。后来我发现,问题出在我把"遍历"当成了三个独立的知识点去记,而它其实只是同一件事的三个观察窗口。

这篇文章就用一棵树走到底:遍历、还原、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,正好是升序。这不是这棵树的运气,而是二叉搜索树的定义决定的,后面第六节会用到。

三、把递归改成迭代:用显式栈自己维护"经过"

递归跑得爽,但有两类场景必须换迭代:

  1. 深度太大。斜树有 10 万个节点,递归深度就是 10 万,C/C++/Java 的线程栈撑不住,会栈溢出。Go 的 goroutine 栈能自动扩容,不容易炸,但深度太大照样是隐患。
  2. 面试官就说"不许用递归"。

写迭代的关键是想清楚:递归帮你干了什么? 它把"待处理的节点"存在了调用栈里。那迭代就自己开一个栈,把这个活儿接管过来。

前序 :出栈即访问,然后把右孩子、左孩子依次压栈。

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++
    // ...
}

四、已知两种遍历,怎么把树还原回来

这是"遍历"最实用的一次反向使用,也是面试高频。

规律只有一条:某种遍历里"最先/最后出现"的那个节点,就是根。

  • 前序:第一个是根
  • 后序:最后一个是根
  • 中序:根在中间,左边全是左子树,右边全是右子树

前序 + 中序的流程:

  1. 前序第一个是根,在这棵树里是 8;
  2. 去中序里找 8,左边 1 3 4 6 7 是左子树,右边 10 13 14 是右子树;
  3. 左子树有 5 个节点,那么前序里根后面的 5 个就是左子树的前序;
  4. 左右分别递归。

用代码实现时,别每层都去扫一遍中序找根的位置------那会退化。先用 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. 只有一个孩子:让孩子顶替自己的位置;
  3. 有两个孩子 :找中序后继(右子树里最小的节点,也就是右子树一路向左到底)来接替自己的值,然后回到情况 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 不是两块知识,它们咬合在同一个递归结构上。下次再遇到"还原树"或"删除节点",先问自己两句话:"根在哪?"、"左子树和右子树的分界线在哪?"------大部分题目的入口就出来了。

相关推荐
yi0112 小时前
Leetcode 49 用 “身份证‘‘巧解
人工智能·笔记·python·算法·leetcode·哈希表
yi0112 小时前
DAY22: LeetCode 733:图像渲染——从二维网格开始理解 DFS 和 BFS
数据结构·笔记·python·算法·leetcode·深度优先·宽度优先
Pniubi2 小时前
力扣41缺失的第一个正数(哈希表法)
算法·leetcode·职场和发展
今夜有雨.3 小时前
图像运算、掩膜/ROI 与绘制交互
c语言·数据结构·c++·qt·算法·计算机视觉
垆边人似月.4 小时前
日志时间窗口内的最大并发请求数(100分 / 滑动窗口 + 排序)
数据结构·c++·算法
Nil2084 小时前
leetcode 70爬楼梯
算法·leetcode·动态规划
Jasmine_llq5 小时前
《P5663 [CSP-J 2019] 加工零件》
数据结构·算法·bfs·广度优先搜索·分层 bfs /·奇偶分层最短路·奇数最短步数
stolentime5 小时前
AT_arc182_a [ARC182A] Chmax Rush!题解
数据结构·c++·算法·动态规划
longlongzihan5 小时前
LeetCode 189. 轮转数组:从直观模拟到最优原地算法
c++·算法·leetcode