【配对堆】
明儿就是2017NOIP初赛了,老师还说上午依旧进行模拟赛,下午参加初赛,然而迷迷糊糊的我此时在被窝里写起了PairingHeap的学习小结,老师对我的不满度可能又上升了(如果他知道的话)。
[产品特色]
①沛堆堆(乱取的绰号)是一颗多叉树。
②包含Priority_Queue的所有功能,可用于优化最短路。
③属于可并堆,因此对于集合合并维护最值的问题很实用。
④速度快于一般的堆结构(左偏树,斜堆,随机堆……),具体快在这里:
这里就顺带引出它的基本操作啦:
·合并(Merge): O(1)
·插入(Insert/Push): O(1)
·修改值(Change): O(1)/O(logn)
·取出维护的最值(Top): O(1)
·弹出堆顶元素(Pop): O(logn)
[功能介绍]
①可并堆的灵魂——Merge操作
这里令人惊奇的是,配对堆只需要O(1)的时间完成这一步,具体做法为比较两个需要合并的堆的根的权值大小,然后就将那优先级较低(比如你要求大的在堆顶,那么权值越大,优先级越高)置为另一个点的儿子,即fa[v]=u,再将u向v建边即可。
②经典操作之一——插入(Push/Insert)操作
就很容易了,将插入元素新建为一个单独的节点作为一个堆,与当前的堆进行Merge操作就可以了。
③经典操作之二——取最值(Top)操作
就直接用Root记录堆根,然后返回val[Root]就美妙完成任务。
④重要而具有特色的操作——修改操作(Change)
修改一个节点的的权值,那么怎么处理来继续保持配对堆的堆性质?首先将这个点和父节点的连边断掉,即fa[u]=0(由于父节点连边使用链式前向星,不方便删除,就不删除,但是这样并不会影响正确性,因为后文枚举一个点的儿子节点时,要确认某个点是它的儿子节点,不仅是要这个点能够有边指向这个儿子,同时需要这个儿子的fa[]中存储的就是这个节点)。
断掉与父亲的连边后,相当于形成两个堆,接下来进行一次Merge操作就好了。可以发现这个操作的时间复杂度是O(1),但有资料认为这个操作可能会破坏配对堆应有的结构(这"应有"的结构在下文会体现出来,它是Pop操作是O(logn)而不是O(n)的重要保证),结构改变后就会影响Pop的复杂度,使其向 O(n)退化,因此计算后认定其实修改操作从时间复杂度贡献分析来看,可能是O(logn)而不是O(1)。
⑤最缓慢但很重要的操作——弹出最值(Pop)操作
你会发现上文的操作都那么偷懒,几乎都是胡乱Merge一下,Merge函数又是随随便便连一条边就完事儿了……因此这个操作需要来收拾这个烂摊子。我们现在的任务是删除根节点,那么我们就要从它的儿子中选出合法继承人。如果直接将所有儿子挨个挨个Merge起来,那么这样很容易使得一个点有很多个儿子,从而影响后来的Pop操作时间,将O(logn)退化为O(n)。较快的做法是将子树两两合并,不断这样合并,最终形成一棵树,同理,这样之所以快是因为保证了后面pop操作时候点的儿子个数不会太多。
[要点尝鲜]
①链式前向星建边:
②Merge操作:
③Insert/Push操作:
④ChangeVal操作:
⑤Top操作:
⑥Pop操作:
[产品代码]
接下来一份简洁的代码,内容是将n个数排序。
其中的Stack是用来回收空间的。这里没有给出ChangVal函数,原因是这个函数适用于有特定位置的元素的修改,比如将数组插入堆,然后修改数组下表为i的元素权值。上文内容毫无保留地讲述了ChangVal的内容,直接打就是了。
同样的,如果要用来维护一些信息,比如Dijkstra的优化,那就在点的信息上添加记录最短路中点的编号之类的形成映射以达成快速取值的目的,其实呢和STL优先队列是一样的。
1 #include<stdio.h> 2 #define go(i,a,b) for(int i=a;i<=b;i++) 3 #define fo(i,a,x) for(int i=a[x],v=e[i].v;i;i=e[i].next,v=e[i].v) 4 const int N=10000010; 5 int n,a[N],b[N]; 6 7 struct Stack 8 { 9 int S[N],s=0,k=0; 10 int get(){return s?S[s--]:++k;} 11 void Save(int index){S[++s]=index;} 12 }Node,Edge; 13 14 struct Pairing_Heap 15 { 16 int sz=0,fa[N],head[N],k,val[N],Root; 17 int S[N],s;struct E{int v,next;}e[N]; 18 19 void ADD(int u,int v){e[k=Edge.get()]=(E){v,head[u]};head[u]=k;} 20 int Merge(int u,int v){val[u]>val[v]?u^=v^=u^=v:1;ADD(fa[v]=u,v);return u;} 21 void Push(int Val){int u=Node.get();val[u]=Val;Root=Root?Merge(Root,u):u;} 22 int Top(){return val[Root];} 23 24 void Pop() 25 { 26 s=0;fo(i,head,Root)Edge.Save(i),fa[v]==Root?fa[S[++s]=v]=0:1; 27 fa[Root]=head[Root]=0;Node.Save(Root);Root=0; 28 int p=0;while(p<s){++p;if(p==s){Root=S[p];return;} 29 int u=S[p],v=S[++p];S[++s]=Merge(u,v);} 30 } 31 }q; 32 int main() 33 { 34 scanf("%d",&n); 35 go(i,1,n)scanf("%d",a+i),q.Push(a[i]); 36 go(i,1,n)printf("%d\n",q.Top()),q.Pop();return 0; 37 }//Paul_Guderian
大米飘香的总结:
沛堆堆继承了斐波拉契堆的优秀操作复杂度,同时相比之下降低了空间复杂度和代码复杂度,这样优美高效的数据结构当然适合用在竞赛领域。如果谈到什么时候会用到沛堆堆,大米饼认为主要是两个方面——代替优先队列和代替常规的可并堆。常规可并堆如斜堆,随机堆和左偏树虽然代码更短,但是时间复杂度不够理想,再说了沛堆堆代码其实也很短的(这使得我们可以直接手写而不用冒风险去调用STL中的沛堆堆了)。最后这篇博文有一个小小的缺陷是,由于大米饼笨笨的,其实上文中ChangeVal是有局限的,其中修改值只能比原值小(如果越小优先级越高的话),因为如果修改为较大值,其操作就类似与Pop了,虽然个人认为时间复杂度由于都是O(logn)不会影响,但毕竟还没试验过,须谨慎使用。如果大米饼出错了或者出现冗余,希望来浏览的人加以指出批评。
我相信这生命总有辉煌,梦想总会在不远的地方,
朋友请陪着我走过坎坷,当我眼泪流淌就在这沉默不羁的大桥上。————汪峰《大桥上》