「数据结构详解·十三」优先队列 & 二叉堆的初步


二叉树的概念不了解的可以看「数据结构详解·二」二叉树的初步


1. 优先队列、堆、二叉堆的概念

优先队列(Priority queue),顾名思义,是具有优先级的一个集合。对于集合中的每个元素都有一个权值,这些元素的优先顺序就由这些权值决定。对一个优先队列,我们可以对其进行:

  • 查找:获得当前优先队列中优先级最高的元素;
  • 删除:删去当前优先队列中优先级最高的元素;
  • 插入:插入一个元素。

堆(Heap) 可以说是最高效的优先队列。它可以迅速找到当前优先队列中优先级最高的元素。
堆有很多实现形式,其中最常见的是 二叉堆(Binary heap)
二叉堆,即堆的形式是一棵完全二叉树。对于一个节点 i i i,其左、右儿子的优先级均小于 i i i 的优先级。
二叉堆通常有大根堆(Max heap)(即元素值越大,优先级越大)、小根堆(Min heap)(即元素值越小,优先级越小)。

2. 二叉堆的原理

我们以小根堆为例。
我们假设它现在的形态是这样的:

我们现在要插入元素 1 1 1
首先,将 1 1 1 插入到这棵树的末尾(即插入后的树仍为完全二叉树)。

然后,我们注意到 1 < 7 1<7 1<7,因此 7 7 7 不能作为 1 1 1 的父亲,所以我们交换 1 1 1 7 7 7

同理,注意到 1 < 2 1<2 1<2,我们交换 1 1 1 2 2 2

至此,插入操作完成。
这个过程被称为向上调整

那如果我们要删除一个节点呢?
我们现在要删除 5 5 5
很显然,如果直接删掉,会使得这棵树被破坏。
我们注意到删除后的堆仍然是一棵完全二叉树。
我们不妨将要删去的节点和最后一个节点交换。
于是就是这样:

于是我们便可以直接方便的删去最后一个 5 5 5

但是注意到一个显然的问题。
6 < 7 6<7 6<7 7 7 7 不能作为 6 6 6 的父亲。
有的时候可能会出现父亲的两个儿子都比它大的情况。
怎么办呢?
考虑插入操作的逆向操作,我们可以将 7 7 7 和一个较小的儿子交换,直到符合要求。

这一过程被称为向下调整

至此我们就完成了插入和删除。
二者的时间复杂度都是关于这棵树的深度。设一共 n n n 个节点,则时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)

而在程序实现的时候,很显然,使用顺序存储会方便的多。
实现不难,就留给读者自行实现了。

3. 二叉堆的 STL

C++ 中有 priority_queue 这个 STL 提供了堆操作。
定义方法是这样的:

  • priority_queue<T,vector<T>,less<T>>q;:定义一个大根堆 q q q
  • priority_queue<T,vector<T>,greater<T>>q;:定义一个小根堆 q q q

定义的后两项如果省略,即 priority_queue<T>q;,则默认为大根堆。
如果涉及结构体等特殊情况,需要重载小于号运算。
它的函数主要有:

  • q.size() q q q 的节点数;
  • q.empty() q q q 是否为空;
  • q.top() q q q 的堆顶元素;
  • q.push(x) q q q 中插入 x x x
  • q.pop() q q q 中删除堆顶元素。

4. 例题

4-1. P3378 【模板】堆

即小根堆的基本操作。上面已经解析过。

#include<bits/stdc++.h>
using namespace std;

priority_queue<int,vector<int>,greater<int>>a;

int main()
{
	int t;
	cin>>t;
	while(t--)
	{
		int opt;
		cin>>opt;
		if(opt==1)
		{
			int x;
			cin>>x;
			a.push(x);
		}
		else if(opt==2) cout<<a.top()<<endl;
		else a.pop();
	}
	return 0;
}

4-2.P1090 [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G

注意到,我们肯定希望数字越大的被合并次数越少。
于是不难想到贪心做法是:每次合并最小的两个。
于是我们直接用小根堆模拟即可。

#include<bits/stdc++.h>
using namespace std;

priority_queue<int,vector<int>,greater<int>>a;

int main()
{
    int n,ans=0;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        int m;
        cin>>m;
        a.push(m);
    }
    while(--n)
    {
        int s=a.top();
        a.pop();
        int t=a.top();
        a.pop();
        a.push(s+t);//合并
        ans+=s+t;
    }
    cout<<ans;
    return 0;
}

4-3. P1631 序列合并

容易想到 O ( N 2 log ⁡ N ) O(N^2\log N) O(N2logN) 复杂度的枚举并用小根堆处理的做法,但这显然会超时。
怎么优化呢?
首先,因为我们只要前 N N N 小的数,因此堆中的元素数不应超过 N N N
怎么让它不超过 N N N 呢?
我们考虑使用大根堆,这样每次枚举插入 a i + b j a_i+b_j ai+bj 时就只要看:

  • 如果当前元素数 < N <N <N,直接插入;
  • 如果当前元素数 = N =N =N,看堆顶的元素 t t t
    • t > a i + b j t>a_i+b_j t>ai+bj,那么 t t t 一定不是前 N N N 小的数,把它弹出堆顶,插入 a i + b j a_i+b_j ai+bj
    • t ≤ a i + b j t\le a_i+b_j tai+bj,那么 a i + b j + 1 , a i + b j + 2 , ⋯   , a i + b N a_i+b_{j+1},a_i+b_{j+2},\cdots,a_i+b_{N} ai+bj+1,ai+bj+2,,ai+bN,因为序列的单调不减,也绝对 ≥ t \ge t t,所以我们直接 break; 掉即可。
for(int i=1;i<=n;i++)
{
	for(int j=1;j<=n;j++)
	{
		if(q.size()<n) q.push(a[i]+b[j]);
		else if(q.top()>a[i]+b[j])
		{
			q.pop();
			q.push(a[i]+b[j]);
		}
		else break;
	}
}

至于时间复杂度,大致为 O ( N log ⁡ 2 N ) O(N\log^2N) O(Nlog2N),这留给读者自证。

5. 巩固练习

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值