FHQ Treap 学习笔记
FHQ Treap
这是一个很好理解、码量短、应用多变、容易可持久化的平衡树。我认为这是最实用的平衡树。
我们结合例题讲解:【模板】普通平衡树。
主要通过 split 和 merge 来维护。
首先平衡树有三个性质:
-
堆性质,这里我们将维护一个小根堆(事实上维护大根堆也没有关系),即是一棵二叉树,树中每个节点的优先级值都会大于它的左儿子和右儿子。
-
搜索树性质,也就是每个节点的权值大于等于它左儿子的权值(如果有的话),并且小于等于它右儿子的权值(如果有的话)。更进一步,每个节点的权值大于等于它左子树所有节点的最大权值,小于等于它右子树所有节点的最小权值。
-
深度为 \(\log n\) 左右。
那么我们怎么维护它呢?
我们用一个结构体来维护平衡树每个节点的信息。
struct bst{
int l,r;//左右儿子
int pri,val;//优先级和权值
int sz;//以它为根的子树大小
}t[100010];
当然不同题目不同对待。
然后就是当前树根的编号 \(rt\),以及当前新节点的编号 \(cnt\),设为全局变量。
首先我们要知道如何新增节点。
inline newnode(int x){//新增一个权值为 x 的节点
t[++cnt]={0,0,rand(),x,1};
//新增节点编号为 cnt,左右儿子为空、优先级随机(保证了平衡树的深度)、权值为 x、子树大小为 1(它自己一个点)
}
然后是维护大小,每个节点的子树大小是它左儿子的大小加上右儿子的大小加一(它自己也是一个点)。
inline update(int x)//更新编号为 x 的节点的子树大小
t[x].sz=t[t[x].l].sz+t[t[x].r].sz+1;
}
接下来是分裂,假设我们要将以节点 \(x\) 为根的平衡树按照权值分为 2 棵平衡树,一棵所有权值 \(\le k\),一棵所有权值 \(>k\),它们的根分别为 \(l\),\(r\)。
那我们就要依据权值的大小,看一下根节点属于 \(l\) 还是 \(r\),然后再递归地考虑它的儿子就可以了。
void split(int x,int k,int &l,int &r){
if(!x){l=r=0;return;}//空节点,直接返回两个空节点
if(t[x].val<=k){//x 权值小于等于 k,说明整个左子树都是 l 的
l=x;
split(t[x].r,k,t[x].r,r);
}else{
r=x;
split(t[x].l,k,l,t[x].l);
}
}
还有另外一种分裂,就是按照权值的排名分裂,我们要将以节点 \(x\) 为根的子树按照排名分为 2 棵,一棵所有排名 \(\le k\),一棵所有排名 \(>k\),它们的根分别为 \(l\),\(r\)。
void split(int x,int k,int &l,int &r){
if(!x){l=r=0;return;}
if(t[t[x].l].sz+1<=k){
l=x;
split(t[x].r,k-t[t[x].l].sz-1,t[x].r,r);
}else{
r=x;
split(t[x].l,k,l,t[x].l);
}
}
接下来是更简单的合并,传入两棵平衡树的根 \(l\),\(r\),合并这两棵平衡树,返回合并后的根节点编号。
由于 \(l\)、\(r\) 已经满足平衡树性质,并且 \(l\) 中最大权值小于等于 \(r\) 中最小权值,所以直接考虑照堆的性质合并就可以了。
注意如果不满足以上任意两点中的一点就会出错。
我们直接依据优先级的大小,看一下根节点属于 \(l\) 还是 \(r\),然后再递归地考虑它们的左右儿子就可以了。
具体来说:
如果 \(l\) 的优先级更小,那么 \(l\) 是根节点,将 \(l\) 的右子树与 \(r\) 合并并且更新 \(l\)、返回 \(l\)。
如果 \(r\) 的优先级更小,那么 \(r\) 是根节点,将 \(l\) 与 \(r\) 的左子树合并并且更新 \(r\)、返回 \(r\)。
与左偏树很像,但是不用维护左偏性质,不难。
inline int merge(int l,int r){
if(!l||!r)return l+r;
if(t[l].pri<=t[r].pri){
t[l].r=merge(t[l].r,r);
update(l);
return l;
}else{
t[r].l=merge(l,t[r].l);
update(r);
return r;
}
}
接下来是求以 \(x\) 为根的平衡树里第 \(k\) 小值。递归地求就可以。
具体来说,求到了一个节点,看一下 \(k\) 和 \(x\) 左子树大小的关系:
-
如果 \(k\) 小于等于 \(x\) 左子树的大小,那么就返回 \(x\) 的左子树的第 \(k\) 小值。
-
如果 \(k\) 刚好等于 \(x\) 左子树的大小加一(指 \(x\)),那么就返回 \(x\) 这个位置对应的值。
-
如果 \(k\) 大于 \(x\) 左子树的大小加一,那么就返回 \(x\) 的右子树的第 \(k-左子树大小-1\) 小值。
inline int kth(int x,int k){
if(t[t[x].l].sz>=k)return kth(t[x].l,k);
else if(t[x].sz==k-1)return b[t[x].val];//因为离散化过
else return kth(t[x].r,k-t[t[x].l].sz-1);
}
然后就是其它正常操作了。
- 要往平衡树里增加一个值为 \(x\) 的元素。
先把 \(x\) 看做一棵以 \(cnt\) 为根的平衡树。
那我们可以将权值 \(\le x\) 的与权值 \(>x\) 的分开,变成平衡树 \(l\),\(r\),然后再将 \(l\) 与 \(cnt\) 合并,再与 \(r\) 合并。
inline void insert(int x){
int l,r;
split(rt,x,l,r);
newnode(x);
rt=merge(merge(l,cnt),r);
}
- 要删掉一个值为 \(x\) 的元素,而且只删掉 1 个。
那我们可以把值为 \(x\) 的所有节点单独分离出来。怎么做呢?
我们先把平衡树 \(rt\) 分为值 \(\le x\) 的平衡树 \(l\) 和值 \(>x\) 的平衡树 \(r\)。
然后再把平衡树 \(l\) 分为值 \(<x\) 的平衡树 \(l\) 和值 \(=x\) 的平衡树 \(p\)。
那我们要在 \(p\) 中删掉一个节点,方便起见删掉根节点,就是将 \(p\) 的左右子树合并,再与 \(l\) 合并,再与 \(r\) 合并即可。
inline void delet(int x){
int l,r,p;
split(rt,x,l,r);
split(l,x-1,l,p);
rt=merge(merge(l,merge(t[p].l,t[p].r)),r);
}
- 如果要查询数字 \(x\) 的排名,那就是要输出 \(\le x-1\) 的数的个数加一。
把平衡树 \(rt\) 分裂成值 \(\le x-1\) 的平衡树 \(l\) 和值 \(\ge x\) 的平衡树 \(r\)。
输出 \(l\) 的大小加一后,再合并回去就行。
inline void getrank(int x){
int l,r;
split(rt,x-1,l,r);
cout<<t[l].sz+1<<'\n';
rt=merge(l,r);
}
- 要查询排名为 \(x\) 的数的值。
直接调用 \(kth(rt,x)\) 即可。
- 求数值 \(x\) 的前驱,那就是要输出小于 \(x\) 的最大数。
把平衡树 \(rt\) 分裂成值 \(\le x-1\) 的平衡树 \(l\) 和值 \(\ge x\) 的平衡树 \(r\)。
输出 \(l\) 的最大数,也就是 \(l\) 的第 \(t[l].sz\) 个数后,再合并回去就行。
inline void getpre(int x){
int l,r;
split(rt,x-1,l,r);
cout<<kth(l,t[l].sz)<<'\n';
rt=merge(l,r);
}
- 求数值 \(x\) 的后继,那就是要输出大于 \(x\) 的最小数。
把平衡树 \(rt\) 分裂成值 \(\le x\) 的平衡树 \(l\) 和值 \(\ge x+1\) 的平衡树 \(r\)。
输出 \(r\) 的最小数,也就是 \(r\) 的第一个数后,再合并回去就行。
inline void getback(int x){
int l,r;
split(rt,x,l,r);
cout<<kth(r,1)<<'\n';
rt=merge(l,r);
}
把以上几个综合起来就是代码了。理解之后其实很好写。
事实上,除了 split 都很好理解。由于 split、merge、kth 都是 \(O(h=\log n)\) 的,所以总复杂度 \(O(n\log n)\)。
然后就是 【模板】文艺平衡树。
这个也不难,就是要处理区间翻转,那我们要像线段树一样,给平衡树打上懒标记。
我们可以发现,如果我们搞一个中序遍历,那么平衡树的权值就是单调的。
然后,翻转一个区间 \([l,r]\),对于任意 \(p∈[l,r]\),可以看作:
-
翻转 \([l,p-1]\)。
-
翻转 \([p+1,r]\)。
-
交换 \([l,p-1]\) 与 \([p+1,r]\)。
那么对应在平衡树上,就是找到区间 \([l,r]\) 的根节点 \(x\),然后交换它子树内所有节点的左、右儿子。
之所以可以这么操作,是因为这题不用排大小,所以权值不用满足二叉搜索树的性质。
我们只需要用二叉搜索树维护当前的序列,也就是保证它的所有懒标记下传后,中序遍历是当前序列就好了。
那么可以考虑用懒标记,为 1 代表要翻转,为 0 代表不翻转。
由于 FHQ Treap 的父子关系总是变来变去,为了防止下传到错误的节点上,每一次 split 或者 merge 之前都要下传懒标记。
下传的方式是异或,因为翻转两次代表不翻转。
考虑 split 操作,对于每一次操作,我们是要对整个 \([l,r]\) 打懒标记,但是不能对其余的任何值打标记。
那我们可以每一次把整棵树都 split 2 次,变成 3 棵,分别代表了区间 \([1,l-1]\),\([l,r]\),\([r+1,n]\),对于 \([l,r]\) 打标记即可。
我们是要对区间进行 split,那就是依据排名分裂。
然后这道题就做完了。时间复杂度 \(O(n\log n)\)。
inline void newnode(int x){t[++cnt]={0,0,x,rand(),1,0};}
inline void update(int x){t[x].sz=t[t[x].l].sz+t[t[x].r].sz+1;}
inline void pushdown(int x){
swap(t[x].l,t[x].r);
if(t[x].l)t[t[x].l].t^=1;
if(t[x].r)t[t[x].r].t^=1;
t[x].t=0;
}
inline int merge(int l,int r){
if(!l||!r)return l+r;
if(t[l].pri<=t[r].pri){
if(t[l].t)pushdown(l);
t[l].r=merge(t[l].r,r);
update(l);
return l;
}else{
if(t[r].t)pushdown(r);
t[r].l=merge(l,t[r].l);
update(r);
return r;
}
}
inline void split(int x,int k,int &l,int &r){
if(!x){l=r=0;return;}
if(t[x].t)pushdown(x);
if(t[t[x].l].sz+1<=k){
l=x;
split(t[x].r,k-t[t[x].l].sz-1,t[x].r,r);
}else{
r=x;
split(t[x].l,k,l,t[x].l);
}
update(x);
}
inline void dfs(int x){
if(t[x].t)pushdown(x);
if(t[x].l)dfs(t[x].l);
write(t[x].val);
putchar(32);
if(t[x].r)dfs(t[x].r);
}
int main(){
srand(time(0));n=read();m=read();
for(int i=1;i<=n;i++){
newnode(i);
rt=merge(rt,cnt);
}
for(int i=1,l,r,p,x,y;i<=m;i++){
x=read();y=read();
split(rt,y,l,r);
split(l,x-1,l,p);
t[p].t^=1;
rt=merge(merge(l,p),r);
}
dfs(rt);
return 0;
}