Splay 是最灵活的平衡树,除了常数和不能完全可持久化,它几乎没有缺点。
前言
前置知识:
- 二叉搜索树
可以放在 LCT、支持区间操作……
基础操作
准备操作
我们先把节点要维护的先定义出来。
子树大小 | 节点的权值 | 左儿子 | 右儿子 | 父亲 |
---|---|---|---|---|
size | val | ch[0] | ch[1] | fa |
1 | struct node{int size, val, ch[2], fa;}d[N]; |
再定义几个基础函数。
1 | int root, tot = 0, stk[N], top; |
旋转
rotate 操作的本质是把某个给定节点上移一个位置,并保证二叉搜索树的性质不改变。
在 Splay 中,旋转操作分为左旋(Zag) 和右旋(Zig)(图上节点是编号)。
我们来模拟一下右旋的操作(红色是要删除的,蓝色是更改后的)。
这样就完成了一次旋转。
而在实现中,我们会把 zag 和 zig 写在一起。
这里的 rotate(x)
表示将 旋转到其父节点的位置。
1 | void rotate(int x){ |
splay
splay(x)
表示把 节点旋转到根节点。
我们可以简单分成 3 种:
zag/zig
很简单,对 做一次 rotate 即可(图就不放了)。
zag-zig/zig-zag
即 get(fa(x)) != get(x)
。
对 做两次旋转即可。
zag-zag/zig-zig
即 get(fa(x)) == get(x)
,这个时候只旋转 是不对的。
我们要先旋转 fa(x)
再旋转 (双旋)。
为什么我们要弄出双旋呢?
因为双旋会减少一层,明显会更优。
代码
1 | void splay(int x){ |
时间复杂度
证明
这段内容来自博客mr_spade。
为了方便描述,定义如下内容:
我们用 表示一棵完整的 Splay,并(不严谨地)用 表示 的节点数目。
如无特殊说明,小写英文字母(如 ,,)表示 的一个节点, 表示节点 在 中。
并(不严谨地)用 表示以节点为根的子树的大小。
我们默认 代表节点 在经过了上下文中描述的操作以后的状态,因此对应的 代表之前的状态。
我们用 表示整棵 的势能函数, 则表示节点对 贡献的势能。
先来讲一下我们的势能函数,我们定义:
可以发现,对于任意时刻,因为 ,因此 ,从而得到 ,因此势能函数是合法的。
同时 ,因此我们总有 。这个上界是比较松的,但是对我们的分析没有影响。
下面考虑一次伸展操作对于势能函数的影响。
由于我们可以把从根向下查找的代价计算到伸展过程中对应的旋转操作上,此时旋转操作复杂度不变,只是常数增大,从而忽略了查找对复杂度的影响。
我们可以简单地通过增大势的单位来支配隐藏在操作中的常数。
因此我们只需证明对于一次伸展操作的所有旋转操作,其复杂度是均摊 的,我们就完成了对 Splay 复杂度的证明。
- zig/zag
由于 zig 操作与 zag 相似,因此只需要证明 zig 即可。
假设我们 zig 的对象是 ,其父亲为 ,显然在旋转以后,只有 和 的子树大小发生了变化。
因此势能变化量为:
显然 ,且 ,因此消去 与 ,并将 替换为 ,有:
因此 zig 操作的均摊代价为 ,其中 代表旋转操作本身的复杂度。
而在一次伸展操作中也只会有一次 zig 操作,因此这额外的 代价不会对分析造成影响,因此我们可以只关心其中的 。
- zig-zig/zag-zag
由于 zag-zag 操作与 zig-zig 相似,因此只需要证明 zig-zig 即可。
假设我们 zig-zig 的对象是 ,其父亲为 ,其祖父为 ,与 zig 操作类似,势能变化量为:
同样地,由于 ,因此将它们消去:
而我们又有 ,因此有:
推到这里,我们先来做一个小工作,来证明 (注意与上面的式子不一样)的值不大于 。
假设 ,那么我们有:
我们将 合并,得到:
由于 (可以结合旋转过程思考一下),而 是单调的,因此:
证明完毕。
现在我们已经知道 zig-zig 操作的摊还代价不大于:
其中 为旋转操作的复杂度。由于之前的推导我们可以知道 ,因此 ,我们在摊还代价上加上这个非负数得到:
化简一下,就得到:
通过增大我们刚刚加的那个非负数以及势的单位,我们就可以支配隐藏在 中的常数,因此一次 zig-zig 操作的摊还代价为:
- zig-zag/zag-zig
分析的过程和 zig-zig 操作完全一样,之前分析用到的所有性质此时仍然适用,因此略过分析过程。其摊还代价依然为:
总结:
综上所述,除了最后一次旋转可能增加 的代价以外。
其余操作的摊还代价只和我们伸展的对象 的势能有关。
我们假设旋转操作一共执行了 次,并用 来表示节点 在经过 次旋转后的状态,那么整一个伸展操作的摊还代价就为:
显然除了 与 外,所有的势能都被抵消了,因此摊还代价为:
至此,我们不必关心 的值了。
此时 是 的根,因此 。
我们成功的证明了一次伸展操作的摊还代价为 。
所以是均摊 。
其他操作
剩下的操作就和普通的二叉搜索树区别不大了(记得splay)。
插入
1 | void insert(int val){ |
删除
删除真的挺麻烦的,在大部分的平衡树中都是除平衡操作外最长的。
1 | void del(int val){ |
查排名
平衡树内可能有好几个权值为 的节点,但我们要找的是严格小于的。
1 | int query_rank(int val){ |
查询第 k 小
根据 size 判断走哪边即可。
1 | int kth(int rak){ |
前驱
一种简单的写法:
先插入一个 ,这样 就是根了。
那 的前驱,就是先走根的左儿子,然后再一直走右儿子走到底。最后再删掉插入的这个 。
但原本写了删除,插入还好,如果没有,我还要单独写。
我们换一种写法:
若 ,说明答案在 的左子树,。
否则,我们记录一下,然后走进右子树,,。
1 | int ask_pre(int val){ |
后继
跟前驱差不多,反一下就好了。
1 | int ask_next(int val){ |
完整代码
序列操作
我们可以不在关心每个节点之间的大小关系。
我们让树的中序遍历的结果变成序列上的顺序。
splay操作
假设我们要操作 的区间,我们需要让树变成这样:
如果直接使用之前的 splay 操作的话明显不好实现,所以我们要更改一下。
把旋转到根节点改为,旋转到 节点的儿子节点。
1 | void splay(int x, int p){ |
使用更改后的 splay 就可以比较方便的实现了。
先将 节点旋转至根节点,然后在右子树内找到 节点,然后旋转到 的儿子,就好了。
为了防止 的情况,我们可以先加入 和 两个虚点,注意不要输出了。
find
和之前的 kth 区别不大,但因为虚点,所以要将排名 ,也不用 splay 了。
1 | int find(int k){ |
输出
别把虚点输出了。
1 | void print(int now){ |