浅谈几种常用二叉堆合并

前言

可合并的堆有二叉堆、二项堆、配对堆、斐波那契堆等,光是二叉堆的合并就有无脑启发式合并、左偏树、斜堆等做法。

这里主要讲的是二叉堆,想了解其它优秀的可并堆可以看看DSCN上优秀的博客。

启发式合并

使用优先队列,每次把较小的堆的点一一取出来合并到大的堆中,复杂度均摊 O ( log ⁡ 2 n ) O(\log^2 n) O(log2n)

优点:思路简单清晰,非常好实现。

缺点:主要是太慢了。其次是只允许普通的合并和弹出,搞其它奇怪的操作可能会破坏均摊复杂度。

代码很简单就不水了。

左偏树

最标准常用的做法,复杂度非常优秀。

对普通二叉堆(不平衡)的每个节点定义一个“距离”:如果右儿子为空则距离为1,否则距离为右儿子距离+1。

一棵左偏树满足以下条件(左偏性):每个节点的右儿子距离不大于左儿子距离。这样一来,一棵 n n n 个节点的左偏树的距离最大为 ⌊ log ⁡ ( n + 1 ) − 1 ⌋ \lfloor\log (n+1)-1\rfloor log(n+1)1

合并操作时,每次将优先级低的根节点与优先级高的根节点的右儿子合并,递归下去,回溯时判断是否交换左右儿子维护左偏性,以及更新距离即可。由于操作次数只与右儿子方向的深度,也就是“距离”有关,所以单次复杂度最大 O ( log ⁡ n ) O(\log n) O(logn)

核心代码——合并:

inline bool cmp(int a,int b){return a>b;}//设置比较函数
inline void update(int x){	//维护左偏性
	if(t[t[x].sn[0]].dis<t[t[x].sn[1]].dis)
		swap(t[x].sn[0],t[x].sn[1]);
	t[x].dis=t[t[x].sn[1]].dis+1;
}
inline int merg(int x,int y){
	if(!x||!y)return x^y;
	if(cmp(t[x].val,t[y].val))swap(x,y);
	t[x].sn[1]=merg(t[x].sn[1],y);
	update(x);
	return x;
}

插入节点就把它当单个的堆合并即可。

删除节点时,如果节点是根,就直接把左右子树合并即可。否则比较麻烦,需要把左右子树合并后,接在父亲节点上,然后往上更新维护左偏性。

注意,左偏树不保证平衡,所以最大深度大于 log 级,从一个点往上遍历到根会超时。但是我们可以加一个优化:从一个点往上更新不动时,直接退出。这时往上遍历的长度不超过最大的 dis,所以删点的总复杂度也是 O ( log ⁡ n ) O(\log n) O(logn)

由于每次操作都是严格 O ( log ⁡ n ) O(\log n) O(logn),所以可以进行可持久化。

斜堆

合并操作和左偏树非常相似,然而比左偏树还要简单。每次合并到右儿子后,直接无脑交换左右儿子即可。

inline bool cmp(int a,int b){return a>b;}
inline int merg(int x,int y){
	if(!x||!y)return x^y;
	if(cmp(t[x].val,t[y].val))swap(x,y);
	t[x].sn[1]=merg(t[x].sn[1],y);
	swap(t[x].sn[0],t[x].sn[1]);
	return x;
}

赞美太阳! 这时间不会被卡吗?

不会。这个合并的复杂度是均摊 O ( n log ⁡ n ) O(n\log n) O(nlogn) 的。复杂度分析可以看这里

合并的复杂度是对的,删除根节点的复杂度是对的,但是我不知道删除任意点的时候是否可以直接删。

另外,这个复杂度是均摊的,所以如果整一些奇怪操作可能会破坏均摊复杂度(比如可持久化,重复某一个历史操作),所以斜堆 应 该 是不能可持久化的。

我自己yy的一个可并堆的打法。

我们知道普通的暴力堆合并最坏是 O ( n ) O(n) O(n) 的,因为如果一直往左儿子或右儿子合并可能会出现很长的一条链。

回忆一下平衡树是怎么解决这个问题的:随机!

具体地,我们每次合并两个根时,如果在上面的根有一个儿子是空的,那么就合并到空的儿子那儿,否则随机往左儿子或右儿子方向合并。

inline bool cmp(ll a,ll b){return a>b;}
inline int mergh(int x,int y){
	if(!x||!y)return x^y;
	if(cmp(t[x].val,t[y].val))swap(x,y);
	bool o=rand()&1;
	if(!t[x].sn[o^1])o^=1;
	t[x].sn[o]=mergh(t[x].sn[o],y);
	return x;
}

随机过后,给普通堆合并带来哪些变化呢?经过测试,堆的最大深度的期望并没有达到期望的 log ⁡ n \log n logn 级别,但是合并、插入、删除操作单次的期望复杂度都是 O ( log ⁡ n ) O(\log n) O(logn) 的。

就拿合并来说,由于算法遇到儿子数<2的节点就直接合并一次返回了,所以主要都是在左右儿子不为空的节点上遍历。随机访问情况下,这种节点显然最多遍历 O ( log ⁡ n ) O(\log n) O(logn) 个。

删除任意节点的时候,只需要合并左右子树然后接到父亲上即可,不需要像左偏树那样往上维护什么左偏性。因为通过上面的分析我们知道,合并的复杂度与树的形态无关。

另外,随堆也可以实现可持久化。由于每次合并复杂度是均匀的,不会出现斜堆的那种均摊被卡的情况。而且由于不维护距离、不交换左右子树,它的可持久化 应 该 比左偏树好打一些。

附上可持久化的代码(仅多了1行):

inline bool cmp(ll a,ll b){return a>b;}
inline int mergh(int x,int y){
	if(!x||!y)return x^y;
	if(cmp(t[x].val,t[y].val))swap(x,y);
	int o=rand()&1;
	if(!t[x].sn[o^1])o^=1;
	int res=++IN;t[res]=t[x];
	t[res].sn[o]=mergh(t[x].sn[o],y);
	return res;
}

可见,除了常数大一点,随堆其实和左偏树一样万能。

update2021.10.4:

最近学了一下C++11里面的新随机数,发现同为 O ( 1 ) O(1) O(1) 随机数,新旧常数的差别却非常大。因为这个随堆合并每次都要调用随机数,非常依赖它的速度,所以应该根据情况选用不同随机方法。

如果运行不开O2,那么最好用上面的经典rand随机;

开了O2的话,用梅森旋转的随机数引擎可以快10~20倍:
(注意:不开O2的梅森旋转会比rand慢很多)

inline bool cmp(ll a,ll b){return a>b;}
mt19937 Rand(*new(int));
inline int mergh(int x,int y){
	if(!x||!y)return x^y;
	if(cmp(t[x].val,t[y].val))swap(x,y);
	int o=Rand()&1;
	if(!t[x].sn[o^1])o^=1;
	t[x].sn[o]=mergh(t[x].sn[o],y);
	return x;
}

顺便提一下:以time(0)作为随机数种子是最好的,但是在有的比赛或网站上面却是禁用的,在这里插入图片描述
CCF官方曾经也声明过禁止使用这个,后来好像没有了限制,但又没有声明可以使用。所以在比赛和做题中用time(0)是有风险的。

怎么办呢?HandInDevil给出了祂的**大法:用新声明的整形变量的地址(*new(int))作为随机的种子,同样可以使每次运行程序得到不同的结果!(好像只有Windows下管用)

C++ pb_ds库 binary heap tag

好家伙,没想到C++11已经有现成的可并堆了。学NM直接用

详细介绍可以看于纪平的《C++的pb_ds库在OI中的应用》:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

总结

其实手写的堆合并并不复杂,而且总是比封装的数据结构灵活一些。

反正我大多数情况都用的随堆。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值