作者:hsez_yyh
链接:左偏树(可并堆)初步及其应用_hsez_yyh的博客-优快云博客
来源:湖北省黄石二中信息竞赛组
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处
堆(heap) 相信大家都学过,其是一个非常好用的数据结构。虽然,现在大家都会直接用 STL 直接调用一个 priority_queue 来水题,很少有人会像本蒟蒻一样手写一个 堆 ,但是,现在会手写,以后面试就好装杯 。
堆,也就是优先队列,它千好万好,什么都好。机房的神犇 ljz 大佬用它切了好多水题,比如2021年提高组第一题 ,然后,他就飘了,在教练跟新生讲 堆 时直接无情嘲笑 。教练实在是看不下去了,于是问 神犇ljz :现在我有个题(题干略),大概操作就是要完成两个 堆 的合并,该怎么办呢?某蒟蒻不明所以,说道:从一个堆中一个个弹出来,再一个个放到另一个堆中去不就好了? 然后,某蒟蒻就遭到了 ljz 的无情嘲讽 。 就在大家不知所措时,本篇的主角——左偏树 出现了。
左偏树,是可并堆的一种实现方式,其主要功能是支持两个优先队列快速合并,本蒟蒻为了帮忙打压 ljz的嚣张焰气,刻苦学习了很长一段时间(半个上午+一个下午) ,终于是领悟了 左偏树 的皮毛。结果,就在我准备爆切紫题时,ljz直接用可合并线段树爆切了所有左偏树的题!!!但是,线段树代码那么长,合并操作那么复杂,非神犇而不能为,所以,总而言之,在这类题目上,还是左偏树更好(蒟蒻的胜利!) 。
·口胡了那么就,我们现在进入正题: 左偏树其实是一个比较简单的高级数据结构,其操作相对于LCT、线段树合并要简单了许多,其精华函数只有一个,但是,之所以认为它难,主要是因为它的题目都非常的难搞,经常要和并查集混合乱搞,因此常常会出现莫名其妙的 RE 。所以,左偏树说难不难,说简单不简单,全靠悟性。
左偏树是一棵二叉树,其具有堆的性质,即任意节点(子节点非空)的键值都小于(或大于)等于其左右两个子节点的键值。 键值:即左偏树维护的信息(值域)。 左偏树上的每个节点,除了包括键值信息外,还有一个很重要的信息:dist 。 某个节点 dist 的内涵就是该节点到一个离它最近的空节点(或者是外节点) 的距离,特别的,我们规定一个空节点的 dist 值为-1,一个叶子节点的 dist 值为 0 ;那么我们可以得到,某个叶子节点的父节点,如果其有左右两个子节点(非空),那么它的 dist 值应该是其son 的 dist 值 +1 ;如果其只有一个子节点,那么其 dist 值应当是 0; 我们规定,当一个左偏树中的节点,其左右儿子中只要有一个节点是空节点,那么我们就称该节点为外节点 。所以,我们可以知道,任意一个叶子节点一定是一个外节点,且任意外节点的 dist 值为 0 。
左偏性质: 左偏树,之所以叫左偏树,其肯定向左侧偏。而左偏的定义就是,对于任意节点,其左儿子的 dist 值 >= 其右儿子的 dist 值,很显然,当左右儿子有空节点时该性质依然成立。
也就是说一个节点离其左子树上的外节点的距离的最小值一定是 >= 离其右子树上的外节点的距离的最小值。 故我们可以发现,任意节点的 dist 值是由其右儿子的 dist 值 + 1 递推得到的,与其左儿子无关 。
拓展性质1 : 对于左偏树,我们称一棵左偏树的距离为其根节点的 dist 值,那么我们可以发现,如果一棵左偏树的距离为 k ,那么其 节点数应不少于个。 如果一棵左偏树的距离为定值 k ,则其节点最少的情况为一棵满二叉树(完全二叉树) 。
拓展性质2: 对于一棵左偏树,若其节点数为 n ,那么其距离应当小于或等于,此性质可以根据拓展性质1 得来,此处不给出证明(
不会太简单了)。
以上拓展1和拓展2 两个引理对于我们左偏树的代码实现作用不大,想记就记,不记也没什么关系的。
下面给出一棵左偏树:
上图中,每个节点中的数值为每个节点的键值,而每个节点下方的值为每个节点的 dist 值。
下面我们介绍左偏树唯一需要介绍的函数:merge(合并操作)
合并操作有三个要点,其和 fhq-treap的合并其实并没有什么太大区别:
1). 对于两棵要合并的左偏树的根节点 xx ,yy ,我们会先比较 xx 和 yy 的键值 key 的大小,并令 key值 较小的节点来做新左偏树的根,并令其左子树为新左偏树的根节点的左子树,然后继续递归合并 。
2). 聪明的你一定会有一个这样的疑问:如果我们合并后新左偏树的右子树的距离改变了,要是万一它的 dist 值比新左偏树根节点的左子树的 dist 的值大了该怎么办? 其实,答案很简单——没办法。确实,在两棵左偏树合并时会经常出现这种情况,但是,我们观察左偏树的性质:某个节点的左右两个子节点的 key值一定会大于或等于 该节点的 key值,然而,我们并没有规定两个子节点 key值的大小关系,并且,根据堆的定义,左偏树中任意一棵子树都是一棵左偏树,都满足堆的性质。基于此,我们可以发现,左右两个子节点唯一的关系就是 dist的值,所以,当我们发现合并后的新的左偏树的根节点的 右子树的dist 的值>左子树的 dist 的值的化,我们就swap(左子树,右子树)就好了(就这),当然 ,这项操作要随着递归的进行一直进行,因为每一层的子树的合并都有可能会出现这种情况 。
3). 就一句话:当合并遇到空节点时,停止递归,开始回溯(也就是规定出口啦,学过FHQ的都知道)。
简单吧,下面是 merge 操作的代码:
int merge(int l,int r)//非常简单,我都懒得打注释了
{
if(!l||!r) return l|r;
if(tr[l].mn>tr[r].mn||(tr[l].mn==tr[r].mn&&l>r))// mn就是key值
swap(l,r);
tr[l].r=merge(tr[l].r,r);
f[tr[l].r]=l;
if(tr[tr[l].r].dis>tr[tr[l].l].dis)
swap(tr[l].l,tr[l].r);
tr[l].dis=tr[tr[l].r].dis+1;
return l;
}
好久没学过这么水的数据结构了,怀念STL时代。
好吧,既然基本操作都讲完了,那我们就看到例题:【模板】左偏树(可并堆) - 洛谷
希望你认真看过并想过例题,你可能会发现,虽然可以轻易看出这是一个左偏树的板子题,但是,想要真正的完成题目的条件还是相当复杂的。
这里对于左偏树的建树简单说两句:对于题目开始给出的 n 个数,可以直接用 手写 堆时的方法,O(nlogn)建树。 这里提供更快的一种方法(大家都这么写,但是也没快多少):把每个点都看成一棵只包含一个节点的左偏树,然后,我们将它们全都放进一个队列(queue)中去,然后每次从队头弹出两个左偏树进行合并(merge一下),再把它放进队尾,直到队列中只剩下一个数为止。把最后一个数弹出,得到的就是一棵 n 个节点的左偏树(的根节点)。
题目中说要我们第x 个数和第 y个数所在的小根堆合并,怎么判断第x个数和第y个数所在的小根堆(左偏树)的根节点是谁呢,好吧,这就要请出我们的老朋友——并查集了。但是这道题的牛马之处在于,我们是要删掉根的,这样的化,我们就不能以根作为并查集的根了,所以我们每次删根时,就要把原根屏蔽掉,也就是将其也指向新合并之后的左偏树的根,然后更新得到的新根即可。
对于删掉某个根,我们只需要把根的左右儿子全指成空节点,然后dist的值改掉再把 key值设成无限大就可以了,下面提供AC代码:
//由于码风习惯,本蒟蒻习惯将空节点的 dist值设成0,外节点的 dist值设成1
// 之所以这么写是因为本蒟蒻之前经常数组越界,RE怕了,所以能不写负数就不写负数
//这样写本质上是无影响的,就相当于把所有节点的 dist + 1,不过还是要尊重原定义
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10,INF=0x3f3f3f3f;
int n,m;
struct node
{
int l,r;
int mn,dis;
}tr[N];
int f[N],idx;
int merge(int l,int r)
{
if(!l||!r) return l|r;
if(tr[l].mn>tr[r].mn||(tr[l].mn==tr[r].mn&&l>r))
swap(l,r);
tr[l].r=merge(tr[l].r,r);
f[tr[l].r]=l;
if(tr[tr[l].r].dis>tr[tr[l].l].dis)
swap(tr[l].l,tr[l].r);
tr[l].dis=tr[tr[l].r].dis+1;
return l;
}
int find(int xx)
{
if(f[xx]==xx) return xx;
else return f[xx]=find(f[xx]);//路径压缩
}
int insert(int val,int pos)
{
tr[++idx].mn=val;
tr[idx].dis=1;
return merge(idx,pos);
}
int delet(int pos)// 删根
{
int l=tr[pos].l,r=tr[pos].r;
tr[pos].l=tr[pos].r=0;
tr[pos].dis=1;
tr[pos].mn=INF;
f[l]=l,f[r]=r;
return f[pos]=merge(l,r);//这里很重要,必须要更新f[pos],以免指错(本人在此wa很久)
}
int build()
{
deque<int> q;
for(int i=1;i<=n;i++)
q.push_back(i);
while(!q.empty())
{
int a=q.front();
q.pop_front();
if(q.empty())
{
q.push_back(a);
break;
}
int b=q.front();
q.pop_front();
q.push_back(merge(a,b));
}
int root=q.front();
q.pop_front();
return root;
}
int main()
{
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++)
{
f[i]=i;
tr[i].dis=1;
}
for(int i=1;i<=n;i++)
scanf("%d",&tr[i].mn);
int op,xx,yy;
while(m--)
{
scanf("%d",&op);
if(op==1)
{
scanf("%d %d",&xx,&yy);
int a=find(xx);
int b=find(yy);
if(tr[xx].mn==INF||tr[yy].mn==INF||a==b) continue ;
merge(a,b);
}
else
{
scanf("%d",&xx);
if(tr[xx].mn==INF)
{
printf("-1\n");
continue ;
}
int a=find(xx);
printf("%d\n",tr[a].mn);
delet(a);
}
}
return 0;
}
其实代码也很简单,本蒟蒻也不详细讲了,毕竟代码很容易懂得。
左偏树性质拓展:其实,本质上左偏树是不支持删除任意数的,因为我们的左偏树是一个堆的结构,而一个二叉堆所能提供的不过是一个最小(大)值,对于在一棵左偏树中删除一个大小为 xx的数的话,我们要找到 xx 的位置,就只能遍历整棵 左偏树 ,这样的复杂度很显然我们是无论如何都接受不了的,虽然这并不代表左偏树不能删除其树中任意数值的数,只是这样做的复杂度实在是太高了(是一个OIer不能接受的),如果题目非要我们做这样的操作的话,我们就只能考虑在外部写一棵平衡树之类的结构来维护值域与左偏树中的节点下标的对应关系。但是,这种两个大数据结构的题,应该不会有哪个 毒瘤 出吧,而且能碰到这种题的人一定是高手或大佬了,所以我这种不想思考问题的蒟蒻就不瞎掺和了(逃)。
但是!但是!!!左偏树之所以不能删除掉树中一个任意数值的数,那是因为在查找这个数所对应节点的下标时花费的时间代价太大了。不过,如果省去这个查找下标的过程——即删除左偏树中 k 号元素(节点)时,我们就不会因为时间复杂度而发愁了。如果我们知道了要删除节点的下标,那么我们就可以分情况来讨论:
1). 当要删除的节点是根,那么和之前一样,直接merge左右儿子即可。
2). 如果我们的删除操作推广到一般意义下的话,我们就还是可以直接merge该节点的左右儿子,但值得注意的是,我们这时删掉了一个非根节点的话,其可能会影响到其祖先节点的 dist的值,所以,我们这时还要向上进行一个push_up()操作,将删除后的 dist的值更新,同时维护左偏的性质(即swap)。 我们现在进行复杂度分析:可以发现,对任意节点的删除操作包含两个部分 1、向下递归合并部分。 2、向上更新部分。 可以发现,这两个操作都是以删除点为起点向上到根,向下到一个外节点结束。那么,对于此,我们发现左偏树的合并和更新操作所依赖的是该左偏树的高度,由于左偏树是二叉树,虽然不严格,但是其高度也是近似于logn的,所以对于左偏树的合并和更新操作,我们的复杂度在期望上是logn的(即平均logn,而且几乎卡不住)。然而由于左偏树树形的不确定性,实际上的复杂度是比logn要小上不少的。所以,左偏树的操作,可以放心大胆的过百万级数据。
那么我们再来看一道提高题目:[SCOI2011]棘手的操作 - 洛谷
很好的一道线段树合并左偏树的好题,该题我们要维护大根堆,就稍微改一下合并操作即可,由于此题要进行修改某个点的key值的操作,我们便不妨打上一个懒标记,只有查找时才去更新 key值(惰性删除法),当然,这道题还要对于并查集进行一个版本的维护,有点可持久化并查集的感觉,但是又不是,总之奇奇怪怪的,本蒟蒻瞎搞一通,莫名其妙就A了,下面给出代码,还是代码讲的清楚:
#include<bits/stdc++.h>
using namespace std;
const int N=3e5+10;
struct node
{
int l,r;
int mn,dis;
int par;
//左偏树的节点
// mn是键值,dis是dist距离 par是当前节点对应的并查集上的编号
}tr[2*N];
struct ufs
{
int val;//该点此版本下的key值
int ver,id;//ver是版本编号,id是在左偏树中对应的节点
bool operator<(const ufs &aa)const
{
return val<aa.val;
}
//某个版本的左偏树在并查集中的代表性节点——代表某次状态
//
//
}temp;
int n,q;
int idx,ver1[N],rt[N],chg[N],top[N],si[N],ver2[N];
//ver1存并查集的版本 ver2存并查集上某个点对应的左偏树上的节点的版本
//top存并查集中某个节点对应的左偏树中的节点所在的左偏树的根
//si 并查集大小 chg 懒标记
// rt 标准并查集————某个节点指向某个节点
priority_queue<ufs> qw;
//维护全局最大值
int merge(int l,int r)
{
if(!l||!r) return l|r;
if(tr[l].mn<tr[r].mn||(tr[l].mn==tr[r].mn&&l>r))
swap(l,r);
tr[l].r=merge(tr[l].r,r);
if(tr[tr[l].r].dis>tr[tr[l].l].dis)
swap(tr[l].l,tr[l].r);
tr[l].dis=tr[tr[l].r].dis+1;
return l;
}
int find(int xx)
{
if(!(xx^rt[xx])) return rt[xx];
else return rt[xx]=find(rt[xx]);
}
void push_down(int pos,int v)
{
if(!pos) return ;
tr[pos].mn+=v;
push_down(tr[pos].l,v);
push_down(tr[pos].r,v);
}
bool check(int pos)
{
return ver2[tr[pos].par]!=pos;
}
int main()
{
tr[0].dis=0;
scanf("%d",&n);
idx=n;
for(int i=1;i<=n;i++)
{
scanf("%d",&tr[i].mn);
tr[i].par=i;
ver1[i]=si[i]=1;
rt[i]=top[i]=ver2[i]=i;
temp.id=i;
temp.val=tr[i].mn;
temp.ver=1;
qw.push(temp);
}
scanf("%d",&q);
char op[5];
int xx,yy;
//实现操作,一定要多看几遍,弄清楚映射关系
while(q--)
{
scanf("%s",op);
if(op[0]=='U')
{
scanf("%d %d",&xx,&yy);
xx=find(xx),yy=find(yy);
if(xx==yy) continue ;
if(si[xx]<si[yy]) swap(xx,yy);
push_down(top[yy],chg[yy]-chg[xx]);
top[xx]=merge(top[xx],top[yy]);
rt[yy]=xx;
si[xx]+=si[yy];
ver1[xx]++;
ver1[yy]=0;
temp.id=xx,temp.val=tr[top[xx]].mn+chg[xx],temp.ver=ver1[xx];
qw.push(temp);
}
else if(op[0]=='A'&&op[1]=='1')
{
scanf("%d %d",&xx,&yy);
idx++;
tr[idx].par=xx;
tr[idx].mn=tr[ver2[xx]].mn+yy;
ver2[xx]=idx;
xx=find(xx);
top[xx]=merge(top[xx],idx);
while(check(top[xx]))
top[xx]=merge(tr[top[xx]].l,tr[top[xx]].r);
ver1[xx]++;
temp.id=xx,temp.ver=ver1[xx],temp.val=tr[top[xx]].mn+chg[xx];
qw.push(temp);
}
else if(op[0]=='A'&&op[1]=='2')
{
scanf("%d %d",&xx,&yy);
xx=find(xx);
chg[xx]+=yy;
ver1[xx]++;
temp.id=xx,temp.ver=ver1[xx],temp.val=tr[top[xx]].mn+chg[xx];
qw.push(temp);
}
else if(op[0]=='A'&&op[1]=='3')
{
scanf("%d",&xx);
chg[0]+=xx;
}
else if(op[0]=='F'&&op[1]=='1')
{
scanf("%d",&xx);
yy=find(xx);
printf("%d\n",tr[ver2[xx]].mn+chg[yy]+chg[0]);
}
else if(op[0]=='F'&&op[1]=='2')
{
scanf("%d",&xx);
xx=find(xx);
printf("%d\n",tr[top[xx]].mn+chg[xx]+chg[0]);
}
else
{
while(!qw.empty())
{
temp=qw.top();
//printf("%d %d\n",temp.ver,ver1[temp.id]);
if(temp.ver!=ver1[temp.id]) qw.pop();
else break ;
}
/*while(!qw.empty())
{
temp=qw.top();
qw.pop();
printf("1111: %d\n",temp.val);
}*/
//printf("%d\n",chg[0]);
printf("%d\n",temp.val+chg[0]);
}
}
return 0;
}
如果你搞透了这道题,那么你的左偏树的基本操作算是过关了。但是还是要锻炼左偏树的思维,所以留下一道思考题作礼物:[BalticOI 2004]Sequence 数字序列 - 洛谷 加油,我相信你可以的。
虽然左偏树不如线段树、平衡树那样流行,但是他也有自己独特的美。在某些题中,我们或许能放弃线段树的复杂写法而采取左偏树的技巧,而左偏树的常数小,代码量少,复杂程度低,是我们简化复杂数据结构的一个好的途径,希望大家也能领悟数据结构的美感,不仅爱上左偏树,还要热爱每一种数据结构!!!