堆就是一种特殊的完全二叉树。
向上、向下调整:
有没有发现这棵二叉树有什么特点?就是所有的父结点都比子结点要小(注意:圆圈里面的数是值,圆圈上面的数是这个结点的编号)。符合这样特点的完全二叉树我们称为最小堆。反之,如果所有的父结点都比子结点要大,这样的完全二叉树称为最大堆。
假如有14个数,分别是99、5、36、7、22、17、46、12、2、19、25、28、1和92 。现在我们需要删去其中的最小数并且增加一个23,再求这14个数中的最小数。
首先我们把这14个数按照最小堆的要求(就是所有的父结点都比子结点要小)放入一棵完全二叉树,就像下面这棵树一样。
我们将这个完全二叉树存入一个一维数组中。
很显然最小的数就在堆顶,假设储存这个堆的数组叫h,最小的数就是h[1] 。接下来我们删除堆顶部的数,再将新增加的23放到堆顶。再向下调整,与它的两个儿子2和5比较,选择较小的一个与它交换。一直向下调整直到符合最小堆的特性为止。
综上所述,当新增加一个数被放到堆顶时,如果此时不符合最小堆的特性,则需要将这个数向下调整,直到找到合适的位置,使其重新符合最小堆的特性。
向下调整的代码如下:
void siftdown(int i){//传入一个需要向下调整的结点编号i,这里传入1,即从堆顶开始向下调整
int t, flag = 0;//flag用来标记是否需要继续向下调整
//当i结点有儿子(其实是至少有左儿子的情况下)并且有需要继续调整的时候,循环就执行
while(i * 2 <= n && flag == 0){
//首先判断它和左儿子的关系,并用t记录较小的结点编号
if(h[i] > h[i * 2]) t = i * 2;
else t = i;
//如果它有右儿子,再对右儿子进行讨论
if(i * 2 + 1 <= n){
//如果右儿子的值更小,更新较小的结点编号
if(h[t] > h[i * 2 + 1])
t = i * 2 + 1;
}
//如果发现最小的结点编号不是自己,说明子结点中有比父结点更小的
if(t != i){
swap(t, i);//交换它们
i = t;//更新i为刚才与它交换的儿子的编号,便于接下来继续向下调整
}
else flag = 1;//否则说明当前的父结点已经比两个子结点都要小了,不需要再进行调整了
}
return ;
}
如果只是想新增一个值,而不是删除最小值,又该如何操作呢(即如何在原有的堆上直接插入一个新元素呢?)?只需要将新元素插入到末尾,再根据情况判断新元素是否需要上移,直到满足堆的特性为止。例如现在我们要新增一个数3.
向上调整代码:
//向上调整代码:
void siftup(int i){//传入需要向上调整的结点编号
int flag = 0;//用来标记是否需要向上调整
if(i == 1) return ;//如果是堆顶就返回,不用继续向上调整了。
//不在堆顶,并且当前结点i的值比父结点的值小的时候就继续向上调整
while(i != 1 && flag == 0){
//判断是否比父结点小
if(h[i] < h[i / 2])
swap(i, i / 2);//如果比父结点小那么交换它和它爸爸的位置
else flag = 1;//表示已经不需要调整,当前结点的值比它父结点的值要大
i = i / 2;//这句话很重要,更新i的值为它父结点的值便于继续向上调整
}
return ;
}
如何建立堆:
法一:
可以从空堆开始,然后依次往堆中插入每一个元素,直到所有的数都被插入(转移到堆中)为止。代码如下:
//建立堆
n = 0;
for (int i = 1; i <= m; ++i){
n++;
h[n] = a[i];//或者写成cin >> h[n];
siftup(n);
}
法二:
其实我们还有更快的方法建立堆。
把n个元素建立一个堆,首先我们可以将这个n个结点以自顶向下、从左到右的方式从1~n编码,这样就可以把这n个结点转换成一棵完全二叉树。紧接着从最后一个非叶结点(结点编号为n / 2)开始到根结点(结点编号为1),逐个扫描所有的结点,根据需要将当前结点向下调整,直到以当前结点为根结点的子树符合堆的特性。
代码实现:
for (int i = n / 2; i >= 1; --i)
siftdown(i);
堆排序:
与快速排序一样,堆排序的时间复杂度也是O(NlogN)。
堆排序的实现:
法一:
比如我们现在要进行从小到大排序,可以先建立最小堆,然后每次删除顶部元素并将顶部元素输出或者放入一个新的数组中,直到堆为空为止。最终输出的或者存放在新数组中的数就已经是排序好的了。
//删除最小元素
int deletemin(){
int t;
t = h[1];//用一个临时变量记录堆顶的值
h[1] = h[n];//将堆的最后一个点赋值到堆顶
n--;//堆的元素减少1
siftdown(1);//向下调整
return t;//返回之前记录的堆的顶点的最小值
}
建堆以及堆排序的完整代码如下:
![在这里插入代码片](https://img-blog.csdnimg.cn/20210220150844442.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0xYQ18wMDc=,size_16,color_FFFFFF,t_70)
法二:
当然,堆排序还有更好的方法。从小到大排序的时候不建立最小堆而是最大堆,最大堆建立好以后,最大元素在h[1],因为我们的需求是从小到大的排序,希望最大的放在最后。因此我们可以把h[1]和h[n]交换,此时h[n]就是数组中的最大元素。请注意,交换后还需要将h[1]向下调整以保持堆的特性。OK,最大元素已经归为后我们需要将堆的大小减1,即n–,并将交换后的新h[1]向下调整以保持堆的特性。如此反复,直到堆的大小变成1为止。此时数组h中就已经是排好序的了。代码如下:
//堆排序
void heapsort(){
while(n > 1){
swap(1, n);
n--;
siftdown(1);
}
}
完整的堆排序代码如下,注意使用这种方法需要建立最大堆。
//堆排序法二:
#include <bits/stdc++.h>
using namespace std;
int h[101];//用来存放堆的数组
int n;//用来存储堆中的元素个数,也就是堆的大小
//交换函数,用来交换堆中的两个元素的值
void swap(int x, int y){
int t;
t = h[x];
h[x] = h[y];
h[y] = t;
return ;
}
//向下调整函数
void siftdown(int i){//传入一个需要向下调整的结点的编号i,这里传入1,即从堆顶开始向下调整
int t, flag = 0;//用来标记是否需要继续向下调整
//当i结点有儿子(其实是至少有左儿子)并且有需要调整的时候循环就执行
while(i * 2 <= n && flag == 0){
//首先判断它和它左儿子的大小,并用t记录值较大的结点的编号
if(h[i] < h[2 * i]) t = i * 2;
else t = i;
//如果它有右儿子,再对右儿子进行讨论
if(i * 2 + 1 <= n){
//如果右儿子的值更大,则更新为较大的结点的编号
if(h[t] < h[2 * i + 1]) t = 2 * i + 1;
}
//如果发现最大的结点编号不是自己,说明子结点中有比父结点更大的
if(t != i){
swap(i, t);//交换它们
i = t;//i更新喂刚才与它交换的儿子结点的编号,便云接下来继续向下调整。
}
else flag = 1;//否则说明当前的父结点已经比自己的两个子结点都要大了,不需要再进行调整了
}
return ;
}
//建立堆
void creat(){
int i;
//从最后一个非叶结点到第1个结点依次进行向下调整
for (i = n / 2; i >= 1; --i){
siftdown(i);
}
return ;
}
//堆排序
void heapsort(){
while(n > 1){
swap(1, n);
n--;
siftdown(1);
}
return ;
}
int main(){
int i, num;
//读入n个数
cin >> num;
for (int i = 1; i <= num; ++i){
cin >> h[i];
}
n = num;
//建堆
creat();
//堆排序
heapsort();
//输出
for (i = 1; i <= num; ++i){
cout << h[i] << ' ';
}
return 0;
}
//
测试样例
//14
//99 5 36 7 22 17 46 12 2 19 25 28 1 92
//
运行结果
//1 2 5 7 12 17 19 22 25 28 36 46 92 99
总结:
像注意支持插入元素和寻找最大(小)值的元素的数据结构称为优先队列。堆就是一种优先队列的实现。
另外Dijkstra算法中每次找离远点最近的一个顶点也可以用堆来优化。使算法的时间复杂度降到O((M + N)logN)。
堆还经常被用来求一个数列中的第K大数,只需要建立一个大小为K的最小堆,堆顶就是第K大数了。(举个例子,假设有10个数,要求第3大的数。第一步选取任意3个数,比如说是前3个数,并将这三个数建成最小堆,然后从第4个数开始,与堆顶的数比较,如果比堆顶的数要小,那么这个数就不要,如果比堆顶的数要大,则舍弃当前的堆顶而将这个新数作为新的堆顶,并再去维护,用同样的方法处理剩下的数。)
如果求一个数列中第K小的数,只需要建立一个大小为K的最大堆,堆顶就是第K小的数,这种方法的时间复杂度为O(NlogK)。当然也可以用来求前K大的数和前K小的数。