算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)

1. 写在前面

今天这篇文章树专题的最后一块二叉搜索树的复习,这也是挺高频的一块, 首先需要知道二叉搜索树的特性和定义,大部分时候,解题思路就依赖着二叉搜索树的特性。二叉搜索树,也称有序二叉树,排序二叉树, 下列性质:

  1. 左子树上所有节点的值均小于它的根节点
  2. 右子树上所有节点的值均大于它的根节点
  3. 左右子树也分别是二叉搜索树

二叉搜索树的中序遍历是升序遍历, 二叉搜索树常见的操作是查询,插入新节点和删除等。

有了二叉搜索树的这些特性,其实会发现二叉搜索树的遍历方式和普通二叉树会有些区别,所以这里通过这篇文章把二叉搜索树的相关题目整理到一块来。 二叉搜索树这里的题目,会体会中序遍历框架的威力(一口气打六个), 如果再加上递归, 可以不夸张的说, 树专题这块的所有高频题目(大约40道), 7个遍历框架+1个递归思维框架能全部搞定, 做到真正一打五, 这就是底层思维的魅力, 真正的道, 下面开始默写

2. 二叉搜索树的属性

首先, 得先会从二叉搜索树中查找元素,二叉搜索树一个很大的作用就是方便查找,把从树中查找元素的时间复杂度降到log级别, 通过 LeetCode700: 二叉搜索树中的搜索这个题目,把如何在二叉搜索树中查找元素作为一个模板, 把下面两种查找方法学会默写。

递归查找方式, 这里正好又可以复习递归的四要素, 对于二叉搜索树中的元素查找, 我们考虑:

  1. 递归终止条件和参数设计: 终止条件是当前的root为空了, 就返回None,没法查找,注意这里是返回None。 参数设计上,就是root和要查找的值

  2. 当前层的逻辑: 这里就来对比一下,当前root的值是否是要查找的值,如果是,直接返回root,找到了目标

  3. 下一层的逻辑:这里利用了二叉搜索树的特性

    1. 如果当前root的值大于了要查找的值,那么说明要找的值可能在root的左子树,去左子树找,
    2. 如果当前root的值小于了要查找的值,说明要找的值可能在root的右子树, 去右子树找


    可以看到根据二叉搜索树的特性,可以每次把搜索空间缩小一半, 另外要注意这个只字,这个在代码中体现就是return,也就是我们不需要搜索整棵树。

  4. 返回值: 这里每一层之后,不需要返回什么东西, 找到的时候才返回,所以返回操作都放在上面做就行。

代码如下:

def searchBST(self, root: TreeNode, val: int) -> TreeNode:
        
    # 递归终止条件
    if not root:
        return None
        
    # 当前层的处理逻辑
    if root.val == val:
        return root
        
    # 下一层的逻辑
    if val < root.val:
        return self.searchBST(root.left, val)
    if val > root.val:
        return self.searchBST(root.right, val)
        
    return self.searchBST(root, val)

这个题的非递归搜索也需要掌握一下, 之前第一篇里面也提到过, 非递归搜索更能体会到二叉搜索树的结构性质,且处理中间结果的时候感觉更好处理一些,这个代码也是挺简洁的(相比之前各种遍历):

def searchBST(self, root: TreeNode, val: int) -> TreeNode:
        
    while root:
        if root.val > val:
            root = root.left
        elif root.val < val:
            root = root.right
        else:
            return root
    return None

下面梳理这里的一些题目了:

  • LeetCode98: 验证二叉搜索树: 这个题目二叉搜索树的中序遍历终于派上用场了, 我第一篇里面中序遍历那里的题目梳理依然是空, 终于可以把这个补进去了。 验证是否是二叉搜索树,可以利用二叉搜索树的性质: 中序遍历递增。所以这个题目可以用中序遍历的方式解决,具体的看第一篇, 整理到那里吧,顺便体会中序的非递归框架的强大之处。当然这个题目也可以用普通的递归思路解决, 还是想好四步, 这里的上界和下界参数很重要
    算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)_第1张图片
  • LeetCode530: 二叉搜索树的最小绝对差: 完全二叉树中序遍历的非递归模板, 修改当前节点的处理逻辑,这个题看上去挺吓人,但仔细一想,当前节点与其他节点的最小绝对值之差只可能出现在相邻节点,因为中序遍历递增,所以只需要当前求一下与之前节点的差值,然后更新全局最小即可。这个整理到第一篇的中序遍历那。
  • LeetCode501: 二叉搜索树的众数: 完全二叉树中序遍历的非递归模板, 需要修改当前节点的处理逻辑, 整理到了第一篇中序遍历那
  • LeetCode538: 把二叉搜索树转换为累加树: 完全二叉树中序遍历的非递归模板, 修改当前节点逻辑,需要先去右边,再去左边,并且当前值要进行累加操作,具体看第一篇中序遍历操作。
  • LeetCode230. 二叉搜索树中第K小的元素: 完全二叉树中序遍历非递归模板, 修改当前节点逻辑,k减操作
  • 剑指 Offer 54. 二叉搜索树的第k大节点: 完全二叉树中序遍历非递归逆序, 先去右再去左, 修改当前节点逻辑, k减操作

可以看到,二叉树中序遍历的非递归(或者递归)框架, 可以一口气打掉上面的6个, 只需要修改当前节点的处理逻辑,当然有时候需要逆序遍历,也就是先去右后去左,这种就看具体情况了。 但底层的思维框架是一样的。

当然,还有个要结合二叉搜索树特性进行搜索的一个题目, 就是LeetCode235: 二叉搜索树的最近公共祖先:这个题目用之前二叉树的后序遍历框架就能搞掉,但是时间会慢些, 毕竟二叉搜索树的数值排列是有规律可言的,所以对于这个题目的搜索, 考虑二叉搜索树的两个模板思路反而更好一些。 尤其是非递归的那个, 会发现找东西相当简洁, 关键是想清楚这个逻辑。
算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)_第2张图片

3. 二叉搜索树的修改与改造

这里是改变二叉搜索树自身结构的几个题目, 大部分考察二叉树的特性, 这时候二叉搜索树的搜索框架会发挥作用。首先是二叉搜索树的插入和删除操作

  • LeetCode701: 二叉搜索树的插入操作: 二叉树搜索树的非递归框架找父节点, 循环退出之后执行插入操作。递归框架的插入方式也可。
    算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)_第3张图片

  • LeetCode450: 删除二叉搜索中的节点: 这个题目涉及到了调整树的结构, 会比插入的有些复杂, 核心是想好如果找到要删除节点之后的处理逻辑, 这个想好了之后, 找的过程还是依托上面的两个框架, 这里一开始我用的非递归的那个框架, 有些麻烦,换成递归的可能会简单一些,这个题重点考虑各种情况,然后调整树结构。思路简单,就是找到删除的节点,然后操作

    • 如果此时删除节点左右子树都是空, 那么返回None
    • 如果此时有一个不是空, 返回不是空的那个
    • 如果此时都不是空, 那么找到删除节点右子树的最左边,然后把左子树接到最左边这个位置,返回删除节点的右子树。


    如果找不到删除节点,返回原树, 这个题如果用递归的方式, 那就是这个思路,核心是当前层的处理逻辑。这个方式代码相对简洁一些。

    class Solution:
        def deleteNode(self, root: TreeNode, key: int) -> TreeNode:
    
            # 如果没有找到,返回root
            if root == None: return root
    
            # 当前层处理逻辑
            if root.val == key:
                # 此时删除root,但得分为几种情况 左右都是空,一个为空, 不是空
                if not root.left and not root.right:
                    return None
                elif not root.left:
                    return root.right
                elif not root.right:
                    return root.left
                else:
                    cur = root.right
                    while cur.left:
                        cur = cur.left
                    cur.left = root.left
                    return root.right
            
            # 下一层 去查找,删除
            if root.val > key:
                root.left = self.deleteNode(root.left, key)
            if root.val < key:
                root.right = self.deleteNode(root.right, key)
            
            return root
    

    但如果是非递归代码, 看起来会冗余一些,因为非递归代码查找的时候需要记录父节点, 然后删除的时候,得判断是修改父节点的左孩子还是右孩子,没有标识,所以代码写起来得加一些额外的判断。并且为了统一删除根节点和其他节点的操作,我还弄了个根的前驱节点。我第一的代码就是非递归的a掉的。

    class Solution:
        def deleteNode(self, root: TreeNode, key: int) -> TreeNode:
    
            # 搞一个先驱节点, 为了后面的删除操作一致
            tree_pre = TreeNode(10000)
            tree_pre.left = root
    
            # 找要删除的节点
            pre, cur = tree_pre, root
            while cur:
                # 这时候cur是删除的节点
                if cur.val == key:
                    if not cur.left and not cur.right:  # 左右都是空
                        if cur.val < pre.val:
                            pre.left = None
                        else:
                            pre.right = None
                    elif not cur.left:          # 左边是空, 直接返回右
                        if cur.val < pre.val:
                            pre.left = cur.right
                        else:
                            pre.right = cur.right
                    elif not cur.right:            # 右边是空, 直接返回左
                        if cur.val < pre.val:
                            pre.left = cur.left
                        else:
                            pre.right = cur.left
                    else:                  
                        # 左右都不是空,找cur的右节点的最左边
                        temp = cur.right
                        while temp.left:
                            temp = temp.left
                        temp.left = cur.left
                        if cur.val < pre.val:
                            pre.left = cur.right
                        else:
                            pre.right = cur.right
                    
                    return tree_pre.left
                    
                # 下面开始找
                pre = cur
                if cur.val > key:
                    cur = cur.left
                else:
                    cur = cur.right
            
            return tree_pre.left
    
  • LeetCode669: 修剪二叉搜索树: 这个题一开始想复杂了, 直接拿上面的代码改的,因为这个题看了之后,发现如果不符合这个区间就相当于删除这个节点啊, 那么不就是上面的这个思路了吗? 于是乎就把上面的代码原版拿了过来, 然后==key那里改成了不符合情况, 当然这个得改成从底向上的修剪方式才行。 要不然可能有左子树没经过修剪就接到了右子树的最左边。果然a掉了。 看了题解之后,才发现想复杂了,这个题目其实不用一个个的判断与删除,而是能整片的删除。 比如给定我当前的一个树,如果我发现根节点小于最小值了, 那么根和左子树其实都小于最小值的,这时候修剪完了的树应该在右子树里面。 而根节点如果大于最大值了, 根和右子树显然都大于最大值,修剪完了的树在左子树里面。 如果不符合上面两种情况, 说明当前的节点合格了, 那么就修剪它的左子树和右子树就可以了。 所以这个代码才是最简洁的:

    算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)_第4张图片

  • LeetCode108: 将有序数组转换为二叉搜索树: 这个题目递归的思维框架就能搞定, 又涉及到了构造树了, 再过一遍递归的四部曲:

    1. 参数设计和返回条件: 涉及到构造树,一般标配是一个数组, 然后left和right两个参数用来控制下标
    2. 当前层的逻辑: 找到中点位置,然后构造出根节点, 然后计算出左右子树的长度
    3. 下一层的逻辑: 若左子树长度不为0, 递归构造左子树,右子树同理,控制好下标
    4. 返回: 每一层都需要返回构造好的树回来

    算法刷题重温(三): 二叉树之二叉搜索树系列打通(树专题)_第5张图片

4. 小总一下

用了二周的时间, 把树专题梳理完毕, 大约40到树的高频题目。 大致上都是围绕着二叉树的四大遍历方式和二叉搜索树的性质进行的各种变式。 这个过程中梳理了7个二叉树的遍历框架(前中后的递归和非递归,层次遍历), 1个递归思维框架(递归四部曲)和2个二叉搜索树的搜索框架(递归和非递归)。 正好算是10个模板代码,这些感觉非常重要。 因为二叉树这里的题目比较中规中矩, 虽然千变万化,都基本上都在考察固定的知识点,所以用这10个框架基本上都能搞定,这就是底层思维的重要性。但是碰到不同的题目,用不同框架的时候, 也应该有针对性些,有些题目深度遍历好想,有些题目层次遍历好想,注意此时关注好想而不是仅仅简单,论简单的话递归简单,但面试的时候不一定能写出来。 而好想了之后,自然而然就能记住了。 这里也总结Carl的一个规律,感受了一下,确实也是这样:

  • 涉及到二叉树的构造,无论普通二叉树还是二叉搜索树一定前序,都是先构造中节点。

  • 求普通二叉树的属性,一般是后序,一般要通过递归函数的返回值做计算。

  • 求二叉搜索树的属性,一定是中序了,毕竟明显的特性得用上。

然后就是悟出了几点刷题原则,当然不一定对,但感觉适合我哈哈:

  1. 代码追求清晰性,而不是过分简洁
  2. 学习底层思维,理解框架而不是刻意背代码
  3. 养成每天刷题的习惯,数量可以不管, 但最好是一个大类或者专题的,这样更容易看到底层的东西
  4. 勤记录, 尤其是这些底层的东西, 记录下来经常的翻看才行,不要过分的相信大脑,我一开始就犯了这个错误,总以为像树的遍历这些东西还用记吗?那么简单? 然后我拿出一张白纸写层序遍历和非递归的时候, 我知道了上面问题的答案,立马乖乖的记录了,这样方便有空看,混的眼熟了到时候人家才愿意出来帮咱

最后分门别类的汇总一下,方便复习想思路用。这里非常感谢瑞程(组队学习的伙伴)的整理,太赞了整理的。也重点参考了Carl大神的题解,这个非常适合突击之用哈哈。

  • 1.二叉树的遍历方式:

    • 144.二叉树的前序遍历
    • 145.二叉树的后序遍历
    • 94.二叉树的中序遍历
    • 102.二叉树的层序遍历
    • 107.二叉树的层次遍历II
    • 199.二叉树的右视图
    • 637.二叉树的层平均值
  • 2.求二叉树的属性:

    • 101.对称二叉树
    • 100.相同的树
    • 572.另一个树的子树
    • 104.二叉树的最大深度
    • 559.N叉树的最大深度
    • 111.二叉树的最小深度
    • 110.平衡二叉树
    • 257.二叉树的所有路径
    • 404.左叶子之和
    • 513.找树左下角的值
    • 112.路径总和
    • 113.路径总和II
  • 3.二叉树的修改与构造:

    • 226.翻转二叉树
    • 106.从中序与后序遍历序列构造二叉树
    • 105.从前序与中序遍历序列构造二叉树
    • 654.最大二叉树
    • 617.合并二叉树
  • 4.求二叉搜索树的属性:

    • 700.二叉搜索树中的搜索
    • 98.验证二叉搜索树
    • 530.二叉搜索树的最小绝对差
    • 501.二叉搜索树中的众数
    • 538.把二叉搜索树转换为累加树
  • 6.二叉树公共祖先问题:

    • 236.二叉树的最近公共祖先
    • 235.二叉搜索树的最近公共祖先
  • 7.二叉树搜索树的修改与构造:

    • 701.二叉搜索树中的插入操作
    • 450.删除二叉搜索树中的节点
    • 669.修剪二叉搜索树
    • 108.将有序数组转换为二叉搜索树

你可能感兴趣的:(算法刷题笔记,算法刷题,LeetCode,二叉树,二叉搜索树)