Lec 7AVL 树
Lec 7 AVL 树
资料依据:课程视频与 transcript · Lecture notes
TL;DR
- AVL 树要求每个节点的左右子树高度差至多为 1,该局部条件足以保证整树高度为 。
- 单旋和双旋在保持中序顺序的同时改变局部深度,因此不会破坏二叉搜索树的键顺序。
- 插入后一次局部重平衡即可停止;删除可能继续降低子树高度,因而需要沿祖先链反复检查。
高度平衡
如何维护树的高度 ? 在动态操作下能够维持树的高度为 的二叉树我们称其是平衡的(balanced)。有很多平衡二叉树方案用于插入和删除节点,红黑树、B 树、伸展树(Splay Tree)、2-3 树 。第一个提出的平衡方案是AVL 树(Adelson-Velsky 和 Landis, 1962),所有的节点都是高度平衡(height-balanced)的(即满足AVL 性质),即左右子树的高度差不超过 1。
为了形式化论证的方便,我们定义节点的倾斜度 s为其右子树高度减去左子树高度,那么我们说,如果一个节点的偏斜度为 -1, 0 或 1 ,那么它是高度平衡的。
[!IMPORTANT]
【定理】高度平衡树(height-balanced tree)是平衡的,即
一个高度平衡的二叉树的高度为 (即 )
证明:平衡意味着,,换句话说,平衡意味着 是高度 h 的下界,表示为,即最少的节点数量与树的高度呈指数关系。为了证明这点,我们引入一个函数 F(h),表示高度为 h 的平衡树中最少的节点数。基本情况和递推关系为,,根据递推关系,我们可以推出 ,得证。
树的旋转
当我们高度平衡的树中添加或删除叶节点时,可能会导致不平衡,我们想要在不改变遍历顺序的情况下改变树的结构——通过旋转!旋转操作会将一个子树从以下两种局部结构中的一种转换为另一种,并通过在 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> 执行一次左旋转(双旋)

设。那么高度 ,而高度 和 都是 h 或 h - 1
旋转后:
- <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)。有如下情况,
-
如果在 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> 的高度可以在 时间内通过它的子节点高度计算得到:在 时间内查找并获取左子树和右子树的高度,然后取两个高度中的较大值加 1。在动态操作过程中,当树的结构发生变化时,必须维护节点的高度信息,即在子树改变的节点重计算子树增强
- 旋转操作时更新重新链接的节点,在 时间内完成(祖先节点不会改变)
- 插入或删除节点时,通过向上遍历树更新所有祖先节点的高度,时间复杂度为
增强二叉树的步骤如下
- 明确要维护的子树性质 P(<X>),也就是你希望在每个节点 <X> 上记录什么信息,例如:子树大小、最大值、总和、深度等
- 说明如何在 O(1)时间内通过子节点信息计算 P(<X>),也就是说,给定节点 <X> 的左子节点和右子节点的 P 值,你需要能够在常数时间内计算出 <X> 的 P 值。
只要满足这两点,就可以在执行插入、删除等动态操作时,用 O(h) 的时间更新这些扩展字段,从而不增加原有操作的时间复杂度。
应用:集合
通过维护平衡二叉节点定义,二叉树集合的实现可以立即支持所有操作在 h = O(log n) 时间内完成。除了build(x)和iter()操作,分别在运行在和事件,这种数据结构通常称为AVL 树,但我们称其为 Set AVL Tree
应用:序列
在序列二叉树中,为了支持诸如 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 二叉树的情况
1 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)。由于每次递归调用至多会对一个子节点进行递归调用,因此该操作耗时 。
我的理解
我的理解
AVL 树的核心不是记住旋转图,而是证明两个不变量:旋转不改变中序顺序,重新计算局部高度后又恢复平衡。这也是所有平衡树实现的验证骨架。