先看一道例题(洛谷P3369):
为了满足快速查询某个节点,并保证这些节点有序的操作, 我们可以想到二叉搜索树(BST),对于BST的某个节点,它的左孩子一定比它小,右孩子一定比它大,所以它的中序遍历一定是有序的。但是问题很明显:二叉搜索树的搜索时间取决于树的高度,例如下图中的两棵树,它们的中序遍历都是一样的,但是很明显左边那棵平均搜索时间要低很多。
这时就可以引入我们今天要讲的概念:Splay树(伸展树),它首先是一颗平衡二叉树。Splay树通过不断将需要的节点旋转到根节点,并在旋转过程中优化树的结构,从而达到快速搜索和修改的操作。在正常情况下,对一棵Splay进行若干次操作,它的增加、删除、修改的复杂度总能保持在O(logn)以内。
Splay的优势在于它可以快速处理频繁操作或翻转的线段问题,相较于线段树,Splay显得更加灵活和方便。
Splay树有以下几个基本操作:
1、旋转(左旋和右旋)
以这棵树为例,对于某个点的旋转操作即是把该点作为根,保留所有向左和向右延伸的两条链
左旋即为逆时针,右旋即为顺时针,(如图为右旋),把所有的节点向旋转方向移动一位
最后再补上原树中的其他节点即可。
(左旋和右旋互为反操作,上图的逆向操作即为左旋)
对于splay来说,我们要把某一个节点x旋转到根节点,就会有以下几种情况:
1、x已经是根节点了,此时不需要操作
2、x的父节点是根节点,此时只需要对x的父节点进行一次左旋或者右旋即可
3、x的父节点不是根节点,我们可以把x前往根节点的这一段路分成四种情况:
前两种情况x、y、z都在一条直线上,所以翻转时需要一起走,后两种情况是折线,翻转时需要隐藏一个节点再翻转。
具体如下:
(1)、连续以最高点右旋两次
(2)、与(1)操作相反,连续以最高点左旋两次
(3)、由于x并不在根节点的左右两条链上,随意翻转会将x隐藏,所以我们要先将x翻转到最外面一层上,再向根节点移动
(4)、与(3)一致但操作相反
通过0和1的异或关系,我们可以将孩子节点指针放进数组s里,用s[0]表示左儿子,s[1]表示右儿子,那么左旋和右旋就可以放在一起写了。
这里我建议每次写的时候都画一张x、y、z的关系图,放在一起写的核心原理就是根据x、y、z相互之间的边的方向,更新旋转后的方向。
void pushup(int x)
{
tr[x].size = tr[tr[x].s[0]].size + tr[tr[x].s[1]].size + 1;
}
void rotate(int x)
{
int y = tr[x].p, z = tr[y].p;
int k = tr[y].s[1] == x; // k=0表示x是y的左儿子;k=1表示x是y的右儿子
tr[z].s[tr[z].s[1] == y] = x, tr[x].p = z;
tr[y].s[k] = tr[x].s[k ^ 1], tr[tr[x].s[k ^ 1]].p = y;
tr[x].s[k ^ 1] = y, tr[y].p = x;
pushup(y), pushup(x);
}
有了旋转函数,我们就可以做splay的核心操作:将某个节点x旋转到k下面:splay(x, k)
void splay(int x, int k)
{
while (tr[x].p != k)
{
int y = tr[x].p, z = tr[y].p;
if (z != k)
if ((tr[y].s[1] == x) ^ (tr[z].s[1] == y)) rotate(x);
else rotate(y);
rotate(x);
}
if (!k) root = x;
}
2、查询序列第k个数
我们可以将每个节点所在子树的节点个数存在子树的根节点上,那么在查找第k个数的时候,我们只需要将k与左子树的孩子进行比较,就可以判断第k个数在左子树还是右子树。
int get_k(int k)
{
int u = root;
while (true)
{
pushdown(u); //pushdown将splay维护的节点内容向下传递
if (tr[tr[u].s[0]].size >= k) u = tr[u].s[0];
else if (tr[tr[u].s[0]].size + 1 == k) return u;
else k -= tr[tr[u].s[0]].size + 1, u = tr[u].s[1];
}
return -1;
}
3、插入新节点
由二叉搜索树的性质可以很容易知道,当x为根节点时,x的前驱即x的左子树的最右节点;同理,后继为x的右子树的最左节点。
利用这个特点,我们可以找到新节点的前驱y,并将它旋转到根,再找到新节点的后继z,并将它旋转到y的下面,这样以来,z的左孩子一定是空的,因为z是y的后继。那么我们就可以将新节点插在z的左边。
void insert(int v)
{
int u = root, p = 0;
while (u) p = u, u = tr[u].s[v > tr[u].v];
u = ++ idx;
if (p) tr[p].s[v > tr[p].v] = u;
tr[u].init(v, p);
splay(u, 0);
}
4、删除节点
删除操作和插入操作刚好相反,假设我们要删除的区间为[l, r],那么我们可以将r + 1旋转到根节点,l - 1旋转到根节点下面,那么区间[l, r]一定在l - 1的右边边,直接清空即可。
最后放上完整代码:
参考文献:
bilibili-董晓算法,《普通平衡树》
OIwiki,Splay
洛谷P3369【模板】普通平衡树