Skip to content

Lec 7 AVL树

我们这节课的终极目标是实现树的平衡:n个节点的树如果它的高度是O(logn),那么它是平衡的。

  • 高度平衡
  • 树的旋转
    • 局部重平衡
    • 全局重平衡
  • 计算高度
  • 应用:序列
  • 应用:排序
  • 练习题

高度平衡

如何维护树的高度 h=O(logn)? 在动态操作下能够维持树的高度为 h=O(logn)的二叉树我们称其是平衡的(balanced)。有很多平衡二叉树方案用于插入和删除节点,红黑树、B树、伸展树(Splay Tree)、2-3树 。第一个提出的平衡方案是AVL树(Adelson-Velsky 和 Landis, 1962),所有的节点都是高度平衡(height-balanced)的(即满足AVL性质),即左右子树的高度差不超过1。

为了形式化论证的方便,我们定义节点的倾斜度s为其右子树高度减去左子树高度,那么我们说,如果一个节点的偏斜度为 -1, 0 或 1 ,那么它是高度平衡的。

IMPORTANT

【定理】高度平衡树(height-balanced tree)是平衡的,即

一个高度平衡的二叉树的高度为 h=O(logn)(即 n=2Ω(h)

证明:平衡意味着,h=O(logn),换句话说,平衡意味着 logn 是高度h的下界,表示为n=2Ω(h),即最少的节点数量与树的高度呈指数关系。为了证明这点,我们引入一个函数F(h),表示高度为 h 的平衡树中最少的节点数。基本情况和递推关系为,F(0)=1,F(1)=2,F(h)=1+F(h1)+F(h2)2F(h2)​,根据递推关系,我们可以推出 F(h)2h/2=2Ω(h)​,得证。

树的旋转

当我们高度平衡的树中添加或删除叶节点时,可能会导致不平衡,我们想要在不改变遍历顺序的情况下改变树的结构——通过旋转!旋转操作会将一个子树从以下两种局部结构中的一种转换为另一种,并通过在 O(1)时间内修改节点之间的连接来实现这种转换

image-20241014153608700

上述操作保留了遍历顺序,但能改变<A>和<E>的深度,后面会讲解如何在插入或或者删除一个节点后强制平衡。

python
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> 执行一次左旋转,令设 h=height(A),则:

  • 如果skew(<F>) = 0,说明 height(<D>) = h + 1
  • 如果skew(<F>) = 1,说明 height(<D>) = h

截屏2024-08-03 17.53.14

旋转后:

  • <B> 的偏斜度在情况 1 中为 1,在情况 2 中为 0,因此<B> 是高度平衡的
  • <F> 的偏斜度为 -1,因此 <F> 是高度平衡的
  • <B> 的高度在之前是 h+3,之后在情况 1 中为 h+3,在情况 2 中为 h+2

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

截屏2024-08-03 17.56.04

h=height(A)。那么高度 height(<G>)=h,而高度 height(<C>)height(E) 都是 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,且该子树的其他所有节点都是高度平衡的,我们可以通过最多两次旋转恢复该子树的平衡。因此,为了重新平衡整棵树,只需从叶子节点走到根节点,沿途对每个节点进行重新平衡,总共最多执行 O(logn)次旋转

IMPORTANT

断言: 通过O(n)次旋转可以将二叉树转变为任何具有相同遍历顺序的树

证明:重复执行遍历顺序中最后可能的右旋转,生成的树是一个规范链(canonical chain)。每次旋转将最后一个节点的深度增加 1。最终链中最后一个节点的深度是 n−1,因此最多执行 n−1 次旋转。反转规范旋转以达到目标树。 可以通过使用 O(n)次旋转将树完全平衡来维持高度平衡,但很慢 😦

IMPORTANT

【定理】从高度平衡树 T 中添加或删除一个叶子节点,产生树 T‘。然后 T’ 可以通过最多 O(logn) 次旋转变成高度平衡树 T‘’

证明:只有受影响叶子节点的祖先节点在 T'中的高度与 T 不同,受影响的叶子节点最多有 h=O(logn) 个祖先节点,其子树可能发生变化。设 <X> 为最低的非高度平衡的祖先节点(偏斜度为 2)。有如下情况,

  1. 如果在 T 中添加了一个叶子:

    • 插入增加了 <X> 的高度,因此在局部重平衡的情况 2 或 3 中

    • 旋转减少了子树高度:一次旋转后平衡

  2. 如果从 T 中删除了一个叶子:

    • 删除减少了 <X> 一个孩子的高度,而不是 <X>,所以只有不平衡

    • 可能减少 <X> 的高度 1;<X> 的父节点现在可能不平衡

    • 因此可能需要平衡 <X> 的每一个祖先,但最多有 h=O(logn)

因此,在插入/删除后,只需 O(logn) 次旋转即可保持高度平衡! 但需要评估可能 O(logn)​ 个节点是否高度平衡。

python
# 非子树增强版本
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: 朴素算法如下:

  1. 递归计算 <X> 的左子树和右子树的高度
  2. 取两个高度中的较大值加 1
  3. 时间复杂度为 Ω(n),因为我们需要对每个节点递归计算 😦

改进思路:给每个节点增加一个字段,记录它子树的高度!如此一来, 节点 <X> 的高度可以在 O(1) 时间内通过它的子节点高度计算得到:在 O(1)时间内查找并获取左子树和右子树的高度,然后取两个高度中的较大值加 1。在动态操作过程中,当树的结构发生变化时,必须维护节点的高度信息,即在子树改变的节点重计算子树增强

  • 旋转操作时更新重新链接的节点,在 O(1) 时间内完成(祖先节点不会改变)
  • 插入或删除节点时,通过向上遍历树更新所有祖先节点的高度,时间复杂度为 O(h)

增强二叉树的步骤如下

  1. 明确要维护的子树性质P(<X>),也就是你希望在每个节点 <X> 上记录什么信息,例如:子树大小、最大值、总和、深度等
  2. 说明如何在O(1)时间内通过子节点信息计算P(<X>),也就是说,给定节点 <X> 的左子节点和右子节点的 P 值,你需要能够在常数时间内计算出 <X> 的 P 值。

只要满足这两点,就可以在执行插入、删除等动态操作时,用 O(h) 的时间更新这些扩展字段,从而不增加原有操作的时间复杂度。

应用:集合

通过维护平衡二叉节点定义,二叉树集合的实现可以立即支持所有操作在h = O(log n) 时间内完成。除了build(x)iter()操作,分别在运行在O(nlogn)O(n)事件,这种数据结构通常称为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二叉树的情况

python
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: image-20241014190145047

练习:维护一个包含 n 个比特的序列,支持以下两个操作,每个操作的时间复杂度为 O(logn)

  • flip(i):翻转索引 i 处的比特值
  • count_ones_upto(i):返回从索引 0 到 i 的前缀中比特为 1 的数量

Solution:维护一个 Sequence Tree(序列树),将比特位存储为树的项,并为每个节点 A 增加 A.subtree_ones,即其子树中 1 的数量。我们可以在 O(1) 时间内从其子节点中维护此增强信息

python
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 及其每个祖先节点的增强信息,这个操作可以在 O(logn)时间内完成。

为了实现 count_ones_upto(i),我们首先定义基于子树的递归函数 subtree_count_ones_upto(A, i),该函数返回节点 A 的子树中索引至多为 i 的 1 的数量。然后,count_ones_upto(i) 语义上等价于 subtree_count_ones_upto(T.root, i)。由于每次递归调用至多会对一个子节点进行递归调用,因此该操作耗时 O(logn)