Splay树学习笔记
Splay 树
定义
Splay 树是一个二叉平衡搜索树,它可以通过 Splay 操作 将一个结点旋转至根结点或者一个给定的结点的下一层,使得整棵树仍然满足二叉搜索树的性质。
Splay 树可以在均摊 O(log n) 的时间内完成查找、插入、查询、删除等操作。
二叉搜索树的定义:
- 空树是一个二叉搜索树;
- 根结点左子树中的结点权值均小于根结点的权值;
- 根结点右子树中的结点权值均大于根结点的权值;
- 根结点的左右子树均为二叉搜索树。
结构
在 Splay 中,一共需要维护如下信息:
root:树的根结点编号。tot:当前总共开了点的个数(Splay 树显然使用动态开点)。fa[i]:结点 i 的父亲结点。son[i][0/1]:结点 i 的左右儿子编号,左儿子为son[i][0],右儿子为son[i][1]。val[i]:结点 i 的权值。cnt[i]:权值为val[i]的数字的出现个数。siz[i]:结点 i 及其子树的大小。
基本操作
辅助函数
pushup(x):合并左右儿子的信息(即大小),更新至当前结点。get(x):返回 0 表示 x 是父结点的左儿子,返回 1 表示 x 是父结点的右儿子。clear(x):销毁结点 x,即将一切信息清零。
1 | void pushup(int x) { // 合并 x 的左儿子与右儿子,得到 x 的大小 |
旋转

左旋是右旋的逆操作,所以下面只讨论右旋。
旋转操作如上图。
可以发现,右旋是把 x 提到 z 的下面然后把 y 压下去,此时由于 x 有有儿子了,所以如图可以想象成 B 不动,此时就接到了 y 的下面(胡思乱想中)。
实际把图片记住就行了,上面扯一大通 P 用没有
在右旋中,我们需要知道 x 的父亲结点和爷爷结点,所以
1 | int y = fa[x], z = fa[y]; |
然后我们还需要知道 x 是 y 的左儿子还是右儿子,如果 x 是左儿子就右旋,反之左旋。
1 | int id = get(x); // 判断 x 是 y 的左儿子还是右儿子 |
容易发现,在右旋中,改变的边的关系只有 x − z,x − y,y − B,所以我们分别考虑。
修改为 x − z
由于 x 替换掉的是 y 的位置,所以需要先知道 y 是 z 的哪个儿子,故先
get(y)。
当 z 点不存在时(即 z = 0),那么更新
son[z] 会发生错误,因为访问的时候一般是用是否为 0 判断点是否存在。若 z
为根结点,那么就会遍历下去导致错误的答案。
而 x 必然存在,无需判断。
1 | if (z) son[z][get(y)] = x; |
修改为 x − y
由于右旋保证了 y 是存在的,所以两种情况都无需判断。
1 | son[x][id ^ 1] = y, fa[y] = x; |
修改为 y − B
B 是 x 的右儿子,而 x 是 y 的左儿子,所以发现 B 就是
son[x][id ^ 1]。
同理此时 B 不一定存在,所以也需要判断。
1 | son[y][id] = son[x][id ^ 1]; |
完整代码
1 | void rotate(int x) { |
Splay 操作
Splay 操作规定:每操作(包括但不限于插入、删除,详见代码)一个结点 x 后,都要将这个节点 x 旋转为结点 k 的儿子,若 k = 0 则将其旋转至根结点。
根据定义,当 fa[x] != k 时,需要一直向上旋转,故写一个
while 循环。
特殊型

如图,k 是 x 的爷爷结点,此情况称为特殊型。
对于这两种情况,我们只需要将 x 点分别左旋、右旋,就可以让 x 顶替掉 y 的位置,成为 k 的儿子。
同构型

如图,当 x 的爷爷节点非 k,且 x, y, k 三点共线时,此情况称为同构型。
此时我们的目标是让 x 顶替掉 z,成为这一条链中深度最浅的链头。

首先,我们将 y 点旋转,此时 y 成为深度最浅的结点,同时 x, z 是 y 的两个儿子。

然后我们将 x 点旋转,让 x 成为 y 的父亲,此时 x 就成为了深度最浅的点,操作完成。
总结:对于同构型,先旋转 y,再旋转 x。
异构型

如图,当 x 的爷爷结点非 k,且 x, y, z 三点构成折线时,此情况称为异构型。
此时我们的目标同样是让 x 顶替掉 z,成为这一条链中深度最浅的链头。

首先,我们将 x 点旋转,此时 x 称为 z 的儿子,三点共线。

然后我们再次将 x 点旋转, z 称为 x 的儿子,此时 x 就成为了深度最浅的点,操作完成。
总结:对于异构型,先旋转 x,再旋转 x。
代码实现
发现无论对于哪种情况,最后都会旋转一次 x,所以可以将这一次操作提取出来。
如何判断三点是同构还是异构呢?可以用 get(x) ⊕ get(y) 获得。
具体实现详见代码。
判断同构、异构的解释
同构
此时 x 是 y 的左(右)儿子,y 是 z 的左(右)儿子,儿子左右情况相同,那么 get(x) = get(y),异或值为 0。
异构
此时 x 是 y 的左(右)儿子,y 是 z 的右(左)儿子,儿子左右情况不同,那么 get(x) ≠ get(y),且一个为 0 一个为 1,故异或值为 1。
1 | void splay(int x, int k) { // 将 x 转到 k 的下面 |
时间复杂度分析
本部分来自 OI-wiki。
考虑对 Splay 操作中的三种情况分析复杂度。采用势能分析,定义一个 n 个节点的 Splay 树进行了 m 次 Splay 操作。
可记 w(x) = ⌊log sizex⌋,定义势能函数为 φ = ∑w(x),其中 φ(0) ≤ nlog n。
在第 i 次操作后势能为 φ(i),则我们只需求出初始势能和每次的势能变化量的和即可。
特殊型:势能变化量为
同构型:势能变化量为
异构型:势能变化量为
由此可见,三种操作的势能全部可以缩放为 ≤ 3(w′(x) − w(x))。
令 w(n)(x) = w′(n − 1)(x),w(0)(x) = w(x),Splay
操作一次依次访问了 x1, x2, ⋯, xn,最终
x1
成为深度最浅的结点,那么可得:
因此基于 Splay 的操作的时间复杂度也是均摊 O(log n) 的。
应用 1:维护一个集合
例题:#104. 普通平衡树 - 题目 - LibreOJ (loj.ac)
插入
由于二叉搜索是递归定义的,所以可以用递归的思想考虑(假设插入值为 k):
- 如果当前结点为空,那么就新建一个结点存储当前值。
- 如果当前结点的权值等于 k,那么更新当前结点的计数器并且更新当前结点与父亲的大小。
- 若 k 小于权值就进入左子树,大于权值就进入右子树。
注意,最后更新/新建结点之后,必须执行 Splay 操作,否则时间复杂度不正确!
1 | void insert(int k) { |
根据权值查询排名
假设当前给定的权值为 k,要查找 k 的排名。
维护一个 res 统计当前已经计算了权值小于 k 的结点个数。
当前结点为空,返回 res + 1。
k 小于当前结点的权值,那么进入当前结点的左子树查找,无需更新 res。
k 大于等于当前结点的权值
那么当前结点的左子树中的结点都小于 k,
res += siz[son[x][0]],加上左子树的大小。如果 k 等于当前结点的权值,那么将其旋转至根结点,返回 res + 1。
如果 k 大于当前结点的权值,那么当前结点的权值也小于 k 了,
res += cnt[x],同时进入右子树。
1 | int get_rank(int k) { // 查询 k 的排名 |
根据排名查询权值
假设当前要查询排名为 k 的数,但是在下面,k 是实时维护的。
如果
k <= siz[son[x][0]],那么排名为 k 的数就在左子树中,进入左子树即可。否则让
k -= cnt[x] + siz[son[x][0]],相当于减去根结点的数量和左子树大小。如果此时 k ≤ 0,那么说明
siz[son[x][0]] < k <= cnt[x],即排名为 k 的数就是当前结点。将其旋转至跟节点后返回即可。否则进入右子树。
1 | int get_kth(int k) { // 查询排名为 k 的数 |
查询根结点的前驱/后继
至于为什么要查询根结点的前驱/后继,将会在后面的操作中给出解释。
如果想要查找一个任意权值 k 的前驱/后继,只需先将 k 插入树中。
由于插入函数中执行了 splay,所以此时 k 就位于根结点的位置,可以直接调用函数。
查询完之后删除 k 即可。
由于前驱是小于根结点权值的最大的数,所以只要先进入左子树,然后一直向右找即可。
后继同理。
注意,下面代码返回的是结点编号,所以在最后输出的时候要套一层
val[]。
1 | int get_pre() { // 查询根节点的前驱 |
合并两颗 Splay 树
设两棵树的根结点分别为 x, y,那么要求 x 树中的最大值小于 y 树中的最小值。
- 若 x = ⌀ 或 y = ⌀,那么返回非空的树或者空树。
- 否则将 x 树中最大值 splay 至根结点,然后将其右子树设为 y。这样就保证了二叉搜索树的性质。
这只是一个辅助删除结点的思想,并不需要具体实现为一个函数。
删除一个结点
假设要删除一个权值为 k 的数。
如果要将权值为 k 的数,那么直接将计数器置为 0 即可。
首先将 x 旋转到根结点。
- 若
cnt[x] > 1,那么--cnt[x]并返回。 - 否则删除根结点,并合并左右子树。
思路听起来很简单,但是实现起来有一定的理解难度。
1 | void erase(int k) { // 删除一个权值为 k 的数 |
完整代码
其中 N
为最大的总共开的点的数量,如果不确定可以用 std::vector
代替。
1 | struct Splay { |
