跳转至

Lec 1: AVL Trees, Splay Trees and Amortized Analysis⚓︎

4314 个字 110 行代码 预计阅读时间 23 分钟

提醒

不论是 AVL 树,还是 Splay 树,它们的原型还是二叉查找树,具备二叉查找树的所有性质。

AVL Trees⚓︎

引入

FDS 的学习中,我们知道:对一棵二叉查找树进行多次增删操作,有可能破坏二叉树的平衡性,从而降低查找效率。最糟糕的情况是将一组节点按照升 / 降序插入,这样的话二叉树就会退化成一个链表,访问节点的时间复杂度相应地从 \(O(\log N)\) 降到 \(O(N)\)

为了提升二叉查找树的查找效率,我们需要保证整棵树的平衡性,AVL 树(以及后面要讲的伸展树)便是不错的解决之道。

定义

空的二叉树被认为是平衡的。如果树 \(T\) 有左右子树 \(T_L\) \(T_R\),那么满足下列条件的树 \(T\) 才是平衡的:

  • \(T_L\) \(T_R\) 是平衡的
  • \(|h_L - h_R| \le 1\),其中 \(h_L\) \(h_R\) 分别是 \(T_L\) \(T_R\) 的高度

AVL 树的每个节点都有一个平衡因子(balance factor)\(BF(node) = h_L - h_R\)。根据上面的定义知,它的取值仅有 -1, 0 1

AVL 树的高度

一棵高度为 \(h\) 的最小节点的平衡树应该长这个样子:

AVL 树的定义,不难得出关于 AVL 的最小节点数 \(n_h\) 和树高 \(h\) 相关的递推关系式(与斐波那契数列很像

\[ n_h = \begin{cases}n_{h - 1} + n_{h - 2} + 1 & \mathrm{if}\ h > 1\\ 2 & \mathrm{if}\ h = 1\\ 1 & \mathrm{if}\ h = 0\end{cases} \]

事实上,\(n_h = F_{h + 3} - 1(h \ge -1)\)。而斐波那契数 \(F_h \approx \dfrac{1}{\sqrt{5}}(\dfrac{1 + \sqrt{5}}{2})^i\),所以:

\[ n_h \approx \dfrac{1}{\sqrt{5}}(\dfrac{1 + \sqrt{5}}{2})^{h+3} - 1 \]

因而高度 \(h = O(\log n)\)

AVL 树能够保持平衡的关键在于:每次插入或删除一个节点之后,都要检查一下这次操作是否会破坏原来二叉树的平衡性,

  • 如果没有被破坏,那么就 OK
  • 如果被破坏了:
    • 先找到离插入节点(被称为 trouble maker最近的异常(\(|BF(tf)| > 1\))节点 \(tf\)(trouble finder)
    • 然后通过「旋转」(rotate) 的方法,将 \(tf\) 旋转到合适的位置,使得原来以 \(tf\) 为根节点的子树继续保持 AVL 树的性质(此时这棵子树不一定以 \(tf\) 为根节点)

  • 如果有多个 trouble finder,我们只要关注那个离 trouble maker 最近的节点即可,因为只要当以它为根节点的子树恢复为一棵 AVL 树,以它的祖先节点为根节点的树自然也变回 AVL 树。
  • 在介绍 AVL 树的旋转操作时,主要对插入操作带来的平衡破坏展开讨论,而对删除操作的分析就一笔带过了

旋转的具体操作与插入节点 (trouble maker) 的位置有密切的关系,共分为四种情况:

  • LL:插入的位置位于 trouble finder 的左孩子的左子树上
  • RR:插入的位置位于 trouble finder 的右孩子的右子树上
  • LR:插入的位置位于 trouble finder 的左孩子的右子树上
  • RL:插入的位置位于 trouble finder 的右孩子的左子树上

其中前两种情况和后两种情况分别是镜像对称的,也就是说本质上只有两种不同的旋转方法需要讨论,其中前两种情况的解决方法被称为「单旋」(single rotation),后两种情况的解决方法被称为「双旋。下面将详细介绍这两种旋转方法。

LL & RR⚓︎

先分析 LL

过程演示

可以看到,trouble maker(用红色阴影标记,因为不清楚其具体的位置,但它一定在整棵子树最深的位置上)在 trouble finder(用橙色标出)的左孩子的左子树上。

注:根据定义,右子树可以和左孩子的右子树保持一致的高度,但这里画的是高度相差 1 的情况。

如何实现「旋转

  • 从初始状态中可以看出,trouble finder 的左孩子的左子树太高了,而 trouble finder 的右子树较矮,因此我们希望“降低”左边的高度,而“提高”右边的高度
  • 所以,选择 trouble finder 的左孩子作为新树的根节点(用橙色阴影标出,以下称为 NR,而 trouble finder 则成为它的右孩子
  • NR 原来的右孩子则由 trouble finder 接管,成为它的左子树(红色实线表示删除的边,绿色虚线表示新增的边)
  • 如图蓝色箭头所示,为了让新树更加美观,应当将 trouble finder左旋” 下来,但保持这些节点或子树的相对水平位置不变(这样维持了二叉查找树的性质)

PPT 给出的左旋示意图

有了上面的图示,理解起来应该不算困难。所以 RR 部分的演示我就省略了(其实是我懒得画了 x,直接从 PPT 上截下图片(风格上与我的有很大差异,但是只要理解了上面的 LL,这张图应该还算比较容易理解的

一句话总结:LL 左旋RR 右旋

LR & RL⚓︎

这两种情况稍微复杂一些,因为仅靠一次旋转是无法恢复树的平衡的,需要转两次(double rotation)才能成功。而且双旋需要关注三个节点(trouble findertf 中有 tm 儿子节点孙子节点(单旋不需要关注孙子节点。下面将以 LR 为例介绍双旋的过程:

过程演示

小问题

2 步和第 4 步的蓝色箭头画反了,所以不要管那个蓝色箭头!

trouble maker 的具体位置并不重要。

首先,通过右旋交换孙子节点(黄色)和它的父亲(橙色)的位置(倒反天罡 bushi

然后,通过左旋交换原来的孙子节点(现在是子节点)与 trouble maker(红色)的位置。

来自 PPT LR 过程

RL 的旋转方法与之类似,只要做一个镜像变换即可,这里也省略具体过程了,仅贴上一张 PPT 的图片:

时间复杂度:\(O(\log N)\),其中 \(N\) 为节点数,即与树的高度成正比(最坏情况是从叶子节点开始,一直调整到根节点

总结

我们应该记住的是两类旋转方法的具体步骤(增加 / 删除边变换节点的祖先 - 后代关系,而不是记所谓的左右旋转。因为在“旋转”前后,节点的水平相对位置并没有发生改变,变的只是它们的竖直方向上的相对位置(这样保证整棵树是一棵二叉搜索树,这样很难让人直观感受到旋转的过程,所以个人不太赞同将这种操作称为旋转。

关于删除

PPT 没有详细讨论 AVL 树的删除,这里仅仅简单讲几句(对删除操作感兴趣的读者可以看 wiki 的描述

  • 先按照普通二叉查找树的删除方法进行删除
  • 这一过程中子树的高度发生变化,很有可能破坏 AVL 树的平衡性,这时需要通过上面讲到的旋转方法使其再次回到平衡状态
  • 但如果运气差一点的话,旋转完一次还不够,因为这可能破坏了其他子树的平衡,因此需要再次旋转,直至整棵树平衡
  • 时间复杂度:\(O(\log N)\)

Implementation by Code⚓︎

节点应该存什么字段

有两种做法:存储节点的高度,或者存储节点的平衡因子 bf,它们各有优劣:

  • 高度:简单、清晰;但是如果树过高,占用内存空间较大
  • 平衡因子:只需要存三个值,节省内存空间(只需 2 位即可;但是不太便于理解

我们现在不会遇到很高的 AVL 树,所以下面的代码实现使用前者。

代码实现(摘自教材)
声明
#ifndef _AvlTree_H

struct AvlNode;
typedef struct AvlNode *Position;
typedef struct AvlNode *AvlTree;

AvlTree MakeEmpty(AvlTree T);
Position Find(ElementType X, AvlTree T);
Position FindMin(AvlTree T);
Position FindMax(AvlTree T);
AvlTree Insert(ElementType X, AvlTree T);
AvlTree Delete(ElementType X, AvlTree T);
ElementType Retrieve(Position P);

#endif /* _AvlTree_H */

// Place in the implementation file
struct AvlNode {
    ElementType Element;
    AvlTree Left;
    AvlTree Right;
    int Height;
} 
计算高度
static int Height(Position P) {
    if (P == NULL) 
        return -1;
    else 
        return P->Height;
}
插入节点
AvlTree Insert(ElementType X, AvlTree T) {
    if (T == NULL) {
        // Create and return a one-node tree
        T = (AvlTree)malloc(sizeof(struct AvlNode));
        if (T == NULL) {
            FatalError("Out of space!!!");
        } else {
            T->Element = X;
            T->Height = 0;
            T->Left = T->Right = NULL;
        }
    } else if (X < T->Element) {
        T->Left = Insert(X, T->Left);
        if (Height(T->Left) - Height(T->Right) == 2)
            if (X < T->Left->Element)
                T = SingleRotateWithLeft(T);
            else
                T = DoubleRotateWithLeft(T);
    } else if (X > T->Element) {
        T->Right = Insert(X, T->Right);
        if (Height(T->Right) - Height(T->Left) == 2)
            if (X > T->Right->Element)
                T = SingleRotateWithRight(T);
            else
                T = DoubleRotateWithRight(T);
    }
    // Else X is in the tree already; we'll do nothing

    T->Height = Max(Height(T->Left), Height(T->Right)) + 1;
    return T;
}
左旋
static Position SingleRotateWithLeft(Position K2) {
    // K2: trouble finder
    // K1: the left son of K1

    Position K1;          

    K1 = K2->Left;
    K2->Left = K1->Right;
    K1->Right = K2;

    K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
    K1->Height = Max(Height(K1->Left), K2->Height) + 1;

    return K1;
}
右旋
static Position SingleRotateWithRight(Position K2) {
    // K2: trouble finder
    // K1: the right son of K1

    Position K1;          

    K1 = K2->Right;
    K2->Right = K1->Left;
    K1->Left = K2;

    K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
    K1->Height = Max(Height(K1->Right), K2->Height) + 1;

    return K1;
}
LR 双旋
static Position DoubleRotateWithLeft(Position K3) {
    // 先右旋 touble finder 的左孩子
    K3->Left = SingleRotateWithRight(K3->Left);

    // 再左旋 trouble finder 自身
    return K3 = SingleRotateWithLeft(K3);
}
RL 双旋
static Position DoubleRotateWithRight(Position K3) {
    // 先左旋 touble finder 的右孩子
    K3->Right = SingleRotateWithLeft(K3->Right);

    // 再右旋 trouble finder 自身
    return K3 = SingleRotateWithRight(K3);
}

Splay Trees⚓︎

AVL 树虽然能够始终保持树的平衡,但是需要维护每一个节点的高度 / 平衡因子字段,这有些麻烦。在 AVL 树的基础上发展出来的 Splay 树(伸展树)相比 AVL 树更加简单粗暴:

  • 它继承了 AVL 树的旋转操作;但是它并不维护节点的高度 / 平衡因子字段,因此它无法保障树的平衡
  • Splay 树的核心操作在于:每当我们访问一个节点后,就将该节点通过 AVL 树的旋转方法将其调到根节点的位置上。
  • 因此它能够保证 \(M\) 次操作(增删查改)的均摊 (amortized) 复杂度 \(O(M \log N)\)(在均摊分析一节再作具体分析)
错误示范

单纯的左旋或右旋无法降低下面这棵树的复杂度(某些节点复杂度降低,但另外一些节点的复杂度增加了

如果按升序插入节点之后(树退化成链表)再按升序访问每个节点,时间复杂度为 \(O(N)\)(我们每次都要找到最底下的节点)

下面介绍正确的操作,分为 3 种情况(其中被访问的节点记作 \(X\),它的父亲和祖父分别记作 \(P\) \(G\)

  • Case 1:\(P\) 是根节点 -> 旋转 \(X\) \(P\) 即可(AVL 树的单旋)
  • Case 2:\(P\) 是非根节点

    • Case 2-1:Zig-zag\(X, P, G\) 三者不在一条直线上)-> \(X\) 节点转两次(其实就是 AVL 树的 LR RL 情况)

    • Case 2-2:Zig-zig\(X, P, G\) 三者在一条直线上)-> 先转 \(P\) 节点,后转 \(X\) 节点(前半部分过程类似 AVL 树的 LL RR 情况)

Case 2-2 的过程演示

应用

教材《数据结构与算法分析》\(P_{128-131}\) 上有访问一棵 32 个节点构成的(按升序插入的,因此是一张链表)二叉树的例子,可以发现每访问一次,并将被访问的节点移到根节点上后,整棵树的高度也会渐渐降低。

  • 在调整的过程中,千万不要和 AVL 树的操作搞混:AVL 树是通过检测树高或平衡因子来决定是否要继续旋转;而 Splay 树是要一直盯着被访问的那个节点来旋转,直到它位于根节点为止
  • 在节点数足够多的理想情况下,把被访问的节点移到根节点上之后,路径上大多数节点的高度降至原来的一半左右

Splay 树的插入和删除操作

  1. 先插入新的节点
  2. 将该节点通过 Splay 树的旋转操作移动到根节点的位置上
  1. 访问要删除的节点 \(X\),根据规则,\(X\) 将会作为整棵树的根节点
  2. 移除 \(X\),这样就剩下两棵子树 \(T_L\) \(T_R\)
  3. \(FindMax(T_L)\) 或者 \(FindMin(T_R)\),也就是说从两棵子树中找到合适的节点,作为新树的根节点,使新树仍然保持二叉查找树的性质

Splay 树的复杂度涉及到均摊分析,下面将介绍这一知识点(在今后的学习中还会多次用到均摊分析,所以请务必认真阅读下面的内容

Amortized Analysis⚓︎

我们先来理解「均摊」这个词:

  • 保证在 \(N\) 个数据上的 \(M\) 次运算的总体复杂度为 \(O(M \log N)\)
  • 它考虑的是全局(所有数据)的耗时,因此允许在某些数据的运算上耗时久一些,因为可以用在另一些数据上耗时较短的运算来弥补这个损失
  • 区分最坏复杂度均摊复杂度平均复杂度
    • 最坏:保证单个运算以及整体运算的复杂度不超过某一边界(考虑数据最理想的情况)
    • 均摊:保证整体的运算复杂度不超过某一边界,但允许某几次运算可以超过该边界(考虑数据的真实情况)
    • 平均:在给定数据分布平均的情况下(是一种理想的假设,保证整体运算的复杂度不超过某一边界

      注:只有平均复杂度考虑数据分布,前两者并不考虑数据的分布。

通过上面的分析,不难得出以下不等关系:

worst-case bound \(\ge\) amortized-case bound \(\ge\) average-case bound

何时使用均摊分析?

个人的一些见解:当我们难以确定所有的数据分布(比如情况太多了,或者某些情况难以直接分析得到等等)从而无法用平均复杂度分析,且希望得到更精准的复杂度分析时(最坏复杂度假定每次操作都是最坏的,但并不是所有操作都会遇到最坏情况,均摊分析就能发挥较大的作用了!

常见的均摊分析方法有:

  • 聚合法(aggregate method)
  • 核算法(accounting method)
  • 势能法(potential method)

Aggregate Method⚓︎

思路:均摊复杂度 = 所有操作的总时间 / 操作次数,即 \(T_{amortized} = \dfrac{\sum\limits^nT_i}{n}\)

这是最为简单暴力的方法

例子

对于一个栈,创建一个 MultiPop 的方法:依次弹出栈内所有的元素(如果有的话,并且有限制次数 \(k\)

Algorithm {
    while (!isEmpty(S) && k > 0) {
        Pop(S);
        k--;
    }
}

易知 \(size(S) \le n\)\(n\) 为数据总数,所以均摊复杂度 \(T_{amortized} = \dfrac{O(n)}{n} = O(1)\)

Accounting Method⚓︎

一句话概括“取长补短”

思路:当我们的均摊成本(amortized cost) \(\hat{c}_i\) 高于实际成本(actual cost) \(c_i\) 时,多出来的部分就转化为积分(credit)。如果之后的均摊成本小于实际成本时,可以用之前攒的积分抵消实际的成本(注意:不允许“均摊成本 + 积分 < 实际成本”,不然的话均摊复杂度就沦为下界了。公式为:

\[ T_{amortized} = \dfrac{\sum\limits^n\hat{c}_i}{n} \]

其中 \(\sum\limits^n\hat{c}_i \ge \sum\limits^nc_i\)(确保了均摊复杂度是一个上界。实际上,均摊成本是我们预先设置的值,所以在设置的时候要预估好,确保均摊总成本高于实际总成本,即使积分保持非负状态。

例子

接着上面 MultiPop 的例子,假如我们的实际成本和均摊成本分别如下:

  • \(c_i\)Push:1,Pop:1,MultiPopmin(sizeof(S), k)
  • \(\hat{c}_i\)Push:2,Pop:0,MultiPop:0

我们可以这样理解:每次 Push 一个元素的时候,我们用两块钱执行这个只需一块钱的操作,多出来的一块钱存起来作为积分。这样的话,当我们 Pop 一个元素的时候,就不需要花钱,只要花费等价的积分即可。通过上面给出的信息不难判断,每当 Pop 一个元素的时候,我们都会有足够多的积分来消费,所以 MultiPop 的运算我们可以不花一分钱就可以完成。

用符号语言表述:

\[ sizeof(S) \ge 0 \Rightarrow Credits \ge 0 \Rightarrow O(n) = \sum\limits^n\hat{c}_i \ge \sum\limits^n\hat{c}_i \Rightarrow T_{amortized} = \dfrac{O(n)}{n} = O(1) \]

Potential Method⚓︎

核算法看似非常简单,但它的缺陷在于:我们需要考虑每一步操作的成本和积分,这样就比较繁琐,不利于我们从全局分析问题。而势能法能够克服这一缺陷。

思路:我们将核算法提到的「积分」概念转化为「势能差」概念——即 \(\hat{c}_i - c_i = \Phi(D_i) - \Phi(D_{i-1})\),其中 \(D_i\) 表示经历了 i 次运算后的数据结构,\(\Phi(D_i)\) 表示该数据结构的势能函数(potential function)。经过一些转化,可以得到:

\[ \begin{align} \sum\limits_{i = 1}^n\hat{c}_i= & \sum\limits_{i=1}^n(c_i + \Phi(D_i) - \Phi(D_{i-1})) \notag \\ = & (\sum\limits_{i=1}^nc_i) + \Phi(D_n) - \Phi(D_0) \notag \end{align} \]

因此,我们只需考虑初始和末尾的势能,确保它们的势能差 \(\Phi(D_n) - \Phi(D_0) \ge 0\) 即可(而通常会令 \(\Phi(D_0) = 0\),那么关键(难点)就在于设计一个合理的势能函数。

例子

还是接着 MultiPop 的例子,我们假定 \(D_i\) 为经过 i 次操作后的栈,\(\Phi(D_i)\) 表示栈的元素个数,那么易知 \(\Phi(D_i) \ge 0 = \Phi(D_0)\)

  • Push\(\Phi(D_i) - \Phi(D_{i-1}) = (sizeof(S) + 1) - sizeof(S) = 1\) \(\Rightarrow \hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) = 1 + 1 = 2\)

  • Pop\(\Phi(D_i) - \Phi(D_{i-1}) = (sizeof(S) - 1) - sizeof(S) = -1\) \(\Rightarrow \hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) = 1 - 1 = 0\)

  • MultiPop\(\Phi(D_i) - \Phi(D_{i-1}) = (sizeof(S) - k') - sizeof(S) = -k'\) \(\Rightarrow \hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1}) = k' - k' = 0\)

\(\therefore \sum\limits_{i = 1}^n\hat{c}_i = \sum\limits_{i = 1}^nO(1) = O(n) \ge \sum\limits_{i=1}^nc_i \Rightarrow T_{amortized} = \dfrac{O(n)}{n} = 1\)

Analysis for Splay Tree⚓︎

结论:Splay 树的均摊复杂度为 \(T_{amortized} = O(\log N)\)

下面我们用势能法来进行势能分析。

分析

我们假定:

  • \(D_i\) = 以被访问的节点为根节点(经过变换后)的新树
  • \(c_i\) = 转换过程中所需的实际成本
    • zig:\(c_i = 1\)
    • zig-zag & zig-zig:\(c_i = 2\)
  • \(\Phi(T) = \sum\limits_{i \in T} \log S(i) = \sum\limits_{i \in T} R(i)\),其中 \(i\) \(T\) 的后代,\(S(i)\) 是以 \(i\) 为根节点的子树的节点数,将其取对数就是 \(i\) (rank) \(R(i)\)

  • \(R(i) \approx H(i)\)\(H(i)\) 为树的高度)

为什么不用树的高度作为势能函数

因为很多子树的高度在旋转的时候会发生改变,而大小在旋转的过程中虽然也有不小的变化,但是由于取了对数,所以秩的变化就变得很小了,这便于我们的计算。

下面我们就具体分析 Splay 树的三种情况:

\[ \begin{align} \hat{c}_i = & 1 + (R_{k+1}(X) - R_k(X)) + (R_{k+1}(P) - R_k(P)) \notag \\ \le & 1 + R_{k+1}(X) - R_k(X) \notag \end{align} \]
  • 显然,在旋转前后,以 \(P\) 为根节点的子树的大小变小了,因此 \(R_{k+1}(P) - R_k(P) < 0\)
引理

如果 \(a + b \le c\),且 \(a, b\) 都是正整数,则

\[\log a + \log b \le 2 \log c - 2\]

证明:

  • 根据算术 - 几何不等式 \(\sqrt{ab} \le \dfrac{a+b}{2}\)
  • 因此 \(\sqrt{ab} \le \dfrac{c}{2}\)
  • 两边平方: \(ab \le \dfrac{c^2}{4}\)
  • 再两边取对数,得到上述引理

\[ \begin{align} \hat{c}_i = & 2 + (R_{k+1}(X) - R_k(X)) + (R_{k+1}(P) - R_k(P)) + (R_{k+1}(G) - R_k(G))\notag \\ \le & 2(R_{k+1}(X) - R_k(X)) \notag \end{align} \]
  • 旋转前 \(G\) 的大小和旋转后 \(X\) 的大小相等,所以 \(R_{k+1}(X) = R_{k}(P)\),可以相互抵消
  • 旋转后,\(S(X) \ge S(P) + S(G)\),根据上面的引理知,\(\log S(P) + \log S(G) \le 2 \log S(X) - 2\),即 \(R_{k+1}(P) + R_{k+1}(G)\le 2 R_{k+1}(X) - 2\)
  • 旋转前,\(P\) \(X\) 的父节点,因此 \(R_k(P) \ge R_k(X)\)
  • 通过上述的转换和抵消,可以得到最终的不等式

错误示范

用和推导 zig-zag 类似的方法放缩不等式,可以得到下面的结果:

\[ \begin{align} \hat{c}_i & = 2 + (R_{k+1}(X) - R_k(X)) + (R_{k+1}(P) - R_k(P)) + (R_{k+1}(G) - R_k(G))\notag \\ & \le 2(R_{k+1}(X) - R_k(X)) + 2 \notag \end{align} \]

从放缩的角度看挺不错的,但是后面多出了个常数 2,而我们不知道要执行多少次 zig-zig 旋转操作,所以会带来不确定的数据,这不利于我们的精确分析。

上面的 zig 旋转可以带常数,是因为只有一次 zig 操作(很容易验证

\[ \begin{align} \hat{c}_i & = 2 + (R_{k+1}(X) - R_k(X)) + (R_{k+1}(P) - R_k(P)) + (R_{k+1}(G) - R_k(G))\notag \\ & = 2 + (R_{k+1}(X) - R_k(G)) + (R_{k+1}(G) + R_k(X)) + R_{k+1}(P) - 2R_k(X) - R_k(P) \notag \\ & \le 2R_{k+1}(X) + R_{k+1}(X) - 2R_k(X) - R_k(X)\notag \\ & \le 3(R_{k+1}(X) - R_k(X)) \notag \end{align} \]
  • 旋转后的 X 的大小 = 旋转前的 G 的大小,所以两者对应的秩相抵消
  • 观察旋转前的 X 子树和旋转后的 G 子树,不难发现它们加起来的大小 \(\le\) 旋转后的 X 树的大小,所以再次用到上面的引理,可以得到 \(R_{k+1}(G) + R_k(X) \le 2R_{k+1}(X) - 2\)
  • \(R_{k+1}(P) \le R_{k+1}(X), R_k(P) \ge R_k(X)\),所以最后部分进行简单的放缩
  • 最终我们得到了正确的不等式

结论

最后,我们将这三部分并起来,得到最终的均摊成本。但在此之前,我们需要进一步的放缩,便于后续计算:

\[ \begin{align} \hat{c}_{zig} & \le 1 + 3(R_{k+1}(X) - R_k(X)) \notag \\ \hat{c}_{zig-zag} & \le 3(R_{k+1}(X) - R_k(X)) \notag \\ \hat{c}_{zig-zig} & \le 3(R_{k+1}(X) - R_k(X)) \notag \\ \end{align} \]

将所有旋转操作对应的均摊成本加起来:

\[ \begin{align} \hat{c}_X & = \hat{c}_{zig} + \sum \hat{c}_{zig-zag} + \sum \hat{c}_{zig-zig} \notag \\ & = O(1) + 3(R_{k+1}(X) - R_0(X)) \notag \\ & = O(\log N) \notag \end{align} \]

这样,我们成功证明了 Splay 树的均摊复杂度。

评论区

如果大家有什么问题或想法,欢迎在下方留言~