【数据结构】长篇详解堆,堆的向上/向下调整算法,堆排序及TopK问题

文章目录

  • 堆的概念
      • 性质
      • 图解
  • 向上调整算法
      • 算法分析
      • 代码整体实现
  • 向下调整算法
      • 算法分析
      • 整体代码实现
  • 堆的接口实现
      • 初始化堆
      • 销毁堆
      • 插入元素
      • 删除元素
      • 打印元素
      • 判断是否为空
      • 取首元素
      • 实现堆
  • 堆排序
      • 创建堆
      • 调整堆
      • 整合步骤
  • TopK问题

堆的概念

堆就是将一组数据所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足树中每一个父亲节点都要大于其子节点称为大堆(树中每一个父亲节点都要大于其子节点称为小堆)。

性质

①对于大堆(大根堆)来说,堆的顶部也就是数组首元素一定是最大的元素
②对于小堆(小根堆)来说,堆的顶部也就是数组首元素一定是最小的元素
(这两点对于下面的堆排序来说十分重要)

此外,堆总是一棵完全二叉树,因为堆本身就是二叉树的一种顺序存储结构的实现模式
注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段

图解

在这里插入图片描述

在这里插入图片描述
通过图再去对比上面的概念和性质,理解会更加清晰

所谓的存储结构也就数据在内存中真实的存储情况,在一维数组中
逻辑结构就是我们想象出来的,能够帮助我们理解并且通过这个也是根据二叉树中父节点和子节点之间的下标关系来确定的

①已知父亲节点求子节点

LeftChild = Parent * 2 + 1; //左孩子的节点下标
RightChild = Parent * 2 + 2; //右孩子的节点下标

②已知子节点求父节点

Parent = (Child - 1) / 2;  //切记是减1之后再除以2

向上调整算法

向上调整算法主要在堆的插入堆排序中应用最为广泛

算法分析

对于堆的插入,就是在数组的末尾进行数字的插入,并且在插入数据之后,我们仍要保证现有的结构仍然是一个!
在这里插入图片描述
如上图,是一个小堆

然后在数组的末尾插入了一个数字,即最后一个孩子节点,但是在插入之后,我们自身的堆结构发生了变化,所以我们必须对堆的结构进行调整.

不难发现,在最后插入一个数之后,其他子树仍然保持了小堆的性质(即父节点的值小于子节点),而正在需要调整的就是该子节点的’祖宗’这条线路,如上图红色箭头一步一步指向的位置,
而利用的公式就是Parent = (Child - 1) / 2;把新插入的数和它的父节点作比较,如果这个新插入的数小于于父节点,那么就和父节点交换位置

在向上调整代码中,我们需要传入的参数是数组和插入的那个子孩子的节点的下标

void AdjustUp(HeapDataType* a, int child)  //child是下标

在实际的不断向上调整中,我们需要用循环来实现代码,并且要合理的设置循环

while (child > 0)   //不能设置为parent >= 0 {				//Parent = (Child - 1) / 2, 通过这个公式因为parent永远都不可能小于零...}

时间复杂度 -------O(logN)

根据最坏情况来看(比如上图),数据多少层,我们就需要调整多少次,所以次数=高度h
再根据二叉树节点数量和高度的关系可知:
在这里插入图片描述
所以可以得到关系: 次数 = h = logN
所以时间复杂度就为:O(logN)

代码整体实现

算法既可以实现小堆也可以大堆,具体看你函数内部符号的控制

整体实现如下:

typedef struct HeapNode
{HeapDataType* a;int size;int capacity;
}HP;
void Swap(HeapDataType* p1, HeapDataType* p2)
{HeapDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}
//循环写法
void AdjustUp(HeapDataType* a, int child)  //child,parent是下标
{int parent = (child - 1) / 2;while (child > 0){//小堆:判断子节点和父亲结点的大小if (a[child] < a[parent])//大堆:if (a[child] > a[parent]){Swap(&a[child], &a[parent]);//交换孩子和父亲child = parent;parent = (child - 1) / 2;}else{break;}}
}//递归写法
void AdjustUp(HeapDataType* a, int child)
{int parent = (child - 1) / 2;if (child > 0){//小堆:if (a[child] < a[parent]){Swap(&a[child], &a[parent]);child = parent;AdjustUp(a, child);  //递归}else{return;}}else{return;}
}

向下调整算法

向上调整算法主要在堆的数据删除堆排序中应用最为广泛

算法分析

在这里插入图片描述
对于上图根节点27来说,它的左右子树都是小堆,所以就需要将27不断向下调整,保证其整体还是一个小堆
由此可见,向下调整的前提是左右子树必须是堆
利用的公式就是
LeftChild = Parent * 2 + 1;
RightChild = Parent * 2 + 2;

在每一轮的调整中你都需要比较左右子节点的大小,比如上图就是对于27来说,15和17两个节点,15更小,所以就将15和27进行交换,然后对于19这个子树来说本身就是一个小堆,就可以不用管了,并且15本身也小于19,所以也符合小堆性质,然后继续对左边的子树进行如此的调整

在向下调整代码中,我们需要传入的参数是数组,数组大小和整棵树根节点的下标

void AdjustDown(HeapDataType* a, int size, int parent)

时间复杂度 -------O(logN)

根据最坏情况来看(比如上图),数据多少层,最坏的情况我们就需要向下调整多少次,所以次数=高度h, 再根据二叉树节点数量和高度的关系可知:
在这里插入图片描述
所以可以得到关系: 次数 = h = logN
所以时间复杂度就为:O(logN)

整体代码实现

typedef int HeapDataType;typedef struct HeapNode
{HeapDataType* a;int size;int capacity;
}HP;
//转换
void Swap(HeapDataType* p1, HeapDataType* p2)
{HeapDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}
//循环写法
void AdjustDown(HeapDataType* a, int size, int parent)
{int child = parent * 2 + 1;while (child < size)//这里的child是左孩子下标,之所以不是child+1<size,是因为{			        //如果没有右孩子的话,这次循环将会终止,调整就会进行不彻底//小堆:if (child + 1 < size && a[child + 1] < a[child]){child++;}//小堆:if (a[child] < a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;//交换位置}else{break;}}
}
//递归写法
void AdjustDown(HeapDataType* a, int size, int parent)
{int child = parent * 2 + 1;if (child < size) {//小堆:if (child + 1 < size && a[child + 1] < a[child]){child++; //如果右孩子小,那么下标就换成右孩子的下标}//小堆:if (a[child] < a[parent]){Swap(&a[child], &a[parent]);parent = child;AdjustDown(a, size, parent);  //递归}else{return;}}else{return;}
}

堆的接口实现

接下里,我将把堆的实现过程一步一步实现出来

初始化堆

void HeapInit(HP* hp)
{assert(hp);hp->a = NULL;hp->size = 0;hp->capacity = 0;
}

销毁堆

void HeapDestroy(HP* hp)
{assert(hp);free(hp->a);hp->a = NULL;hp->size = hp->capacity = 0;
}

插入元素

在尾部插入之后,要用AdjustUp函数向上调整形成堆

void HeapPush(HP* hp, HPDataType x)
{assert(hp);// 扩容if (hp->size == hp->capacity){int new = hp->capacity == 0 ? 4 : hp->capacity * 2;HPDataType*tmp=(HPDataType*)realloc(hp->a, sizeof(HPDataType) * new);if (tmp == NULL){perror("realloc fail");exit(-1);}hp->a = tmp;hp->capacity = new;}hp->a[hp->size] = x;hp->size++;AdjustUp(hp->a, hp->size - 1);//插入之后向上调整堆
}

删除元素

一般指删除首元素,至于为什么HeapPop是删除首元素
根本就是因为要弹出尾元素很简单,直接size–不就完了

void HeapPop(HP* php)
{assert(php);assert(php->size > 0);Swap(&php->a[0], &php->a[php->size - 1]);//首元素换到尾部来,然后再size----php->size;AdjustDown(php->a, php->size, 0);//再用AdjustDown函数再来调整堆
}

打印元素

void HeapPrint(HP* php)
{assert(php);for (size_t i = 0; i < php->size; i++){printf("%d ", php->a[i]);}printf("\n");
}

判断是否为空


bool HeapEmpty(HP* php)
{assert(php);return php->size == 0;
}

取首元素

HPDataType HeapTop(HP* php)
{assert(php);assert(php->size > 0);return php->a[0];
}

实现堆

int main()
{HP hp;HeapInit(&hp);int a[] = { 20, 11, 28, 31, 111, 52, 34, 16, 7, 9 };for (int i = 0; i < sizeof(a) / sizeof(int); i++){HeapPush(&hp, a[i]);//插入}HeapPop(&hp);//把堆的首元素7删除删除HeapPrint(&hp);printf("堆顶元素:%d\n", HeapTop(&hp));HeapDestroy(&hp);return 0;
}

运行出来的结果:
在这里插入图片描述

堆排序

首先堆排有几个关键的步骤
①创建堆 ②调整堆

创建堆

创建堆的方式有两种①向上调整建堆 ②向下调整建堆

①首先我们来看第一种:向上调整建堆

这种方式的原理就是看作最开始堆中只有一个元素,从第一个元素开始就已经在向上调整,然后逐渐像堆中加入元素,随着一个一个元素的加入,也就形成了堆
图解如下:
在这里插入图片描述
而代码就是通过AdjustUp函数和一个for循环就可以完成上面步骤

//以前n个数建小堆
for (int i = 0; i < n; ++i)
{AdjustUp(a, i);  //a为数组的指针
}

时间复杂度: O(n*logn)

分析:首先我们上面详细分析了AdjustUp()的时间复杂度为O(logn),然后循环了n此每次建堆,所以两者相乘,时间复杂度也就是 n*logn


②我们来看第一种:向下调整建堆 -----堆排序中最主要用到的方法

这种建堆的关键就是从倒数第一个非叶子节点开始调(也就是树中最后一个父节点),然后逐渐+1,就可以调整从最后一个父节点开始的每一棵树.
不难发现这样也符合向下调整的前提,即左右子树都是堆
那么我们如何找到最后一个节点的父亲?
就需要用到公式:Parent = (Child - 1) / 2;
图解如下
在这里插入图片描述

而代码就是通过AdjustDown函数和一个for循环就可以完成上面步骤

for (int i = ((n-1)-1)/2; i >= 0; --i) 
//(n-1)是拿到树最后一个节点,然后再根据公式Parent = (Child - 1) / 2;
{AdjustDown(a, size, i);
}

时间复杂度:O(n)
根据下面的思路
在这里插入图片描述
在这里插入图片描述

因此建堆的时间复杂度为O(n)

总结😗:其实两种方式建堆之所以时间复杂度有差距,就是因为向下调整建堆可以看作忽略了最后一排的节点,直接从倒数第二排节点开始调整的,而在一棵满二叉树中最后一排的节点其实就占据了整棵树的二分之一,所以相当于向下调整比向上调整少经历了很多的节点
所以实际堆排序中我们更多的使用的是向下调整建堆,因此时间复杂度为O(n)

还有一点需要注意的是:如果你想要升序,即从小打大,需要建大堆.
建了大堆之后,再交换首元素(最大的)和末尾元素,然后把最大的元素不算入堆中的元素,
再进行向下调整
如果你建小堆,当你拿到首元素(最小的元素之后),需要将数组依次前移然后重新建堆,每次都前移然后每次都建堆,时间复杂度直接拉满!!!
同理 如果你想要降序,即从大打小,需要建小堆.


调整堆

在堆建好之后,就可以开始调整堆了,比如你是升序,即从小打大,需要建大堆.
建了大堆之后,循环N次 ,进行N次调整堆操作,每一次调整 堆得到的最大值,将此值和数组的最后一个元素进行交换,交换减小数组的长度(最后被减小的那几个值不参与堆的调整),直到最后一个元素,就完成了堆的排序.

如下图,降序—小堆, 展示了其中一个调整过程

在这里插入图片描述

整合步骤

综合建堆和调整,完整的堆排序代码就出来了

void HeapSort(int* a, int n)
{// 建堆 (大堆)or  (小堆)for (int i = 1; i < n; i++){AdjustUp(a, i);}
int end = n - 1;
while (end > 0)
{Swap(&a[0], &a[end]);  //交换AdjustDown(a, end, 0); //向下调整--end;   //换下来的最后一个数不计入堆中
}

升序建大堆,降序建小堆很重要!

TopK问题

最后我们再来解决一个堆在实际应用中很重要的Topk问题

通常这是在数据很大的情况下才会使用到的,如世界前500强,全省高考前十等等…
因为如果数据很大,你不可能在内存中创建一个这么大的数组来装下这么多数据,所以就要用topk问题的思路
举个简单的例子:
比如你有1000个数据,你要找前100个大的数据,那么你先随便拿100个数据(无论其大小多少)建小堆,然后另外900个数据依次与堆顶的最小数据进行比较,比它大就替换,然后再调整堆,这样1000个数据都参与了对比,对比了900次,900个最小的被拿走,剩下的100个一定是最大的,再进行堆排序

接下来用文件传输数据的形式进行举例

void CreateNDate()
{// 造数据int n = 10000000;srand(time(0));const char* file = "data.txt";FILE* fin = fopen(file, "w");if (fin == NULL){perror("fopen error");return;}for (int i = 0; i < n; ++i){int x = (rand() + i) % 10000000;fprintf(fin, "%d\n", x);}fclose(fin);
}void TestTopK(const char* filename, int k)
{// 1. 建堆--用a中前k个元素建堆FILE* fout = fopen(filename, "r");if (fout == NULL){perror("fopen fail");return;}int* minheap = (int*)malloc(sizeof(int) * k);if (minheap == NULL){perror("malloc fail");return;}for (int i = 0; i < k; i++){fscanf(fout, "%d", &minheap[i]);}// 前k个数建小堆for (int i = (k-2)/2; i >=0 ; --i){AdjustDown(minheap, k, i);}// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换int x = 0;while (fscanf(fout, "%d", &x) != EOF){if (x > minheap[0]){// 替换你进堆minheap[0] = x;AdjustDown(minheap, k, 0);}}for (int i = 0; i < k; i++){printf("%d ", minheap[i]);}printf("\n");fclose(fout);
}int main()
{CreateNDate();TestTopK("data.txt", 5);return 0;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/107039.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

abb PPC902AE101 3BHE010751R0101控制板

通信接口&#xff1a; 控制板通常配备了多种通信接口&#xff0c;以便与其他设备和系统进行数据交换。这些接口可能包括以太网、串行通信、Modbus等。 处理能力&#xff1a; 控制板可能具有一定的数据处理和计算能力&#xff0c;以执行控制算法、数据处理或逻辑功能。 数据存…

目标检测笔记(十四): 使用YOLOv8完成对图像的目标检测任务(从数据准备到训练测试部署的完整流程)

文章目录 一、目标检测介绍二、YOLOv8介绍三、源码获取四、环境搭建4.1 环境检测 五、数据集准备六、 模型训练6.1 方式一6.2 方式二6.3 针对其他任务 七、模型验证八、模型测试九、模型转换9.1 转onnx9.1.1 方式一 9.2 转tensorRT9.2.1 trtexec9.2.2 代码转换9.2.3 推理代码 一…

js函数变量提升理解

var n 10function fn() {// var n 20function f() {// 没用var声明&#xff0c;去外层寻找n,直到找到windows为止&#xff0c;找到的话用的就是哟个全局变量&#xff0c;会改变原始全局变量的值n;console.log(n)}var nn 20f()console.log(n);return f}var x fn()// 会在上一…

华为CD32键盘使用教程

华为CD32键盘使用教程 用爱发电写的教程&#xff01; 最后更新时间&#xff1a;2023.9.12 型号&#xff1a;华为有线键盘CD32 基本使用 此键盘在不安装驱动的情况下可以直接使用&#xff0c;但是不安装驱动指纹识别是无法使用的&#xff01;并且NFC功能只支持华为的部分电脑…

git-命令行显示当前目录分支

1. 打开家目录.bashrc隐藏文件&#xff0c;找到如下内容 forlinxubuntu:~$ vi ~/.bashrcif [ "$color_prompt" yes ]; thenPS1${debian_chroot:($debian_chroot)}\[\033[01;32m\]\u\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ elsePS1${debian_chroot:($debi…

TSINGSEE青犀AI视频分析/边缘计算/AI算法·人脸识别功能——多场景高效运用

旭帆科技AI智能分析网关可提供海量算法供应&#xff0c;涵盖目标监测、分析、抓拍、动作分析、AI识别等&#xff0c;可应用于各行各业的视觉场景中。同时针对小众化场景可快速定制AI算法&#xff0c;主动适配大厂近百款芯片&#xff0c;打通云/边/端灵活部署&#xff0c;算法一…

Docker部署jar包

centos7下载docker&#xff1a;https://blog.csdn.net/qq_39997939/article/details/131005939 1、编写dockerfile https://blog.csdn.net/liben0429/article/details/126858971 2、如何确定在dockerfile安装jdk11 https://www.5axxw.com/questions/content/mc5fpt 打包镜…

ROS rviz常用可视化插件

文章目录 ROS rviz常用可视化插件rviz常用可视化组件GridMapLaser ScanPointCloud2TFImagesPathMarker ROS rviz常用可视化插件 rviz RViz&#xff08;Robot Visualization&#xff09;是ROS&#xff08;Robot Operating System&#xff09;中的一个重要工具&#xff0c;用于…

taro h5 点击页面任意地方关闭弹窗组件 --- findDOMNode 判断点击节点是否属于某个组件

场景&#xff1a;如图&#xff0c;弹窗在大组件中&#xff0c;点击小组件显示弹窗&#xff0c;要求点击除弹窗外的任何元素都能关闭弹窗并且能执行元素原有的逻辑 方法一 最简单的是弹窗背后有一个覆盖整个页面的透明的cover, 点击直接关闭&#xff0c;但是这样虽然点击页面…

redis实战-redis实现异步秒杀优化

秒杀优化-异步秒杀思路 未优化的思路 当用户发起请求&#xff0c;此时会请求nginx&#xff0c;nginx会访问到tomcat&#xff0c;而tomcat中的程序&#xff0c;会进行串行操作&#xff0c;分成如下几个步骤 1、查询优惠卷 2、判断秒杀库存是否足够 3、查询订单 4、校验是否是一…

数据治理实战步骤

写在前面:数据治理是数字化转型的基础,是数字要素流通的首要任务。但是面对不同的情况,数据治理的手段不同。 数据治理专员要转换思想,数据治理中单靠技术、软件是不行的,比如一些单位认为数据治理平台是万能的,直接上平台一般是做不好的,需基于企业的组织文化、愿景等对…

分类预测 | Matlab实现基于LFDA-SVM局部费歇尔判别数据降维结合支持向量机的多输入分类预测

分类预测 | Matlab实现基于LFDA-SVM局部费歇尔判别数据降维结合支持向量机的多输入分类预测 目录 分类预测 | Matlab实现基于LFDA-SVM局部费歇尔判别数据降维结合支持向量机的多输入分类预测效果一览基本介绍程序设计参考资料 效果一览 基本介绍 基于局部费歇尔判别数据降维的L…