Lec 7 AVL树
我们这节课的终极目标是实现树的平衡:n个节点的树如果它的高度是
- 高度平衡
- 树的旋转
- 局部重平衡
- 全局重平衡
- 计算高度
- 应用:序列
- 应用:排序
- 练习题
高度平衡
如何维护树的高度
为了形式化论证的方便,我们定义节点的倾斜度s为其右子树高度减去左子树高度,那么我们说,如果一个节点的偏斜度为 -1, 0 或 1 ,那么它是高度平衡的。
IMPORTANT
【定理】高度平衡树(height-balanced tree)是平衡的,即
一个高度平衡的二叉树的高度为
证明:平衡意味着,
树的旋转
当我们高度平衡的树中添加或删除叶节点时,可能会导致不平衡,我们想要在不改变遍历顺序的情况下改变树的结构——通过旋转!旋转操作会将一个子树从以下两种局部结构中的一种转换为另一种,并通过在 O(1)时间内修改节点之间的连接来实现这种转换

上述操作保留了遍历顺序,但能改变<A>和<E>的深度,后面会讲解如何在插入或或者删除一个节点后强制平衡。
def subtree_rotate_right(D):
assert D.left
B, E = D.left, D.right
A, C = B.left, B.right
D, B = B, D
D.item, B.item = B.item, D.item
B.left, B.right = A, D
D.left, D.right = C, E
if A:
A.parent = B
if E:
E.parent = D
#B.subtree_update()
#D.subtree_update()
def subtree_rotate_left(B):
assert B.right
A, D = B.left, B.right
C, E = D.left, D.right
B, D = D, B
B.item, D.item = D.item, B.item
D.left, D.right = B, E
B.left, B.right = A, C
if C:
C.parent = B
if E:
E.parent = D
#B.subtree_update()
#D.subtree_update()局部再平衡
IMPORTANT
当给定二叉树节点<B>满足以下条件时:
- 其偏斜度skew为 2(即右子树比左子树高2);
- <B> 的子树中的所有其他节点都是高度平衡的;
那么我们可以通过 一次或两次旋转,将以<B> 为根的子树重新变为高度平衡的,并且旋转后的<B> 的高度最多减少 1
Proof:既然 skew(<B>) = 2,说明<B> 的右子节点 <F> 存在。我们考虑 <F> 的 skew 有三种情况:
- 情况 1:<F> 的偏斜度为 0 或
- 情况 2:<F> 的偏斜度为 1
此时可以直接对<B> 执行一次左旋转,令设
- 如果skew(<F>) = 0,说明 height(<D>) = h + 1
- 如果skew(<F>) = 1,说明 height(<D>) = h

旋转后:
- <B> 的偏斜度在情况 1 中为 1,在情况 2 中为 0,因此<B> 是高度平衡的
- <F> 的偏斜度为 -1,因此 <F> 是高度平衡的
- <B> 的高度在之前是
,之后在情况 1 中为 ,在情况 2 中为
情况 3:<F> 的偏斜度为 -1,因此 <F> 的左孩子 <D> 存在, 先需要对 <F> 执行一次右旋转,然后对<B> 执行一次左旋转(双旋)

设
旋转后:
- <B> 的偏斜度是 0 或 -1,因此<B> 是高度平衡的
- <F> 的偏斜度是 0 或 1,因此 <F> 是高度平衡的
- <D> 的偏斜度为 0,因此 <D> 是高度平衡的
- <B> 的高度在之前是 h + 3,之后为 h + 2
全局再平衡
假设我们有一棵高度平衡的AVL树,并且通过添加或删除叶子节点执行了一次插入或删除操作。操作后的树要么仍然是高度平衡的,要么至少有一个节点的平衡因子(skew)绝对值大于1。特别地,在叶子节点修改后,树中子树发生变化的唯一节点是该叶子节点的祖先节点(最多 O(h) 个),因此这些节点是唯一可能发生平衡因子变化的节点,并且其平衡因子最多变化1,达到绝对值为2。
假设一个子树的根节点的平衡因子为2,且该子树的其他所有节点都是高度平衡的,我们可以通过最多两次旋转恢复该子树的平衡。因此,为了重新平衡整棵树,只需从叶子节点走到根节点,沿途对每个节点进行重新平衡,总共最多执行
IMPORTANT
断言: 通过O(n)次旋转可以将二叉树转变为任何具有相同遍历顺序的树
证明:重复执行遍历顺序中最后可能的右旋转,生成的树是一个规范链(canonical chain)。每次旋转将最后一个节点的深度增加 1。最终链中最后一个节点的深度是 n−1,因此最多执行 n−1 次旋转。反转规范旋转以达到目标树。 可以通过使用 O(n)次旋转将树完全平衡来维持高度平衡,但很慢 😦
IMPORTANT
【定理】从高度平衡树 T 中添加或删除一个叶子节点,产生树 T‘。然后 T’ 可以通过最多
证明:只有受影响叶子节点的祖先节点在 T'中的高度与 T 不同,受影响的叶子节点最多有
如果在 T 中添加了一个叶子:
插入增加了 <X> 的高度,因此在局部重平衡的情况 2 或 3 中
旋转减少了子树高度:一次旋转后平衡
如果从 T 中删除了一个叶子:
删除减少了 <X> 一个孩子的高度,而不是 <X>,所以只有不平衡
可能减少 <X> 的高度 1;<X> 的父节点现在可能不平衡
因此可能需要平衡 <X> 的每一个祖先,但最多有
因此,在插入/删除后,只需
# 非子树增强版本
def height(A):
if A is None:
return -1
return 1 + max(height(A.left), height(A.right))
def skew(A):
return height(A.right) - height(A.left)
def rebalance(A):
if A.skew() == 2:
if A.right.skew() < 0:
A.right.subtree_rotate_right()
A.subtree_rotate_left()
elif A.skew() == -2:
if A.left.skew() > 0:
A.left.subtree_rotate_left()
A.subtree_ratate_right()
def maintain(A):
A.rebalance()
A.subtree_update()
if A.parent:
A.parent.maintain()计算高度
如何判断节点 <X> 是否是高度平衡的?
Sol: 计算其子树的高度!
如何计算节点 <X> 的高度?
Sol: 朴素算法如下:
- 递归计算 <X> 的左子树和右子树的高度
- 取两个高度中的较大值加 1
- 时间复杂度为
,因为我们需要对每个节点递归计算 😦
改进思路:给每个节点增加一个字段,记录它子树的高度!如此一来, 节点 <X> 的高度可以在
- 旋转操作时更新重新链接的节点,在
时间内完成(祖先节点不会改变) - 插入或删除节点时,通过向上遍历树更新所有祖先节点的高度,时间复杂度为
增强二叉树的步骤如下
- 明确要维护的子树性质P(<X>),也就是你希望在每个节点 <X> 上记录什么信息,例如:子树大小、最大值、总和、深度等
- 说明如何在O(1)时间内通过子节点信息计算P(<X>),也就是说,给定节点 <X> 的左子节点和右子节点的 P 值,你需要能够在常数时间内计算出 <X> 的 P 值。
只要满足这两点,就可以在执行插入、删除等动态操作时,用 O(h) 的时间更新这些扩展字段,从而不增加原有操作的时间复杂度。
应用:集合
通过维护平衡二叉节点定义,二叉树集合的实现可以立即支持所有操作在h = O(log n) 时间内完成。除了build(x)和iter()操作,分别在运行在
应用:序列
在序列二叉树中,为了支持诸如 subtree_at(i) 之类的操作,我们需要维护每个节点的子树大小。对于简单的插入和删除叶节点,这种维护很直接,只需在沿着祖先路径上更新 size 字段。但当我们引入旋转操作(如在 AVL 树中进行平衡)时,维护就更复杂了。
不过好消息是:子树大小是一种子树性质,因此可以用增强子树方式来维护。由于一个节点的子树大小可以通过其左、右子节点的大小相加再加 1 来计算,这一计算只需 O(1) 时间,所以即便发生旋转,也能在 O(1) 时间内更新相关节点的 size 字段,整体操作仍保持在 O(log n) 时间。我们将这种数据结构称为 Sequence AVL。
序列 AVL 树(Sequence AVL Tree)也支持所有序列操作(如插入、删除、查找第 i 项)在 O(log n) 时间内完成,构建和遍历为 O(n)
应用:排序
实际上,任何集合结构都可以用来实现排序:先通过构建或逐个插入元素将数据放入集合中,然后遍历集合,即可得到一个排序结果。
例如,
第 5 讲中的直接访问数组排序(Direct Access Array Sort)
使用 AVL 树实现的排序被称为 AVL Sort,其时间复杂度为 O(n log n),是一种有效的比较排序算法。
练习题
画出每个阶段执行完的Seq二叉树的情况
python1 T = Seq_Binary_Tree() # 创建一个序列二叉树 2 T.build([10,6,8,5,1,3]) # 构建一个包含 [10,6,8,5,1,3] 的树 3 T.get_at(4) # 获取索引 4 处的元素 4 T.set_at(4, -4) # 将索引 4 处的元素设置为 -4 5 T.insert_at(4, 18) # 在索引 4 处插入元素 18 6 T.insert_at(4, 12) # 在索引 4 处插入元素 12 7 T.delete_at(2) # 删除索引 2 处的元素
Solution: 
练习:维护一个包含 n 个比特的序列,支持以下两个操作,每个操作的时间复杂度为
:
flip(i):翻转索引 i 处的比特值count_ones_upto(i):返回从索引 0 到 i 的前缀中比特为 1 的数量
Solution:维护一个 Sequence Tree(序列树),将比特位存储为树的项,并为每个节点 A 增加 A.subtree_ones,即其子树中 1 的数量。我们可以在 O(1) 时间内从其子节点中维护此增强信息
def update(A):
A.subtree_ones = A.item
if A.left:
A.subtree_ones += A.left.subtree_ones
if A.right:
A.subtree_ones += A.right.subtree_ones为了实现 flip(i),我们需要找到索引为 i 的节点 A,使用 subtree_node_at(i),然后翻转存储在 A.item 的比特位。接着通过向上遍历树,更新 A 及其每个祖先节点的增强信息,这个操作可以在
为了实现 count_ones_upto(i),我们首先定义基于子树的递归函数 subtree_count_ones_upto(A, i),该函数返回节点 A 的子树中索引至多为 i 的 1 的数量。然后,count_ones_upto(i) 语义上等价于 subtree_count_ones_upto(T.root, i)。由于每次递归调用至多会对一个子节点进行递归调用,因此该操作耗时