Splay是一种BST树,它的查找、插入、删除、分割、合并等操作复杂度都是O(log2n)。它的主要特点是可以把某个结点旋转到指定位置(常常是根节点)。
在Treap篇中我们提到,Treap利用随机优先级剔除输入顺序对树的形状的影响,把树的平衡交给数学期望,在新结点加入时,Treap用动态调整的方法维护树满足规则,但Treap的操作实际上都不会对树的形状产生太大的变化,这使得Treap树的平衡性几乎完全依赖与一开始随机给定的优先级。但在Splay里,我们不再用优先级去维护树的平衡,而是更随意(动态)一点:根据实际操作修改树的形状,使它起码不会总停留在很差的状态,这就是Splay树的核心逻辑。
除了与Treap和普通二叉树没有太大区别的查找、删除、添加结点的功能外,Splay有一个核心的、足以对整棵树的形状产生颠覆效果的操作——提根(将某一个结点旋转到根,也可以到别的指定位置),提根操作是Splay足以有意义的基础。
我们将以提根操作为核心,从结点创建到常见功能实现,提供简要的思想逻辑和代码实例。
Splay的结点
想要实现提根以及所有二叉树基本操作,我们的Splay结点相对于Treap结点有些许不同,由于我们希望能够特别地直接定位结点的位置,所以用数组来实现Splay变得更简单,因此Splay结点不再是一个简单的类或是结构体,而是一系列的数组和函数。
同时需要额外注意的是,由于我们希望Splay的初始状态最好是平衡的,因此对于Splay结点,其具有键(key)和值(value)两个参数共标记了一个结点,key是这个结点在树中的位置依据(二叉树规则),而value是这个结点的真值(根据题目输入),二者的数值没有任何的联系。对于二者的作用我们会在下一节解释。首先放出结点参数:
(1)一个整数记录根的位置
(2)维护i的父节点的数组pre[i]
(3)记录i的子树的结点个数的数组size[i]
(4)记录数的数组tree[i][2],代表i的左右儿子的位置
(5)记录i的键key和值value和重载了<用于sort排序的结构体Node数组
(6)更新x的size的函数update()
const int maxn = 10000;
int root; //记录根
int pre[maxn], size[maxn]; //父结点、size
int tree[maxn][2]; //记录树
struct Node{
int key, value;
bool operator<(const Node &A)const{
if(value==A.value) return key<A.key;
return value<A.value;
}
}node[maxn];
void update(int x){ //计算更新x的size
size[x] = size[tree[x][0]] + size[tree[x][1]] + 1;
}
Splay结点的插入
我们先介绍更普遍的、更常用的操作——结点插入。如果你想单纯用结点插入来建造Splay树,或者为已经存在的树补充新的结点,我们的思路和普通二叉树几乎是一样的:
(1)找到这个结点的value应该放的位置,连接它
(2)回溯,同时更新路径上的size
首先我们专门用一个Newnode函数处理新的叶子结点,这里可以和二叉树笔记中的add函数中创建结点的部分相对比,你会发现原理是一致的。
void Newnode(int &x,int fa, int value){
x = value;
pre[x] = fa;
size[x] = 1;
tree[x][0] = tree[x][1] = 0;
}
接下来是插入结点函数:
void add(int &x, int fa, int val){//在主函数中调用时,x=root,fa=0,val想插入的值
if(x==0)
Newnode(x,fa,x);
else{
if(val>node[x].val) add(tree[x][1],x,val);
if(val<node[x].val) add(tree[x][0],x,val);
}
update(x); //回溯,更新size
}
事实上你会发现在朴素的结点插入中,key并没有被用到,这是因为我们直接用value代替了本应该由key起到的作用,key什么时候应该和value区分,起到独立的作用?这将是一种特殊的情况——Splay树的预处理建树。
Splay树的创建——预处理
如果你的Splay树的规模相对稳定,并不会经常需要新结点的插入,而是在一切其余操作开始以前便先输入了整棵树的结点,那通过预处理创建一棵树无疑是更平衡的,预处理的思路如下:
(1)在输入每个结点的value的同时,按照1-n的顺序赋予每个结点的key。
(2)按照key而不是value建树,由于此时key的数量是固定的,我们可以建出最平衡的树。
ps.笔者并不认为这是一种实用性很强的技巧,它的价值仅在一棵一开始结点就确定的树上能够体现。但这就像我们一开始说的,Splay树由于其提根操作的特殊性,其形状是动态的,这使得预处理建树在Splay上简直显得有些自作聪明,事实上它反而可以用于形态稳定的Treap树或者普通二叉树上,因此让我们不要花太多时间,只是放下代码模板以备不时之需:
void buildtree(int &x, int l, int r, int fa){
if(l>r) return;
int mid = (l+r)>>1;
Newnode(x,fa,mid);
buildtree(tree[x][0],l,mid-1,x);
buildtree(tree[x][1],mid+1,r,x);
update(x);
}
void Init(int n){
root = 0;
tree[root][0] = tree[root][1] = pre[root] = size[root] = 0;
buildtree(root,1,n,0);
}
int main(){
int n;
cin>>n;
Init(n);
for(int i=1;i<=n;i++){
cin>>node[i].val;
node[i].key = i;
}
}
Splay的提根
无论如何,你已经有一棵树了,接下来让我们把它变成一棵真正的Splay——进行提根操作的实现。首先来理解提根的实现逻辑:
所谓的提根,实现核心是我们在Treap篇中提到的旋转,如果先不去纠结旋转的左右区别,我们可以把提根操作分为3种情况:
1、x的父结点就是根——一次旋转
如果x的父节点就是根结点,显然只需要对x所在的子位进行一次反方向的旋转(x是左二子,则右旋,x是右儿子,则左旋)。
2、x、x的父节点、x的父节点的父节点共线——两次同向旋转
所谓三点共线可以由图观之,在代码逻辑上的体现是:x所在的子位和x的父节点所在的子位相同,则进行两次与子位相反方向的旋转。
3、x、x的父节点、x的父节点的父节点不共线——两次异向旋转
当三点不共线,则自然需要两次异向旋转,看到这里你应该明白,提根操作的最核心部分是反向旋转——即与节点所在的子位相反的方向旋转。
需要注意的是:提根或旋转都遵守二叉树的根本规则,因此先序排序的顺序是不会改变的。
关于旋转的实现步骤,我们在Treap篇笔记中已经简要描述,此处不再重复,直接放出旋转代码:
void Rotate(int x, int c){ //c=0 左旋,c=1 右旋
int fa = pre[x];
tree[fa][!c] = tree[x][c];
pre[tree[x][c]] = fa;
if(pre[fa]) tree[pre[fa]][tree[pre[fa]][1] == fa] = x;
pre[x] = pre[fa];
tree[x][c] = fa;
pre[fa] = x;
update(fa);
}
给出提升结点Splay函数实现:
void Splay(int x,int goal){//将x提升为goal的儿子,如果goal为0,则旋转到根
while(pre[x]!=goal){
if(pre[pre[x]]==goal) //情况(1)
Rotate(x,tree[pre[x]][0]==x);
else{
int fa = pre[x];
int c = (tree[pre[fa]][0]==y);
if(tree[y][c]==x){ //情况(2)
Rotate(x,!c);
Rotate(x,c);
}
else{ //情况(3)
Rotate(fa,!c);
Rotate(x,c);
}
}
update(x);
if(goal==0) root = x; //更新根结点
}
基于Splay提根的操作——删除、分裂和合并
由于我们已经可以把x提到根结点,相对于Treap或者普通二叉树,Splay实现删除和分裂的操作非常简单快捷,至于合并,那将是一个很苛刻的情况。
1、删除
删除x结点的逻辑是:将x提升到根节点,然后删除根结点。虽然说起来简单,但二叉树终究不是链表,一个根节点有多种情况:
(1)其左子树为空,说明根结点是最小数,则直接把根结点删除,右儿子做新的根。
(2)其左子树不为空,说明根结点不是最小数,则先找到根节点右子树中的最大结点,把这个结点提升到根节点的右儿子,将根结点本来的右儿子作为根节点新的右儿子的右儿子,切断根结点与此结点的连接即可。
当然,如果你想的话,也可以找到左树的最大结点进行相同的操作。
为了应付第二种情况,给出找到右子树最大结点的代码:
int get_max(int x){
while(tree[x][1]){
x = tree[x][1];
update(x);
}
return x;
}
给出删除根结点的代码:
void del_root{
if(tree[root][0]==0){ //如果左儿子为空
root = tree[root][1];
pre[root] = 0;
}
else{
int m = get_max(tree[root][0]);
Splay(m,root);
tree[m][1] = tree[root][1];
pre[tree[root][1]] = m;
root = m;
pre[root] = 0;
update(root);
}
}
2、分裂
Splay树的分裂更简单:将根分界点x提根,然后将x的左或者右儿子设为0即可。
3、合并
Splay树的合并有两种:
1、Splay树特色的合并
如果A树结点的最大值比B树结点的最小值小,则可以直接合并,分别将最值点提根然后合并。
2、普通树的合并,将一棵树分裂,一个个结点融合,和Treap树的合并原理一样。