leafy_tree其实不是一个树,是一类树的统称。所有将有效信息放在叶子结点的树都能称之为 leafy_tree,如大名鼎鼎的线段树。
此处介绍的便是使用leafy_tree实现加权平衡树。与fhq对比,它码量略大,但常数效率优秀,并且支持一切fhq可支持的操作。
注:下文中树的大小/重量均指该树中叶子的个数
#define 柚子树 右子树
后文wblt、leafy_tree名字乱变,请不要介意。
基础操作
树结构及常用基本操作
实现平衡树,首先要考虑如何实现二叉搜索树。
在此,我们先写出leafy_tree实现二叉搜索树的结构:
- 对于叶子,权值存储真正的值,且从左到右排列叶子的权值可得到升序序列。
- 对于非叶子,一定有两个子节点,其权值为左、右子节点权值的最大值。
为什么这样定义树结构?
先回想二叉搜索树的性质:
- 左子结点权值不大于右子节点权值
- 任意节点权值不小于左子结点的权值,不大于右子节点的权值。
- 中序遍历后为有序序列。
再回到leafy_tree,我们发现,这样的定义刚好满足所有条件:
- 由于叶子有序,相邻叶子两两求最大值后依然有序,以此类推,每一层都有序,显然所有节点的左子树权值\(≤\)柚子树权值。
- 由于左子结点权值不大于右子节点权值,所以父节点权值\(=\)右子节点权值,\(≥\)左子结点权值。
- 类比中序遍历,将叶子从左到右排列,根据定义一定有序。
可以写出树结构的程序定义:
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\)平衡:
- 对\(x\)执行cut操作,其子节点存在\(p,q\)中。
- 将\(q,y\)连接,新树的根作为\(u\)的右子节点。
- 将\(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\)拆开,再对两子树进行重新合并。所以,类比维护平衡,可以写出合并的步骤:
- 判断空树,\(x\)、\(y\)有一个为空就返回\(x | y\)(异或、加法也可以)。
- 判断两棵树的大小,如果满足平衡条件,就直接连接。
- 对于不平衡的情况,再假设左树 \(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;
}
下面给出两种不对的方式:
-
直接连接后维护平衡。
hack:如图所示
![图挂了]()
计算可知其不平衡。 -
不进行上面所说的第三步。
错误的合并
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大佬,他的文章对我有很大的示范作用,我写这第一篇长博客就是仿照他的写法写的。


浙公网安备 33010602011771号