文章目录
1.BST
1.1 BST是啥
要说平衡树必须要从BST( B i n a r y S e a r c h T r e e Binary\ Search\ Tree Binary Search Tree)说起
我们定义一棵BST满足一下性质
1.是一棵二叉树,既每个点最多拥有两个儿子
2.对于每个节点,满足他的左儿子
<
<
<他
<
<
<他的右儿子
是不是很简单呢?
1.2 BST的基本操作
我们发现,利用BST的这个特殊的性质,可以解决很多东西
虽然大家肯定也不会写BST,但是这一部分建议大家还是读一读,因为后面的一些过程和这里是差不多的
1.2.1 插入x
我们发现,对于给定的一棵BST,我们是可以定位出下一个东西是在哪里的
具体流程是:
1.从根开始查找
2.对于经过的每一个点
i
i
i,如果
x
<
v
a
l
i
x<val_i
x<vali,根据定义,我们应该查询他的左子树,反之则右子树,当
i
=
0
i=0
i=0的时候就是我们要插入的地方
当然如果
x
=
v
a
l
i
x=val_i
x=vali的时候可能还需要出现特判的情况,这就需要在题目中具体情况具体分析。当然你以后肯定也不会写这玩意
1.2.2 删除x
同样的,我们按照插入的时候的方法,找到我们要删除的位置,然后直接断掉一条边就可以
1.2.3 查询x的排名
这个东西怎么做呢?
我们还是从根节点开始查找,记录一个
r
e
s
res
res表示答案
对于我们查到到的一个点
i
i
i
如果
x
<
v
a
l
i
x<val_i
x<vali,
i
=
l
c
i=lc
i=lc
如果
x
=
v
a
l
i
x=val_i
x=vali,那么我们就要查的是
i
i
i的排名,答案应该是
r
e
s
res
res加上左子树的大小再加上一对吧,也就是
r
e
s
=
r
e
s
+
s
i
z
[
l
c
]
+
1
res=res+siz[lc]+1
res=res+siz[lc]+1
如果
x
>
v
a
l
i
x>val_i
x>vali,说明
i
i
i在右子树,那么所有的左子树的部分和
i
i
i都比
x
x
x小,所以
r
e
s
=
r
e
s
+
s
i
z
[
l
c
]
+
1
,
u
=
r
c
res=res+siz[lc]+1,u=rc
res=res+siz[lc]+1,u=rc
很好理解,下面给出简要代码,为了后面写的方便,我们用 s o n [ u ] [ 0 ] son[u][0] son[u][0]表示 u u u的左子树, 1 1 1表示右子树
int rnk(int x){
int u=rt,res=0;
while(1){
if(val[u]>x)u=lc;
else if(val[u]==x)return res+siz[son[u][0]]+1;
else res+=siz[son[u][0]]+1,u=son[u][1];
}
}
1.2.4 查询排名为x的数
这个东西根上面就是非常类似的了
就是把上面的过程反过来了
如果
x
≤
s
i
z
[
l
c
]
x\leq siz[lc]
x≤siz[lc],则
i
=
l
c
i=lc
i=lc
否则如果
x
≤
s
i
z
[
l
c
]
+
1
x\leq siz[lc]+1
x≤siz[lc]+1,则返回
v
a
l
i
val_i
vali
否则
x
=
x
−
s
i
z
[
l
c
]
−
1
,
i
=
r
c
x=x-siz[lc]-1,i=rc
x=x−siz[lc]−1,i=rc
代码:
int kth(int x){
int u=rt;
while(1){
if(siz[son[u][0]]>=x)u=son[u][0];
else if(siz[son[u][0]]+1>=x)return val[u];
else x-=siz[son[u][0]]+1,u=son[u][1];
}
}
1.2.5 查询x在数中的编号
这个不难,一直往下走就可以,根据权值决定走做左子树还是右子树
int find(int x){
int u=rt;
while(u&&son[u][x>val[u]]!=x)u=son[u][x>val[u]];
return u;
}
这个地方我们就看出来为什么要写 s o n [ u ] [ 0 ] , s o n [ u ] [ 1 ] son[u][0],son[u][1] son[u][0],son[u][1]了
1.2.6 查询x的前驱
这里
x
x
x的前驱定义为小于
x
x
x的最大的数
这个其实很简单,首先要找到 x x x,然后考虑BST的定义就可以,首先他的前驱一定在他的左子树里面。然后我们要找最大,所以不停的往右子树走,直到没有右子树,他就是最大的
代码:
int pre(int x){
int u=find(x);
u=son[u][0];
while(son[u][1])u=son[u][1];
return u;
}
1.2.7 查询x的后继
和上一个非常像,不过是先往右子树走再往左子树走
1.3 时间复杂度
这个东西因为是一个二叉树,那么每次查询的复杂度最坏的时候就是树的深度,但是因为每个节点的插入顺序不同会导致不同形态的BST,那么很容易被退化成一条链,那么这个时候查询一次的复杂度就是 Θ ( n ) \Theta(n) Θ(n),就是平方级别的了
下面给出几种退化的情况(其实很好卡对吧)
那么 Θ ( n 2 ) \Theta(n^2) Θ(n2)的效率我们显然是无法接受的(和暴力都一样了要他有啥用),所以我们考虑如何优化——于是,平衡树诞生了!
2.从BST到平衡树
我们发现,我们让复杂度尽量的低的一种处理方法就是,让左右两颗子树大小差尽量的小,那么深度就会尽量小。我们把通过一些奇奇怪怪的处理方法使得期望深度为 log n \log n logn级别的BST的优化叫做平衡树
平衡树的实现方法有很多,最常见的有
s p l a y splay splay系,通过旋转使得每次操作均摊 Θ ( log n ) \Theta(\log n) Θ(logn) 优点是代码短,理解难度低,可以套LCT,是万能的一种平衡树,但是缺点是常数太大,被各种吊打
t r e a p treap treap系,通过旋转+玄学使得每次操作均摊 Θ ( log n ) \Theta(\log n) Θ(logn)(我们接下来会讲到) 优点是 f h q t r e a p fhq\ treap fhq treap是代码最短的平衡树了吧,同时能够基本实现 s p l a y splay splay的各项操作并且快。但是原 t r e a p treap treap的局限性很大,不能维护序列上的问题。包括 f h q t r e a p fhq\ treap fhq treap在内的 t r e a p treap treap系平衡树都有一个问题就是有点看脸(虽然一般不会被卡),另外 f h q t r e a p fhq\ treap fhq treap维护LCT会比 s p l a y splay splay维护多一个 log \log log
其他快速平衡树,比如SGT,SBT,AVL,RBT等等,都是利用一些奇奇怪怪的技巧让他尽量平衡。优点是常数小,速度快,但是功能都没有 s p l a y splay splay齐全,而且码量都很长(比如SBT,RBT板子码量都在200+左右,但是 s p l a y , t r e a p splay,treap splay,treap写的好看一点都只用80左右)
那么下面我们会着重讲解 t r e a p treap treap系的平衡树,如果想学 s p l a y splay splay也可以点这里
3.treap
3.1 treap是啥
这个词啥意思啊?你翻遍字典也找不着(学术界的或许有),因为这个词本来就是人造出来的,他从这两个词合成的
t r e a p = t r e e + h e a p treap=tree+heap treap=tree+heap
所以一般翻译出来就叫树堆,当然一般没人读中文叫法
t r e e tree tree好理解,二叉查找树嘛,那 h e a p heap heap是怎么来的呢?这就是 t r e a p treap treap的精髓所在
t r e a p treap treap的核心思想是
- 让 v a l val val满足二叉查找树性质,既 t r e e tree tree
- 在新储存一个键值,使得这整颗 t r e a p treap treap的键值满足堆性质(我习惯小根堆)
那么键值怎么来呢?题目里又没给。 方法很简单,没有就随机一个嘛,所以这个键值我们就把它命名成 r n d rnd rnd好了(在实际写程序中我习惯写成 t r e a p treap treap)
可能光说不太好理解,配张图
比如在棵 t r e a p treap treap中, v a l val val满足全部BST性质,并且 r n d rnd rnd满足堆性质,既每个点 r n d ≤ rnd\leq rnd≤他的两个子树的 r n d rnd rnd
然后我们再看几个基本操作
3.2 treap的基本操作
3.2.1 插入x
首先,我们先创建处新的节点,也就是将 r n d rnd rnd随机出来,这里以插入为例
然后按照权值找到这个点应该加在哪里(也就是BST的插入操作)
那么这个时候我们的权值是满足条件了,但是
r
n
d
rnd
rnd又不满足堆性质了啊
所以我们需要进行上调
等会别着急,这个上调不能直接把它和他爸爸交换啊,要不然
v
a
l
val
val的性质又不对了啊
这怎么办啊…
学过
s
p
l
a
y
splay
splay的同学们一定知道,我们需要通过左旋右旋的方式来进行调整
这里就是左右旋,左旋之后再右旋又回去了,这两个是相对的
大家观察之后会发现,这样旋转之后,新的树仍然满足性质,这个思想也就是 s p l a y splay splay的核心思想
然后我们进行分部上调
回到刚才的过程中,这里我们需要左旋
变成
继续右旋
然后旋转完了之后满足条件还需要把相应的信息更新了,比如说 s i z siz siz什么的
那么这个时候我们就成功的在 t r e a p treap treap中插入的一个数(真累啊)
3.2.2 删除x
一个比较常见的操作是找到该点,然后把那个点的值调为
+
∞
+\infty
+∞,然后再维护堆性质,这个时候这个点就会下去了,具体看演示
我们要删除27,先找到该节点,然后将随机值改成inf,然后比较左右子树的 r n d rnd rnd大小,把根转到叶子,这个时候和插入就不太一样了
以此类推,等他转到最下面的时候,直接断掉他和他爸爸之间的边就可以,这样我们就完成了删除节点的操作
3.2.3 其他操作
和BST基本一样,详见上面说的1.2.3到1.2.7部分
3.3 代码
相信经过上面的讲解最关键的问题已经都解决了,接下来就是写代码了
但是因为我从来没写过treap所以这里放一个简单写的核心代码,既插入和删除,其他部分上面我们都有讲到,应该不难写
void rotate(int &u, int d){//x代表的是旋转时作为父节点的节点,d代表的是旋转的方向
//d==0时是左儿子旋上来, d==1是右儿子旋上来.
int c=son[u][d];
son[u][d]=son[c][d^1];
son[c][d^1]=u;
update(u),update(u=c);//更新相应信息
}
void insert(int &u, int val){
if(!u){//找到对应位置就新建节点
u=++tot;//节点数
cnt[u]=siz[u]=1;
val[u]=val,rnd[u]=rand();
return;
}
siz[u]++;//因为插入了数,所以在路径上每个节点的size都会加1
if(val[u]==val){cnt[u]; return;}//找到了直接返回
int d=val[u]<val;
insert(son[u][d],val);//否则递归查找插入位置
if(rnd[u]>rnd[son[u][d]])rotate(u,d);
}
void delet(int &u, int val){
if(!u) return;//防止越界
if(val[u]==val){
if(cnt[u]>1){cnt[u]--,siz[u]--;return;}//有相同的就直接cnt--
bool d=rnd[son[u][0]]>rnd[son[u][1]];
if(!son[u][0]||!son[u][1])u=son[u][0]|son[u][1]//只有一个儿子就直接把那个儿子放到这个位置
else rotate(u,d), delet(u,val);//否则将x旋下去,找一个随机值小的替代,直到回到1,2种情况
}
else siz[u]--, delet(son[u][val>val[u]], val);//递归找到要删除的节点.
}
3.4 treap一些问题
3.4.1 时间复杂度
t
r
e
a
p
treap
treap的时间其实算是靠脸拿分,如果你脸比较黑生成的随机数刚好让你的
t
r
e
a
p
treap
treap退化成一条链,那也没辙。
但是这种情况一般不会出现,只要你没有忘掉写srand(time(0))
,当然虽然大部分题目不写这一行也能AC,但是很容易会退化,甚至比
s
p
l
a
y
splay
splay还要慢
3.4.2 功能
我们发现这些单点修改之类的问题
t
r
e
a
p
treap
treap都是可以支持的,但是我们发现,我们无法对于一段区间进行求和之类的操作,因此功能比较局限
而且转来转去好麻烦啊…
3.4.3 优化
有没有一种
t
r
e
a
p
treap
treap既可以维护一段区间上的问题,又可以不用旋转呢?
没错,是有的,范浩强大佬(%)发明了这样的一种
t
r
e
a
p
treap
treap,因此这样的
t
r
e
a
p
treap
treap就叫做
f
h
q
t
r
e
a
p
fhq\ treap
fhq treap
同时根据是否旋转,最本身的 t r e a p treap treap叫做双旋 t r e a p treap treap,而 f h q t r e a p fhq\ treap fhq treap叫做无旋 t r e a p treap treap
f h q t r e a p fhq\ treap fhq treap的优点有很多很多,比如说可以算是码量最小的平衡树了,同时比 s p l a y splay splay快,虽然稍微比 s p l a y splay splay难理解,但是结合图形还是不难理解的
那么接下来,我们就来讲解一下 f h q t r e a p fhq\ treap fhq treap,他的根本思想是通过分裂和合并提取出我们想要的部分
t r e a p treap treap?被 f h q fhq fhq碾压好不好
4.fhq-treap
4.1 分裂
我们要把 f h q t r e a p fhq\ treap fhq treap分裂成两棵平衡树,但是还需要有个条件吧,我们把所有权值 ≤ k \leq k ≤k的分到左树,剩下的分到右树
为了方便,我们用 x x x表示左树根, y y y表示右树根
这个不太好理解,上图
我们从根节点向下找,我们发现 22 22 22是比 27 27 27小的,所以 22 22 22这个点应该在左树中,既 x = 22 所 在 节 点 编 号 x=22所在节点编号 x=22所在节点编号,而且根据BST性质,他的左子树也应该在左树里面,所以我们拿右子树进行分裂
然后我们看右子树, 31 31 31比 27 27 27大,所以我们拿这个点的左子树进行分裂,而 31 31 31和他的右子树就应该属于分裂之后的右树
分裂左子树, 27 = 27 27=27 27=27,应该划到左树里,相同的
继续操作,直到分裂完成
最后就变成了这个样子的两棵树
但是这个东西看上去不太好写啊?
s p l i t split split其实有两种分裂方法,一种是按照权值,把权值 ≤ k \leq k ≤k的分裂,当然另一种是按照排名分裂,根据不同的题目各有不同
void split_by_val(int o,int &u,int &v,int k)//o表示当前访问的原平衡树节点,u表示如果分裂到左子树要接到哪里(所以要引用),v表示如果分裂到右子树要接到哪里,k表示按多少分裂
{
if(!o){u=v=0;return;}//边界条件
if(val[o]<=k)split_by_val(son[u=o][1],son[o][1],v,k);//应当放到左子树,所以u=o,同时需要分裂o的右子树,所以要分裂的就是son[o][1],v不动
else split_by_val(son[v=o][0],u,son[o][0],k);//同理,需要往右子树分裂
update(o);//因为他的子树发生了变化,所以要更新o的信息
}
另一个是按照排名分裂
void split_by_rank(int o,int &u,int &v,int k){
if(!o){u=v=0;return;}//边界条件
int rank=siz[son[o][0]]+1;//计算排名
if(rank<=k)split_by_rank(son[u=o][1],son[o][1],v,k-rank);//应该往右子树进行分裂,但是这个时候我们分裂的排名就应该是k-rank了
else split_by_rank(son[v=o][0],u,son[o][1],k);//往左子树分裂不用懂
update(o);
}
然后分裂就没了,程序也很短呢>_<
4.2 合并
要分裂还要合并回去啊,怎么合并呢?注意这里合并要满足我们合并的两棵树 u , v , max ( v a l u ) ≤ min ( v a l v ) u,v,\max(val_u)\leq\min(val_v) u,v,max(valu)≤min(valv)才能合并,否则只能启发式合并。
那么怎么合并呢?我们发现因为 r a n k rank rank满足相应的关系,所以我们只需要考虑合并之后的 r n d rnd rnd满足堆性质就可以
比如我们要把这两个东西合并上来,我们比较两个根的 r n k rnk rnk,发现 22 22 22那个点的 r n d rnd rnd比较小,所以我们为了维护小根堆的性质,应该把 22 22 22作为合并之后的新的根。
然后他的左儿子是不用变的,我们需要用右儿子 25 25 25和 31 31 31进行合并
然后我们再比较这个时候的两个根 25 , 31 25,31 25,31发现 31 31 31的 r n d rnd rnd比较小,所以把它拎上来
那么我们就需要拿 31 31 31的右儿子和 25 25 25进行合并
再经过比较,我们就得到了
这样我们就合并完了,我觉得合并比分裂更好理解一点吧
下面是程序
int merge(int u,int v){
if(!u||!v)return u|v;//边界情况,如果有一个待合并子树空了就返回另一个
int rt;//合并之后的根
if(treap[u]<treap[v])son[rt=u][1]=merge(son[u][1],v);//合并右子树(treap就是rnd)
else son[rt=v][0]=merge(u,son[v][0]);//合并左子树
return update(rt),rt;
}
4.3 其他操作
4.3.1 插入x
这个东西怎么做呢?显然不能按照之前的遍历的方法了,为什么呢?因为 r n d rnd rnd不满足小根堆了,于是,旋转…
那怎么做呢?之前说的 m e r g e merge merge和 s p l i t split split是干啥的?
所以插入一个数 k k k的流程就是
1. s p l i t ( r t , x , y , k ) split(rt,x,y,k) split(rt,x,y,k),注意这里是按照权值 s p l i t , x , y split,x,y split,x,y是我们单独开的两个变量表示分裂之后的两个根
2. r t = m e r g e ( m e r g e ( x , n e w n o d e ( k ) ) , y ) rt=merge(merge(x,newnode(k)),y) rt=merge(merge(x,newnode(k)),y)
也就是说我们只要在这两个子树之间添加一个点就可以了,然后合并回去,大功告成
代码在下面
4.3.2 删除x
这个东西怎么做呢?可以先把 ≤ x − 1 \leq x-1 ≤x−1的拆出来,再把 > x >x >x的拆出来,但是这样真的对吗?有可能有很多个 v a l = x val=x val=x的对吧,但是我们只要删掉一个,怎么办呢?
删除 k k k的流程
1.
s
p
l
i
t
(
r
t
,
x
,
z
,
k
)
split(rt,x,z,k)
split(rt,x,z,k)
2.
s
p
l
i
t
(
x
,
x
,
y
,
k
−
1
)
split(x,x,y,k-1)
split(x,x,y,k−1)
3.
y
=
m
e
r
g
e
(
s
o
n
[
y
]
[
0
]
,
s
o
n
[
y
]
[
1
]
)
y=merge(son[y][0],son[y][1])
y=merge(son[y][0],son[y][1])这里我们把他的两棵子树合并起来相当于只删掉了一个点了
4.
r
t
=
m
e
r
g
e
(
m
e
r
g
e
(
x
,
y
)
,
z
)
rt=merge(merge(x,y),z)
rt=merge(merge(x,y),z)注意顺序
4.3.3 查询x的排名
这个怎么办呢?当然我们可以按照BST的方法,但是我们比较懒怎么办呢?
查询 k k k的排名的流程
1.
s
p
l
i
t
(
r
t
,
x
,
y
,
k
−
1
)
split(rt,x,y,k-1)
split(rt,x,y,k−1)这里必须是
k
−
1
k-1
k−1
2.
p
r
i
n
t
s
i
z
x
+
1
print\ siz_x+1
print sizx+1分裂后左树都比他小,再
+
1
+1
+1
3.
r
t
=
m
e
r
g
e
(
x
,
y
)
rt=merge(x,y)
rt=merge(x,y)还原
4.3.4 查询排名为x的数
这个东西当然可以仿照上面的写一个按照
r
a
n
k
rank
rank的
s
p
l
i
t
split
split
但是太麻烦了,我们还要再写一个
s
p
l
i
t
split
split,所以这里我们一般使用BST的查询方式
4.3.5 查询x的前驱
思路就是先裂出来,然后再找左边最大的
查询 k k k的前驱的流程
1.
s
p
l
i
t
(
r
t
,
x
,
y
,
k
−
1
)
split(rt,x,y,k-1)
split(rt,x,y,k−1)
2.查找
x
x
x中最大的就是前驱
3.
r
t
=
m
e
r
g
e
(
x
,
y
)
rt=merge(x,y)
rt=merge(x,y)
4.3.6 查询x的后继
和查询前驱没有什么本质上的区别
4.3.7 查询区间情况
这个也不难,我们可以利用两次 s p l i t split split拎出来需要查询的那一段,让后获得答案之后再合并回去就好了
4.4 代码
这里是洛谷模板题的代码,当然不是加强版
为了清晰一点我把每次操作都写成了函数
#include <bits/stdc++.h>
using namespace std;
# define Rep(i,a,b) for(int i=a;i<=b;i++)
# define _Rep(i,a,b) for(int i=a;i>=b;i--)
# define RepG(i,u) for(int i=head[u];~i;i=e[i].next)
typedef long long ll;
const int N=1e5+5;
template<typename T> void read(T &x){
x=0;int f=1;
char c=getchar();
for(;!isdigit(c);c=getchar())if(c=='-')f=-1;
for(;isdigit(c);c=getchar())x=(x<<1)+(x<<3)+c-'0';
x*=f;
}
int n;
int tot;
int son[N][2],val[N],siz[N],treap[N];
int rt,x,y,z;
int newnode(int x){
int u=++tot;
son[u][0]=son[u][1]=0;
val[u]=x,siz[u]=1;
treap[u]=rand();
return u;
}
void update(int x){
siz[x]=siz[son[x][0]]+siz[son[x][1]]+1;
}
int merge(int u,int v){
if(!u||!v)return u|v;
int rt;
if(treap[u]<treap[v])son[rt=u][1]=merge(son[u][1],v);
else son[rt=v][0]=merge(u,son[v][0]);
return update(rt),rt;
}
void split(int o,int &u,int &v,int k){
if(!o){u=v=0;return;}
if(val[o]<=k)split(son[u=o][1],son[o][1],v,k);
else split(son[v=o][0],u,son[o][0],k);
update(o);
}
void ins(int k){
split(rt,x,y,k);
rt=merge(merge(x,newnode(k)),y);
}
void del(int k){
split(rt,x,z,k);
split(x,x,y,k-1);
y=merge(son[y][0],son[y][1]);
rt=merge(merge(x,y),z);
}
void rnk(int k){
split(rt,x,y,k-1);
printf("%d\n",siz[x]+1);
rt=merge(x,y);
}
int kth(int k){
int u=rt;
while(1){
if(siz[son[u][0]]>=k)u=son[u][0];
else if(siz[son[u][0]]+1>=k)return val[u];
else k-=siz[son[u][0]]+1,u=son[u][1];
}
}
int pre(int k){
split(rt,x,y,k-1);
int u=x;
while(son[u][1])u=son[u][1];
rt=merge(x,y);
return u;
}
int nxt(int k){
split(rt,x,y,k);
int u=y;
while(son[u][0])u=son[u][0];
rt=merge(x,y);
return u;
}
int main()
{
srand(19260817);
read(n);
Rep(i,1,n){
int opt,x;
read(opt),read(x);
switch(opt){
case 1:ins(x);break;
case 2:del(x);break;
case 3:rnk(x);break;
case 4:printf("%d\n",kth(x));break;
case 5:printf("%d\n",val[pre(x)]);break;
case 6:printf("%d\n",val[nxt(x)]);break;
}
}
return 0;
}
4.5 fhq-treap的一些问题
4.5.1 时间复杂度
通常来说只要不脸黑应该是比
s
p
l
a
y
splay
splay和普通
t
r
e
a
p
treap
treap快的,因为旋转操作常数太大
当然理论复杂度还是 Θ ( n log n ) \Theta(n\log n) Θ(nlogn)
4.5.2 srand
写 t r e a p treap treap千万别忘了写 s r a n d srand srand,虽然能对,但是说不定就被卡没了
关于
s
r
a
n
d
srand
srand里面的数,srand(time(0))
是可以的,但是不太好调试,可能会写出一些锅有的时候随机的数是对的,有的时候又错了,所以在调试的时候建议大家写srand(一个比较大的数)
,比如说
19260817
,
1919810
19260817,1919810
19260817,1919810之类的,让随机种子固定。或者当然手写随机数也是可以的
5.写在最后
这篇博客我大概写了快三个小时?
里面的图都是从我老师那里盗的(bushi