浅谈平衡树
前言
在日常的学习生活中,我们经常会遇到如下问题:
维护一个数据结构,可以插入或删除一个数、求该数排名、求排名给定的数、求一个数的前驱后继。
这种情况下,权值线段树可以不优雅的解决这个问题。为了找到更优雅、适应性更强的算法,人们发明了平衡树。平衡树已经渗透入大部分 \(OIer\) 的代码中。对于一些简单的问题,我们可以直接使用 \(set\) 或 \(map\),他们的基本结构是平衡树的其中一种(红黑树)。
二分查找树
二分查找树顾名思义,每个点最多两个儿子,并且要求左子树内所有点小于该子树根节点,右子树内所有点大于该子树根节点。
建立是容易的,这里不说,只分析时间复杂度。
考虑任何操作实际上都要从根节点走向其他节点,因此时间复杂度 \(O(h)\),其中 \(h\) 表示树高。
但是我们很容易就可以让二分查找树高度为 \(n\),那么此时他直接退化成链表。所以为了让二分查找树树高为 \(\log n\),人们发明了平衡树来解决这个问题。
在接下来的部分,我将会讲解 \(FHQ-Treap\) 和替罪羊树两种容易理解且应用范围较广的算法(不要问我为什么不写 \(Splay\),因为我根本不会)。
2025.1.23:我的同学 xzz_cat6 由于极度不喜欢 \(Treap\),所以用 \(AVL\) 树替代了 \(FHQ-Treap\) 的所有操作。详见 这篇算法总结。
\(FHQ-Treap\)
要想知道 \(FHQ-Treap\) 是什么,首先要知道 \(Treap\) 是什么。
要想知道 \(Treap\) 是什么,首先要知道笛卡儿树是什么。
一个序列 \(a\) 他的笛卡儿树形态应满足如下特点:
-
若只看 \(i\),笛卡儿树是一棵二分查找树。
-
若只看 \(a_i\),笛卡儿树是一个堆。
所以 \(Treap=Tree+heap\),也就是说 \(Treap\) 是一种特殊的笛卡儿树,因为它的 \(a_i\) 是随机的,所以能保证树高大概是 \(\log\) 的。
一般的 \(Treap\) 是通过 \(zig,zag\) 操作来保持平衡的,但是我们观察到另一条重要性质:
对于一个序列 \(a\),它的笛卡儿树是固定的。
也就是说,当我们确立了 \(a_i\) 是,树的形态就是固定的。
所以 \(fhq\) 就发明了无旋 \(Treap\),就是 \(FHQ-Treap\)。
\(FHQ-Treap\) 通过按值分裂和按随机值合并的方式进行各种基本操作,单次时间复杂度显然 \(\log n\)。下给出模版题代码:
#include<bits/stdc++.h>
#define ls(x) pl[x].ls
#define rs(x) pl[x].rs
#define sz(x) pl[x].sz
#define rk(x) pl[x].rk
#define val(x) pl[x].val
using namespace std;
const int N=1e5+5;
int n,opt,x;
struct fhq{
int ls,rs,val,rk,sz;
}pl[N];int id,rt;
int mkn(int x){
pl[++id]={0,0,x,rand(),1};
return id;
}struct fhq_treap{
void push_up(int x){
sz(x)=sz(ls(x))+sz(rs(x))+1;
}inline void spilt(int x,int v,int &a,int &b){
if(!x) return a=b=0,void();
if(val(x)<=v) a=x,spilt(rs(x),v,rs(a),b);
else b=x,spilt(ls(x),v,a,ls(b));
push_up(x);
}inline int merge(int x,int y){
if(!x||!y) return x+y;
if(rk(x)<rk(y)){
rs(x)=merge(rs(x),y);
push_up(x);return x;
}ls(y)=merge(x,ls(y));
push_up(y);return y;
}void add(int v){
int a=0,b=0;spilt(rt,v,a,b);
rt=merge(merge(a,mkn(v)),b);
}void del(int v){
int a=0,b=0,c=0;
spilt(rt,v-1,a,b),spilt(b,v,b,c);
rt=merge(merge(a,merge(ls(b),rs(b))),c);
}inline int kth(int x,int k){
if(k<=sz(ls(x))) return kth(ls(x),k);
if(k==sz(ls(x))+1) return x;
return kth(rs(x),k-sz(ls(x))-1);
}
}fhq_tr;
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n;
while(n--){
cin>>opt>>x;
if(opt==1) fhq_tr.add(x);
if(opt==2) fhq_tr.del(x);
if(opt==3){
int a=0,b=0;
fhq_tr.spilt(rt,x-1,a,b);
cout<<sz(a)+1<<"\n";
rt=fhq_tr.merge(a,b);
}if(opt==4)
cout<<val(fhq_tr.kth(rt,x))<<"\n";
if(opt==5){
int a=0,b=0;
fhq_tr.spilt(rt,x-1,a,b);
cout<<val(fhq_tr.kth(a,sz(a)))<<"\n";
rt=fhq_tr.merge(a,b);
}if(opt==6){
int a=0,b=0;
fhq_tr.spilt(rt,x,a,b);
cout<<val(fhq_tr.kth(b,1))<<"\n";
rt=fhq_tr.merge(a,b);
}
}return 0;
}
\(FHQ-Treap\) 的第一个优势无疑是它又短又极具逻辑性,因此十分好写。而第二个优势,在于它容易实现区间反转。
\(FHQ-Treap\) 有一种特殊的分裂方式,可以将前 \(k\) 个数分裂出来,这也使得假如把一个区间扔进 \(FHQ-Treap\) 后,它能够快速分裂出区间 \([l,r]\),它甚至还能把这个区间再放回去。于是我们就可以用它来写文艺平衡树。至于如何反转,只需要用类似于线段树的思路,写一个懒标记,记录是否反转就可以了。下给出模版题代码(它真的好短):
#include<bits/stdc++.h>
#define val(x) pl[x].val
#define ls(x) pl[x].ls
#define rs(x) pl[x].rs
#define rk(x) pl[x].rk
#define sz(x) pl[x].sz
#define fl(x) pl[x].fl
#define int long long
using namespace std;
const int N=2e5+5;
const int p=998244353;
int n,m,tot,a[N];
struct node{
int ls,rs,val;
int rk,sz,fl;
}pl[N];int id,rt;
int mk(int x){
return pl[++id]={0,0,x,rand(),1,0},id;
}struct fhq_treap{
void push_up(int x){
sz(x)=sz(ls(x))+sz(rs(x))+1;
}void push_down(int x){
if(!fl(x)) return;
swap(ls(x),rs(x)),fl(x)=0;
if(ls(x)) fl(ls(x))^=1;
if(rs(x)) fl(rs(x))^=1;
}inline void split(int x,int k,int &a,int &b){
if(!x) return a=b=0,void();
push_down(x);
if(sz(ls(x))<k)
a=x,split(rs(x),k-sz(ls(x))-1,rs(a),b);
else b=x,split(ls(x),k,a,ls(b));
push_up(x);
}inline int merge(int x,int y){
if(!x||!y) return x+y;
if(rk(x)<rk(y)){
push_down(x);
rs(x)=merge(rs(x),y);
push_up(x);return x;
}push_down(y);
ls(y)=merge(x,ls(y));
push_up(y);return y;
}void gets(int x){
if(!x) return;
push_down(x);
gets(ls(x));
a[++tot]=val(x);
gets(rs(x));
}
}fhq;
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
rt=fhq.merge(rt,mk(i));
while(m--){
int l,r,a,b,c;
cin>>l>>r,fhq.split(rt,r,b,c);
fhq.split(b,l-1,a,b),fl(b)^=1;
rt=fhq.merge(fhq.merge(a,b),c);
}fhq.gets(rt);
for(int i=1;i<=n;i++)
cout<<a[i]<<" ";
return 0;
}
\(FHQ-Treap\) 有一个经典操作,即可持久化平衡树,这个我写过了(说来惭愧,这个总结想写很久了,但是一直放鸽子,以至于写得比可持久化平衡树还晚了)。
它还有一个不那么经典的操作,就是让某些特殊情况下的珂朵莉树的时间复杂度变成真的。
替罪羊树
它拥有一个令人悲伤的名字。
好的,我们请山羊同志为我们解释一下这个名字的来历:
好的感谢山羊同志!
替罪羊树的思想的的确确相当暴力:
-
用二分查找树的方式加点。
-
看子树是否平衡。
-
不平衡?直接拍平重构成一颗完全二叉树!
………………我们理解了山羊同志的苦衷。
关于平不平衡,这里的判定方法用到了类似于估价函数的思想。
考虑假设左子树比右子树大,那么我们不能一直拍平,但是差太多的话就要使用 政府的力量 代码的力量进行宏观调控。
我们考虑设一个参数 \(\alpha\),当 \(\max(sz(ls(x)),sz(rs(x)))>\alpha\times sz(x)\) 时,我们进行拍平重构。
通常 \(\alpha\in[0.7,0.85]\) 时比较合适。虽然玄之又玄,但是时间复杂度没有问题。它甚至还被称为“高速平衡树”。
- 高速平衡树:除 \(Treap,FHQ-Treap,Splay\) 以外的平衡树是高速平衡树。
………………好吧它真的很快。上模版题代码。
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
const double alp=0.8;
int q,a[N],cnt,num;
namespace sad_goat_tree{
#define ls(x) sgt[x].ls
#define rs(x) sgt[x].rs
#define vl(x) sgt[x].vl
#define sz(x) sgt[x].sz
#define al(x) sgt[x].al
#define dl(x) sgt[x].dl
struct sad_goat{
int ls,rs,vl,sz,al,dl;
}sgt[N];int rt;
void push_up(int x){
sz(x)=sz(ls(x))+sz(rs(x))+1;
al(x)=al(ls(x))+al(rs(x))+1;
}void build(int &x,int l,int r){
if(l>r) return;
int mid=(l+r)/2;x=a[mid];
sgt[x]={0,0,vl(x),1,1,1};
build(ls(x),l,mid-1);
build(rs(x),mid+1,r);
push_up(x);
}void dfs(int x){
if(ls(x)) dfs(ls(x));
if(dl(x)) a[++cnt]=x;
if(rs(x)) dfs(rs(x));
}void rebuild(int &x){
if(!sz(x)) x=0;
else cnt=0,dfs(x),build(x,1,cnt);
}int check(int x){
return 1.0*max(sz(ls(x)),sz(rs(x)))>alp*sz(x);
}void add(int &x,int val){
if(!x) return sgt[x=++num]={0,0,val,1,1,1},void();
sz(x)++,al(x)++,add((vl(x)<val)?rs(x):ls(x),val);
if(check(x)) rebuild(x);
}int rnk(int x,int val){
if(!x) return 0;
if(val<=vl(x)) return rnk(ls(x),val);
return sz(ls(x))+dl(x)+rnk(rs(x),val);
}void delk(int x,int val){
sz(x)--;
if(dl(x)&&sz(ls(x))+1==val)
return dl(x)=0,void();
if(sz(ls(x))+dl(x)>=val) delk(ls(x),val);
else delk(rs(x),val-sz(ls(x))-dl(x));
}void del(int x){
delk(rt,rnk(rt,x)+1);
if(al(rt)*alp>sz(rt)) rebuild(rt);
}int kth(int val){
int x=rt;
while(x){
if(sz(ls(x))>=val) x=ls(x);
else if(sz(ls(x))+dl(x)==val) return vl(x);
else val-=sz(ls(x))+dl(x),x=rs(x);
}return vl(x);
}
}using namespace sad_goat_tree;
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>q;
while(q--){
int opt,x;cin>>opt>>x;
if(opt==1) add(rt,x);if(opt==2) del(x);
if(opt==3) cout<<rnk(rt,x)+1<<"\n";
if(opt==4) cout<<kth(x)<<"\n";
if(opt==5) cout<<kth(rnk(rt,x))<<"\n";
if(opt==6) cout<<kth(rnk(rt,x+1)+1)<<"\n";
}return 0;
}
证明一下吧:
容易发现其时间复杂度决定于最终树高和 \(build\) 次数。删除时的 \(build\) 感觉删去不会影响时间复杂度,所以这里就不证了。
树高:贪心的认为 \(sz(ls(x))=\alpha\times sz(x)\),那么树高 \(h\) 应满足 \(n\times \alpha^h=1\),那么 \(h=\log_{\alpha^{-1}}n\)。
\(build\) 次数:显然我们每次都往树的最左边加点是能让 \(build\) 的时间复杂度最大的(也许操作次数能更多,但是是以牺牲 \(build\) 的子树的大小为代价的,根本不优)。设大小为 \(x\) 的子树刚刚拍平,那么假如想要让它再一次拍平,需要再插入 \(\Delta>\frac{2\alpha-1}{2-2\alpha}x\) 个点,均摊时间复杂度为 \(\frac{O(x+\Delta)}{\Delta}=O(\frac{2-2\alpha}{2\alpha-1}+1)=O(\frac{1}{2\alpha-1})\)。
所以时间复杂度为 \(O(n\log_{\alpha^{-1}}n(1+\frac{1}{2\alpha-1})=O(n\frac{2\alpha}{2\alpha-1}\log_{\alpha^{-1}}n)\)
当 \(\alpha=0.75\) 时,时间复杂度 \(O(n\log_{\frac 43}n)\),\(\frac{2\alpha}{2\alpha-1}=3\) 看作常数。当 \(n=10^5\) 时,\(\log_{\frac 43}n\approx 36\)。
替罪羊树的一个优势是它没有旋转,所以它可以进行一些不容易在旋转时动态维护的量,如 \([BZOJ3600]\) 没有人的算术 一题中,我们就可以很开心的用替罪羊树的暴力拍平重构原树中的权值,而这显然是其他平衡树算法难以维护的。
啥,你问我 \(FHQ\) 为什么不行?你用 \(FHQ\) 加点时经过它的父亲吗?难以锁定权值啊。
替罪羊树在 \(KDT\) 中也有相当重要的应用:替罪羊树重构维护动态加点 \(KDT\)。下给出代码:
//BZOJ4066 简单题
#include<bits/stdc++.h>
#define ll long long
#define ls(x) nd[x].ls
#define rs(x) nd[x].rs
#define sm(x) nd[x].sm
#define sz(x) nd[x].sz
#define vl(x) nd[x].vl
#define id(x,i) nd[x].id[i]
#define lp(x,i) nd[x].lp[i]
#define rd(x,i) nd[x].rd[i]
using namespace std;
const int N=2e5+5;
const double alp=0.7;
struct kdt{
int ls,rs,sz;ll sm,vl;
int id[2],lp[2],rd[2];
}nd[N];int n,la,cnt,a[N],kw;
int cmp(int x,int y){
return id(x,kw)<id(y,kw);
}namespace KDT{
int rt,tot;
void push_up(int x){
sz(x)=sz(ls(x))+sz(rs(x))+1;
sm(x)=sm(ls(x))+sm(rs(x))+vl(x);
lp(x,0)=min({id(x,0),lp(ls(x),0),lp(rs(x),0)});
lp(x,1)=min({id(x,1),lp(ls(x),1),lp(rs(x),1)});
rd(x,0)=max({id(x,0),rd(ls(x),0),rd(rs(x),0)});
rd(x,1)=max({id(x,1),rd(ls(x),1),rd(rs(x),1)});
}int check(int x){
return max(sz(ls(x)),sz(rs(x)))>=alp*sz(x);
}void clear(int &x){
if(!x) return;
int nw=(a[++cnt]=x);x=0;
clear(ls(nw)),clear(rs(nw));
}int build(int l,int r,int k){
int mid=(l+r)/2;kw=k;
nth_element(a+l,a+mid,a+r+1,cmp);
if(l<mid) ls(a[mid])=build(l,mid-1,k^1);
if(r>mid) rs(a[mid])=build(mid+1,r,k^1);
return push_up(a[mid]),a[mid];
}void rebuild(int &x,int k){
cnt=0,clear(x),x=build(1,cnt,k);
}void add(int &x,int y,int k){
if(!x) return x=y,void();
if(id(y,k)<=id(x,k)) add(ls(x),y,k^1);
else add(rs(x),y,k^1);push_up(x);
if(check(x)) rebuild(x,k);
}void insert(int x,int y,int val){
nd[++tot]={0,0,1,val,val,{x,y},{x,y},{x,y}};
add(rt,tot,0);
}ll que(int x,int xa,int ya,int xb,int yb,int k){
ll re=0;
if(lp(x,0)>=xa&&rd(x,0)<=xb)
if(lp(x,1)>=ya&&rd(x,1)<=yb) return sm(x);
if(lp(x,0)>xb||rd(x,0)<xa) return 0;
if(lp(x,1)>yb||rd(x,1)<ya) return 0;
if(id(x,0)>=xa&&id(x,0)<=xb)
if(id(x,1)>=ya&&id(x,1)<=yb) re+=vl(x);
if(ls(x)) re+=que(ls(x),xa,ya,xb,yb,k^1);
if(rs(x)) re+=que(rs(x),xa,ya,xb,yb,k^1);
return re;
}
}using namespace KDT;
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n,lp(0,0)=lp(0,1)=2e9;
while(1){
int opt,x,y,z,w;cin>>opt;if(opt==3) return 0;
cin>>x>>y>>z,x^=la,y^=la,z^=la;if(opt==1) insert(x,y,z);
else cin>>w,w^=la,la=que(rt,x,y,z,w,0),cout<<la<<"\n";
}return 0;
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)