浅谈平衡树

前言

在日常的学习生活中,我们经常会遇到如下问题:

维护一个数据结构,可以插入或删除一个数、求该数排名、求排名给定的数、求一个数的前驱后继。

这种情况下,权值线段树可以不优雅的解决这个问题。为了找到更优雅、适应性更强的算法,人们发明了平衡树。平衡树已经渗透入大部分 \(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\) 他的笛卡儿树形态应满足如下特点:

  1. 若只看 \(i\),笛卡儿树是一棵二分查找树。

  2. 若只看 \(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\) 有一个经典操作,即可持久化平衡树,这个我写过了(说来惭愧,这个总结想写很久了,但是一直放鸽子,以至于写得比可持久化平衡树还晚了)。

它还有一个不那么经典的操作,就是让某些特殊情况下的珂朵莉树的时间复杂度变成真的。

替罪羊树

它拥有一个令人悲伤的名字。

好的,我们请山羊同志为我们解释一下这个名字的来历:

image

好的感谢山羊同志!

替罪羊树的思想的的确确相当暴力:

  1. 用二分查找树的方式加点。

  2. 看子树是否平衡。

  3. 不平衡?直接拍平重构成一颗完全二叉树!

………………我们理解了山羊同志的苦衷。

关于平不平衡,这里的判定方法用到了类似于估价函数的思想。

考虑假设左子树比右子树大,那么我们不能一直拍平,但是差太多的话就要使用 政府的力量 代码的力量进行宏观调控。

我们考虑设一个参数 \(\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;
}
posted @   长安一片月_22  阅读(3)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
点击右上角即可分享
微信分享提示