洛谷 P3377 【模板】左偏树(可并堆)
题目链接
【题目描述】
如题,一开始有N个小根堆,每个堆包含且仅包含一个数。接下来需要支持两种操作:
操作1: 1 x y 将第x个数和第y个数所在的小根堆合并(若第x或第y个数已经被删除或第x和第y个数在用一个堆内,则无视此操作)
操作2: 2 x 输出第x个数所在的堆最小数,并将其删除(若第x个数已经被删除,则输出-1并无视删除操作)
【输入格式】
第一行包含两个正整数N、M,分别表示一开始小根堆的个数和接下来操作的个数。
第二行包含N个正整数,其中第i个正整数表示第i个小根堆初始时包含且仅包含的数。
接下来M行每行2个或3个正整数,表示一条操作,格式如下:
操作1 : 1 x y
操作2 : 2 x
【输出格式】
输出包含若干行整数,分别依次对应每一个操作2所得的结果。
【样例输入】
5 5
1 5 4 2 3
1 1 5
1 2 5
2 2
1 4 2
2 2
【样例输出】
1
2
(当堆里有多个最小值时,优先删除原序列的靠前的,否则会影响后续操作1导致WA)
【样例解释】
初始状态下,五个小根堆分别为:{1}、{5}、{4}、{2}、{3}。
第一次操作,将第1个数所在的小根堆与第5个数所在的小根堆合并,故变为四个小根堆:{1,3}、{5}、{4}、{2}。
第二次操作,将第2个数所在的小根堆与第5个数所在的小根堆合并,故变为三个小根堆:{1,3,5}、{4}、{2}。
第三次操作,将第2个数所在的小根堆的最小值输出并删除,故输出1,第一个数被删除,三个小根堆为:{3,5}、{4}、{2}。
第四次操作,将第4个数所在的小根堆与第2个数所在的小根堆合并,故变为两个小根堆:{2,3,5}、{4}。
第五次操作,将第2个数所在的小根堆的最小值输出并删除,故输出2,第四个数被删除,两个小根堆为:{3,5}、{4}。
故输出依次为1、2。
【数据范围】
对于30%的数据:N<=10,M<=10
对于70%的数据:N<=1000,M<=1000
对于100%的数据:N<=100000,M<=100000
解题思路
我们先很容易想到合并有序表的方法,每次合并的时间复杂度为O(n),总的时间复杂度为O(nm),虽然肯定通过优化(比如已经在一个集合中的就不需要在合并了)可以降低时间复杂度,但是还是很慢,对于恶心数据肯定还是过不了。
既然我们需要得到的是每次合并之后的最小值,并且数据最开始就是堆,那我们能不能通过合并堆的方式来解决呢?如果我们每次暴力合并两个堆,即把一个堆中的所有元素一个一个插入另一个堆中,这样肯定能够维护堆的性质,但是细想一下,每插入一个元素的时间复杂度为logn,即每次2操作总的时间复杂度为nlogn,m次操作的最坏时间复杂度为m*nlogn,还不如最开始的暴力呢。
如果我们能找到一种快速合并堆并且保持堆的某些性质就好了,至于为什么是某些性质呢?因为我们只需要合并之后的最小的元素就可以了,至于是不是严格的堆不重要。比如下图的合并
如果是合并成一个严格的堆,形态是比较固定的,但是如果只是合并成一个满足父亲结点<子节点的二叉树呢(保证最小值在顶部),那么形态就不唯一了,并且完全可以很快的合并,比如:
能看懂什么意思吗?每次到第一棵子树的左子树中去找到第一个比右子树根结点大的结点,然后交换这两颗子树(数字小的点就是根结点),直到找到某一棵子树为空为止。这样的合并时间复杂度肯定低于O(n),最多为两棵树的最长链之和,但是也有人会问,这样合并之后就不再是堆了,那么如果退化成近似一条链了该怎么办呢?那不是效率也很低了。
首先,我们要知道,堆为什么很快?因为他是一棵完全二叉树,他的深度是相同结点的二叉树中深度最小的,每次向下查找肯定很快。前面我们也说了,复杂度最坏为两棵树的最长链之和,那我们不选择最长链不就好了,我们选择最短链,那效率不就很高了,关键是怎么才能保证我们选择的链一定是最短的呢?让树向一边偏,即让每个子树的其中一边的链>=另一边的链,然后我们每次合并往短的一条链上合并,这就是左偏树的原理,至于为什么叫左偏树,不叫右偏树呢?emmmmmmm,我又不是发明者,我怎么知道。接下来就是详细介绍左偏树的时候了。
为了方便,我们先定义两个新概念:
外结点:如果一个结点的左右子树至少有一个为空,那么这个结点就是外结点
点的距离:该结点到他的子树中最近的一个外结点的距离
左偏树的性质:
(1)左偏树中,任何一个结点的左右儿子的权值>=该结点的权值(保持堆的性质,当然根据需要可以反过来)
(2)左偏树中,任何一个结点左儿子的距离<=右儿子的距离(左偏性质)
(3)如果我们定义空节点为-1,那么左偏树中任意一个结点的距离=右儿子的距离+1
(4)n个结点的左偏树,最大点距离为log(n+1)-1;这就保证了左偏树的合并的时间复杂度为logn
(5)左偏树并不一定是完全二叉树。
接下来就是左偏树的操作了,左偏树最重要的操作就是——合并(merge)
(1)比较两棵树(a,b)的根结点的大小,如果val[a]>val[b],就交换两棵子树的根,保证b树向a数合并
(2)因为是左偏树,所以我们合并的时候把一棵树向另一棵树的右子树合并
(3)合并之后,如果不满足左偏性质,交换两棵子树
(4)递归执行上述操作,直到一棵树为空,表示合并结束。
如图:
合并结束后,我们会发现什么问题呢?他此时并不是一棵左偏树,也就是说如果我们不能保证他是一棵左偏树,那么在后面的合并中我们就不能保证合并的效率,应该怎么做才能更快的让这棵树变成一棵左偏树呢?
前面都是讲解和演示,现在上代码(带注释) 洛谷 P3377
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=100100;
int n,m;
struct node
{
int l,r,f,val,dis;
}h[maxn];
int getf(int x)//找根,注意不能路径压缩,要保持原来的二叉堆的形态而不是变成一颗仙人球
{
while(h[x].f) x=h[x].f;
return x;
}
void swap(int &x,int &y)
{
int tmp=x;x=y;y=tmp;
}
int merge(int x,int y)//返回值为根结点
{
if(x==0||y==0)
return x+y;//如果一棵子树为空,返回另一棵子树
if(h[x].val>h[y].val||(h[x].val==h[y].val&&x>y))//后一个是按照题目要求
swap(x,y);//保证x是合并之后的树的根
h[x].r=merge(h[x].r,y);//既然是左偏树,那么肯定合并到右子树更快
h[h[x].r].f=x;//不要忘记更新父亲节点
if(h[h[x].l].dis<h[h[x].r].dis)
swap(h[x].l,h[x].r);//保证左偏树的形态不改变
h[x].dis=h[h[x].r].dis+1;//为什么可以直接+1,前面h[0].dist=-1很重要
return x;//返回根结点
}
void del(int x)//删除函数
{
int l=h[x].l;
int r=h[x].r;
h[x].val=h[l].f=h[r].f=0;//因为都是正整数,所以这里赋初值为0。
//删除之后x的左右儿子就是两颗独立的子树的根结点了,是没有父亲的
merge(l,r);//合并两颗子树
}
int main()
{
int t,x,y;
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d",&h[i].val);
h[0].dis=-1;//合并时更新父亲结点距离时有很大作用
for(int i=1;i<=m;i++)
{
scanf("%d",&t);
if(t==1)
{
scanf("%d%d",&x,&y);
if(h[x].val&&h[y].val)//如果已经被删除
{
int fx=getf(x);
int fy=getf(y);//类似于并查集,合并堆的根结点
if(fx==fy) continue;
merge(fx,fy);
}
}
else if(t==2)
{
scanf("%d",&x);
if(!h[x].val)//如果已经被删除
printf("-1\n");
else
{
int fx=getf(x);//最小的结点就是根结点
printf("%d\n",h[fx].val);
del(fx);
}
}
}
return 0;
}