当数组和链表无法提供所需性能时,一种不错的选择时试试树。
现在关注查找/搜索任务:给定一个元素,需要确定其索引
- 对于无序数组和链表,我们只能使用简单查找(逐个遍历),复杂度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}}-12Nlayer−1,因此查找性能就是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)
这篇博客介绍了二叉搜索树(BST)的基本概念和特性,包括中序遍历得到递增序列、查找节点、插入操作、删除操作以及验证BST的合法性。插入操作通过递归找到合适位置插入新节点,保持BST性质。删除节点时需考虑无子节点、一个子节点和两个子节点的情况。验证BST合法性使用了辅助函数,通过维护最小值和最大值范围来确保所有子树满足BST条件。


816

被折叠的 条评论
为什么被折叠?



