- 「数据结构详解·一」树的初步
- 「数据结构详解·二」二叉树的初步
- 「数据结构详解·三」栈
- 「数据结构详解·四」队列
- 「数据结构详解·五」链表
- 「数据结构详解·六」哈希表
- 「数据结构详解·七」并查集的初步
- 「数据结构详解·八」带权并查集 & 扩展域并查集
- 「数据结构详解·九」图的初步
- 「数据结构详解·十」双端队列 & 单调队列的初步
- 「数据结构详解·十一」单调栈
- 「数据结构详解·十二」有向无环图 & 拓扑排序
- 「数据结构详解·十三」优先队列 & 二叉堆的初步
对二叉树的概念不了解的可以看「数据结构详解·二」二叉树的初步。
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
t≤ai+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),这留给读者自证。