数据结构——堆(建堆、向下调整法、堆排序、TopK问题)

一、堆的基本概念和性质

堆(heap)是一个完全二叉树 ,并且满足以下性质:每个节点的值都大于或等于其左右孩子节点的值,称为大根堆;或是每个节点的值都小于或等于其左右孩子的值,称为小根堆。

复习:完全二叉树 设二叉树的深度为h,除第h层外,其它各层(1 ~ h-1)的节点数都达到最大个数,第h层所有的节点都连续集中在最左边,这就是完全二叉树。

完全二叉树:

非完全二叉树:
堆:
从堆的概念不难看出,大根堆堆顶为集合的最大值,小根堆堆顶为集合的最小值

堆的存储

那么在数据结构中,我们如何存储堆呢?由于堆是一个完全二叉树,这就决定了每个节点的相对位置是固定的,所以这里我们不同于以往的用链表来存储,我们采用一种船新的存储方式:用一个一维数组来存储堆。为了方便操作,我们采用下标从1开始,下标1的位置就是根节点,节点x的左儿子是2x,节点x的右儿子是2x + 1

二、堆的两个基本操作(down、up)

1、向上调整法

向上调整法用于除最后一个元素意外,其余节点均满足堆的性质时,我们用向上调整法将最后一个节点调整成堆。具体做法如下(以小跟堆为例):
从最后一个节点开始,比较该节点和其父节点的大小,如果该节点的值小于父节点的值,则进行交换。一直到该节点到堆顶或者该节点的值大于等于父节点的值时结束。

那么,具体代码该如何实现呢?我们知道了从父亲推出左右儿子的关系为x = 2x 和 x = 2x + 1,那知道当前节点的下标,可以反推其父亲节点的下标。假设该节点下标为u,则其父亲节点下标为u / 2或者是(u - 1) / 2,那经过向下取整的规则后,其父节点下标可以直接写为u / 2,那我们就可以写出代码了。

cpp 复制代码
//h[]为堆(数组)
void up(int u){
    while(u > 1 && h[u] < h[u / 2]>){
        swap(h[u], h[u / 2]);
        u /= 2;
    }
}

2、向下调整法

向下调整法是从上向下调,该方法在建堆,解决排序等问题上有重大作用,所以应当重点掌握。对于采用向下调整法来说,应该满足条件:该根节点左右子树均为大堆或者均为小堆,只有根节点不满足,这时候我们采用向下调整法来将整个树调整成堆。具体做法如下(以小根堆为例):
从根节点开始,比较该节点与其左右孩子节点的值,如果该节点的值大于孩子节点的值,将其与左右孩子中较小的那个进行交换,一直到该节点为最后一层或者该节点的值小于等于左右孩子节点的值时结束。

代码实现思路:从根节点,左右孩子节点中比较出最大的那个,如果最大的那个节点是孩子节点,则将根节点与孩子节点进行交换,假设根节点小标为u,则左孩子节点下标为2u,右孩子节点下标为2u + 1,假设节点数为my_size,则代码如下:

cpp 复制代码
void down(int u){
    //定义一个变量t来表示最大值下标
    int t = u;

     //如果父亲有左孩子,并且左孩子为最小值。
    if(2 * u <= my_size && h[2 * u] < h[t]) t = 2 * u;

     //如果父亲有右孩子,并且右孩子为最小值。
    if(2 * u + 1 <= my_size && h[2 * u + 1] < h[t]) t = 2 * u + 1;
    
    //如果最小值变化了,说明需要调堆,则交换。
    if(t != u){
        swap(h[t], h[u]);
        down(t);    
    } 
}

三、堆的一些操作

1、插入一个数

该操作其实就是向上调整法的应用,将该数插入到数组末尾,然后进行向上调整操作即可。

heap[my_size++] = x; up(my_size);

2、求集合当中的最值

直接返回堆顶元素即可。

return h[1];

3、删除集合中的最值

删除集合中的最值我们采用覆盖的方法,即将最后一个节点的值覆盖到根节点的值,再将my_size--,然后对新的根节点进行向下调整操作即可。为什么要这么做呢?因为我们使用数组储存的堆结构,而删除第一个元素的话,后面的元素都会进行改变且结构被破坏,比较麻烦。而如果我们用最后一个元素去覆盖的话,前面的其他元素不需要进行改变且维持原有堆结构保持不变(除了新覆盖的根节点不满足),我们只需要将根节点down()操作一下即可,非常方便。

heap[1] = heap[my_size--]; down(1);

4、删除任意一个元素

删除任意一个元素,假设删除第k个元素,则需要将末尾元素覆盖第k个元素,然后将,my_size--,然后得分情况(以小跟堆为例),如果是变大的话,需要down()一下,如果是是变小的话,需要down()一下,这里我们直接将两个都写,因为这里两个中只会执行一个。

heap[k] = heap[my_size--]; up(k); down(k);

5、修改任意一个元素

修改任意一个元素与前面同理。

heap[k] = x; down(k); up(k);

四、堆排序、TopK问题

建堆

知道了堆的基本性质和操作,那给定一个无序数组如何将其建成大堆呢?我们可以用向上调整法和向下调整法。这里我们采用向下调整法建堆,因为其时间复杂度优于一个一个插入的向上调整法(时间复杂度为O(NlogN)。用向下调整法建堆的时间复杂度为O(N) ,具体建堆过程分析如下:

我们从最后一个非叶子结点开始递归向上进行down操作(叶结点自然不用调整),假设总共有n个节点,则最后一个非叶子节点下标为n / 2,则我们从n / 2处开始进行向下调整即可。 代码如下:

cpp 复制代码
for(int i = n / 2; i ; i--){
    down(i);
}

向下调整时间复杂度分析

假设右n个节点,从倒数第二层开始,该层有n/4个节点,每个节点需要调一次,倒数第三层,该层有n/8个节点,每个节点需要调两次,依次类推,时间复杂度为:

n/4 * 1 + n/8 * 2 + n/16 * 3 + n/32 * 4 +...

= n(1/2^2 + 2/2^3 + 3/2^4 + 4/2^4 + ...)

令S = 1/2^2 + 2/2^3 + 3/2^4 + 4/2^5 + ...

则2S = 1/2 + 2/2^2 + 3/2^3 + 4/2^4 + ... 2S - S = S =1/2 + 2/2^2 + 1/2^3 + 1/2^4 + ... < 1 则n(1/2^2 + 2/2^3 + 3/2^4 + 4/2^4 + ...) < n

故时间复杂度小于N,进一步分析可知向下调整建堆的时间复杂度为O(N)

堆排序

我们来利用堆的性质来进行排序。如果要排升序或者降序,我们应该建大堆还是小堆呢?答案是排升序,建大堆;排降序,建小堆 。原因是我们要在原数组上进行排序的话,以排升序为例,我们假如建小堆,那么每次的根节点将是当前最小值,而下一步的话最小值根节点为左孩子或者右孩子,这样继续排下去的话,整个堆的结构会被打乱,无法满足要求。而我们采用前面删除的思想,建大堆。每次只需将堆顶和堆底元素进行交换,然后对堆顶进行向下调整,每次都能将最大值放入后面正确的位置,且前面的堆结构维持不变。具体过程如下: 代码如下:

cpp 复制代码
while(my_size >= 1){
    swap(h[1], h[my_size--]);
    down(1);
}

堆排序时间复杂度:向下调整N次,每次调整为O(logN),建堆为O(N),则为O(N + NlogN),即为O(NlogN);

Topk问题

接下来我们来看一个经典问题:Topk问题

题目:输入一个长度为 n的整数数列,从小到大输出前 m小的数。

此题我们可以用快排等排序来做, 今天我们用堆来做(堆在处理海量数据时具有重要应用)

思路:题目要求我们从小到大输出前m小的数,那么我们可以构造一个小根堆,每次取出堆顶元素即可。具体代码如下:

cpp 复制代码
#include<iostream>
#include<algorithm>

using namespace std;

const int N = 100010;

int h[N], MySize;

int n, m;

void down(int u){
    //用t来表示三个点里最小值的编号
    int t = u;
    
    //如果父亲有左孩子,并且左孩子小于父亲。
    if(2 * u <= MySize && h[2 * u] < h[t]) t = 2 * u;
    
    //如果父亲有右孩子,并且右孩子小于父亲。
    if(2 * u + 1 <= MySize && h[2 * u + 1] < h[t]) t= 2 * u + 1;
    
    //如果最小值变化了,说明需要调堆,则交换。
    if(t != u){
        swap(h[u], h[t]);
        down(t);
    }
}

int main(){
    cin >> n >> m;
    MySize = n;
    for(int i = 1; i <= n; i++){
        cin >> h[i];
    }
    
    //从n / 2,即最后一个孩子的父亲开始调。
    for(int i = n / 2; i ; i--){
        down(i);
    }
    
    while(m--){
        cout << h[1] << " ";
        h[1] = h[MySize--];
        down(1);
    }
    
    return 0;
}

五、总结

  • 本篇文章主要介绍了堆的基本概念和性质
  • 重点介绍了堆的两个基本操作:向上调整法(up)和向下调整法(down) ,向下调整法的应用较多,应当重点掌握。
  • 接着我们介绍了基于up和down两种操作的一些堆能实现的操作,插入一个数、求集合中的最值、删除集合中的最值、删除任意一个元素、修改任意一个元素 ,以及删除时的覆盖思想。
  • 重点分析了建堆的过程:堆排序和Topk问题 ,了解了建堆的方法以及时间复杂度,一个一个元素插入+向上调整法建堆的时间复杂度是O(N*longN),而从最后一个非叶子节点开始递归向上进行向下调整法建堆的时间复杂度为O(N)
  • 了解了堆的应用,堆排序、Topk问题,知道排升序,建大堆;排降序,建小堆。堆在Topk问题上重要应用,我们只需要关注前k个数。堆还有很多重要作用,海量数据时Topk的问题,外排序等内容。
  • 一个元素要成为Topk,经历的过程可不简单,那么我们何尝不是如此?所以,加油吧,朋友。

以上就是关于堆的入门的全部内容了,本文章为作者的学习记录与分享,如果文章有什么错误或者遇到什么问题,或者有什么好的建议,欢迎随时和我交流联系。

相关推荐
van叶~9 分钟前
算法妙妙屋-------1.递归的深邃回响:二叉树的奇妙剪枝
c++·算法
knighthood200120 分钟前
解决:ros进行gazebo仿真,rviz没有显示传感器数据
c++·ubuntu·ros
半盏茶香1 小时前
【C语言】分支和循环详解(下)猜数字游戏
c语言·开发语言·c++·算法·游戏
小堇不是码农1 小时前
在VScode中配置C_C++环境
c语言·c++·vscode
Jack黄从零学c++1 小时前
C++ 的异常处理详解
c++·经验分享
捕鲸叉6 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer7 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq7 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
wheeldown7 小时前
【数据结构】选择排序
数据结构·算法·排序算法
青花瓷8 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode