Splay学习笔记
Splay
伸展树,是平衡树的一种。对比Treap或者STL中的set,它可以实现更多功能,一般为区间操作。
基本实现
Splay核心函数有2个:Splay(int x, int k)和rotate(int x),分别表示将节点x旋转至k点下方,以及将x向上旋转一层。
rotate函数实现较为简单,可以类比Treap中的zig和zag。看代码即可理解。
struct SplayTree {
int s[2], p, val, size; //左右子节点,父节点,权值,子树大小
void init(int _v, int _p) { //初始化
val = _v; p = _p; size = 1;
}
} a[N];
void rotate(int x) {
int y = a[x].p, z = a[y].p, k = a[y].s[1] == x; //子节点性质
a[z].s[a[z].s[1] == y] = x; a[x].p = z;
a[y].s[k] = a[x].s[k ^ 1]; a[a[x].s[k ^ 1]].p = y;
a[x].s[k ^ 1] = y; a[y].p = x;
}
Splay函数通过不断将x向上旋转2层(最后为1层)实现。设x的父节点为y,y的父节点为z。当y对于z、x对于y的子节点性质(左/右)相同时(可以在形式上看做一条直线),先将y上旋,再将x上旋。不同时(折线),将x上旋2次。纸上推演即得。
void Splay(int x, int k) {
while (a[x].p != k) {
int y = a[x].p, z = a[y].p;
if (z != k)
if (a[z].s[1] == y ^ a[y].s[1] == x) rotate(x);
else rotate(y);
rotate(x);
}
if (!k) root = x;
}
插入、删除、查询操作与Treap类似。但在插入和删除之后,需要将节点向上旋转至根,即Splay(x,0)。如此可以保证之后每次操作的均摊复杂度为\(O(\log{N})\)。证明请看这里。
区间操作
Splay最引人注目的地方在于它支持区间操作,这使得它在一众平衡树中脱颖而出。否则平衡树的操作都可以用set实现,我们还学它干嘛……
若想要在原序列中加入一段连续的数,且保证它们在新的有序序列中也相邻,那么使用Splay可以\(O(\log{N}+M)\)实现(其中\(M\)为插入序列的长度)。设新的有序序列为\(a_1,a_2,...,a_n\),插入的序列为\(a_l,a_{l+1},...,a_r\),那么可以保证在原序列中\(a_{l-1}\)与\(a_{r+1}\)相邻。设它们在树中的编号分别为\(x,y\)。那么我们Splay(x,0)并Splay(y,x),此时y的左子树一定为空。只需先将要插入的序列构造成一棵二叉搜索树,将根节点作为y的左儿子,即可实现。代码量极短。区间删除类似。
除此之外,其与线段树相似,可以实现懒惰标记。这意味着我们可以对其进行更多样的区间操作,例如同时加上/减去一个数。另外线段树难以实现的区间翻转操作,利用Splay特殊的性质,也可以轻松实现。
应用
首先是一般的平衡树操作,不多说。
来看一道区间操作:郁闷的出纳员
同时加减一个数可以用懒惰标记实现。特别地,此题中薪水的变动针对全体员工,那么可以进一步简化,用一个Δ值表示实际薪水与记录的薪水的差距。每次操作修改Δ值,在减薪时删除记录值小于min-Δ的节点。可以发现删除的节点一定是一段连续的区间。可以用Splay实现。
再来一道:Splay
这题很有意思,要求翻转,自然想到Splay(题目也给足了暗示)。但是,若我们用Splay维护数值有序,那么翻转的区间在树的中序遍历中不构成一段区间。那就无法用Splay实现。
正解使用了一种很奇妙的思路:在Splay中维护下标有序。换句话说,数的中序遍历刚好对应实时的序列。于是得以解决。注意,因为此时Splay中维护的东西为下标而非数值,所以此时无法再在这棵树上执行“查询第k大的数”这样的操作,只能执行“查询序列中的第k项元素”。不要产生混淆。
懒惰标记的实现:
void pushdown(int x) {
if (!a[x].lazy) return ;
swap(a[x].s[0], a[x].s[1]);
a[a[x].s[0]].lazy ^= 1; a[a[x].s[1]].lazy ^= 1;
a[x].lazy = 0;
}
这种维护下标有序的方法在Splay的题目中很常见,可以将其作为一种常用的思考方向。