前言
堆有个常见的应用就是堆排序(HeapSort),将数据排序是次要的,这里主要是理解堆这一数据结构以及它的几个相关操作。
注*: 堆还有其他应用如各语言的STL或API中优先队列(priority_queue),都是用的大根堆或小根堆实现的。
首先,堆是一个完全二叉树,用来表示一个完整的数据集合,每个结点就是一个数据,为了降低操作的复杂度,利用数组heap[]
来存取堆,其根结点为heap[1]
。
对于heap[n]
,它的左结点为heap[2 * n]
,右结点为heap[2 * n + 1]
。
一般都是对堆的根结点感兴趣,由此引申两个概念——大根堆和小根堆。
大根堆是指对于堆中任意一个结点,它的父结点的值一定大于该结点以及该结点的兄弟结点,因此大根堆的根结点一定是整个集合中值最大的,小根堆的定义反之亦然。
为了构建和维护堆,有以下两个核心操作:
up(x)
表示向上调整结点x
以保证堆符合性质,down(x)
同理表示向下调整结点x
以保证堆符合性质。
其中,这两个操作的复杂度与堆的高度是成正比的,而由完全二叉树的性质得知n节点的树的高度为logn,因此复杂度都是 O(logn) 的,这也是为什么堆会比一些 O(n) 算法来维护集合争宠的原因。
比如下面的堆中,结点16
向上调整前为:
调整后:
以小根堆为例,一般的操作为如下5个:
值得注意的是,“删除xx”这一操作其实本质上是 “值的覆盖”,因为在数组中搬动大量的元素会非常麻烦,所以一般都是将堆的尾部元素heap[size]
覆盖掉要抹去的结点,然后再①向上或下调整、②size --
,整个集合大小减1。这样就变相删除了
堆排序
输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。
输入格式
第一行包含整数 n 和 m。
第二行包含 n 个整数,表示整数数列。
输出格式
共一行,包含 m 个整数,表示整数数列中前 m 小的数。
数据范围
1≤m≤n≤105,
1≤数列中元素≤109
输入样例:
5 3
4 5 1 3 2
输出样例:
1 2 3
建堆(着重理解)
一开始读入数据时只会for ~ i
然后顺序存入heap[]
里,此时不构成堆,所以需要再调整元素一遍使得变成一个堆。
一种思路是利用down
操作,要知道down
操作是向下调整子根堆,如果从最后一个结点heap[size]
开始一直往前down()
操作,直到根heap[i]
为止,这样整个堆就建起来了。
分析复杂度可知,n
个结点,每个结点down
一遍,复杂度就是 O(n * logn),较高!
但仔细一想,似乎叶子结点没有down
的必要啊,因为它没有子结点了,而它们的父结点down
时必定会将其考虑至其中。
因为完全二叉树的性质:总结点为n —> 叶子结点的个数约为n / 2
个,所以从第一个叶子结点的父结点开始,那么对应代码如下:
for (i = n / 2; i >= 1; i--) down(i);
再来分析复杂度会怎样呢,这里有个推导公式:
可以惊奇地发现,复杂度降了近一个数量级!
C++代码
#include <iostream>
#include <algorithm>
using namespace std;
#define size Size
const int N = 1e5 + 10;
/**
* 小根堆
*/
int n, m, size;
int heap[N];
void down(int x){ //递归向下调整子结点
int t = x; //t:存三个中最小的结点
if(2 * x <= size && heap[t] > heap[2 * x]) t = 2 * x; //比较左结点
if(2 * x + 1<= size && heap[t] > heap[2 * x + 1]) t = 2 * x + 1; //比较右结点
//如果t == x那么这个子堆往下的所有子堆都调整好,退出
if(t != x){
swap(heap[x], heap[t]);
down(t);
}
}
int main(){
cin >> n >> m;
size = n;
for(int i = 1;i <= n;i ++) cin >> heap[i];
for(int i = n / 2; i ;i --) down(i); //建堆
while(m --){
cout << heap[1] << " ";
heap[1] = heap[size --]; //删除根
down(1); //维护堆
}
return 0;
}
模拟堆
维护一个集合,初始时集合为空,支持如下几种操作:
I x
,插入一个数 x;
PM
,输出当前集合中的最小值;
DM
,删除当前集合中的最小值(数据保证此时的最小值唯一);
D k
,删除第 k 个插入的数;
C k x
,修改第 k 个插入的数,将其变为 x;
现在要进行 N 次操作,对于所有第 2 个操作,输出当前集合的最小值。
输入格式
第一行包含整数 N。
接下来 N 行,每行包含一个操作指令,操作指令为 I x,PM,DM,D k 或 C k x 中的一种。
输出格式
对于每个输出指令 PM,输出一个结果,表示当前集合中的最小值。
每个结果占一行。
数据范围
1≤N≤105
−109≤x≤109
数据保证合法。
输入样例:
8
I -10
PM
I -10
D 1
C 2 8
I 6
PM
DM
输出样例:
-10
6
映射和反映射
由于此时出现了一个新概念 “堆中插入的第k个数”,而heap[x]
仅仅能映射出x
的结点值,而如何通过x
找到它是第几个被插入的点以及又如何通过次序k
找到第k
个插入点的结点下标呢?
所以就引入了两个映射数组:
正映射:
ph
,第k个数中的k映射—>结点下标i,即ph[k] = i
。
反映射:hp
,结点下标i —> k,即hp[i] = k
。
由于ph
和hp
是实时记录的,所以在结点交换时不再是单纯的swap(heap[a], heap[b])
了。
而是要使对应的映射跟着结点下标a、b
变动。
所以新的swap
操作:
void heap_swap(int a, int b){
//a, b是两个结点的下标
swap(heap[a], heap[b]);
swap(hp[a], hp[b]);
swap(ph[hp[a]], ph[hp[b]]);
}
有关于映射—反映射和其它几样操作的详细说明还可以参照这篇Acwing的文章:
👉如何理解模拟堆中的heap_swap,hp[N], ph[N]?
C++代码
#include <iostream>
#include <algorithm>
#include<string.h>
#define size Size
using namespace std;
const int N = 1e5 + 10;
char op[3];
int n, x, k, cnt = 0, size = 0, heap[N];
int ph[N], hp[N]; //ph:正映射,第k个数中的k映射—>结点下标i; hp:反映射,结点下标i —> k
void heap_swap(int a, int b){ //由于引入两个映射,结点交换时会跟着变,所以专门封装一系列的swap操作
//a, b是两个结点的下标
swap(heap[a], heap[b]);
swap(hp[a], hp[b]);
swap(ph[hp[a]], ph[hp[b]]);
}
void down(int x){ //思路一样直接copy堆排序
int t = x;
if(2 * x <= size && heap[t] > heap[2 * x]) t = 2 * x;
if(2 * x + 1<= size && heap[t] > heap[2 * x + 1]) t = 2 * x + 1;
if(t != x){
heap_swap(t, x);
down(t);
}
}
void up(int x){
while(x / 2 && heap[x / 2] > heap[x]){ //父结点存在且大于该结点就交换
heap_swap(x / 2, x);
x >>= 1;
}
}
int main(){
cin >> n;
int i;
while(n --){
scanf("%s", op);
if(!strcmp(op, "I")){ //尾部插入
cin >> x; //此时cnt也即是堆的 “size”
size ++; //结点下标
cnt ++; //第k个数
ph[cnt] = size, hp[size] = cnt;
heap[ph[cnt]] = x;
up(ph[cnt]);
}
else if(!strcmp(op, "PM")) cout << heap[1] << endl;
else if(!strcmp(op, "DM")){ //用尾部交换成根结点后size-- 代以删除
heap_swap(1, size);
size --;
down(1);
}
else if(!strcmp(op, "D")){
cin >> k;
i = ph[k]; //k对应的结点下标
heap_swap(i, size);
size --;
up(i), down(i); //up和down只会执行其中一个
}
else{
cin >> k >> x;
i = ph[k];
heap[i] = x;
up(i), down(i);
}
}
return 0;
}