leafy_tree其实不是一个树,是一类树的统称。所有将有效信息放在叶子结点的树都能称之为 leafy_tree,如大名鼎鼎的线段树。
此处介绍的便是使用leafy_tree实现加权平衡树。与fhq对比,它码量略大,但常数效率优秀,并且支持一切fhq可支持的操作。

注:下文中树的大小/重量均指该树中叶子的个数

#define 柚子树 右子树

后文wblt、leafy_tree名字乱变,请不要介意。

基础操作

树结构及常用基本操作

实现平衡树,首先要考虑如何实现二叉搜索树。
在此,我们先写出leafy_tree实现二叉搜索树的结构:

  • 对于叶子,权值存储真正的值,且从左到右排列叶子的权值可得到升序序列。
  • 对于非叶子,一定有两个子节点,其权值为左、右子节点权值的最大值。

为什么这样定义树结构?

先回想二叉搜索树的性质:

  1. 左子结点权值不大于右子节点权值
  2. 任意节点权值不小于左子结点的权值,不大于右子节点的权值。
  3. 中序遍历后为有序序列。

再回到leafy_tree,我们发现,这样的定义刚好满足所有条件:

  1. 由于叶子有序,相邻叶子两两求最大值后依然有序,以此类推,每一层都有序,显然所有节点的左子树权值\(≤\)柚子树权值。
  2. 由于左子结点权值不大于右子节点权值,所以父节点权值\(=\)右子节点权值,\(≥\)左子结点权值。
  3. 类比中序遍历,将叶子从左到右排列,根据定义一定有序。

可以写出树结构的程序定义:

struct Node{//一个树节点
	int lc,rc;   //左右子节点编号
	int leaf,val;//权值,叶子个数(会用到,后面会讲)
	// int tag;  //标记,讲区间操作时用到
}tr[N<<1];//注意:要开两倍空间,别开少也没必要开成线段树的4倍

附上一些常用基本操作:

#define lc(u) (tr[u].lc)
#define rc(u) (tr[u].rc)
#define W(u) (tr[u].leaf)
#define val(u) (tr[u].val)
//方便代码编写
#define del(u) (pts[++cur]=(u))//回收节点以保证空间复杂度(后面会讲到)

inline int New(int v){//新建节点
	int u=cur?pts[cur--]:(++tot);//垃圾篓里有就拿出来,没有就新开一个
	tr[u]={0,0,1,v};
	return u;//返回节点的编号
}
inline void pushup(int u){//向上更新
	if(!W(lc(u)))return;//注意特判叶子
	W(u)=W(lc(u))+W(rc(u));
	val(u)=max(val(lc(u)),val(rc(u)));
}
inline int link(int x,int y){//直接连接以x,y为根的树
	int u=New(0);lc(u)=x;rc(u)=y;
	return pushup(u),u;
	//先新建节点,把新点的左右子树改为x,y,再pushup
}
inline void cut(int u,int &x,int &y){x=lc(u);y=rc(u);del(u);}
//拆开u的左右子树,并删除u
//由于cut操作太简单,所以在后面的代码中,没有将它定义成一个函数。

上面有一些操作让人摸不着头脑,各位看官暂且一放,它们的用法后面会一一道来。
现在,让我们真正地进入平衡树的实现。

平衡的定义与维护

一棵不平衡的平衡树是没有灵魂的。废话

平衡性对于平衡树的效率的影响不言而喻。一般我们认为,打眼看上去,左右两边子树大小差别不大就算平衡。但计算机不行,它需要确切的方法进行判断。
因此,我们对平衡给以严格的判定方法:

  • 对于点u,其左右子节点分别为\(x,y\),若满足 \(max(W(x),W(y))≤min(W(x),W(y))*3\) ,则称点\(u\)为平衡的。
  • 相反,若不满足,则称重量更大的子树过重。

判定的代码:

#define pd(w1,w2) ((w1)<=3*(w2)&&(w2)<=3*(w1))//传入的为重量

其实经典的判定方法并非这样,但其需要实数运算,在大数据中效率偏低。此处介绍的方法是对经典方法的一种变形,规避了实数运算的效率损失。

平衡树代码的难写除了由于二叉搜索树本身不好写,平衡性的维护也是一大原因。fhq利用随机化这种最简单的方式,使它成为最好写的平衡树,但其大部分操作依赖分裂合并,这造成了较大的常数开销。


看了好多题解、博客,大多数介绍维护平衡都是用的旋转。但正如教我fhq的银牌大神所言,学平衡树懵逼的人基本都是被旋转转晕的,难道一棵树非得旋转才能平衡吗?
由于我讨厌旋转其实是被转晕了,故此不再介绍旋转的方法。

注:此方法来自p6136第三篇题解

对于一个节点\(u\),其子节点为\(x,y\)保证\(x,y\)均平衡,假定\(u\)不平衡且\(x\)过重,则我们使用以下方法可使\(u\)平衡:

  1. \(x\)执行cut操作,其子节点存在\(p,q\)中。
  2. \(q,y\)连接,新树的根作为\(u\)的右子节点。
  3. \(p\)作为\(u\)的左子结点。
    类似下图(忘画\(u\)了):

对于柚子树过重的情况同理。

何时需要维护平衡呢?如果树结构发生了变化,就需要在回溯时自下而上地维护平衡。具体实现见下文。

平衡维护
inline void maintain(int u){
	int x=lc(u),y=rc(u);
	if(pd(W(x),W(y)))return;
	if(W(x)>=W(y)){
		int p=lc(x),q=rc(x);del(x);
		lc(u)=p,rc(u)=link(q,y);return;
	}
	int p=lc(y),q=rc(y);del(y);
	lc(u)=link(x,p),rc(u)=q;
}

插入

将数\(v\)插入到树中,并维护树的正确结构。

与其他平衡树一样,leafy_tree也是通过权值确定\(v\)所在位置的。
假设现在访问到点\(u\),如果\(u\)为叶子节点,则新建两个节点作为其左右儿子,左子结点权值\(=v\),右子节点权值\(=val(u)\)
否则,若\(v≤val(lc(u))\),则\(v\)应存在于左子树,递归左子树,反之插入右子树。注意在回溯时进行pushup和平衡维护。

插入
void insert(int u,int v){
	if(W(u)==1){
		lc(u)=New(val(u)),rc(u)=New(v);
		if(val(u)>v)lc(u)^=rc(u)^=lc(u)^=rc(u);
		return pushup(u);
	}
	if(val(lc(u))>=v)insert(lc(u),v);
	else insert(rc(u),v);
	pushup(u);maintain(u);
}

如果你愿意的话,也可以用后面介绍的合并、分裂实现插入。

删除

删除树中的一个元素\(v\)

对于现在访问到的节点\(u\),如果\(val(lc(u))≥v\),表明\(v\)在左子树,这时需要判断左子树是否为叶子,如果是,则令右子节点代替\(u\),否则递归左子树进行删除。\(v\)在柚子树同理。

注意:代替之前应对没用的节点进行回收,递归回溯时依然要pushup、维护平衡。

删除
void erase(int &u,int v){//注意引用
	if (val(lc(u))>=v){
		if(W(lc(u))==1)del(lc(u)),del(u),u=rc(u);
		else erase(lc(u),v),pushup(u),maintain(u);
        return;
	}
	if(W(rc(u))==1)del(rc(u)),del(u),u=lc(u);
	else erase(rc(u),v),pushup(u),maintain(u);
}

查第k小

如果左子树重量\(≥k\),直接跑到左子树上继续。
否则,令\(k-=\)左子树重量,跑到右子树上继续。
由于此处只需要查询,没有任何树结构的修改,不需要回溯,所以出于常数考虑,写成非递归形式。

第k小
inline int kth(int k){
	int u=rt;
	while(W(u)!=1){
		if(W(lc(u))>=k)u=lc(u);
		else k-=W(lc(u)),u=rc(u);
	}
	return val(u);
}

查排名

如果当前节点权值\(≥v\),走左子树。否则答案累加上左子树的重量,走柚子树。同样采用非递归实现。

排名
inline int Rank(int v){
	int u=rt,ans=0;
	while(W(u)!=1){
		if(val(lc(u))>=v)u=lc(u);
		else ans+=W(lc(u)),u=rc(u);
	}
	return ans+1;
}

查前驱后继

直接根据第k小和排名做就行了。

前驱后继
inline int pre(int v){return kth(Rank(v)-1);}
inline int nxt(int v){return kth(Rank(v+1));}

基础操作完结撒花!

进阶操作

合并

假设我们有两棵正确的wblt,根节点为\(x\)\(y\)。如何将它们合并为一个树呢?

我们可以发现,对于维护平衡操作,其本质就是把\(u\)拆开,再对两子树进行重新合并。所以,类比维护平衡,可以写出合并的步骤:

  1. 判断空树,\(x\)\(y\)有一个为空就返回\(x | y\)(异或、加法也可以)。
  2. 判断两棵树的大小,如果满足平衡条件,就直接连接。
  3. 对于不平衡的情况,再假设左树 \(x\) 过重:
    (1). 对\(x\)执行cut操作,其子节点存在\(p,q\)中。
    (2). 不同于维护平衡,将\(q,y\)递归合并,再将\(p\)与新树连接。
    (3). 对连接后的树进行平衡维护,返回其根节点。

时间复杂度 $ O(\log \frac {W(x)}{W(y)} ) $

正确的合并
int merge(int x,int y){//返回合并出的树的根节点
	if(!x||!y)return x^y;
	if(pd(W(x),W(y)))return link(x,y);
	if(W(x)>=W(y)){
		int p=lc(x),q=rc(x);del(x);//cut操作,未进行函数定义
		x=link(p,merge(q,y));maintain(x);
		return x;
	}
	int p=lc(y),q=rc(y);del(y);
	y=link(merge(x,p),q);maintain(y);
	return y;
}

下面给出两种不对的方式:

  1. 直接连接后维护平衡。
    hack:如图所示
    图挂了
    计算可知其不平衡。

  2. 不进行上面所说的第三步。

    错误的合并
    int merge(int x,int y){
    	if(!x||!y)return x^y;
    	if(pd(W(x),W(y)))return link(x,y);
    	if(W(x)>=W(y)){
    		int p=lc(x),q=rc(x);del(x);
    		return link(p,merge(q,y));
    	}
    	int p=lc(y),q=rc(y);del(y);
    	return link(merge(x,p),q);
    }
    

    hack:每次都把整棵树与单个节点进行合并,当数据达到30时,就不平衡了。

显然,如果我们把maintain、merge全写进代码里,会导致行数爆炸。
所以这里为追求代码简洁的读者介绍一种不复杂的独创压行方法。

对比维护平衡和合并的代码,我们发现,它们长得十分相似,方法也很接近,能否让merge不依赖maintain,而是通过递归进行平衡维护?
仍假设左边重,按代码模拟一下合并的过程,执行到maintain时,相当于令\(x=p,y=merge(q,y)\),又调用了一遍\(merge(x,y)\),但这次合并中再执行到关联\(p,merge(q,y)\)时不再继续向下递归(\(merge(p,merge(q,y))\))而是直接连接(\(link(p,merge(q,y))\)),因此不能直接递归合并。但可以由此让merge不再依赖maintain(此时已经压掉部分行数了):

摆脱maintain后的合并
int merge(int x,int y){
	if(!x||!y)return x^y;
	if(pd(W(x),W(y)))return link(x,y);
	if(W(x)>=W(y)){
		int p=lc(x),q=rc(x);del(x);
		x=p;y=merge(q,y);//模拟调用merge(p,merge(q,y))
	}
	else{
		int p=lc(y),q=rc(y);del(y);
		x=merge(x,p);y=q;
	}
	//模拟进行下一层递归:
	if(pd(W(x),W(y)))return link(x,y);
	if(W(x)>=W(y)){
		int p=lc(x),q=rc(x);del(x);
		return link(p,link(q,y));//这一层不能再调用merge(p,link(q,y))了。
	}
	int p=lc(y),q=rc(y);del(y);
	return link(link(x,p),q);
}

为了进一步压行,我们需要让merge实现“双标”——有时向下继续递归合并,有时直接连接,所以我们在merge中加一个参数:\(Merge(k,x,y),k=1\)时表示继续向下递归,\(k=2\)表示不再向下递归,加个判断即可。

压行版合并
int Merge(int k,int x,int y){
	if(!x||!y)return x^y;
	if(pd(W(x),W(y)))return link(x,y);
	if(W(x)>=W(y)){
		int p=lc(x),q=rc(x);del(x);
		if(k)return Merge(0,p,Merge(1,q,y));//本层递归,但下一层不再递归
		return link(p,Merge(1,q,y));//本层不再向下递归
	}
	int p=lc(y),q=rc(y);del(y);
	if(k)return Merge(0,Merge(1,x,p),q);//同上
	return link(Merge(1,x,p),q);
}
inline int merge(int x,int y){return Merge(1,x,y);}

这样,我们把合并操作简单到了极致。
同时也可实现利用合并的维护平衡:

拆分重新合并实现维护平衡
inline void maintain(int &u){
	if(W(u)==1)return;
	int x=lc(u),y=rc(u);
	if(pd(W(x),W(y)))return;
	del(u),u=merge(x,y);
}

分裂

按权值分裂

将以 \(u\) 为根的树权值\(≤v\) 的元素分裂进一棵树 \(x\),后面的分裂进另一棵树 \(y\)。(一般用于普通平衡树操作)

类似fhq-treap。如果当前树左子树权值\(≤v\),则左子树都可以放进 \(x\) 中,递归分裂右子树 。
然后,柚子树分出的右半部分做 \(u\) 分出的右半部分;柚子树分出的左半部分与 \(u\) 的左子树合并后,做 \(u\) 分出的左半部分。

否则,右子树都可以放进 \(y\) 中,递归分裂左子树。后面的操作没有什么差别,把上面的“左”“右(柚)”反过来就行。

需要注意,这里递归分裂完后不能像 \(fhq-treap\) 一样直接给 \(u\) 的左右子树赋值,而是用合并以维护分裂出的树的平衡,并及时回收无用节点。时间复杂度\(O(\log n)\)

按权值分裂
void split(int u,int v,int &x,int &y){
	if(W(u)==1){//叶子结点
		if(val(u)<=v)x=u,y=0;
		else x=0,y=u;
		return;
	}
	if(W(lc(u))<=v){
		split(rc(u),v,x,y);
		del(u);x=merge(lc(u),x);return;
	}
	split(lc(u),v,x,y);
	del(u);y=merge(y,rc(u));
}

按子树大小分裂

将以 \(u\) 为根的树前 \(k\) 个元素分裂进一棵树 \(x\),后面的分裂进另一棵树 \(y\)。(一般用于区间操作)

同样类似fhq-treap。如果当前树左子树大小\(≤k\),则左子树都可以放进 \(x\) 中,令 \(k-=\)左子树大小,然后递归分裂右子树。剩下的操作同按权值分裂

否则,右子树都可以放进 \(y\) 中,直接递归分裂左子树。剩下的操作同按权值分裂
注意垃圾回收。

按子树大小分裂
void split(int u,int k,int &x,int &y){
	if(!k){x=0,y=u;return;}
	if(W(u)==1){x=u,y=0;return;}
	if(W(lc(u))<=k){
		split(rc(u),k-W(lc(u)),x,y);
		del(u);x=merge(lc(u),x);return;
	}
	split(lc(u),k,x,y);
	del(u);y=merge(y,rc(u));
}

合并、分裂的常数效率依然略胜于fhq。

区间操作

注意: 维护区间操作的平衡树不再是一棵二叉搜索树

leafy_tree可以像线段树一样打标记,干所有线段树能干的操作。
但对于线段树不能干的操作,就束手无策了。。。。吗?
由于wblt支持分裂合并,所以可以类似fhq-treap把区间分裂出来,打上标记或查询答案。复杂度 \(O(\log n)\)

区间修改
inline void changly(int l,int r){
	int x,y,mid;
	split(rt,l-1,x,y);split(y,r-l+1,mid,y);//rt为全树的根
	mark(mid);//打标记
	rt=merge(x,merge(mid,y));
}

对于标记的下传,凡是访问子节点,就必须先下传标记。

对于建树,可以往后插入,也可以用类似线段树的方法实现:

建树
int build(int l, int r){//返回对应[l,r]区间的树根节点编号 
	if (l==r)return New(a[l]);
	int mid=l+r>>1,u=New(0);
	lc(u)=build(l,mid),rc(u)=build(mid+1,r);
	return pushup(u),u;
}

这样,我们就完成了平衡树的区间操作。

后记

平衡树真的太抽象了。

后记

本来不打算写的,既然这么多都写了,就写一写吧。
简述一下自己学平衡树的心路历程。
2025暑假集训时,学校请来了本校最强的NOI银牌大神给我们讲平衡树,讲的是最常用的fhq。
然而,提前预习的我,在翻阅普通平衡树的题解时,发现了leafy_tree这个神奇的数据结构。它效率高,本领强,并且结构与线段树类似,是种相当不错的平衡树。
尽管我已经学会了fhq,但一身反骨的我总喜欢搞点不同于众的东西。于是,我在暑假作业还没补完的情况下,冒着开学被制裁的风险,毅然决然地学起了leafy_tree。

注:不推荐这种做法,作业还是应该好好写。
并且不推荐假期末尾补作业的行为,自律、规划才是最好的学习方法。

我的学习,主要看两位大佬写的博客:从Leafy Tree到WBLT抛弃随机,保持理性——Leafy Tree 到 WBLT,除此之外还有OI-wiki,在此由衷地感谢两位大佬给我指明前行的方向。
然而,前行之路总不是一帆风顺的。
以我菜的没边的水平,学会平衡树这样抽象的东西,是很难的。我很快就发现,我连树的结构都不完全懂。
一下子想到要放弃,可自己总想用与别人用的东西不同的反骨、以及对wblt的情有独钟,总推着我继续向前。
于是我又拿起了leafytree,又开始对着两篇博客翻来覆去。
在成功搞懂插入删除等之后,我看到了合并。原因前文提到过,我被旋转转晕了,于是考虑用合并进行平衡的维护。
但看到合并如此抽象的代码后,我又想退缩了——我的编码能力一直不咋地,真的能写出这样抽象的代码吗?
此时,我又想起,fhq所谓的好写,也没好到哪里去,那天全机房的人,没一个是自己打出来的,全是抄的大神的标程。既然那天都抄了个痛快,现在又算啥?
于是我开始死磕合并,并考虑简化代码的办法。
最开始想在右树过重时交换左右树,未果,因一看就不对终。
后来就只好老老实实照着两篇博客写常规方法合并oiwiki码风太奇特了

终于学会了合并分裂!!!并由此写出了普通平衡树/文艺平衡树两个板子。
这时,我开始考虑如何优化合并的常数、码量,因为我最开始写的wblt码量和效率都落在fhq之后(仅文艺平衡树)。
于是开始疯狂研究简化的方法。从平衡性判断入手,我从写常规方法判断到把平衡常数调到0.25用右移实现,再到发现右移精度太低选择了非常规方法判断。
经过不懈尝试,终于我的wblt跑过了fhq,但代码还是长。

后来,我在复盘wblt学习时,意外翻到了p6136第三篇题解,看到了“假旋转”维护平衡的方法。
脑子灵光一闪,想到了简化的方法。
此时已经开学,仗着gesp8级的优势,我继续投入wblt的研究。我感觉我不是着迷、投入,而是着魔了。
终于,我改出了最短的写法(即合并部分介绍的第二个错误写法)。
高兴了好一阵子,当时感觉我能拿图灵奖了。666无敌了

又是一次复盘,发现我的方法假了。一下子五雷轰顶。
为了简化写法,只好另寻他路。最终,不负自己的努力,终于研究出了最好写的方法。而这次,应该不再会有续集。

也许你们会说,把时间精力投在没啥意义的常数、码量优化上,有啥用?
我觉得对我有用,因为我码代码太容易码错,一错就是一小时调试时间,所以一个好写的方法很重要。我的代码常数也总是大,平时注意代码习惯也很重要。
但这不是我码后记的最终目的。我想说:

  • 只要你能全身心投入一样东西,着魔般地使劲钻研,你总能钻出写东西的。

写的快比正文长了,就到这吧。

update at 2025-11-22 23:00: leafy_tree帮我写对了p4178

完结撒花~


引用致谢:

p6136第三篇题解——by BFqwq
从Leafy Tree到WBLT——by fush
抛弃随机,保持理性——Leafy Tree 到 WBLT——by StayAlone_lrc
OI-wiki

另外致谢Sinktank大佬,他的文章对我有很大的示范作用,我写这第一篇长博客就是仿照他的写法写的。

posted on 2025-09-11 22:48  Cute_lxy  阅读(17)  评论(1)    收藏  举报