伸展树(Splay Tree)

二叉排序树是一种二叉树,满足对于任意节点x,其左子树所有节点的权值均小于x,其右子树所有节点的权值均大于x。那么任意一棵二叉排序树的中序遍历即为排序后的权值序列。

 

理想情况下,一棵n个节点的二叉排序树应该具有的深度,这样我们在二叉树中任意搜索一个节点的时间复杂度不会超过。最差情况下,一棵n个节点的二叉排序树将会退化成一条naive的链,在这种情况下任意搜索一个节点的时间复杂度就高达。我们期望对于任意输入的二叉排序树,能跟通过一些调整使得这棵二叉树保持的深度并能支持增删改查等操作,这样就得到一棵平衡的二叉树,简称平衡树。

 

存在很多种平衡树,例如AVL、红黑树、Treap、SBT、Splay等,这里面灵活而且适用性比较广的就是Splay,中文名称叫做伸展树。

伸展树的核心操作,在于每访问一个节点,即把这个节点变成整棵二叉树的根,叫做splay操作。听起来很玄学,我们需要从微观去看这个操作。

试想一下,把一个节点x变成整棵二叉树的根,那么其原先的父节点y就变成了x的后代节点。我们先考虑如何将x和它的父节点y交换位置。

 

 

 

图中,t是y的左子树,l和r分别是x的左右子树。由于x是y的节点,当x作为父亲时,y应该是x的节点,而l在交换之前是x的子树,在交换后,由于y是x的子节点,因此l是y的子树。注意到这里面标记颜色的位置。我们定义父亲与右子节点的关系为1,与左子节点的关系为0,如果y跟x的关系是1,交换之后x跟y的关系则是0x跟与y产生冲突的l的关系同样是0,而y在交换之后跟l的关系是1。这里面发生了取反的过程,这个过程可以用xor(异或)1来表达。

因此,假设y跟x的关系为flag,交换之后x跟y的关系是flag^1,与y产生冲突的子树跟x的关系也是flag^1,而y在交换之后,与该子树的关系是flag。这个将x跟y交换的过程我们叫旋转(rotate),根据上面的描述,写出代码如下:

void rotate(int x)

{

int y = tree[x].fa, flag = (tree[y].son[1] == x), tmp = tree[x].son[flag^1];

if (tree[tree[y].fa].son[0] == y) tree[tree[y].fa].son[0] = x;

else tree[tree[y].fa].son[1] = x;

tree[x].fa = tree[y].fa;

tree[y].fa = x;

tree[x].son[flag^1] = y;

tree[y].son[flag] = tmp;

tree[tmp].fa = y;

}

通过旋转我们将x与其父节点交换了位置,x的高度上升了一层。因此很快我们得到一个初步的splay操作:每次旋转将x提高一层高度,直到x到达根的位置。那么这样做是不是可行呢?可行!但是效果并不好。

假设我们通过5次旋转将5伸展到根,其过程如下:

 

这样旋转之后,整棵树的高度并没有发生变化。

这种操作,由于每次只旋转一个点,我们称之为单旋。我们不妨试试,每次旋转两个节点,即双旋的操作:

 

 

我们惊喜地发现,树的高度居然变小了!事实上,Splay的创造者之一Tarjan大神(我的偶像)证明了双旋做法的均摊复杂度是。

接下来我们需要考虑一下,是否所有的情况都适合先转父亲再转当前节点?

 

很显然在这种情况下,经过两次旋转,x的高度只提高了一层,其效果甚至不如旋转两次x。

而对于z、y、x在一顺边的情况,上面双旋的例子已经表明了是可行而且效果优秀的,因此,对于双旋操作,在z、y、x一顺边的情况下,适合先转父亲节点再转当前节点;否则旋转两次x更优。

根据以上结论,可以写出splay过程如下:

void splay(int x)

{

while (fa(x) != 0)//根的父节点是0

{

int y = fa(x), z = fa(y);

if (z == 0)//如果y已经是根了

rotate(x);

else

{

if ((tree[z].son[0] == y) ^ (tree[y].son[0] == x))

rotate(x);

else

rotate(y);                //一顺边的情况

rotate(x);

}

}

root = x;//修改当前根节点

}

通过这样的操作,我们就可以将x旋转到根。

接下来我们关注一下普通二叉树的插入与查找操作在Splay里面如何实现。

首先关注查询操作,我们在二叉树中查询第k小的位置。假设当前节点x的左子树有s个节点,那么很显然,x的权值的排名应该是s+1,因为有s个数比它小;如果k<=s,很简单,第k小明显在左子树;如果k = s + 1,第k小就是x;如果k > s,那么我们应该在右子树中查询第k – s – 1小。

void find(int k)

{

int x = root;

for (; tree[tree[x].son[0]].size + 1 != k;)

{

if (tree[tree[x].son[0]].size >= k)

x = tree[x].son[0];

else x = tree[x].son[1];

}

splay(x);//将x旋转到根,此时根就是我们查询的值

}

我们发现这里面,我们需要维护一个子树大小即tree[x].size,在哪里维护呢?当然是在子树发生变化情况下维护,因为此时子树状态发生变化,需要更改值,很显然就是rotate过程和splay过程。

再次观察这张图:

 

我们发现,子树t、l、r的节点保持不变,变化的只有x和y的子树大小,因此我们只需要在rotate里面维护一下y和x的子树大小。注意,由于y是x的子树,因此求解x的子树大小需要y的子树大小,因此需要先维护y。我们定义一个过程update,用来维护子树大小

void update(int x)

{

tree[x].size = tree[tree[x].son[0]].size + tree[tree[x].son[1]].size + 1;

}

修改一下旋转的函数:

void rotate(int x)

{

…//省略上述rotate函数代码

update(y);

update(x);

}

一次旋转需要更新两个值,x和y,有没有必要都更新呢?并没有!只需要更新y就可以了。思考一下为什么?

因为即使我们更新了x,接下来x还要旋转(或是在双旋中被子节点旋转),也就是我们刚刚更新了x,x的值又修改了,多么点背啊!因此我们只需要更新一下y,留着x在全部尘埃落定之后(x不再因旋转而改变位置)再去更新x的子树信息就好。这是一个优化,因此最终的rotate版本为:

void rotate(int x)

{

…//省略上述rotate函数代码

update(y);

}

x何时不再因旋转而改变位置呢?假如x是双旋中第一次旋转的父节点(一顺边的情况先旋转父节点),它在第二次被其子节点旋转时,已经更新过了,并且之后x不再变化,因此我们不用搭理它。如果x就是我们要splay到根的节点,那么我们可以在它已经是根之后,再更新,因为此时x是稳定的。因此改写一下splay函数:

void splay(int x)

{

…//省略上述splay函数代码

update(x);

}

这样我们可以得到各子树的size,并通过它查询第k小。

 

插入操作,首先我们应该找到新节点的位置,也就是新节点的父亲,接下来我们直接确定它是新节点的左子节点或右子节点就可以了。如何找到新节点的父亲呢?按照二叉排序树的定义,如果新节点的权值小于当前节点x,往左子树插,否则往右子树插,直到遇到空位。根据以上描述,代码如下:

void insert(int val)//插入一个值为val的新节点

{

int x = root, fa = 0; //从根开始插入

for (int flag = 0; x; fa = x, x = tree[x].son[flag])

flag = (val < tree[x].data)^1;

//此时,x = 0, fa是新节点的父亲,flag是fa与新节点的方向(左儿子?右儿子?)

tree[++cnt].data = val;

tree[cnt].fa = fa;

tree[cnt].size = 1;

tree[fa].son[flag] = cnt;

splay(cnt);//精髓,旋转到根

}

注意最后的splay(cnt),极其牛逼,因为cnt节点的size为1,这个信息并没有更新到父节点以及祖先节点。通过一个splay操作,将cnt伸展到根,相当于访问了各个祖先节点(类比线段树的更新过程)。

 

接下来我们关注如何删除第k小的数

删除第k小的数,首先我们应该找到这个数对应的节点,由于find函数在最后会splay这个节点,因此当前的根节点即为即将删除的节点。

我们考虑将当前节点删除会怎么样?

首先,当前节点x被删除,遗留下两个子树——原先x的左子树和右子树。由二叉排序树的定义,我们知道,右子树所有的节点权值全部大于左子树,也就是大于左子树中最大的权值。接下来我们这么做,我们在左子树中找到最大权值的节点——一直往右走就好了!然后将这个节点旋转到根(左子树的根),这时我们可以肯定,这个节点是没有右子节点的,因为并不存在比它还大的权值!因此我们可以把这个遗留下的右子树,接到这个节点的右边,更新一下子树信息,得到新的二叉排序树,这个新的树就是删除节点x之后的树。根据以上描述,代码如下:

void del(int k)

{

find(k);

int l = tree[root].son[0], r = tree[root].son[1];

fa(l) = fa(r) = 0;

int x = l;

for (; tree[x].son[1] != 0; x = tree[x].son[1]);//一直往右走,找最大

splay(x);//提到根

tree[x].son[0] = r;

update(x);

}

 

接下来给出伸展树的节点类型定义示范(请具体问题具体分析)以及宏定义

 

#define fa(x) tree[x].fa

#define size(x) tree[x].size

 

struct node

{

int data, fa, son[2], size;

} tree[200010];

 

接下来整合一下上面的代码就好了,这里不再赘述,因为以及很多字了,还有很多图……

 

P.S.:部分代码纯手打,可能出现错误,仅供参考!

转载于:https://www.cnblogs.com/gusc/p/6396554.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值