二叉搜索树


二叉搜索树心法(构造篇)

本文讲解的例题

LeetCode 力扣 难度
95. Unique Binary Search Trees II 95. 不同的二叉搜索树 II 🟠
96. Unique Binary Search Trees 96. 不同的二叉搜索树 🟠

前置知识

阅读本文前,你需要先学习:

之前写了两篇手把手刷 BST 算法题的文章,第一篇 讲了中序遍历对 BST 的重要意义,第二篇 写了 BST 的基本操作。

本文就来写手把手刷 BST 系列的第三篇,循序渐进地讲两道题,如何计算所有有效 BST。

第一道题是力扣第 96 题「不同的二叉搜索树」:

96. 不同的二叉搜索树 | 力扣 | LeetCode |  🟠

给你一个整数 n ,求恰由 n 个节点组成且节点值从 1  n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。

示例 1:

输入:n = 3
输出:5

示例 2:

输入:n = 1
输出:1

提示:

  • 1 <= n <= 19
题目来源:力扣 96. 不同的二叉搜索树

函数签名如下:

def numTrees(n: int) -> int:

这就是一个正宗的穷举问题,那么什么方式能够正确地穷举有效 BST 的数量呢?

我们前文说过,不要小看「穷举」,这是一件看起来简单但是比较有技术含量的事情,问题的关键就是不能数漏,也不能数多,你咋整?

之前 手把手刷二叉树第一期 说过,二叉树算法的关键就在于明确根节点需要做什么,其实 BST 作为一种特殊的二叉树,核心思路也是一样的。

举个例子,比如给算法输入 n = 5,也就是说用 {1,2,3,4,5} 这些数字去构造 BST。

首先,这棵 BST 的根节点总共有几种情况?

显然有 5 种情况对吧,因为每个数字都可以作为根节点。

比如说我们固定 3 作为根节点,这个前提下能有几种不同的 BST 呢?

根据 BST 的特性,根节点的左子树都比根节点的值小,右子树的值都比根节点的值大。

所以如果固定 3 作为根节点,左子树节点就是 {1,2} 的组合,右子树就是 {4,5} 的组合。

左子树的组合数和右子树的组合数乘积就是 3 作为根节点时的 BST 个数。

img

我们这是说了 3 为根节点这一种特殊情况,其实其他的节点也是一样的。

那你可能会问,我们可以一眼看出 {1,2}{4,5} 有几种组合,但是怎么让算法进行计算呢?

其实很简单,只需要递归就行了,我们可以写这样一个函数:

# 定义:闭区间 [lo, hi] 的数字能组成 count(lo, hi) 种 BST
def count(lo: int, hi: int) -> int:

根据这个函数的定义,结合刚才的分析,可以写出代码:

class Solution:
    def numTrees(self, n: int) -> int:
        # 计算闭区间 [1, n] 组成的 BST 个数
        return self.count(1, n)

    # 计算闭区间 [lo, hi] 组成的 BST 个数
    def count(self, lo: int, hi: int) -> int:
        # base case
        if lo > hi: 
            return 1

        res = 0
        for i in range(lo, hi+1):
            # i 的值作为根节点 root
            left = self.count(lo, i - 1)
            right = self.count(i + 1, hi)
            # 左右子树的组合数乘积是 BST 的总数
            res += left * right

        return res

注意 base case,显然当 lo > hi 闭区间 [lo, hi] 肯定是个空区间,也就对应着空节点 null,虽然是空节点,但是也是一种情况,所以要返回 1 而不能返回 0。

这样,题目的要求已经实现了,但是时间复杂度非常高,肯定存在重叠子问题。

前文动态规划相关的问题多次讲过消除重叠子问题的方法,无非就是加一个备忘录:

class Solution:
    # 备忘录
    def __init__(self):
        self.memo = []

    def numTrees(self, n: int) -> int:
        # 备忘录的值初始化为 0
        self.memo = [[0] * (n + 1) for _ in range(n + 1)]
        return self.count(1, n)

    # 定义:返回 [lo, hi] 范围内构造的不同 BST 的数量
    def count(self, lo: int, hi: int) -> int:
        if lo >= hi:
            return 1
        # 查备忘录
        if self.memo[lo][hi] != 0:
            return self.memo[lo][hi]

        res = 0
        for mid in range(lo, hi + 1):
            left = self.count(lo, mid - 1)
            right = self.count(mid + 1, hi)
            res += left * right
        # 将结果存入备忘录
        self.memo[lo][hi] = res

        return res
 算法可视化面板
 算法可视化面板

这样,这道题就完全解决了。

那么,如果给一个进阶题目,不止让你计算有几个不同的 BST,而是要你构建出所有有效的 BST,如何实现这个算法呢?

这道题就是力扣第 95 题「不同的二叉搜索树 II」,让你构建所有 BST:

95. 不同的二叉搜索树 II | 力扣 | LeetCode |  🟠

给你一个整数 n ,请你生成并返回所有由 n 个节点组成且节点值从 1  n 互不相同的不同 二叉搜索树 。可以按 任意顺序 返回答案。

示例 1:

输入:n = 3
输出:[[1,null,2,null,3],[1,null,3,2],[2,1,3],[3,1,null,null,2],[3,2,null,1]]

示例 2:

输入:n = 1
输出:[[1]]

提示:

  • 1 <= n <= 8
题目来源:力扣 95. 不同的二叉搜索树 II

函数签名如下:

List<TreeNode> generateTrees(int n);

明白了上道题构造有效 BST 的方法,这道题的思路也是一样的

1、穷举 root 节点的所有可能。

2、递归构造出左右子树的所有有效 BST。

3、给 root 节点穷举所有左右子树的组合。

我们可以直接看代码:

class Solution:
    def generateTrees(self, n: int) -> List[TreeNode]:
        if n == 0:
            return []
        # 构造闭区间 [1, n] 组成的 BST 
        return self.build(1, n)

    # 构造闭区间 [lo, hi] 组成的 BST 
    def build(self, lo: int, hi: int) -> List[TreeNode]:
        res = []
        # base case
        if lo > hi:
            # 这里需要装一个 null 元素,这样才能让下面的两个内层 for 循环都能进入,正确地创建出叶子节点
            # 举例来说吧,什么时候会进到这个 if 语句?当你创建叶子节点的时候,对吧。
            # 那么如果你这里不加 null,直接返回空列表,那么下面的内层两个 for 循环都无法进入        
            # 你的那个叶子节点就没有创建出来,看到了吗?所以这里要加一个 null,确保下面能把叶子节点做出来
            res.append(None)
            return res

        # 1、穷举 root 节点的所有可能。
        for i in range(lo, hi + 1):
            # 2、递归构造出左右子树的所有有效 BST。
            leftTree = self.build(lo, i - 1)
            rightTree = self.build(i + 1, hi)
            # 3、给 root 节点穷举所有左右子树的组合。
            for left in leftTree:
                for right in rightTree:
                    # i 作为根节点 root 的值
                    root = TreeNode(i)
                    root.left = left
                    root.right = right
                    res.append(root)

        return res
 算法可视化面板
 算法可视化面板

这样,两道题都解决了。

本文就到这里,更多经典的二叉树习题以及递归思维的训练,请参见二叉树章节中的 递归专项练习

二叉搜索树心法(后序篇)

本文讲解的例题

LeetCode 力扣 难度
1373. Maximum Sum BST in Binary Tree 1373. 二叉搜索子树的最大键值和 🔴

前置知识

阅读本文前,你需要先学习:

本文是承接 二叉树心法(纲领篇) 的第五篇文章,主要讲二叉树后序位置的妙用,复述下前文关于后序遍历的描述:

前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。

那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了

其实二叉树的题目真的不难,无非就是前中后序遍历框架来回倒嘛,只要你把一个节点该做的事情安排好,剩下的抛给递归框架即可。

但是对于有的题目,不同的遍历顺序时间复杂度不同。尤其是这个后序位置的代码,有时候可以大幅提升算法效率。

我们再看看后序遍历的代码框架:

def traverse(root):
    if not root:
        return
    traverse(root.left)
    traverse(root.right)
    # 后序代码的位置
    # 在这里处理当前节点

看这个代码框架,你说什么情况下需要在后序位置写代码呢?

如果当前节点要做的事情需要通过左右子树的计算结果推导出来,就要用到后序遍历

下面就讲一个经典的算法问题,可以直观地体会到后序位置的妙用。这是力扣第 1373 题「二叉搜索子树的最大键值和」,函数签名如下:

def maxSumBST(root: TreeNode)

题目分析

题目会给你输入一棵二叉树,这棵二叉树的子树中可能包含二叉搜索树对吧,请你找到节点之和最大的那棵二叉搜索树,返回它的节点值之和。

二叉搜索树(简写作 BST)的性质详见基础知识章节 二叉树基础,简单说就是「左小右大」,对于每个节点,整棵左子树都比该节点的值小,整棵右子树都比该节点的值大。

比如题目给了这个例子:

img

如果输入这棵二叉树,算法应该返回 20,也就是图中绿圈的那棵子树的节点值之和,因为它是一棵 BST,且节点之和最大。

那有的读者可能会问,输入的是一棵普通二叉树,有没有可能其中不存在 BST?

不会的,因为按照 BST 的定义,任何一个单独的节点肯定是 BST,也就是说,再不济,二叉树最下面的叶子节点肯定都是 BST。

比如说如果输入下面这棵二叉树:

img

两个叶子节点 12 就是 BST,比较一下节点之和,算法应该返回 2。

好了,到这里,题目应该解释地很清楚了,下面我们来分析一下这道题应该怎么做。

思路分析

刚才说了,二叉树相关题目最核心的思路是明确当前节点需要做的事情是什么

那么我们想计算子树中 BST 的最大和,站在当前节点的视角,需要做什么呢

1、我肯定得知道左右子树是不是合法的 BST,如果下面的这俩儿子有一个不是 BST,以我为根的这棵树肯定不会是 BST,对吧。

2、如果左右子树都是合法的 BST,我得瞅瞅左右子树加上自己还是不是合法的 BST 了。因为按照 BST 的定义,当前节点的值应该大于左子树的最大值,小于右子树的最小值,否则就破坏了 BST 的性质。

3、因为题目要计算最大的节点之和,如果左右子树加上我自己还是一棵合法的 BST,也就是说以我为根的整棵树是一棵 BST,那我需要知道我们这棵 BST 的所有节点值之和是多少,方便和别的 BST 争个高下,对吧。

根据以上三点,站在当前节点的视角,需要知道以下具体信息

1、左右子树是否是 BST。

2、左子树的最大值和右子树的最小值。

3、左右子树的节点值之和。

只有知道了这几个值,我们才能满足题目的要求,现在可以尝试用伪码写出算法的大致逻辑:

class Solution:
    
    def __init__(self):
        # 全局变量,记录 BST 最大节点之和
        self.maxSum = 0

    def maxSumBST(self, root):
        self.traverse(root)
        return self.maxSum

    # 遍历二叉树
    def traverse(self, root):
        if root is None:
            return

        # ******* 前序位置 *******
        # 判断左右子树是不是 BST
        if self.isBST(root.left) and self.isBST(root.right):
            # 计算左子树的最大值和右子树的最小值
            leftMax = self.findMax(root.left)
            rightMin = self.findMin(root.right)
            # 判断以 root 节点为根的树是不是 BST
            if root.val > leftMax and root.val < rightMin:
                # 如果条件都符合,计算当前 BST 的节点之和
                leftSum = self.findSum(root.left)
                rightSum = self.findSum(root.right)
                rootSum = leftSum + rightSum + root.val
                # 计算 BST 节点的最大和
                self.maxSum = max(self.maxSum, rootSum)
        # **************************

        # 二叉树遍历框架,遍历子树节点
        self.traverse(root.left)
        self.traverse(root.right)

    # 计算以 root 为根的二叉树的最大值
    def findMax(self, root):
        pass

    # 计算以 root 为根的二叉树的最小值
    def findMin(self, root):
        pass

    # 计算以 root 为根的二叉树的节点和
    def findSum(self, root):
        pass

    # 判断以 root 为根的二叉树是否是 BST
    def isBST(self, root):
        pass

这个代码逻辑应该是不难理解的,代码在前序遍历的位置把之前的分析都实现了一遍。

其中有四个辅助函数比较简单,我就不具体实现了,其中只有判断合法 BST 的函数稍有技术含量,前文 二叉搜索树操作集锦 写过,这里就不展开了。

稍作分析就会发现,这几个辅助函数都是递归函数,都要遍历输入的二叉树,外加 traverse 函数本身的递归,可以说是递归上加递归,所以这个解法的复杂度是非常高的

具体来说,每一个辅助方法都是二叉树遍历函数,时间复杂度是 O(N)O(N),而 traverse 遍历框架会在每个节点上都把这些辅助函数调用一遍,所以总的时间复杂度是 O(N2)O(N2)。

但是根据刚才的分析,像 leftMaxrootSum 这些变量又都得算出来,否则无法完成题目的要求。

思路优化

我们希望既算出这些变量,又避免辅助函数带来的额外复杂度,鱼和熊掌全都要,可以做到吗?

其实是可以的,只要把前序遍历变成后序遍历,让 traverse 函数把辅助函数做的事情顺便做掉

你仔细想想,如果我知道了我的左右子树的最大值,那么把我的值和它们比较一下,就可以推导出以我为根的这整棵二叉树的最大值。根本没必要再遍历一遍所有节点,对吧?求最小节点的值和节点的和也是一样的道理。

这就是我在前文 手把手带你刷二叉树(纲领篇) 所讲的后序遍历位置的妙用。

当然,正如前文所讲,如果要利用函数的返回值,就不建议使用 traverse 这个函数名了,我们想计算最大值、最小值和所有节点之和,不妨叫这个函数 findMaxMinSum 好了。

其他代码不变,我们让 findMaxMinSum 函数做一些计算任务,返回一个大小为 4 的 int 数组,我们暂且称它为 res,其中:

res[0] 记录以 root 为根的二叉树是否是 BST,若为 1 则说明是 BST,若为 0 则说明不是 BST;

res[1] 记录以 root 为根的二叉树所有节点中的最小值;

res[2] 记录以 root 为根的二叉树所有节点中的最大值;

res[3] 记录以 root 为根的二叉树所有节点值之和。

对于当前节点,如果分别对左右子树计算出了这 4 个值,只需要简单的运算,就可以推导出以当前节点为根的二叉树的这 4 个值,避免了重复遍历。

直接看代码实现吧:

class Solution:
    
    def __init__(self):
        # 记录 BST 最大节点之和
        self.maxSum = 0

    def maxSumBST(self, root):
        self.findMaxMinSum(root)
        return self.maxSum

    # 计算以 root 为根的二叉树的最大值、最小值、节点和
    def findMaxMinSum(self, root):
        # base case
        if root is None:
            return [1, float('inf'), float('-inf'), 0]
        
        # 递归计算左右子树
        left = self.findMaxMinSum(root.left)
        right = self.findMaxMinSum(root.right)

        # ******* 后序位置 *******
        # 通过 left 和 right 推导返回值
        # 并且正确更新 maxSum 变量
        res = [0, 0, 0, 0]
        if left[0] == 1 and right[0] == 1 and root.val > left[2] and root.val < right[1]:
            # 以 root 为根的二叉树是 BST
            res[0] = 1
            # 计算以 root 为根的这棵 BST 的最小值
            res[1] = min(left[1], root.val)
            # 计算以 root 为根的这棵 BST 的最大值
            res[2] = max(right[2], root.val)
            # 计算以 root 为根的这棵 BST 所有节点之和
            res[3] = left[3] + right[3] + root.val
            # 更新全局变量
            self.maxSum = max(self.maxSum, res[3])
        else:
            # 以 root 为根的二叉树不是 BST
            res[0] = 0
            # 其他的值都没必要计算了,因为用不到
            
        return res

这样,这道题就解决了,findMaxMinSum 函数在遍历二叉树的同时顺便把之前辅助函数做的事情都做了,避免了在递归函数中调用递归函数,时间复杂度只有 O(N)。

最后总结

你看,这就是后序遍历的妙用,相对前序遍历的解法,现在的解法不仅效率高,而且代码量少,比较优美。

这个优化属于前文 算法的本质 中提到的「如何聪明的穷举」的范畴。

那可能有读者问,后序遍历这么好,是不是就应该尽可能多用后序遍历?

不是,主要是看题目,看你这个问题适合「遍历」的思路还是「分解问题」的思路。为什么这道题用后序遍历有奇效呢,因为我们需要的这些变量全都可以通过子问题的结果推到出来,适合用「分解问题」的思路求解。

你计算以 root 为根的二叉树的节点之和,是不是可以通过左右子树的和加上 root.val 计算出来?

你计算以 root 为根的二叉树的最大值/最小值,是不是可以通过左右子树的最大值/最小值和 root.val 比较出来?

你判断以 root 为根的二叉树是不是 BST,是不是得先判断左右子树是不是 BST?是不是还得看看左右子树的最大值和最小值?

那么充分利用子问题的答案,当然要比每次都傻乎乎遍历所有节点要高效。

以我的刷题经验,我们要尽可能避免递归函数中调用其他递归函数,如果出现这种情况,大概率是代码实现有瑕疵,可以进行类似本文的优化来避免递归套递归。

本文就到这里,更多经典的二叉树习题以及递归思维的训练,请参见二叉树章节中的 递归专项练习


文章作者: Mealsee
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Mealsee !
  目录