堆算法
(一)引入:构造大根堆
首先我们给定一个无序的序列,将其看做一个堆结构,一个没有规则的二叉树,将序列里的值按照从上往下,从左到右依次填充到二叉树中。
对于一个完全二叉树,在填满的情况下(非叶子节点都有两个子节点),每一层的元素个数是上一层的二倍,根节点数量是1,所以最后一层的节点数量,一定是之前所有层节点总数+1
所以,我们能找到最后一层的第一个节点的索引,即节点总数/2(根节点索引为0),这也就是第一个叶子节点,所以第一个非叶子节点的索引就是第一个叶子结点的索引-1。
那么对于填不满的二叉树呢?这个计算方式仍然适用,当我们从上往下,从左往右填充二叉树的过程中,第一个叶子节点,一定是序列长度/2。所以第最后一个非叶子节点的索引就是 arr.len / 2 -1
对于此图数组长度为5,最后一个非叶子节点为5/2-1=1,即为6这个节点
那么如何构建呢? 我们找到了最后一个非叶子节点,即元素值为6的节点,比较它的左右节点中最大的一个的值,是否比他大,如果大就交换位置。
在这里5小于6,而9大于6,则交换6和9的位置
找到下一个非叶子节点4,用它和它的左右子节点进行比较,4大于3,而4小于9,交换4和9位置
此时发现4小于5和6这两个子节点,我们需要进行调整,左右节点5和6中,6大于5且6大于父节点4,因此交换4和6的位置
此时我们就构造出来一个大根堆,下来进行排序
首先将顶点元素9与末尾元素4交换位置,此时末尾数字为最大值。排除已经确定的最大元素,将剩下元素重新构建大根堆
一次交换重构如图:
此时元素9已经有序,末尾元素则为4(每调整一次,调整后的尾部元素在下次调整重构时都不能动)
二次交换重构如图:
最终排序结果:
1.堆中父子节点关系
以0开始的:
对于一个完全二叉树,第一个叶子节点,一定是序列长度/2。所以第最后一个非叶子节点的索引就是 arr.len / 2 -1
(一棵二叉树至多只有最下面的一层上的结点的度数可以小于2,并且最下层上的结点都集中在该层最左边的若干位置上,而在最后一层上,右边的若干结点缺失的二叉树,则此二叉树成为完全二叉树。)
(二)建堆算法
将一组数据转变为符合堆特性的另一组数据,通俗来说就是将数组变为堆
1.向上调整建堆
将原数据从第一个元素开始逐一向上调整,假设原数据个数为n,则向上调整n次,类似于将原数据逐一插入到原数据当中
代码:
//交换数据 void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } //向上调整 void AdjustUp(int* pa, int child) { assert(pa); int parent = (child - 1) / 2; while (child > 0) { if (pa[child] > pa[parent]) { Swap(&pa[child], &pa[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } } //向上调整建堆 void CreateHeapUp(int* arr, int n) { assert(arr); //从第一个元素开始逐一向上调整 for (int i = 0; i < n; i++) { AdjustUp(arr, i); } }
复杂度:
自顶向下的建堆方式
则该建堆方式的时间复杂度为
假设该堆理想情况下为满二叉树,则存在 ,即
,则有
即时间复杂度为
时间复杂度:O(nlog2n)
空间复杂度:O(1)
2.向下调整建堆
先调整小树,再调整大树。从原数据的下标最大的的分支结点开始进行向下调整父结点直至下标最小的分支结点。树的分支结点下标<=(n-1)/2
代码:
//交换数据 void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } //向下调整 void AdjustDown(int* pa, int n, int parent) { assert(pa); int child = parent * 2 + 1; /*while (parent <= (n-1) / 2)*/ while (child < n) { if (child + 1 < n && pa[child + 1] > pa[child]) child++; if (pa[child] > pa[parent]) { Swap(&pa[child], &pa[parent]); parent = child; child = parent * 2 + 1; } else { break; } } } //向下调整建堆 void CreateHeapDown(int* arr, int n) { assert(arr); //从最下标最大的分支结点开始向下调整直至下标最小的分支结点 for (int i = (n - 1) / 2; i >= 0; i--) { AdjustDown(arr, n, i); } }
复杂度:
该建堆方式是从倒数第二层的节点(叶子节点的上一层)开始,从右向左,从下到上的向下进行调整。
同样的,假设该堆为满二叉树,堆高为 。同样的,假设每层高度为
, 每层结点数为
, 则建堆复杂度为
, 则有
同样的,该数列和为差比数列。因此,可以用错位相减法,得到时间复杂度为
即,时间复杂度为 .
时间复杂度:O(n)
空间复杂度:O(1)
3.知识补充
大根堆:双亲结点(父结点)大于等于其孩子结点
小根堆:双亲结点(父结点)小于等于其孩子结点
大根堆小根堆建堆区别:在调整函数中改变>或者<符号即可实现彼此
以大根堆为例
向上调整函数(AdjustUp):
时间复杂度:O(log2n) = 树高
功能:调整一棵树为堆
特点:因为叫做向上调整,所以调整下面,一般从最后一个结点开始向上调整,孩子与父亲比较,若孩子权值大孩子上移
向下调整函数(AdjustDown):
时间复杂度:O(log2n) = 树高
功能:调整一棵树为堆
特点:因为叫做向下调整,所以调整上面,一般从第一个结点开始向下调整,父亲与孩子比较,若孩子权值大父亲下移
所以,我们一般采用向下调整建堆。
(三)topK问题
给一个长度为N无序的数组, 请输出最小 (或最大)的K个数。
1.最大的前K个
思想:
将N的前K个建一个大小为K的小堆,从N的第K+1元素开始与堆顶元素比较,若原数据的第K+1个元素大于堆顶元素就覆盖该堆顶元素,然后调整该堆;否则就遍历原数据的下一个元素。直至遍历完原数据的所有元素。
代码:
//topK问题(最大的前K个) void GetTopK1(int* arr, int n,int K) { //将原数据的前K个建小堆 for (int i = (K - 1) / 2; i >= 0; i--) { //向下调整建堆 AdjustDown(arr, K, i); } //从原数据第K+1个元素开始遍历原数据 for (int i = K; i < n; i++) { //如果当前元素大于堆顶元素 if (arr[i] > arr[0]) { //则覆盖堆顶元素 arr[0] = arr[i]; //调整新堆 AdjustDown(arr, K, 0); } } }
复杂度:
时间复杂度:O(Nlog2K)
空间复杂度:O(1)
2.最小的前K个
思想:
将N的前K个建一个大小为K的大堆,从N的第K+1元素开始与堆顶元素比较,若原数据的第K+1个元素小于堆顶元素就覆盖该堆顶元素,然后调整该堆;否则就遍历原数据的下一个元素。直至遍历完原数据的所有元素。
代码:
//topK问题(最小的前K个) void GetTopK2(int* arr, int n,int K) { //将原数据的前K个建大堆 for (int i = (K - 1) / 2; i >= 0; i--) { //向下调整建堆 AdjustDown(arr, K, i); } //从原数据第K+1个元素开始遍历原数据 for (int i = K; i < n; i++) { //如果当前元素小于堆顶元素 if (arr[i] < arr[0]) { //则覆盖堆顶元素 arr[0] = arr[i]; //调整新堆 AdjustDown(arr, K, 0); } } }
复杂度:
时间复杂度:O(Nlog2K)
空间复杂度:O(1)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了