普通平衡树
题目描述
核心思路
这题我们采用FHQ treap来求解。
要学习fhq Treap,你得先简单了解一下普通Treap是啥东西。Treap其实就是Tree+heap,也就是二叉搜索树+二叉堆。它让平衡树上的每一个结点存放两个信息:值和一个随机的索引(也就是优先级)。其中值满足二叉搜索树的性质,索引满足堆的性质,结合二叉搜索树和二叉堆的性质来使树平衡。这也是Treap的性质。
那么Treap为什么可以保持平衡呢?我们知道如果对一颗二叉搜索树进行插入的值按次序是有序的,那么二叉搜索树就会退化成一个链表。那么我们不要让数值按次序插入,一个很好的方法就是把插入次序随机化。比如本来的插入次序是12345,结果随机之后变成了31524,是不是就好多了!
如下图:
Treap用二叉堆来维护随机索引,其实就是相当于把插入次序随机化。插入一值数后你必然要让索引满足二叉堆的特性,但又因为索引是随机的,那就会导致插入的数不知道搞到了哪里去,相当于插入次序随机了。
这里给出二叉搜索树的性质:当前结点左子树的值都比当前结点值小,反之则一定在右子树上
这里给出二叉堆(大根堆)的性质:父结点的优先级总是大于或等于任何一个子节点的优先级
fhq Treap的核心操作只有两个:分裂(split)和合并(merge)
利用结构体来存储节点信息
我们需要保存五个信息:
- 左右节点的编号
- 该节点的值
- 该节点的索引(优先级)
- 以该节点为根的树的大小
struct Node{
int l,r; //左孩子节点的编号 右孩子节点的编号 编号其实就是下标
int val; //该节点内的权值
int key; //该节点的随机索引 即随机优先级
int size; //以该节点为根的所形成的树的大小
}tr[N];
//idx给每个节点分配的编号(下标) 最后它记录的就是这棵树中节点的总个数
//root记录的就是这棵树的根节点
int idx,root;
//x树 y树 z树 后面的操作需要用到 先定义为全局变量
int x,y,z;
rnd()为随机值产生函数,需要导入random库,在开头写下如下代码:
//高性能随机数生成器 随机范围大概在(-maxint,+maxint),233为种子,19937指该随机数循环节为2^19937
std::mt19937 rnd(233);
新建节点
//建立新节点 给该节点分配编号 同记录信息
inline int newnode(int val)
{
tr[++idx].val=val; //记录该节点的值为val
tr[idx].key=rnd(); //随机生成该节点的优先级
tr[idx].size=1; //以该节点为根的树的大小为1
return idx; //返回分配给该节点的编号idx
}
更新节点信息
更新节点信息 其实也就是计算出以当前节点 u u u为根的树的大小
其计算方法就是 左子树的大小+右子树的大小+u自身节点
//更新节点的信息 记录以该节点u为根的树的大小
inline void update(int u)
{
tr[u].size=tr[tr[u].l].size+tr[tr[u].r].size+1;
}
分裂split
分裂有两种:按值分裂和按大小分裂。对于这道题,我们采用按值分裂。
这里按值分裂的方法:把树拆成两棵树x树和y树(x树就是左子树,y树就是右子树),拆出来的一棵树的值全部小于等于给定的值,另外一部分的值全部大于给定的值。
- 一般来说,我们在使用fhq Treap当一棵正常的平衡树用的时候,使用按值分裂
- 在维护区间信息的时候,使用按大小分裂。很经典的例子就是文艺平衡树
split的操作如下:
- 递归遍历左右子树
- 如果当前节点值 t r [ u ] . v a l ≤ v a l tr[u].val\leq val tr[u].val≤val,则前当前节点及其左子树都可以放到 x x x上,右子树中可能有可以放到 x x x上的,所以我们需要对右子树继续递归
- 如果当前节点值 t r [ u ] . v a l > v a l tr[u].val>val tr[u].val>val,则前当前节点及其右子树都可以放到 y y y上,左子树中可能有可以放到 y y y上的,所以我们需要对左子树继续递归
- 若当前节点为0,说明为空,令 x = y = 0 x=y=0 x=y=0,返回
注意这个操作中 x x x和 y y y都是引用,每一层的 x x x和 y y y都表示一层中的树的根节点。
如下图,我们将这棵树按值17分裂:
分裂后,得到左子树x树(绿色部分),右子树y树(蓝色部分),可以发现x树中所有节点的值都是 ≤ 17 \leq 17 ≤17的,而y树种所有节点的值都是 > 17 >17 >17的
//分裂操作:按val值将u树分裂成x树和y树 其中x树中所有节点的值都<=val y树种所有节点的值都>val
//这里用 引用 将分裂得到的x树和y树 返回回去
void split(int u,int val,int &x,int &y)
{
//如果要分裂的当前这棵u树是不存在的 那么不可能分裂出x树和y树
//所以把x和y都设为0
if(!u)
x=y=0;
else
{
//如果当前节点的值<=val 根据二叉搜索树的性质 该节点的左子树肯定都<=val
//但是当前节点的右子树中存在大于val的节点 因此我们需要对右子树再进行递归分裂
if(tr[u].val<=val)
{
x=u;
//按val值将tr[u].r右子树分裂成 tr[u].r树 和y树
split(tr[u].r,val,tr[u].r,y);
}
else
{
y=u;
//按val值将tr[u].l左子树分裂成x树和tr[u].l树
split(tr[u].l,val,x,tr[u].l);
}
//分裂完了之后 要记得更新节点信息
update(u);
}
}
如何理解这段代码呢?
//如果当前节点的值<=val 根据二叉搜索树的性质 该节点的左子树肯定都<=val
//但是当前节点的右子树中存在大于val的节点 因此我们需要对右子树再进行递归分裂
if(tr[u].val<=val)
{
x=u;
//按val值将tr[u].r右子树分裂成 tr[u].r树 和y树
split(tr[u].r,val,tr[u].r,y);
}
如果当前节点的值 ≤ v a l \leq val ≤val,根据二叉搜索树的性质该节点的左子树肯定都 ≤ v a l \leq val ≤val。但是当前节点的右子树中可能存在大于val的节点 因此我们需要对右子树再进行递归分裂。
比如上图,我们按值17分裂,找到 t r [ u ] . v a l = 11 < v a l tr[u].val=11<val tr[u].val=11<val,那么11的左子树的值肯定都是小于17的,但是我们发现11的右子树中有个节点其值是18,大于17。但是假设如果18它还有左子树,比如18的左孩子值为12,那么很明显,12<17,那么18的左子树就都是满足 ≤ v a l \leq val ≤val的,因此我们还需要把这个子树分裂开,弄到绿色部分中。也就是我们还需要对18递归分裂,看看它的左子树和右子树是属于绿色部分管辖还是属于蓝色部分管辖。
如下图理解:
此时tr[u].r是值为18的节点,我们按值val=17将tr[u].r的树分裂两部分x树和y树,那么为什么我们传参是把tr[u].r传给x,y传给y呢?y传给y很好理解,就是18所在的右子树肯定都是>17的,所以y不会变,也就是说18的右子树是归蓝色部分管辖。由于我们采用的是引用,所以当我们传参tr[u].r时,那么结束后我们把18分裂后得到的x树就成为当前节点u=11的右子树。如图,我们把12-10放到了u=11的右子树上。
那么该如何下面这段代码呢?
else
{
y=u;
//按val值将tr[u].l左子树分裂成x树和tr[u].l树
split(tr[u].l,val,x,tr[u].l);
}
这里说明 t r [ u ] . v a l > v a l tr[u].val>val tr[u].val>val,根据二叉搜索树的性质 该节点的右子树肯定都 > v a l >val >val,但是当前节点的左子树中可能存在大于val的节点 因此我们需要对左子树再进行递归分裂
如下图理解:
u u u当前指向值为20的节点,由于当前节点的值大于val,那么其右子树中所有节点值必然也都是大于val的。但是左子树中我们发现有个18它是满足大于17的,所以我们需要对左子树递归分裂,我们按值17将 t r [ u ] . l tr[u].l tr[u].l也就是11所在的节点分裂成 x x x树和 y y y树,那么为什么我们传参是把 x x x传给 x x x, t r [ u ] . l tr[u].l tr[u].l传给 y y y呢?把 x x x传给 x x x很好理解,因为11-5肯定都不会大于17,所以直接把左子树 x x x传进去就好了。由于我们采用引用,所以当我们传参tr[u].l时,那么结束后我们把11分裂后得到的y树就成为当前节点u=20的左子树。如图,我们把18放到了u=20的左子树上。
分裂完了之后,要更新节点的信息
合并merge
合并就是把两颗树x,y合并成一棵树,但是合并前要保证x树上的所有值都小于等于y树上的所有值。并且新合并出来的树依旧满足Treap的性质
//将x树和y树合并 返回的结果是这两棵树合并后的那个根节点
int merge(int x,int y) //参数x是左子树 参数y是右子树 要严格定义
{
//如果x树不存在而y树存在 那么合并后就只有y树 因此返回y树
//如果y树不存在而x树存在 那么合并后就只有x树 因此返回x树
if(!x||!y)
return x+y;
//这里当作是大根堆来处理 写成> 或者>=都可以
//也可以当作小根堆来处理 即写成< <= 那么就是小根堆
if(tr[x].key>tr[y].key)
{
//根据二叉堆的大根堆性质tr[x].key>tr[y].key 那么x在y的上方
//根据二叉搜索树的性质,我们必须保证x树中的值<=y树中的值 所以y树在右子树
//综上y在x的右下方 y树是x的右子树 因此我们把y树和x原来的右子树合并 形成 x树新的右子树
tr[x].r=merge(tr[x].r,y); //注意不能写成merge(y,tr[x].r) 要严格对应左子树和右子树
//更新以x为根的树的大小信息
update(x);
//将y树合并到x树 合并后根节点就是x
return x;
}
else
{
//根据二叉堆的大根堆性质tr[y].key>tr[x].key 那么y在x的上方
//根据二叉搜索树的性质,我们必须保证x树中的值<=y树中的值 所以x树在左子树
//综上x在y的左下方 x树是y的左子树 因此我们把x树和y原来的左子树合并 形成 y树新的左子树
tr[y].l=merge(x,tr[y].l); //注意不能写成merge(tr[y].l,x) 要严格对应左子树和右子树
//更新以y为根的树的大小信息
update(y);
//将x树合并到y树 合并后根节点就是y
return y;
}
}
为什么tr[x].r=merge(tr[x].r,y)
不能写成tr[x].r=merge(y,tr[x].r)
呢?
因为根据二叉搜索树的性质,merge中的参数 x x x是左子树, y y y是右子树,那么就必须保证 x x x中的所有节点的值都 ≤ y \leq y ≤y中所有节点的值。因此,如果写成后面的那种,由于 y y y中所有节点的值大于 t r [ x ] . r tr[x].r tr[x].r中所有节点值,把 y y y传给 x x x,把 t r [ x ] . r tr[x].r tr[x].r传给 y y y,那么就会使得此时 x x x中的所有节点的值都 ≥ y \geq y ≥y 中所有节点的值,这与实际不符合。因此不能写成后者。
上面我们解释了FHQ Treap的两个基本操作split和merge,那么这两个基操有什么用处呢?
插入
设插入的值为 v a l val val,步骤为:
- step1:按值 v a l val val把root树分裂成 x x x树和 y y y树
- step2:将 x x x树和这个新插入的节点合并,得到一棵新树
- step3:最后将这个新树和 y y y树合并,得到插入 v a l val val后的这棵树
那么为什么这样就能实现插入了呢?
我们要插入val这个值,那么我们按值val分裂,于是得到了两棵树x和y,按照按值分裂的定义,我们分裂出来的x树上的所有值一定都小于等于val,y树上的所有值一定都大于val,那么我们就直接把x树和val新建的节点合并得到一个新树,然后再将这个新树和y树合并即可。
如下图所示理解:
我们插入5
void insert(int val)
{
//先按值val分裂成x树和y树
split(root,val,x,y);
//然后新生成节点newnode(val) 把这个节点和x树合并 得到一棵树
//我们再将这棵树和y树合并 就得到了插入val后的完整的新树
root=merge(merge(x, newnode(val)),y);
}
请记住:分裂完了之后一定要记得合并!!!
删除
假设我们要删除val这个值,步骤:
- 首先,按值val把root树分裂成x树和z树
- 再按值val-1把x树分裂成x树和y树
- 那么此时y树上的所有值都是等于val的,在y树上删除一个节点即可,可以选择将y树的根节点删除,而删根节点只需要让根节点等于左右子树合并即可(根就没了,相当于众叛亲离,抛弃了父亲y),我们去掉它的根节点:让y等于合并y的左子树和右子树
- 最后合并x树,y树,z树即可
该怎么理解第三步的y树上全都是值val的呢?
首先第一步,我们已经按值val将root树分裂成x树和y树,那么此时x树中所有值都 ≤ v a l \leq val ≤val。然后我们又按值val-1将得到的x树又分裂成x树(这里为了区分之前的x树,我下面用 x ′ x' x′树来表示第二次分裂得到的左子树)和y树,那么此时 x ′ x' x′树中所有值都 ≤ v a l − 1 \leq val-1 ≤val−1,由于 x ′ x' x′树和 y y y树都是由 x x x树分裂而来,而 x x x树中所有值已经确定了,就都是 ≤ v a l \leq val ≤val。 x x x树的根节点值为 v a l val val,其左子树(也就是 x ′ x' x′树)的值为 ≤ v a l − 1 \leq val-1 ≤val−1,,那么根据二叉搜索树的性质,由于右子树必然是要 ≥ \geq ≥根节点,而现在最大值只能是 v a l val val,因此右子树也就只能取和根节点相同的 v a l val val了。这也就说明了将 x x x树分裂后得到的右子树y树中所有节点的值都是 v a l val val。
如下图理解:
我们删除节点9
//删除
void del(int val)
{
//第一步:按值val将root树分裂成x树和z树 分裂完了之后x树都是<=val z树都是>val
split(root,val,x,z);
//第二步:按值val-1将x树分裂成x树和y树 分裂完了之后x树都是<=val-1 y树都是>val-1 也就是说y树都是=val
split(x,val-1,x,y);
//第三步:将y的左子树和右子树合并 它俩众叛亲离偷偷合并抛弃了父节点y 那么就相当于删除了y这个根
//然后我们把它俩合并后得到的新树的根节点赋值给y
y=merge(tr[y].l,tr[y].r);
//第四步:将x树和 合并后得到的新树y合并 又得到一个新树
//第五步:再将这棵新树和z树合并 最终得到将val删除后的新树
root=merge(merge(x,y),z);
}
查询值的排名
设要查询的值为val,步骤:
- 按值val-1将root树分裂成x树和y树
- 那么x树中就都是 ≤ v a l − 1 \leq val-1 ≤val−1的,统计x树的大小,就可以知道有多少个数比val小,假设有num个
- 那么val的排名就是num+1
- 最后再把x树和y树合并起来
//查询val值的排名
void getrank(int val)
{
//按值val-1将root树分裂成x树和y树 那么此时x树中节点值都是<=val-1的
split(root,val-1,x,y);
//统计一下x树中的大小(即可知道有多少个数比val小)
// 有tr[x].size个数比val小,然后再+1 那么就是val值的排名了
printf("%d\n",tr[x].size+1);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
查询排名的值
//查询排名为rank对应的大数
void getnum(int rank)
{
int u=root;
while(u)
{
if(tr[tr[u].l].size+1==rank) //找到排名为rank所对应的数 就可以退出了
break;
else if(tr[tr[u].l].size>=rank) //在左子树中
u=tr[u].l;
else //在右子树中
{
rank-=tr[tr[u].l].size+1;
u=tr[u].r;
}
}
printf("%d\n",tr[u].val);
}
寻找前驱
假设我们要寻找val这个数的前驱,那么可以按值val-1将root树分裂成x树和y树,再由二叉搜索树和中序序列的性质可知,则x树里面最右边的那个节点的树就是val的前驱
因为我们分裂后x其实就是它原来父节点的左孩子了,所以我们这里不能写成 u = x . l u=x.l u=x.l
//寻找前驱
void pre(int val)
{
//按值val-1将root数分裂成x树(左子树)和y树(右子树)
split(root,val-1,x,y);
int u=x;
//找到x树中的最右节点 则它就是val的前驱
while(tr[u].r)
u=tr[u].r;
printf("%d\n",tr[u].val);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
寻找后继
假设我们要寻找val这个数的前驱,那么可以按值val将root树分裂成x树和y树,再由二叉搜索树和中序序列的性质可知,则y树里面最左边的那个节点的树就是val的后继
因为我们分裂后y其实就是它原来父节点的右孩子了,所以我们这里不能写成 u = y . r u=y.r u=y.r
//寻找后继
void nxt(int val)
{
//按值val将root数分裂成x树(左子树)和y树(右子树)
split(root,val,x,y);
int u=y;
//找到y树中的最左节点 则它就是val的后继
while(tr[u].l)
u=tr[u].l;
printf("%d\n",tr[u].val);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
代码
#include<iostream>
#include<cstring>
#include<random>
#include<algorithm>
using namespace std;
const int N=1e5+10;
std::mt19937 rnd(233); //高性能的随机函数 种子取233
struct Node{
int l,r; //左孩子节点的编号 右孩子节点的编号 编号其实就是下标
int val; //该节点内的权值
int key; //该节点的随机索引 即随机优先级
int size; //以该节点为根的所形成的树的大小
}tr[N];
//idx给每个节点分配的编号(下标) 最后它记录的就是这棵树中节点的总个数
//root记录的就是这棵树的根节点
int idx,root;
//x树 y树 z树 后面的操作需要用到 先定义为全局变量
int x,y,z;
//建立新节点 给该节点分配编号 同记录信息
inline int newnode(int val)
{
tr[++idx].val=val; //记录该节点的值为val
tr[idx].key=rnd(); //随机生成该节点的优先级
tr[idx].size=1; //以该节点为根的树的大小为1
return idx; //返回分配给该节点的编号idx
}
//更新节点的信息 记录以该节点u为根的树的大小
inline void update(int u)
{
tr[u].size=tr[tr[u].l].size+tr[tr[u].r].size+1;
}
//分裂操作:按val值将u树分裂成x树和y树
//这里用 引用 将分裂得到的x树和y树 返回回去
void split(int u,int val,int &x,int &y)
{
//如果要分裂的当前这棵u树是不存在的 那么不可能分裂出x树和y树
//所以把x和y都设为0
if(!u)
x=y=0;
else
{
//如果当前节点的值<=val 根据二叉搜索树的性质 该节点的左子树肯定都<=val
//但是当前节点的右子树中存在大于val的节点 因此我们需要对右子树再进行递归分裂
if(tr[u].val<=val)
{
x=u;
//按val值将tr[u].r右子树分裂成 tr[u].r树 和y树
split(tr[u].r,val,tr[u].r,y);
}
else
{
y=u;
//按val值将tr[u].l左子树分裂成x树和tr[u].l树
split(tr[u].l,val,x,tr[u].l);
}
//分裂完了之后 要记得更新节点信息
update(u);
}
}
//将x树和y树合并 返回的结果是这两棵树合并后的那个根节点
int merge(int x,int y)
{
//如果x树不存在y树存在 那么合并后就只有y树 因此返回y树
//如果y树不存在x树存在 那么合并后就只有x树 因此返回x树
if(!x||!y)
return x+y;
//这里当作是大根堆来处理 写成> 或者>=都可以
//也可以当作小根堆来处理 即写成< <= 那么就是小根堆
if(tr[x].key>tr[y].key)
{
//根据二叉堆的大根堆性质tr[x].key>tr[y].key 那么x在y的上方
//根据二叉搜索树的性质,我们必须保证x树中的值<=y树中的值 所以y树在右子树
//综上 y树是x的右子树 因此我们把y树和x原来的右子树合并 形成 x树新的右子树
tr[x].r=merge(tr[x].r,y);
//更新以x为根的树的大小信息
update(x);
//将y树合并到x树 合并后根节点就是x
return x;
}
else
{
//根据二叉堆的大根堆性质tr[y].key>tr[x].key 那么y在x的上方
//根据二叉搜索树的性质,我们必须保证x树中的值<=y树中的值 所以x树在左子树
//综上 x树是y的左子树 因此我们把x树和y原来的左子树合并 形成 y树新的左子树
tr[y].l=merge(x,tr[y].l);
//更新以y为根的树的大小信息
update(y);
//将x树合并到y树 合并后根节点就是y
return y;
}
}
//插入
void insert(int val)
{
//先按值val分裂成x树和y树
split(root,val,x,y);
//然后新生成节点newnode(val) 把这个节点和x树合并 得到一棵树
//我们再将这棵树和y树合并 就得到了插入val后的完整的新树
root=merge(merge(x, newnode(val)),y);
}
//删除
void del(int val)
{
//第一步:按值val将root树分裂成x树和z树 分裂完了之后x树都是<=val z树都是>val
split(root,val,x,z);
//第二步:按值val-1将x树分裂成x树和y树 分裂完了之后x树都是<=val-1 y树都是>val-1 也就是说y树都是=val
split(x,val-1,x,y);
//第三步:将y的左子树和右子树合并 它俩众叛亲离偷偷合并抛弃了父节点y 那么就相当于删除了y这个根
//然后我们把它俩合并后得到的新树的根节点赋值给y
y=merge(tr[y].l,tr[y].r);
//第四步:将x树和 合并后得到的新树y合并 又得到一个新树
//第五步:再将这棵新树和z树合并 最终得到将val删除后的新树
root=merge(merge(x,y),z);
}
//查询val值的排名
void getrank(int val)
{
//按值val-1将root树分裂成x树和y树 那么此时x树中节点值都是<=val-1的
split(root,val-1,x,y);
//统计一下x树中的大小(即可知道有多少个数比val小)
// 有tr[x].size个数比val小,然后再+1 那么就是val值的排名了
printf("%d\n",tr[x].size+1);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
//查询排名为rank对应的大数
void getnum(int rank)
{
int u=root;
while(u)
{
if(tr[tr[u].l].size+1==rank) //找到排名为rank所对应的数 就可以退出了
break;
else if(tr[tr[u].l].size>=rank) //在左子树中
u=tr[u].l;
else //在右子树中
{
rank-=tr[tr[u].l].size+1;
u=tr[u].r;
}
}
printf("%d\n",tr[u].val);
}
//寻找前驱
void pre(int val)
{
//按值val-1将root数分裂成x树(左子树)和y树(右子树)
split(root,val-1,x,y);
int u=x;
//找到x树中的最右节点 则它就是val的前驱
while(tr[u].r)
u=tr[u].r;
printf("%d\n",tr[u].val);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
//寻找后继
void nxt(int val)
{
//按值val将root数分裂成x树(左子树)和y树(右子树)
split(root,val,x,y);
int u=y;
//找到y树中的最左节点 则它就是val的后继
while(tr[u].l)
u=tr[u].l;
printf("%d\n",tr[u].val);
//有分裂一定有合并 分离完了要记得合并回去
root=merge(x,y);
}
int main()
{
int T;
scanf("%d",&T);
while(T--)
{
int op,x;
scanf("%d%d",&op,&x);
if(op==1) //插入
insert(x);
else if(op==2) //删除
del(x);
else if(op==3) //求排名
getrank(x);
else if(op==4) //求第rank对应的数
getnum(x);
else if(op==5) //求数x的前驱
pre(x);
else if(op==6) //求数x的后继
nxt(x);
}
return 0;
}