FHQ大战Splay

本文详细介绍了Splay树和FHQ Treap两种平衡树数据结构,包括它们的基本概念、操作(如翻转、splay、插入、删除等)、区间操作以及代码实现。此外,还探讨了可持久化FHQ Treap的分裂合并操作,以及这两种数据结构在区间查询和修改中的应用。Splay适用于单点操作,而FHQ Treap在区间操作上有优势。文章通过实例和代码解析帮助读者深入理解这两种数据结构的使用方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

S p l a y Splay Splay可以说是一个常数挺优秀的一个支持区间操作的平衡树,神奇的是在随机数据的情况下,有时候他能跑得玄学一般的快,这也取决于他复杂度玄学的证明方法,当然,他的 O ( m l o g n ) O(mlogn) O(mlogn)复杂度并非上限,而是均分,也就是可以卡。而我学他也只是单纯的用于LCT,因为在LCT中,他是能做到使得LCT的复杂度为 m l o g n mlogn mlogn的我所认知的唯一一颗平衡树。

当然,单点操作我也是用 S p l a y Splay Splay,毕竟 F H Q FHQ FHQ真的在单点操作的时候特别容易出锅,但同时FHQ的区间修改又比较强悍,代码短不容易出错,都是 F H Q FHQ FHQ的好处,而且支持可持久化。

真正的大佬应该再学一个 S B T SBT SBT,单点修改跑的飞快且代码听说也挺短的,我们机房就有个大佬是这样的,不过他不喜欢 S p l a y Splay Splay L C T LCT LCT也不打 S p l a y Splay Splay,Orz。

Splay

Splay是什么,简单来说就是把一个点暴力跳到根节点来以此暴力玄学达到 O ( n l o g n ) O(nlogn) O(nlogn)的复杂度,听起来很玄学,实际上还是挺玄学好用的。

例题

时间限制: 2 Sec  内存限制: 128 MB
【题意】 
     写一种数据结构,来维护一些数,其中需要提供以下操作: 
     1. 插入x数 
     2. 删除x数(若有多个相同的数,应只删除一个) 
     3. 查询x数的排名(若有多个相同的数,应输出最小的排名) 
     4. 查询排名为x的数 
     5. 求x的前驱(前驱定义为小于x,且最大的数) 
     6. 求x的后继(后继定义为大于x,且最小的数)  
    注意:数据保证有解,无需特判
 【输入格式】
    第一行为n,表示操作的个数,下面n行每行有两个数opt和x,opt表示操作的序号。
    (1<=opt<=6,n < = 100000, 所有数字均在-10^710^7内 )
 【输出格式】
    对于操作3,4,5,6每行输出一个数,表示对应答案
 【样例输入】
8
 1 10
 1 20
 1 30
 3 20
 4 2
 2 10
 5 25
 6 -1
【样例输出】
2
 20
 20
 20

学习Splay

我们考虑一个类似堆得数据结构,堆不是要求儿子节点比父亲节点大(小)就行了吗,那么平衡树就是要求左字数所有节点的关键字小于父亲节点,右字数的节点大于父亲节点,也就是说这棵树的中序遍历是按关键字从小到大排序的,很明显,关键字就是决定树的形态的一个值。

5.png

这就是一棵较为典型的平衡树的样子。

而平衡树的复杂度取决于平衡树的层数,层数越大,时间越慢。

因为Splay其实可以说得上是用了大量的翻转来实现平衡的一棵树,所以我们现在先学习翻转。

翻转

总所周知,许多树的中序遍历都是一样的,也就是说连一个序列,Splay的样子也是可能有变化的,那么我们就来学一学如何利用一棵现有的Splay转换成其他形态的Splay。

我们仍然以上张图为例:

这就是一个经典的左旋右旋的例子。

很明显是一个 O ( 1 ) O(1) O(1)的操作。

而旋一个点的话,就是看他在父亲的那个儿子,在左儿子就右旋,在右儿子就左旋。

inline  void  update(int  x){
   
   tr[x].c/*子树内点的个数*/=tr[tr[x].son[0]].c+tr[tr[x].son[1]].c+tr[x].n/*与x号点点权相同的有多少个点*/;}
inline  int  chk(int  x){
   
   return  tr[tr[x].f].son[1]==x;}//判断自己是什么儿子
inline  void  rotate(int  x)
{
   
   
	int  f=tr[x].f,ff=tr[f].f,w=chk(x)^1/*0为左旋,1为右旋*/,son=tr[x].son[w];
	int  r,R;
	r=son;R=f;tr[r].f=R;tr[R].son[w^1]=r;
	r=x;R=ff;tr[r].f=R;tr[R].son[chk(f)]=r;
	r=f;R=x;tr[r].f=R;tr[R].son[w]=r;
	update(f);update(x);
}

splay

Splay的核心操作splay,QMQ。

就是把 x x x一直旋,直到 y y y x x x的父亲,不过要求 y y y一定是 x x x的祖先,那么分几种情况:

  1. x x x f a x fa_{x} fax的左(右)儿子, f a x fa_{x} fax f a f a x fa_{fa_{x}} fafax的左(右)儿子,那么就先旋 f a x fa_{x} fax,再旋 x x x
  2. x x x f a x fa_{x} fax的右(左)儿子, f a x fa_{x} fax f a f a x fa_{fa_{x}} fafax的左(右)儿子,那么就旋 x x x两次。
  3. 如果 y = f a f a x y=fa_{fa_{x}} y=fafax,那么旋一下 x x x就行了。

很多人会不理解一操作,其实这是某大佬实验得出这样会快!

一操作的图(以一条链为例子,都是关键字为 1 1 1的点旋到 0 0 0的儿子,也就是根节点):

先旋父亲,再旋儿子:

7.png

连旋两次:

8.png

我们发现,一个层数为 4 4 4,一个层数为 5 5 5,所以先旋父亲,再旋儿子会使得Splay更平衡,其实我们可以这么想,旋转一个节点,相当于把父亲节点的东西装进我这个节点中,那么如果把一些点装进目前的父亲,一些点装进自己里面,不就会使得树更平衡了吗?

但是二操作为什么又不敢这样了呢。

二操作的情况但是先旋父亲,再旋儿子(以一条链为例子,关键字为 1 1 1的点旋到 0 0 0的儿子,也就是根节点):

9.png

会发现这个节点在旋了两次的情况下,只上了一层,而且理论上将树的层数不会更优,如下图,

外链图片转存失败(img-wIMrcIYb-1565857599323

我们会发现不管父亲节点怎么旋转, 4 4 4的层数就是不变,且树的总体层数也不变,也就是说其实旋父亲是没意义的。

但是又有人问为什么不只旋一次,有时候只旋一次以后就会出现操作一了,这样不会更优吗?

确实可能会更优,但是这是牺牲了一次判断的结果,而且最多就多一层或两层,使得慢得更慢,快得更快,而我们寻求的是一个综合性,所以旋两次也莫得多大问题。

那么这样不就好起来了吗?

inline  void  splay(int  x,int  fa)
{
   
   
	while(tr[x].f!=fa)
	{
   
   
		int  f=tr[x].f,ff=tr[f].f;
		if(ff==fa)rotate(x);//操作三
		else  rotate(chk(x)^chk(f)?x:f),rotate(x);//三目运算符缩减操作一与操作二。
	}
	if(!fa)root=x;
}

求x的排名

这不是很简单吗,跳Splay不就行了?如果跳右子树,答案就加上左子树的树的个数。

然后把找到的点splay上去。

inline  int  findip(int  d)//找到大于d且最接近d的节点或者小于d且最接近d
{
   
   
	int  x=root;
	while(tr[x].d!=d)
	{
   
   
		if(!tr[x].son[tr[x].d<d])break;
		x=tr[x].son[tr[x].d<d];
	}
	return  x;
}
inline  int  findpaiming(int  d)//这种方法仅限于存在这个数字的时候可以这么用
{
   
   
	int  x=findip(d);splay(x,0);
	return  tr[tr[x].son[0]].c+1;
}

提一提,findip不一定会找到最接近 d d d的点,但是能保证不会有点权是卡在 d d d t r [ x ] . d tr[x].d tr[x].d之间的。

求排名为x的数字

一样子,跳一跳,跳右子树就将 x x x减去左子树点的个数。

然后把找到的点splay上去。

inline  int  findkey(int  d)//暴力往下跳
{
   
   
	int  x=root;
	while(1)
	{
   
   
		if(d<=tr[tr[x].son[0]].c)x=tr[x].son[0];
		else  if(d<=tr[tr[x].son[0]].c+tr[x].n)break;
		else  d-=tr[tr[x].son[0]].c+tr[x].n,x=tr[x].son[1];
	}
	splay(x,0);//别忘splay维护形态
	return  tr[x].d;
}

前驱后继

找到最接近 d d d的点,并且splay,看看是不是前驱(后继),是就return,不是的话就往左(右)子树跳,因为是根节点,所以不需要考虑根的情况。

另外一种求法在可持久化FHQ中会写到。

inline  int  prep(int  d)//后继
{
   
   
	int  x=findip(d);splay(x,0);
	if(d<=tr[x].d  &&  tr[x].son[0])
	{
   
   
        //因为是最接近d的值,且把相同的点权压到了一个点上,所以不可能存在小于等于tr[x].d且大于等于d的情况,所以左儿子肯定是小于d的
		x=tr[x].son[0];while(tr[x].son[1])x=tr[x].son[1];
	}
	if(d<=tr[x].d)x=0;
	return  tr[x].d;
}
inline  int  qian(int  d)//前驱
{
   
   
	int  x=findip(d);splay(x,0);
	if(d>=tr[x].d  &&  tr[x].son[1])
	{
   
   
        //同理
		x=tr[x].son[1];while(tr[x].son[0])x=tr[x].son[0];
	}
	if(d>=tr[x].d)x=0;
	return  tr[x].d;
}

插点

首先,如果我们找的到一个点权和新插点的点权相等,那么直接 c , n + + c,n++ c,n++都可以,但是如果找不到的话,那么就找到最接近的点,直接添加为这个点的儿子,考虑最接近的点为 x x x,新加点的点权为 d d d的话,那么 t r [ x ] . d &lt; d tr[x].d&lt;d tr[x].d<d的话, d d d就会添加到右儿子,右儿子有没有可能有数字?有的话findip不就跳了吗,那可不可能跳左儿子? t r [ x ] . d tr[x].d tr[x].d都已经小于 d d d了,不会跳左儿子啦。

t r [ x ] . d &gt; d tr[x].d&gt;d tr[x].d>d也可以以此类推,也就是说findip一下不就可以了吗。

然后记得把新点或者相同点权的点splay一下

inline  void  ins(int  d)
{
   
   
	if(!root){
   
   add(d,0);root=len;return  ;}//没有点
	int  x=findip(d);
	if(tr[x].d==d)tr[x].n++,update(x),splay(x,0);//相同点权
	else  add(d,x),update(x),splay(len,0);
}

删点

这又是一个麻烦的操作,首先我们仍然findip然后splay一下。

这个点就到了根节点,如果这个点的左子树或右子树为空的话, r o o t root root直接更新就行了。

但是不是的话,就会特别的麻烦,我们需要找到左子树最大的点,也就是前驱,然后把他旋到左子树的根,那么左儿子的右子树不就没了吗,那么就可以把 r o o t root root的右子树挪过去,然后取消左儿子与 r o o t root root的关联就可以了。

inline  void  del(int  d)
{
   
   
	int  x=findip(d);splay(x,0);
	if(tr[x].n!=1)tr[x].n--,tr[x].c--;
	else  if(!tr[x].son[0]  &&  !tr[x].son[1])root=0;
	else  if(!tr[x].son[0]  &&  tr[x].son[1]){
   
   root=tr[x].son[1];tr[root].f=0;}
	else  if(tr[x].son[0]  &&  !tr[x].son[1]){
   
   root=tr[x].son[0];tr[root].f=0;}//前面都是各种简单的情况
	else
	{
   
   
		int  p=tr[x].son[0];while(tr[p].son[1])p=tr[p].son[1];//前驱
		splay(p,x);//splay上去
		tr[p].f=0;tr[p].son[1]=tr[x].son[1];update(p);
		tr[tr[x].son[1]].f=p;
		root=p;//让p当根节点
	}
}

代码

#include<cstdio>
#include<cstring>
#define  N  110000
using  namespace  std;
int  root;
struct  node
{
   
   
	int  d,n,f,c,son[2];
}tr[N];int  len;
inline  int  chk(int  x){
   
   return  tr[tr[x].f].son[1]==x;}
inline  void  update(int  x){
   
   tr[x].c=tr[tr[x].son[0]].c+tr[tr[x].son[1]].c+tr[x].n;}
inline  void  add(int  d,int  f)
{
   
   
	len++;
	tr[len].c=tr[len].n=1;tr[len].d=d;tr[len].f=f;
	tr[f].son[d>tr[f].d]=len;
}
inline  void  rotate(int  x)//翻转
{
   
   
	int  f=tr[x].f,ff=tr[f].f,w=chk(x)^1,son=tr[x].son[w];
	int  r,R;
	r=son;R=f;tr[r].f=R;tr[R].son[w^1]=r;
	r=x;R=ff;tr[r].f=R;tr[R].son[chk(f)]=r;
	r=f;R=x;tr[r].f=R;tr[R].son[w]=r;
	update(f);update(x);
}
inline  void  splay(int  x,int  fa)//splay
{
   
   
	while(tr[x].f!=fa)
	{
   
   
		int  f=tr[x].f,ff=tr[f].f;
		if(ff==fa)rotate(x);
		else  rotate(chk(x)^chk(f)?x:f),rotate(x);
	}
	if(!fa)root=x;
}
inline  int  findip(int  d)
{
   
   
	int  x=root;
	while(tr[x].d!=d)
	{
   
   
		if(!tr[x].son[tr[x].d<d])break;
		x=tr[x].son[tr[x].d<d];
	}
	return  x;
}
inline  void  ins(int  d)
{
   
   
	if(!root){
   
   add(d,0);root=len;return  ;}
	int  x=findip(d);
	if(tr[x].d==d)tr[x].n++,update(x),splay(x,0);
	else  add(d,x),update(x),splay(len,0);
}
inline  void  del(int  d)
{
   
   
	int  x=findip(d);splay(x,0);
	if(tr[x].n!=1)tr[x].n--,tr[x].c--;
	else  if(!tr[x].son[0]  &&  !tr[x].son[1])root=0;
	else  if(!tr[x].son[0]  &&  tr[x].son[1]){
   
   root=tr[x].son[1];tr[root].f=0;}
	else  if(tr[x].son[0]  &&  !tr[x].son[1]){
   
   root=tr[x].son[0];tr[root].f=0;}
	else
	{
   
   
		int  p=tr[x].son[0];while(tr[p].son[1])p=tr[p].son[1];
		splay(p,x);
		tr[p].f=0;tr[p].son[1]=tr[x].son[1];update(p);
		tr[tr[x].son[1]].f=p;
		root=p;
	}
}
inline  int  findpaiming(int  d)
{
   
   
	int  x=findip(d);splay(x,0);
	return  tr[tr[x].son[0]].c+1;
}
inline  int  findkey(int  d)
{
   
   
	int  x=root;
	while(1)
	{
   
   
		if(d<=tr[tr[x].son[0]].c)x=tr[x].son[0];
		else  if(d<=tr[tr[x].son[0]].c+tr[x].n)break;
		else  d-=tr[tr[x].son[0]].c+tr[x].n,x=tr[x].son[1];
	}
	splay(x,0);
	return  tr[x].d;
}
inline  int  prep(int  d)
{
   
   
	int  x=findip(d);splay(x,0);
	if(d<=tr[x].d  &&  tr[x].son[0])
	{
   
   
		x=tr[x].son[0];while(tr[x].son[1])x=tr[x].son[1];
	}
	if(d<=tr[x].d)x=0;
	return  tr[x].d;
}
inline  int  qian(int  d)
{
   
   
	int  x=findip(d);splay(x,0);
	if(d>=tr[x].d  &&  tr[x].son[1])
	{
   
   
		x=tr[x].son[1];while(tr[x].son[0])x=tr[x].son[0];
	}
	if(d>=tr[x].d)x=0;
	return  tr[x].d;
}
int  n;
int  main()
{
   
   
	scanf("%d",&n);
	for(int  i=1;i<=n;i++)
	{
   
   
		int  x,y;scanf("%d%d",&x,&y);
		if(x==1)ins(y);
		else  if(x==2)del(y);
		else  if(x==3)printf("%d\n",findpaiming(y));
		else  if(x==4)printf("%d\n",findkey(y));
		else  if(x==5)printf("%d\n",prep(y));
		else  printf("%d\n",qian(y));
	}
	return  0;
}

区间操作

区间操作我都是用FHQ的,但是也提一提。

区间操作也是同样打标记,然后在splay的时候从下往上传标记,或者在其他地方。

下传标记要看情况直接修改儿子节点,比如区间加,那么我们下传 x x x标记的时候要直接改变 x x x左右儿子的key值,否则如果他的某个儿子没有访问到,然后update一下就出锅了,当然也有标记直接修改 x x x的子树就行了,不用去看儿子的子树,比如翻转标记。

如果要搞区间 [ l , r ] [l,r] [l,r],首先Splay维护的是位置,同时将 l − 1 l-1 l1旋上根节点,然后将 r r r旋到 l − 1 l-1 l1的右儿子,那么根节点右儿子以及右儿子的左子树就是这个区间了,然后打个标记。

具体实现看你们的了,要学的话去看看其他博主的实现,在这只提供思路,就不一一赘述。

小结

可以发现,复杂度都是与层数有关的,有人证明过,Splay的复杂度为 O ( m l o g n ) O(mlogn) O(mlogn),但是常数。。。

FHQ Treap

一听,treap?不就是随机数的那位?没错,Treap是在维护 k e y key key为平衡树的情况下,维护随机权值(建点时随机赋予的)呈小根堆的形式,普通的Treap也是旋转实现,但是我不会QAQ,常数也挺优秀,但是Treap不能区间操作,因为不够灵活,相反,FHQ Treap就是非旋Treap,利用分裂合并来进行神奇操作,而且分裂合并的灵活性让他可以支持区间操作,也就可以维护LCT(比Splay多个 l o g log log,其实只要能维护区间的树貌似都能维护LCT吧QMQ),因为不用旋转,所以共用某个节点也是可以的,所以又支持可持久化,唯一的缺点就是常数比Splay还大QAQ,但是区间操作貌似都海星。

例题

例题

题目描述:
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:
插入x数
删除x数(若有多个相同的数,因只删除一个)
查询x数的排名(排名定义为比当前数小的数的个数+1。若有多个相同的数,因输出最小的排名)
查询排名为x的数
求x的前驱(前驱定义为小于x,且最大的数)
求x的后继(后继定义为大于x,且最小的数)

输入格式:
第一行为n,表示操作的个数,下面n行每行有两个数opt和x,opt表示操作的序号(1≤opt≤6)

输出格式
对于操作3,4,5,6每行输出一个数,表示对应答案

输入输出样例:
输入:
10
1 106465
4 1
1 317721
1 460929
1 644985
1 84185
1 89851
6 81968
1 492737
5 493598

输出:
106465
84185
492737

说明/提示:
时空限制:1000ms,128M

1.n的数据范围:n≤100000

2.每个数的数据范围:[-10^7,10^7]
来源:Tyvj1728 原名:普通
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值