数据结构 | 二叉树刷题指引(面试高频基础题)

前言

  • 优秀的算法往往取决于采用的数据结构,在算法面试中,通常涉及较多的还是基本的数据结构,其中树(Tree)是最常考的,也是最难的,建议应试者优先准备关于树的面试题;
  • 在这篇文章里,我将梳理树的基本知识 & 常考题型。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

系列文章


目录


1. 概述

从逻辑上来说,树(Tree)是一种非线性结构,树的节点包含元素值与所有子节点的列表。如果按照图的理论来说,树可以看作是一种特殊的图(N 个节点和 N - 1 条边的有向无环图);

从存储上来说,树可以采用数组 & 链表两种存储方式。链表存储法很直接明了,就是每个节点里都持有子节点的引用,而数组存储法则需要利用下标来寻找父子节点关系,节点内部不再需要持有子节点的引用,以二叉树为例:

  1. 第 [0] 位不存储,根节点存储在第 [1] 位
  2. 对于第 [i] 位上的节点,第 [2 * i] 位是其左节点,第[2 * i + 1] 位是起右节点
  3. 对于第 [i] 位上的节点,第 [i / 2] 位是其父节点
链式与数组对比(引用自《数据结构与算法之美》—— 王争 著)

2. 树的遍历

树的遍历是树最最重点的知识,也是常考的点,因为在解决其他问题的时候,通常就需要遍历整棵树来寻找答案,所以我们必须对 树的各种遍历方式 非常熟悉(特别是二叉树的遍历),同时,递归与非递归 两种实现都需要掌握。

总结常见的题目,可以将遍历一棵树的方式分为:前序遍历 & 中序遍历 & 后序遍历 & 层序遍历 & 路径遍历,这几种遍历方式较常见,另外还有一种比较冷门的垂序遍历

解决前序遍历 & 中序遍历 & 后序遍历这三类问题,需要使用 DFS 遍历,解决层序遍历问题需要使用 BFS 遍历,两个的遍历路径如下图:

DFS & BFS 对比(引用自 LeetCode)

2.1 前序遍历 & 中序遍历 & 后序遍历

这三种遍历的区别:访问到一个节点时,处理当前节点与处理左右子树的顺序不同。在前序遍历中,访问节点顺序与处理节点顺序是一致的,而另外两种是不一致的。

Preorder (root) {
  1. access content of root 
  2. Call Preorder(root.left)
  3. Call Preorder(root.right)
}

Postorder (root) {
  1. Call Postorder(root.left)
  2. Call Postorder(root.right)
  3. access content of root
}

Inorder (root) {
  1. Call Inorder(root.left) 
  2. access content of root
  3. Call Inorder(root.right)
}
  • 递归解法
    递归解法可以说很简单了,就是调整左右子树的递归顺序即可:
  • 非递归解法
    BFS 的非递归解法需要利用栈的 LIFO 特性:

2.2 层序遍历

层序遍历需要利用队列的 FIFO 特性,即:每次迭代都将一整层的节点放进队列里,如下图所示:

层序遍历(引用自《BFS 的使用场景总结:层序遍历、最短路径问题》 —— nettee 著)
fun levelOrder(root: TreeNode?): List<List<Int>> {
        val result = ArrayList<List<Int>>()

        val queue: Queue<TreeNode> = LinkedList()

        if (null != root) {
            queue.offer(root)
        }

        while (!queue.isEmpty()) {
            val level = ArrayList<Int>()
            // 处理一层
            for (index in 0 until queue.size) {
                val node = queue.poll()
                level.add(node.`val`)
                if (null != node.left) {
                    queue.offer(node.left)
                }
                if (null != node.right) {
                    queue.offer(node.right)
                }
            }
            if (level.isNotEmpty()) {
                result.add(level)
            }
        }
        return result
    }

另外,层序遍历也是常见的变型题,例如自底向上、锯齿形,其实无非就是改变输出结果的步骤:

- 锯齿形:

var flag = true

for(...){
    if(flag){
        level.add(node.`val`)
    }else{
        level.addFirst(node.`val`)
    }
}
...
flag = !flag

- 自底向上:

for(...){
    ...
}

result.addFirst(level)

2.3 路径遍历

路径遍历其实是前面四种遍历的升级版,无非就是将经过的节点记录到String。下图的 DFS 解法使用了最简单的前序遍历方法:

2.4 垂序遍历

Editting...


3. 树的概念

通常来说不会直接考察树的相关概念,但是理解清楚这些概念是解决其他复杂问题的基础。

树的相关概念(引用自《数据结构与算法之美》—— 王争 著)
  • 路径:从一个节点走到另一个节点的经过的节点
  • 距离:两个节点到最近共同祖先的路径和(通常指最短距离)
  • 高度:从节点到叶子节点的路径
  • 深度:从节点到根节点的路径
  • 宽度:每层两个端点(该层最左和最右的非空节点,两端点间的 null 节点也计入长度)之间的长度

4. 递归思想

经常地,一棵树要满足某种性质,都会要求它的每个节点也都满足该性质,例如对于一棵二叉搜索树,从它的每棵子树观察,都是一棵二叉搜索树;或者当我们要求解一棵树的某最优解时,一般都可以先求出左右两棵子树的解,再结合当前节点求出最终解。因此,往往可以通过递归函数求解树相关的问题。例如:求二叉树的最大深度:

要求一棵树的最大深度,如果已知左右两个子树的最大深度,那么很明显这棵树的最大深度,就是两棵子树结果的最大值再加一。所以这个问题用递归就很轻松可以解决,当然是用层序遍历也能解决:

fun maxDepth(root: TreeNode?): Int {
    if (root == null) {
        return 0
    } else {
        val leftHeight = maxDepth(root.left)
        val rightHeight = maxDepth(root.right)
        return Math.max(leftHeight, rightHeight) + 1
    }
}

5. 路径问题

前面我们定义了路径的概念:从一个节点走到另一个节点的经过的节点,这一节我们专门讨论路径相关的问题。

5.1 向下的路径(从根到叶子的路径)

这一类问题找出一条满足某个条件的路径,要求路径是从根节点到叶子节点。这等于是指明了路径的起始点和终止点,因此这类问题只需要按照 第2.3节 - 路径遍历 即可轻松解决。

路径要求从根节点到叶子节点

5.2 向下的路径(非必须从根到叶子)

路径不要求从根节点到叶子节点

这一类问题不要求路径是从根节点到叶子节点,可以经过也可以不经过。因此路径的起始点和终止点就不确定了,难度会稍稍增大,那么应该怎么解呢?这就要引入 前缀和 的概念。

假设我们要找和为 10 的路径,我们可以逐步记录访问到节点的前缀和,当我们访问到一个点(记为 B),它的前缀和 - 目标和 正好与之前记录的一个前缀和相同(记为 A),说明从 A 到 B 之间经过的路径和正好就是目标和:

5.3 自由路径

这一类题目不再要求路径是从上到下的,选择的可能性更多,例如下图中经过 A 节点的路径总共有 4 条:

那么这类问题应该怎么解呢?还记得我们说的 递归思想吗?我们要求这棵树的最优解,假设我们已求得左右两颗子树的解,那么再结合当前节点求出最终解,通用的解题模板 & 思路如下:

var result = Integer.MIN_VALUE

fun search(node : TreeNode) : Int{
    1. 左子树最优解
    val leftResult = search(node.left)

    2. 右子树最优解
    val rightResult = search(node.right)

    3. 结合当前节点得出当前解
    val bestResult = ...

    4. 更新最优解 result

    5. 返回当前解
}

例如:124. Binary Tree Maximum Path Sum 二叉树的最大路径和 在这道题里,不要求路径是从上向下的,也不要求结果一定要经过根节点,但是如果我们先假设结果是经过根节点的。给定一棵树,假设已知左子树和右子树的最大路径和,那么对于当前树,最大路径和就是两棵子树结果最大值加上根节点的值。当然,根节点的最大路径和不一定是整棵树的最大路径和,因此我们需要使用一个变量记录录得的最大值。


6. 祖先问题

6.1 最近共同祖先

6.2 最远共同祖先

5.5 距离


7. 特殊的树

前面我们将的树都是普通的二叉树,下面讨论常见的几种特殊的二叉树:

7.1 满二叉树

满二叉树中,叶子节点全部在最底层,即:除了叶子节点外,每个节点都拥有左节点和右节点。对于一棵满二叉树,从任意一个子树看都是满二叉树。

编号2为满二叉树

7.2 完全二叉树

完全二叉树中,叶子节点都在最后两层,并且最后一层的节点都靠左排列。对于一棵完全二叉树,从任意一个子树看都是完全二叉树。

7.3 二叉搜索树

二叉搜索树中,对于任意节点的值来说,都大于左子树中每个节点的值,都小于右子树中每个节点的值。对于一棵二叉搜索树,从任意一个子树看都是二叉搜索树。

7.4 平衡二叉树

平衡二叉树中,对于任意节点来说,左右子树的高度差不大于1。对于一棵平衡二叉树,从任意一个子树看都是平衡二叉树。

平衡二叉树避免了二叉树左右子树高度相差太大是时间和空间复杂度退化的问题。但需要注意的是,在实践中使用的是“近似平衡”,只需要保证左右子树高度相对平均,并不需要严格准守高度差不大于 1 的定义。

7.5 平衡二叉搜索树

平衡二叉搜索树有很多种,例如伸展树(Splay Tree)、树堆(Treap)、红黑树(AVL),其中红黑树是最常见的。


8. 总结

    1. 树的遍历解决树问题的基本编程技巧,必须熟练掌握递归与非递归两种写法;
    1. 树的概念是理解题意的前提,必须保证理解清晰,没有混淆;
    1. 递归思想非常适用于解决树问题,当你遇到一个问题没有解题思路时,应该先想想:如果你知道左右两个子树(子问题)的答案,是否能清晰的解决当前树的问题
    1. 树的路径 & 祖先问题是面试中的常客

参考资料

推荐阅读

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!