平衡树
平衡树
-
定义
全称为二叉平衡搜索树。
-
二叉搜索树
形式化的,这个概念是指一棵节点带权的二叉树,令节点 \(i\) 的权值为 \(w_i\),左子树点集为 \(L\),右子树点集为 \(R\),其满足 \(\forall u \in L,w_u \le w_i\) 且 \(\forall v \in R,w_v > w_i\)。
-
性质 / 作用
-
具备单调性,可实现 \(\log\) 级别复杂度的查找。
-
中序遍历单调不减,于是可以与一个序列形成映射(将下标作为权值即可)。
-
-
平衡的意义
众所周知,一个序列对应的二叉搜索树不一定惟一。我们希望取得高度更小的那一棵,以保证复杂度。
若一棵二叉树的任意节点 \(i\),其左右子树高度差 \(\Delta h \le 1\),则我们称其为一棵二叉平衡树。
-
常用的平衡树
treap、fhq-treap、splay。
FHQ-Treap
-
定义
又称无旋 Treap。即仅靠分裂与合并完成维护。
-
\(\operatorname{split}(k,a,b,val)\)
顾名思义,即将根节点为 \(k\) 的 tree 分裂成根节点为 \(a,b\) 的两棵 tree,点集为 \(A,B\),其中 \(\forall u \in A, w_u \le val\) 且 \(\forall v \in B, w_v > val\)。
大体的思路就是 \(k\) 为当前节点,若 \(w_k \le val\),说明找到了 \(a\),并且其左子树也都在 \(A\) 中,然后去右子树中寻找 \(b\) 和 \(A\) 所缺的右子树。找到了 \(b\) 则反之处理即可。
-
\(\operatorname{merge}(k,a,b)\)
顾名思义,即将根节点为 \(a,b\) 的 tree 合并为根节点为 \(k\) 的 tree,点集为 \(A,B\),其中 \(\forall u \in A,v \in B,w_u \le w_v\)。
根据定义,肯定是 \(A\) 左 \(B\) 右了,但是高度无法确定。
于是,我们给每个节点随机一个 \(rank\),谁 \(rank\) 小谁就在高处,这样退化成一条链的几率很小,可以保证均摊复杂度 \(\log\) 级别。
确定了高度,譬如 \(A\) 在上,那么 \(B\) 就需要和 \(A\) 的右子树继续竞争,递归下去即可。
这两个函数是 FHQ-Treap 的精髓之所在。
其他的因题而异,请自行阅读代码进行理解。本代码只给出函数部分。
实现
void upd(int k){ tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } int add_node(int val){ tree[++tot].siz=1; tree[tot].val=val; tree[tot].rnk=rand(); tree[tot].lt=tree[tot].rt=0; return tot; } void split(int k,int &a,int &b,int val){ if(!k){ a=b=0; return; } if(tree[k].val<=val){ a=k; split(tree[k].rt,tree[k].rt,b,val); } else{ b=k; split(tree[k].lt,a,tree[k].lt,val); } upd(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else{ k=b; merge(tree[k].lt,a,tree[k].lt); } upd(k); } void ins(int &k,int val){ int a=0,b=0,cur=add_node(val); split(k,a,b,val); merge(a,a,cur); merge(k,a,b); } void del(int &k,int val){ int a=0,b=0,c=0; split(k,a,b,val); split(a,a,c,val-1); merge(c,tree[c].lt,tree[c].rt); merge(a,a,c); merge(k,a,b); } int fnd_rnk(int &k,int val){ int a=0,b=0; split(k,a,b,val-1); int res=tree[a].siz+1; merge(k,a,b); return res; } int fnd_num(int k,int x){ while(tree[tree[k].lt].siz+1!=x){ if(tree[tree[k].lt].siz>=x) k=tree[k].lt; else{ x-=tree[tree[k].lt].siz+1; k=tree[k].rt; } } return tree[k].val; } int pre(int &k,int val){ int a=0,b=0; split(k,a,b,val-1); int res=fnd_num(a,tree[a].siz); merge(k,a,b); return res; } int suf(int &k,int val){ int a=0,b=0; split(k,a,b,val); int res=fnd_num(b,1); merge(k,a,b); return res; }
应用
P1486(整体修改、第 \(K\) 大)
看到如上的关键词,我们考虑用一棵平衡树维护工资档案。
具体的:
-
I k
:新建并插入一个节点。 -
A k
:维护一个 \(tag\) 表示整体修改的值,令 \(tag+k \to tag\)。 -
S k
:令 \(tag-k \to tag\),并删除权值 \(< min-tag\)(注意不是 \(<min\))的所有节点。 -
F k
:查询第 \(k\) 大即可。
实现
#include<bits/stdc++.h> #define int long long using namespace std; const int N=1e5+5; int n,ans,root,tot,mini,tag,all; struct fhq{ int val,rnk,siz,lt,rt; }tree[N]; int add_node(int val){ tree[++tot].val=val,all++; tree[tot].siz=1; tree[tot].rnk=rand(); tree[tot].lt=tree[tot].rt=0; return tot; } void upd(int k){ tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } void split(int k,int &a,int &b,int val){ if(!k){ a=b=0; return; } if(tree[k].val<=val){ a=k; split(tree[k].rt,tree[k].rt,b,val); } else { b=k; split(tree[k].lt,a,tree[k].lt,val); } upd(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else { k=b; merge(tree[k].lt,a,tree[k].lt); } upd(k); } void ins(int &k,int val){ int a=0,b=0,cur=add_node(val); split(k,a,b,val); merge(a,a,cur); merge(k,a,b); } void del(int &k){ int a=0,b=0; split(k,a,b,mini-tag-1); ans+=tree[a].siz; all-=tree[a].siz; k=b; } int fnd_num(int k,int x){ while(tree[tree[k].rt].siz+1!=x){ if(tree[tree[k].rt].siz>=x) k=tree[k].rt; else{ x-=tree[tree[k].rt].siz+1; k=tree[k].lt; } } return tree[k].val; } signed main(){ ios::sync_with_stdio(0); cin.tie(0); srand(time(0)); cin>>n>>mini; add_node(-1e18),add_node(1e18); all=0; while(n--){ char x; int k; cin>>x>>k; if(x=='I'&&k>=mini) ins(root,k-tag); else if(x=='A') tag+=k; else if(x=='S') tag-=k,del(root); else if(x=='F'){ if(all<k) cout<<"-1\n"; else cout<<fnd_num(root,k)+tag<<'\n'; } } cout<<ans; return 0; }
总结:
-
第 \(K\) 大考虑平衡树。
-
整体修改考虑给整棵树打标记。
P2234(前驱、后继)
我们仍然考虑使用一棵平衡树维护营业额。
对于每天的营业额,我们顺次插入平衡树,然后在其前驱和后继这两个之间找差最小的即可。
需要注意的是:
-
如果前面出现过相同的营业额,绝对差直接为 \(0\)。
-
应当设置哨兵节点以防找不到前驱 / 后继。
实现
总结:
- 差最小考虑平衡树维护前驱 / 后继。
P3224(与 DSU 的结合)
还是查询第 \(K\) 小,考虑平衡树维护。
很容易想到对于每个联通块建一棵平衡树,然后用并查集维护联通性(合并)的时候顺便把树也合并了。
但是,我们的 merge
函数只能接受满足 \(\forall u \in A,v \in B,u<v\) 的点集为 \(A,B\) 的两棵树进行合并,可联通块并不具有此种性质。
于是启发式合并,将较小的树的点一个一个地扔进较大的树中即可,时间复杂度是 \(\log\) 级别的(一个点至多会被合并 \(\log n\) 次,因为每次合并都会使大小翻倍)。
实现
#include <cstdio> #include <iostream> #include <algorithm> #include <cstring> #include <string> #include <stdlib.h> #include <vector> #include <queue> #include <cmath> #include <stack> #include <map> #include <set> #define int long long using namespace std; const int N=2e5+5; int n,m,q; int root[N],fa[N],tot; struct fhq{ int val,rnk,siz,lt,rt,id; }tree[N<<2]; int fnd(int x){ return (fa[x]==x?x:fa[x]=fnd(fa[x])); } int add_node(int val,int id){ tree[++tot].val=val; tree[tot].siz=1; tree[tot].rnk=rand(); tree[tot].lt=tree[tot].rt=0; tree[tot].id=id; return tot; } void upd(int k){ tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } void split(int k,int &a,int &b,int val){ if(!k){ a=b=0; return; } if(tree[k].val<=val){ a=k; split(tree[k].rt,tree[k].rt,b,val); } else { b=k; split(tree[k].lt,a,tree[k].lt,val); } upd(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else { k=b; merge(tree[k].lt,a,tree[k].lt); } upd(k); } void ins(int &k,int val,int id){ int a=0,b=0,cur=add_node(val,id); split(k,a,b,val); merge(a,a,cur); merge(k,a,b); } void cls(int x){ tree[x].val=tree[x].rnk=tree[x].siz=tree[x].lt=tree[x].rt=tree[x].id=0; } void mrg(int x,int y){ x=fnd(x),y=fnd(y); if(x==y) return; if(tree[root[y]].siz>tree[root[x]].siz) swap(x,y); fa[y]=x; while(1){ ins(root[x],tree[root[y]].val,tree[root[y]].id); cls(root[y]); merge(root[y],tree[root[y]].lt,tree[root[y]].rt); if(!root[y]){ root[y]=root[x]; break; } } } int fnd_num(int k,int x){ while(tree[tree[k].lt].siz+1!=x){ if(tree[tree[k].lt].siz>=x) k=tree[k].lt; else{ x-=tree[tree[k].lt].siz+1; k=tree[k].rt; } } return tree[k].id; } signed main(){ ios::sync_with_stdio(0); cin.tie(0); srand(time(0)); cin>>n>>m; for(int i=1,x;i<=n;i++) cin>>x,root[i]=i,fa[i]=i,add_node(x,i); for(int i=1,u,v;i<=m;i++) cin>>u>>v,mrg(u,v); cin>>q; while(q--){ char opt; int x,y; cin>>opt>>x>>y; if(opt=='Q') cout<<(tree[root[fnd(x)]].siz<y?-1:fnd_num(root[fnd(x)],y))<<'\n'; else mrg(x,y); } return 0; }
总结:
merge
不行,启发式合并来凑。
P3391(区间翻转问题、按 size 分裂)
首先,我们考虑将序列映射到这棵平衡树的中序遍历上。
这样,按照 \([1,r] \to [l,r]\) 的方式分裂两次即可得到 \([l,r]\) 区间。
然后翻转这东西,它就像异或一样,做两次相当于没做。
于是我们考虑对每个节点打标记,于是每次反转操作只需要分出区间再更新标记(别忘了把当前节点的左右子树反过来)即可。
哦对了,最后输出的时候可以一边中序遍历、一边继续下传标记(中间不一定下传完了)。
关于实现,分裂的时候需要写一个按 size 分裂的 fhq-treap,具体看代码8。
实现
#include <cstdio> #include <iostream> #include <algorithm> #include <cstring> #include <string> #include <stdlib.h> #include <vector> #include <queue> #include <cmath> #include <stack> #include <map> #include <set> #define int long long using namespace std; const int N=1e5+5; int n,m,root,tot; struct fhq{ int val,rnk,siz,lt,rt,tag; }tree[N]; int add_node(int val){ tree[++tot].val=val; tree[tot].siz=1; tree[tot].rnk=rand(); tree[tot].lt=tree[tot].rt=0; return tot; } void pushup(int k){ tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } void pushdown(int k){ if(!tree[k].tag) return; swap(tree[k].lt,tree[k].rt); tree[tree[k].lt].tag^=1; tree[tree[k].rt].tag^=1; tree[k].tag=0; } void split(int k,int &a,int &b,int x){ if(!k){ a=b=0; return; } pushdown(k); if(tree[tree[k].lt].siz+1>x){ b=k; split(tree[k].lt,a,tree[k].lt,x); } else { a=k; split(tree[k].rt,tree[k].rt,b,x-tree[tree[k].lt].siz-1); } pushup(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } pushdown(a); pushdown(b); if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else { k=b; merge(tree[k].lt,a,tree[k].lt); } pushup(k); } void ins(int &k,int val){ int a=0,b=0,cur=add_node(val); split(k,a,b,val); merge(a,a,cur); merge(k,a,b); } void qry(int &k,int l,int r){ int a=0,b=0,c=0; split(k,a,b,r); split(a,a,c,l-1); tree[c].tag^=1; merge(a,a,c); merge(k,a,b); } void dfs(int k){ if(!k) return; pushdown(k); dfs(tree[k].lt); cout<<k<<' '; dfs(tree[k].rt); } signed main(){ ios::sync_with_stdio(0); cin.tie(0); srand(time(0)); cin>>n>>m; for(int i=1;i<=n;i++) ins(root,i); for(int i=1,l,r;i<=m;i++){ cin>>l>>r; qry(root,l,r); } dfs(root); return 0; }
总结:
-
序列问题考虑映射到平衡树的中序遍历上,以及使用按 size 分裂的。
-
数据结构注重打标记(对每个节点打标记最常见),然后注意下传。
P5217(按 size 分裂查排名、状压思想)
全家桶了属于是。
插入、删除、翻转、查排名对应的字母都不讲。
P
操作就是查字母对应的排名,具体而言就是从 \(x\) 开始,若它是右儿子,那么不停地往上跳,然后累加左子树的节点数,很容易理解吧。特别注意翻转标记必须先下传完再处理这个操作,不然会导致「当前文本」不是真正的当前文本。
Q
操作这种种类数的询问显然考虑状压维护,每次 pushup
时或一下左右子树的二进制值再传给父亲即可。
实现
#include <cstdio> #include <iostream> #include <algorithm> #include <cstring> #include <string> #include <stdlib.h> #include <vector> #include <queue> #include <cmath> #include <stack> #include <map> #include <set> //#define int long long using namespace std; const int N=2e5+5; int n,m,tot,root; string str; struct fhq{ int tag,val,rnk,lt,rt,siz,word,fa; }tree[N]; void pushup(int k){ if(tree[k].lt) tree[tree[k].lt].fa=k; if(tree[k].rt) tree[tree[k].rt].fa=k; tree[k].word=tree[tree[k].lt].word|tree[tree[k].rt].word|1<<tree[k].val; tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } void pushdown(int k){ if(!tree[k].tag) return; swap(tree[k].lt,tree[k].rt); tree[tree[k].lt].tag^=1; tree[tree[k].rt].tag^=1; tree[k].tag=0; } int add_node(int val){ tree[++tot].siz=1; tree[tot].lt=tree[tot].rt=0; tree[tot].word=0; tree[tot].val=val; tree[tot].rnk=rand(); tree[tot].tag=0; pushup(tot); return tot; } void split(int k,int &a,int &b,int x){ if(!k){ a=b=0; return; } pushdown(k); if(tree[tree[k].lt].siz+1>x){ b=k; split(tree[k].lt,a,tree[k].lt,x); tree[a].fa=0; } else{ a=k; split(tree[k].rt,tree[k].rt,b,x-tree[tree[k].lt].siz-1); tree[b].fa=0; } pushup(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } pushdown(a),pushdown(b); if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else{ k=b; merge(tree[k].lt,a,tree[k].lt); } pushup(k); } void dddd(int k){ if(tree[k].fa) dddd(tree[k].fa); pushdown(k); } void dfs(int k){ if(!k) return; pushdown(k); dfs(tree[k].lt); cout<<(char)(tree[k].val+'a'); dfs(tree[k].rt); } signed main(){ ios::sync_with_stdio(0); cin.tie(0); srand(time(0)); cin>>n>>m>>str; str='#'+str; for(int i=1;i<=n;i++) merge(root,root,add_node(str[i]-'a')); while(m--){ char op,ch; int x,y,a=0,b=0,c=0; cin>>op>>x; if(op=='I'){ cin>>ch; split(root,a,b,x); merge(a,a,add_node(ch-'a')); merge(root,a,b); } else if(op=='D'){ split(root,a,b,x); split(a,a,c,x-1); tree[c].val=-1; merge(root,a,b); } else if(op=='R'){ cin>>y; split(root,a,b,y); split(a,a,c,x-1); tree[c].tag^=1; merge(a,a,c); merge(root,a,b); } else if(op=='P'){ if(tree[x].val==-1){ cout<<"0\n"; continue; } dddd(x); int res=tree[tree[x].lt].siz+1; for(int i=x;tree[i].fa;i=tree[i].fa) if(tree[tree[i].fa].rt==i) res+=tree[tree[tree[i].fa].lt].siz+1; cout<<res<<'\n'; } else if(op=='T'){ split(root,a,b,x); split(a,a,c,x-1); cout<<(char)(tree[c].val+'a')<<'\n'; merge(a,a,c); merge(root,a,b); } else if(op=='Q'){ cin>>y; split(root,a,b,y); split(a,a,c,x-1); cout<<__builtin_popcount(tree[c].word)<<'\n'; merge(a,a,c); merge(root,a,b); } //dfs(root); //cout<<'\n'; } return 0; }
总结:
-
积累了按
size
分裂的 fhq_treap 如何查排名。 -
种类数的维护考虑状压。
P3765(摩尔投票、随机化)
积累一个摩尔投票(求区间内绝对众数)的 trick:
在一个区间内,首先令第一个元素为答案;维护一个 \(cnt\),之后每遇到一个与答案相同的,\(cnt+1 \to cnt\),否则 \(cnt-1 \to cnt\)。若 \(cnt=0\),则令当前这个为答案,继续重复上述步骤,最后的答案就有可能为绝对众数。正确性显然。
很容易发现摩尔投票具有区间可加性,我们就可以使用线段树维护绝对众数。具体而言,若两个区间众数相同,则合起来众数不变、\(cnt\) 相加;否则众数为较大的那个、\(cnt\) 为绝对差。这样在保证存在绝对众数的情况下,可以做到 \(O(1)\) 回答询问。
问题是现在无法保证存在绝对众数,于是我们需要进行验证,也就是验证求出的众数是否真的有 \(r-l+1\) 个人投他。考虑使用平衡树维护之,将每个人插入他投的那个人的平衡树中,于是问题等价于询问排名 \(l \sim r\) 中是否有 \(r-l+1\) 个人,所以我们需要 \(n\) 棵按 val 分裂的平衡树(方便查找排名)。
然后这个题就做完了,但是巨大难写,怎么办?
考虑乱搞。具体的,我们完全可以不需要线段树维护区间绝对众数,我们直接随机一个,再用平衡树检查。只随机一次的错误概率为 \(\frac{1}{2}\),这样重复 \(15 \sim 20\) 次,错误概率最低仅为 \(\frac{1}{2^{20}}\),几乎可以忽略不计。
实现
#include <cstdio> #include <iostream> #include <algorithm> #include <cstring> #include <string> #include <stdlib.h> #include <vector> #include <queue> #include <cmath> #include <stack> #include <map> #include <set> //#define int long long using namespace std; inline int read() { int x=0,f=1; char ch=getchar(); while(ch<'0'||ch>'9') { if(ch=='-') f=-1; ch=getchar(); } while(ch>='0' && ch<='9') x=x*10+ch-'0',ch=getchar(); return x*f; } void write(int x) { if(x<0) putchar('-'),x=-x; if(x>9) write(x/10); putchar(x%10+'0'); return; } const int N=5e6+5; int n,m,root,tot; int a[N]; bool vis[N]; struct FHQ{ int val,rnk,siz,lt,rt; }tree[N]; struct fhq{ int root=0; int add_node(int val){ tree[++tot].val=val; tree[tot].siz=1; tree[tot].rnk=rand(); tree[tot].lt=tree[tot].rt=0; return tot; } void upd(int k){ tree[k].siz=tree[tree[k].lt].siz+tree[tree[k].rt].siz+1; } void split(int k,int &a,int &b,int val){ if(!k){ a=b=0; return; } if(tree[k].val<=val){ a=k; split(tree[k].rt,tree[k].rt,b,val); } else { b=k; split(tree[k].lt,a,tree[k].lt,val); } upd(k); } void merge(int &k,int a,int b){ if(!a||!b){ k=a+b; return; } if(tree[a].rnk<tree[b].rnk){ k=a; merge(tree[k].rt,tree[k].rt,b); } else { k=b; merge(tree[k].lt,a,tree[k].lt); } upd(k); } void ins(int val){ int a=0,b=0,cur=add_node(val); split(root,a,b,val); merge(a,a,cur); merge(root,a,b); } void del(int val){ int a=0,b=0,c=0; split(root,a,b,val); split(a,a,c,val-1); merge(c,tree[c].lt,tree[c].rt); merge(a,a,c); merge(root,a,b); } int fnd_rnk(int val){ int a=0,b=0; split(root,a,b,val); int res=tree[a].siz; merge(root,a,b); return res; } }tr[N]; int qry(int l,int r){ int res=-1,stk[30]={0},top=0; for(int i=1;i<=15;i++){ int seed=a[rand()%(r-l+1)+l]; if(vis[seed]) continue; vis[seed]=1,stk[++top]=seed; if(tr[seed].fnd_rnk(r)-tr[seed].fnd_rnk(l-1)>(r-l+1)/2){ res=seed; break; } } while(top) vis[stk[top]]=0,top--; return res; } signed main(){ srand(time(0)); n=read(),m=read(); for(int i=1;i<=n;i++) a[i]=read(),tr[a[i]].ins(i); for(int i=1,l,r,s,k;i<=m;i++){ l=read(),r=read(),s=read(),k=read(); int winner=qry(l,r); if(winner==-1) winner=s; for(int j=1,x;j<=k;j++) x=read(),tr[a[x]].del(x),a[x]=winner,tr[winner].ins(x); write(winner),putchar('\n'); } write(qry(1,n)); return 0; }
总结:
-
区间绝对众数:摩尔投票 / 随机化。
-
注意平衡树的使用是按 size(一般用于序列问题) 还是 val(一般用于查排名、前驱、后继等) 分裂。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
2024-03-09 Living-Dream 系列笔记 第49期
2024-03-09 Living-Dream 系列笔记 第1期
2024-03-09 Living-Dream 系列笔记 第2期
2024-03-09 Living-Dream 系列笔记 第3期
2024-03-09 CF1846D Rudolph and Christmas Tree 题解
2024-03-09 Living-Dream 系列笔记 第4期
2024-03-09 Living-Dream 系列笔记 第5期