本文参考labuladong算法笔记[二叉搜索树心法(后序篇) | labuladong 的算法笔记]
1、概述
本文是承接 东哥带你刷二叉树(纲领篇) 的第五篇文章,主要讲二叉树后序位置的妙用,复述下前文关于后序遍历的描述:
前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
其实二叉树的题目真的不难,无非就是前中后序遍历框架来回倒嘛,只要你把一个节点该做的事情安排好,剩下的抛给递归框架即可。
但是对于有的题目,不同的遍历顺序时间复杂度不同。尤其是这个后序位置的代码,有时候可以大幅提升算法效率。
我们再看看后序遍历的代码框架:
def traverse(root):
if not root:
return
traverse(root.left)
traverse(root.right)
# 后序代码的位置
# 在这里处理当前节点
看这个代码框架,你说什么情况下需要在后序位置写代码呢?
如果当前节点要做的事情需要通过左右子树的计算结果推导出来,就要用到后序遍历。
1373. 二叉搜索子树的最大键值和
给你一棵以 root
为根的 二叉树 ,请你返回 任意 二叉搜索子树的最大键值和。
二叉搜索树的定义如下:
- 任意节点的左子树中的键值都 小于 此节点的键值。
- 任意节点的右子树中的键值都 大于 此节点的键值。
- 任意节点的左子树和右子树都是二叉搜索树。
示例 1:
输入:root = [1,4,3,2,4,2,5,null,null,null,null,null,null,4,6] 输出:20 解释:键值为 3 的子树是和最大的二叉搜索树。
示例 2:
输入:root = [4,3,null,1,2] 输出:2 解释:键值为 2 的单节点子树是和最大的二叉搜索树。
示例 3:
输入:root = [-4,-2,-5] 输出:0 解释:所有节点键值都为负数,和最大的二叉搜索树为空。
示例 4:
输入:root = [2,1,3] 输出:6
示例 5:
输入:root = [5,4,8,3,null,6,3] 输出:7
提示:
- 每棵树有
1
到40000
个节点。 - 每个节点的键值在
[-4 * 10^4 , 4 * 10^4]
之间。
【题目分析】
题目会给你输入一棵二叉树,这棵二叉树的子树中可能包含二叉搜索树对吧,请你找到节点之和最大的那棵二叉搜索树,返回它的节点值之和。
二叉搜索树(简写作 BST)的性质详见基础知识章节 二叉树基础,简单说就是「左小右大」,对于每个节点,整棵左子树都比该节点的值小,整棵右子树都比该节点的值大。
比如题目给了这个例子:
如果输入这棵二叉树,算法应该返回 20,也就是图中绿圈的那棵子树的节点值之和,因为它是一棵 BST,且节点之和最大。
那有的读者可能会问,输入的是一棵普通二叉树,有没有可能其中不存在 BST?
不会的,因为按照 BST 的定义,任何一个单独的节点肯定是 BST,也就是说,再不济,二叉树最下面的叶子节点肯定都是 BST。
比如说如果输入下面这棵二叉树:
两个叶子节点 1
和 2
就是 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)
,而 traverse
遍历框架会在每个节点上都把这些辅助函数调用一遍,所以总的时间复杂度是 O(N^2)
。
但是根据刚才的分析,像 leftMax
、rootSum
这些变量又都得算出来,否则无法完成题目的要求。
【思路优化】
我们希望既算出这些变量,又避免辅助函数带来的额外复杂度,鱼和熊掌全都要,可以做到吗?
其实是可以的,只要把前序遍历变成后序遍历,让 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 个值,避免了重复遍历。
【python】
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
# 对于叶子结点,需要这么写才能得到自己作为叶子的最小值
res[1] = min(left[1], root.val)
# 对于叶子结点,需要这么写才能得到自己作为叶子的最大值
res[2] = max(right[2], root.val)
# 计算以 root 为根的这棵 BST 所有节点之和
res[3] = left[3] + right[3] + root.val
# 更新全局变量
self.maxSum = max(self.maxSum, res[3])
return res
这样,这道题就解决了,findMaxMinSum
函数在遍历二叉树的同时顺便把之前辅助函数做的事情都做了,避免了在递归函数中调用递归函数,时间复杂度只有 O(N)。
总结
1、考虑到本题要求子树问题,那自然是用[分解问题]思路,同时采用后序遍历,因为只有后序遍历才能拿到左右子树的信息。
2、对于单个树节点,需要掌握①自身是否是BST ②左子树的最小值 ③右子树的最大值 ④自身子树所有节点和,因此我们用一个数组res来记录这些信息。
3、递归终止条件:对于空节点来说,其本身得算一个BST,其节点和自然是0,但其节点最小值和最大值应设为float('inf')和float('-inf'),以便后续做比较时方便赋值。
4、后序位置:判断一个节点是否为BST后,依次刷新该节点的res数组信息,并比较该节点值之和与全局max_sum。