算法学习笔记——数据结构:二叉搜索树BST、AVL树、伸展树、B树、B+树

这篇博客介绍了二叉搜索树(BST)的基本概念和特性,包括中序遍历得到递增序列、查找节点、插入操作、删除操作以及验证BST的合法性。插入操作通过递归找到合适位置插入新节点,保持BST性质。删除节点时需考虑无子节点、一个子节点和两个子节点的情况。验证BST合法性使用了辅助函数,通过维护最小值和最大值范围来确保所有子树满足BST条件。

当数组和链表无法提供所需性能时,一种不错的选择时试试树。

现在关注查找/搜索任务:给定一个元素,需要确定其索引

  • 对于无序数组和链表,我们只能使用简单查找(逐个遍历),复杂度O(n)O(n)O(n)
  • 对于有序数组,高效的方式是二分查找,每次缩小一半搜索区间,查找复杂度O(logn)O(logn)O(logn)
    但是数组缺点在于插入删除需要整体移动大量元素,复杂度O(n)O(n)O(n)
  • 希望同时获得O(logn)O(logn)O(logn)查找复杂度和优于数组的插入删除速度,也就是AVL树(一种自平衡的BST),它兼具有序数组和链表的优点

二叉搜索树BST

二叉搜索树BST,即BinarySearchTree,其特性是:

  • 对于任意节点,如果其值为val,则左子树所有节点值小于等于val,且右子树所有节点值大于等于val
  • 在BST中查找某元素代码如下,理想情况下每次进入左/右子树砍掉了一半搜索范围,BST的查找行为与二分查找的核心思想高度一致,查找复杂度O(logn)O(logn)O(logn)
    def traverseBST(node: TreeNode, target: int):
    	"""BST遍历框架"""
        if node is None:
            return
        if node.val == target:
            # 找到目标,做点什么
        if target < node.val:  # 进入左子树
            traverseBST(node.left, target)
        if target > root.val:  # 进入右子树
            traverseBST(node.right, target)
  • BST树的平衡性决定了查找性能:树越矮,速度越快,因为当左右子树平衡时,查找时每次能排除更多元素

下面展示了两个BST树:
在这里插入图片描述
最佳情况下应该保证树高为lognlognlogn,查找复杂度O(logn)O(logn)O(logn)
最差情况下树高为nnn,查找复杂度也退化为O(n)O(n)O(n)

BST的其他性质:

  • 中序遍历BST,一定得到一个递增序列
  • BST子树,向左走到底,就是值最小的节点,向右走到底就是值最大的节点

AVL树:自平衡的BST

由前面可知,要使BST的查找性能达到高效的O(logn)O(logn)O(logn),应该尽量让BST更矮/更平衡
为此引入AVL树,它能自动纠正BST树失去平衡的情况,始终保证BST高度为lognlognlogn,这种自动平衡是通过旋转操作实现的(ps. 让树平衡有多种方式,AVL采用的是旋转)

基本原理

我们依次添加10、20、30、40、50这几个元素,观察如何使BST平衡
在这里插入图片描述
如图可见,当左右子树的高度差>=2,就需要对更高的子树进行旋转,以达到平衡

具体实现

  • 为了确定何时进行平衡,每个节点保存树高Height平衡因子Balance Factor
    平衡因子BF = 右子树树高 - 左子树树高;当BF={-1,0,1}是可以接受的,因为AVL不必完全平衡;当|BF|>=2也就是当左右子树的高度差>=2,就需要进行旋转
    (两种信息保存其中一种即可,下面为了说明同时标出了两者)

  • 每次添加节点,设置其树高和平衡因子,然后沿树回溯,更新该节点的祖先的树高和平衡因子
    在这里插入图片描述

  • 注意,回溯中(在回到根节点前)一旦发现|BF|>=2的点,立即旋转调整,调整后更新子树的树高和平衡因子;然后继续回溯直到根节点
    在这里插入图片描述

这次回溯没有更新任何信息,因为旋转后我们保证了根节点的平衡因子不变。实际上在AVL树中插入节点后最多只需要做一次重新平衡,平衡后不再需要继续沿树回溯

  • 上面只介绍了一种旋转的情况,没有包括所有情形,但是了解其思想即可,很少有需要自行实现AVL树的情况
  • 复杂度:AVL树的查找思想类似于二分查找,复杂度O(logn)O(logn)O(logn);插入操作本质上也是找出插入位置然后添加指针(类似链表),因此插入复杂度也是O(log)O(log)O(log)
    在这里插入图片描述

如图,AVL树(自平衡BST)就是一种查找和插入速度都很快的数据结构,兼顾了有序数组和链表的优点

注意AVL树不要求绝对意义的平衡:①子树的高可能相差1,②并不要求完全填满一层再开始填下一层(存在空洞)
如图比较了节点数为15的AVL树和完全平衡的树
在这里插入图片描述
如果考虑完全平衡的树,由于每层节点数都是2的幂次,总结点数2Nlayer−12^{N_{layer}}-12Nlayer1,因此查找性能就是O(log2n)O(log_2n)O(log2n),与二分查找相同
然而,AVL树存在空洞,每新增一层时,增加的节点数并不会达到前一层的两倍,因此AVL树的查找性能O(logn)O(logn)O(logn)实际上指的是O(logφn)O(log_{\varphi}n)O(logφn),其中φ≈1.618\varphi\approx1.618φ1.618为黄金分割比例
可见,AVL树和完全平衡树的查找性能非常接近,看上去都是O(logn)O(logn)O(logn)但区别在于底数不同,实际上AVL树的查找性能实际上略低于完全平衡树

伸展树Splay Tree

伸展树是另一种类型的BST,擅长重复查找的工作

  • 伸展树中,如果最近查找过一个元素,再次查找将变得更快
  • 原理:在伸展树中查找一个节点后,它(通过旋转等操作)将这个节点作为根节点
    一般而言,最近查找过的节点将聚集在根节点附近
  • 代价在于,无法保证树是平衡的。对于有些节点的查找时间可能超过O(logn)O(logn)O(logn),甚至需要线性时间
    但是,当执行nnn次查找时总时间为O(nlogn)O(nlogn)O(nlogn),也就是说每次查找的平均时间仍然保持在O(logn)O(logn)O(logn)

B树

B树是一种广义的BST,在完成查找任务的同时兼顾优化了内存调用,是数据库常用的数据结构

  • B树的特点:本身也是BST,因此对于每个键而言,左边所有键比它小、右边所有键比它大
    B树的每个节点可以有多个键多个子节点;并且子节点数 = 键数 + 1
    如图,其中一个节点有3、6两个键,同时有三个子节点
    在这里插入图片描述
  • B树中的数据访问方式也很特别:从左下角出发,蜿蜒前进遍历整棵树,可以发现遍历结果保证为一个单调递增序列
    在这里插入图片描述
  • B树的优点:对内存访问进行了物理优化
    计算机在树中检索数据时,需要移动磁盘头进行物理寻址(读取存储部件中相应位置的数据),需要花费寻道时间seek time。对于普通二叉树,每次检索数据都要花费一定的寻道时间,而B树通过提升节点大小和分支因子,支持局部性预读prefetching(一次寻道后将大量数据读入内存)显著减少了磁盘I/O操作和寻道时间,大大提升了磁盘存储系统的性能

B+树

B+树相比于B树在数据库领域更受欢迎,它极大地提高了查询效率和范围扫描的性能

  • 将索引和数据分离存储的设计:在B+树中,非叶子节点(也常被称为内部节点或索引节点)本身不存储实际的数据记录,它只做一件事:指路(充当多级索引的目录,用于快速导航和定位数据。)
  • 非叶子节点存储键(key)的副本和指向下一级节点的指针(pointer)
    叶子节点存储索引 (Key) + 实际数据 (或指向数据的指针)
  • 可加速查找过程:当需要查找一个数据时,数据库会从根节点开始,通过比较要查找的键值和当前非叶子节点中的“路标”键,来决定下一步应该去哪个子节点。
    这个是个从上到下,逐层缩小查找范围,直到最终到达实际数据的叶子节点的过程(类比查找书籍的目录章节的过程);而且由于B+树通常比较“矮胖”,查找效率极高

二叉搜索树BST相关例题

插入新节点

LeetCode 701. 二叉搜索树中的插入操作

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode:
        if root is None:  # 找到插入的空位
            return TreeNode(val)
        if val < root.val:  # 进入左子树
            root.left = self.insertIntoBST(root.left, val)
        if val > root.val:  # 进入右子树
            root.right = self.insertIntoBST(root.right, val)
        return root
       

判断BST合法性

LeetCode 98. 验证二叉搜索树

  • 注意陷阱:不能只看每个节点和它的左右儿子是否满足要求,应该要求整个左子树、右子树都满足要求才行
  • 如何保证左子树、右子树的所有节点都满足要求?
    可以增加函数参数,维护min和max,限制每个节点的合法范围,从而检查节点是否合法

技巧:如果当前结点对下面的子节点有整体影响,可以借助函数参数传递更多信息

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def isValidBST(self, root: TreeNode) -> bool:
        def valid(node, min, max):
            if node is None:
                return True
            if min is not None and node.val <= min:
                return False
            if max is not None and node.val >= max:
                return False
            return valid(node.left, min, node.val) and valid(node.right, node.val, max)

        return valid(root, None, None)

在BST中删除一个数

LeetCode 450. 删除二叉搜索树中的节点
总体框架是查找目标节点然后删除,但注意,删除后应该保持BST性质不变(分情况处理:无子节点、一个子节点、 两个子节点)

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def deleteNode(self, root: Optional[TreeNode], key: int) -> Optional[TreeNode]:
        def delete(node, key):
            """找到并删除节点,删除后应该保持BST性质不变"""
            if node is None:
                return None
            if node.val == key:  # 找到了,删除当前结点
                if node.left is None and node.right is None:  # 没有子节点
                    return None
                if node.left is None:  # 有一个子节点,让子节点接替自己的位置
                    return node.right
                if node.right is None:  # 有一个子节点,让子节点接替自己的位置
                    return node.left

                # 剩下同时有左右子树的情况,可以用左子树中最大的节点接替自己,也可用右子树最小的节点接替自己

                # 找到右子树最小的节点
                minNode = node.right
                while minNode.left is not None:
                    minNode = minNode.left

                # 接替自己,然后删除右子树中原来的节点(此节点位于最左侧,一定没有子节点)
                # ps. 当内部数据复杂时,直接修改指针更好
                node.val = minNode.val
                node.right = delete(node.right, minNode.val)

            elif node.val > key:  # 进入左子树
                node.left = delete(node.left, key)

            elif node.val < key:  # 进入右子树
                node.right = delete(node.right, key)
            return node

        return delete(root, key)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值