前言:
在上一篇博客中,我们深入了解了堆的基础概念并完成了堆结构的具体实现。本篇将继续探讨堆在实际应用中的使用场景,重点分析两个核心应用:高效的堆排序算法和解决TopK问题的巧妙方法。

一、建堆
建堆算法是将无序数组转化为符合"堆规则"的完全二叉树(逻辑结构)的核心方法,是堆排序、Top K等所有堆应用的基础,相比于通过数据结构中堆的插入建堆,需要额外O(N)的空间复杂度,基于数组原地实现建堆,仅需要O(1)的空间复杂度。
1.1向上调整算法的回顾
场景实例:假设现在存在一个小堆,要向堆中插入一个元素,并维持堆的结构。

核心逻辑:
新元素插入堆尾后,它的父节点可能比新插入子节点小 ------ 让新元素" 上浮 ",与父节点交换,直到它比父节点小,或浮到根节点(堆顶)。
简而言之,对于向上调整算法的核心思维就是:当子节点可能比父节点 "更合适"时,需要让子节点往上浮,找到合适位置。
实例分析:
一、如上图所示,由于新插入的子节点的值为 10 与 其父亲节点的值 28 相比 不满足小堆的关系,所以我们需要让子节点向上浮动,直到它比父节点更小,或者一直向上浮动到根节点为止。
二、详解向上调整的过程
( 1 ) 上图中原来的小堆为: [15 , 18 , 19 , 25 , 28 , 34 , 65 , 49 , 27 , 37] ,堆中元素个数size=10,
现需要插入一个元素 10 (放在堆尾索引为10的位置处)
( 2 ) 新插入的子节点的索引为 child = 10 插入后小堆变为: [15 , 18 , 19 , 25 , 28 , 34 , 65 , 49 , 27 , 37,10]
计算得到其父亲节点的索引为 parent=( child - 1 ) / 2 , 即索引为4 ,arr[4]=28。
( 3 )比较arr[child] 与 arr[parent] 的值,由于要维持小堆,所以当arr[child] < arr [parent] 时,就要对子节点和父亲节点进行交换
交换后的小堆变为: [15 , 18 , 19 , 25 , 10 , 34 , 65 , 49 , 27 , 37,28]
( 4 )更新孩子节点的索引和父亲节点的索引:child=parent ; parent=(child-1) / 2 ;
( 5 ) 重复这个过程,直到满足新插入的子节点比父节点更小,或者一直向上浮动到根节点为止,所以我们可以通过循环进行描述上述三个步骤。
三、向上调整算法的应用
cpp
void AdjustUp(HPDataType* a, int child)
{
//已知孩子节点的下标为child
//父亲节点下标为:(child-1)/2
//初始时父亲节点的下标
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
//进行交换
swap(&a[child], &a[parent]);
//更新孩子节点的下标
child = parent;
//更新父亲节点的下标
parent = (child - 1) / 2;
}
else
{
break;
}
}
//退出条件为:
//1.子节点比父节点大
//2.子节点到达根的位置
}
四、若原来是大堆,只需把比较符号反转,将 "<" 变为 ">", 即当前节点 > 父节点(大根堆性质破坏),则交换并继续上浮。
1.2利用向上调整算法建堆
基于向上调整算法的思想,我们可以对任何一个数组进行建堆(大堆或小堆),通过循环,传入数组中的每个元素,及其下标,相当于对数组中的每个元素进行向上调整,即完成了建堆。
代码示例:
cpp
void swap(int *a,int *b)
{
int tmp=*a;
*a=*b;
*b=tmp;
}
//向上调整算法
void AdjustUp(HPDataType* a, int child)
{
//已知孩子节点的下标为child
//父亲节点下标为:(child-1)/2
//初始时父亲节点的下标
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
//进行交换
swap(&a[child], &a[parent]);
//更新孩子节点的下标
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void PrintArray(int arr[], int size)
{
for (int i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
}
int main()
{
int arr[] = { 9,6,4,7,5,3,2,1 };
int size = sizeof(arr) / sizeof(arr[0]);
printf("建堆前的数组: \n");
PrintArray(arr, size);
for (int i = 0; i < size; i++)
{
AdjustUp(arr, i);
}
printf("\n建堆后的数组:\n");
PrintArray(arr, size);
return 0;
}
如下图所示建堆:

时间复杂度分析:
由于堆是一棵完全二叉树,对于完全二叉树的高度h,满足h≈log2(N) ,最坏的情况下,需要从叶节点一直到根节点,向上浮动h次,故而向上调整算法的时间复杂度为O(logN)。
对于一个数组而言有N个元素,即有N个节点,每个节点的最坏情况都是需要从叶节点到根节点,故而向上调整算法建堆的时间复杂度为:O(N*logN)
1.3 向下调整算法的回顾
场景实例:假设现在存在一个小堆,若堆顶元素被一个更大的值替代,并维持堆的结构。

核心思维:当父节点位置可能比子节点位置"不合适"时,需要让父节点往下沉,找到合适位置。
实例分析:
一、如上图所示,原堆顶元素5被更大的元素27替换后,破坏了小堆性质。
此时父节点位置已不满足条件,所以需要通过向下调整操作,直到它比子节点更小,或者到达叶节点位置
(温馨提示:判断是否到达叶节点位置,即只需要判断其子节点不在堆的范围内,循环的停止条件)。
二、详解向下调整的过程
( 1 ) 上图中原来的小堆为:[5,15,19,18,28,34,65,49,25,37] ,但是堆顶元素被替换为27,
则现在的小堆变为:[27,15,19,18,28,34,65,49,25,37],此时堆中的元素不在满足小堆的特性,需要进行向下调整。
( 2 ) 此时父亲节点的索引为:parent=0,
其左孩子节点的索引为:leftchild=parent * 2 + 1 ,其右孩子节点的索引为:rightchild=parent*2+2。
我们不能确定左孩子节点的值与右孩子节点的值的大小关系,所以需要进行比较确定出其中最小的一个。
这里可以通过假设法,不用单独区分左孩子节点的索引和右孩子节点的索引,只需定义一个子节点索引child,然后假设左孩子节点的值最小,
若左孩子节点的值大于右孩子节点的值,即右孩子节点的更小,仅需要对child++即可找到右孩子节点。
( 3 ) 比较arr[parent]与arr[child]的值,由于需要维持小堆,如果arr[parent]>arr[child],需要对父节点和子节点进行交换。
交换后的小堆为:[15,27,19,18,28,34,65,49,25,37]
( 4 ) 更新parent的索引和child的索引:parent=child; child=parent * 2 + 1;
( 5 ) 重复这个过程,直到它比子节点更小,或者到达叶节点位置(叶节点位置处满足:子节点不在堆的范围内)。
三、向下调整代码的实现:
cpp
void AdjustDown(HPDataType* a, int size, int parent)
{
//父亲节点下标:parent
//左孩子节点下标为:parent*2+1
//右孩子节点下标为:parent*2+2
//假设左孩子是最小的
int child = parent * 2+1;
while (child < size)
{
//保证child+1在有效范围
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;
}
}
//退出循环的条件
//1.父节点无子节点,即到达了叶节点位置,通过不满足while循环条件退出
//2.父节点的值 < 子节点的值,通过break退出
}
四、若原来是大堆,只需把比较符号反转,将 "<" 变为 ">", 即当前节点 > 父节点(大根堆性质破坏),则交换并继续下浮。
1.4利用向下调整算法建堆
向下调整能建堆,本质是利用 " 完全二叉树的特性 "+" 从后向前 " 的调整顺序,让每个非叶子节点调整后,其所在的子树都符合堆规则,最终扩散到整个树,形成完整堆。
这是一种局部到整体的思维,对于叶子节点而言,其可以被视为堆,所以只需要对非叶子节点所在的子树进行向下调整,直到每个节点所在的子树都满足堆的特性即完成了建堆。
代码示例:
cpp
void swap(int *a,int *b)
{
int tmp=*a;
*a=*b;
*b=tmp;
}
void AdjustDown(int* a, int size, int parent)
{
//父亲节点下标:parent
//左孩子节点下标为:parent*2+1
//右孩子节点下标为:parent*2+2
//假设左孩子是最小的
int child = parent * 2+1;
while (child < size)
{
//保证child+1在有效范围
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;
}
}
//退出循环的条件
//1.父节点无子节点,即到达了叶节点位置,通过不满足while循环条件退出
//2.父节点的值 < 子节点的值,通过break退出
}
void PrintArray(int arr[], int size)
{
for (int i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
}
int main()
{
int arr[] = { 9,6,4,7,5,3,2,1 };
int size = sizeof(arr) / sizeof(arr[0]);
printf("建堆前的数组: \n");
PrintArray(arr, size);
for (int i = (size-1-1)/2; i>=0 ; i--)
{
AdjustDown(arr, size, i);
}
printf("\n建堆后的数组:\n");
PrintArray(arr, size);
return 0;
}
如图所示:

时间复杂度分析: 以满二叉为例,对于向下调整算法建堆,++时间复杂度为O(N)++,分析如下所示

二、堆排序
堆是堆排序的 核心数据结构,其核心价值是" 高效维护最大值 或 最小值 ",堆排序的所有逻辑都围绕堆的这一特性展开,本质是把 " 无序数组 " 通过堆转化为 " 有序序列 "的过程。
核心步骤:
1.建堆:排序前的数组是完全无序的(比如: [4,6,8,5,9,1,3,2,7]),此时无法直接快速找到最大值 或 最小值。
堆的第一个作用就是:将无序数组转化为 大堆 或者 小堆,让数组满足 "堆顶是最大值 或 堆顶是最小值" 的特性。
2.反复 "取堆顶(最大值 或 最小值)+ 修复堆",构建有序序列:
建堆后,数组的最大值已经在堆顶,但其他元素仍无序。
堆的第二个作用是:持续提供(最大值 或 最小值),并快速修复堆结构,让每次都能高效取到 "剩余元素的(最大值或最小值)"。
2.1升序:建大堆
为什么说升序建大堆更合理,而不是建小堆更合理呢,这里是很多帅观众的疑惑?
我们知道,小堆的堆顶是堆中最小的元素,对于升序而言,需要每次把最小值放在数组的最前面,而第一个元素已经是最小值了,所以需要从第二个元素开始进行排列,但我们发现,以第二个元素为堆顶,其堆的结构就已经破坏了,需要再次对以第二个元素为堆顶进行建小堆,这样每次的消耗过大,所以更好的方式为:建大堆,请听我娓娓道来。
核心思想:
①目标:将数组排成 升序(小元素在前,大元素在后)
②堆类型:大堆(父节点 >= 子节点,堆顶是当前最大值)
③核心操作:建初始大堆 → 循环交换堆顶(最大值)与堆尾 → 调大堆。
步骤一:将无序数组建成大堆。

步骤二:将堆顶元素与最末尾元素进行互换,如图所示,将堆中最大的元素9与堆尾元素1进行互换,则最大元素放在了堆尾,最大值 9 被固定到数组末尾(最终位置),后续不再处理,未排序区域缩小为[1,7,8,2,5,6,4]。

步骤三:将堆尾元素1与堆顶元素交换后,此时堆顶元素为1,破坏了大堆的性质,所以需要进行向下调整,找出次大的元素放置堆顶。

步骤四:重复这个过程,即可将无序数组,排列成有序数组。
代码实现:
cpp
#include<stdio.h>
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void maxHeapify(int* arr, int size, int parent)
{
//假设左孩子节点最大
int child = parent * 2 + 1;
//进行向下调整,到根节点就退出即 child>=size
while ( child< size )
{
//假设失败,右孩子大于左孩子
if (child + 1 < size && arr[child + 1] > arr[child])
{
child++;
}
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
//更新父亲节点
parent = child;
//更新孩子节点
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int *a,int size)
{
//利用向下调整算法进行建堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
maxHeapify(a, size, i);
}
//end为队尾一个元素的下标
int end = size - 1;
//end=0即为最后一个元素无需排序
while (end > 0)
{
swap(&a[0], &a[end]);
//交换后需要向下调整的元素个数为end
maxHeapify(a, end, 0);
--end;
}
}
void testHeapSort()
{
int a[8] = { 4 , 2 , 8 , 1 , 5 , 6 , 9 , 7 };
printf("未进行堆排之前的元素顺序:\n");
for (int i = 0; i < 8; i++)
{
printf("%d ", a[i]);
}
HeapSort(a, 8);
printf("\n进行堆排之后的元素顺序:\n");
for (int i = 0; i < 8; i++)
{
printf("%d ", a[i]);
}
}
int main()
{
testHeapSort();
return 0;
}
2.2降序:建小堆
同理,若降序建小堆,通过将堆顶元素与堆尾元素进行交换后,然后不断进行调小堆,即可让堆中的最小元素放在堆尾,通过不断进行该操作即可让无序数组变成有序。
核心思想:
①目标:将数组排成 降序(大元素在前,小元素在后)
②堆类型:小顶堆(父节点 ≤ 子节点,堆顶是当前最小值)
③核心操作:建初始小堆 → 循环交换堆顶(最小值)与堆尾 → 调小堆」。
步骤一:将无序数组建成小堆。

步骤二:将堆顶元素与最末尾元素进行互换,如图所示,将堆中最小的元素1与堆尾元素7进行互换,则最小元素放在了堆尾,最小值1被固定到数组末尾(最终位置),后续不再处理,未排序区域缩小为[7,2,6,4,5,8,9]。

步骤三:将堆顶元素1与堆尾元素交换7后,此时堆顶元素为7,破坏了小堆的性质,所以需要进行向下调整,找出次小的元素放置堆顶。

步骤四:重复这个过程,即可将无序数组,排列成有序数组。

代码示例:
cpp
#include<stdio.h>
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void minHeapify(int* a, int size, int parent)
{
//父亲节点下标:parent
//左孩子节点下标为:parent*2+1
//右孩子节点下标为:parent*2+2
//假设左孩子是最小的
int child = parent * 2 + 1;
while (child < 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 HeapSort(int *a,int size)
{
//利用向下调整算法进行建堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
minHeapify(a, size, i);
}
//end为队尾一个元素的下标
int end = size - 1;
//end=0即为最后一个元素无需排序
while (end > 0)
{
swap(&a[0], &a[end]);
//交换后需要向下调整的元素个数为end
minHeapify(a, end, 0);
--end;
}
}
void testHeapSort()
{
int a[8] = { 4 , 2 , 8 , 1 , 5 , 6 , 9 , 7 };
printf("未进行堆排之前的元素顺序:\n");
for (int i = 0; i < 8; i++)
{
printf("%d ", a[i]);
}
HeapSort(a, 8);
printf("\n进行堆排之后的元素顺序:\n");
for (int i = 0; i < 8; i++)
{
printf("%d ", a[i]);
}
}
int main()
{
testHeapSort();
return 0;
}
三、topk问题
TopK 问题是算法中的经典场景 ------从海量数据(N个元素)中找出前 k 个最大 / 最小的元素。
比如:在1千万个人中,找出前10个最富有的人; 找出 1百万条数据中前 5 个最小的数 , 诸如此类问题,我们称之为TopK问题。
方法一:
可以通过使用堆这个数据结构,通过建小堆 / 建大堆 的方式取出堆顶元素,然后进行向下调整,找出次小的元素放在堆顶,然后取出次小的堆顶,依次重复k次,即可找出前 k 个最大 / 最小的元素。
但这样会面临一个问题,在建堆的时候,需要存储海量的数据,导致空间复杂度过高
假设需要存储10亿个整数
根据1GB=1024MB=1024×1024KB=1024×1024×1024Byte
需要大约4GB的内存,即使是放在文件磁盘中也是一个不小的消耗。
方法二:
以找出 1百万条数据中前 k 个最大的数为例,可以先建立k个大小为整形的大堆,然后将N-k个数据与堆顶数据进行比较,如果比堆顶的数据更大,则替代堆顶的数据,然后进行向下调整,继续维持堆的特性。
代码示例:
cpp
#define _CRT_SECURE_NO_WARNINGS
#include"Heap.h"
#include<time.h>
const int N = 10000;
void swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustDown(int* a, int size, int parent)
{
//父亲节点下标:parent
//左孩子节点下标为:parent*2+1
//右孩子节点下标为:parent*2+2
//假设左孩子是最小的
int child = parent * 2+1;
while (child < size)
{
if (child+1<size && a[child] > a[child + 1] )
{
child++;
}
if (a[child] < a[parent])
{
//交换父亲节点和孩子节点
swap(&a[child], &a[parent]);
//更新父亲节点
parent = child;
//更新孩子节点
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//从N个数中,选出K个最大的数
void TopK(int *a,int k)
{
int* kheap = malloc(sizeof(int) * k);
if (kheap == NULL)
{
perror("malloc fail");
return;
}
//读入k个数据
for (int i = 0; i < k; i++)
{
kheap[i] = a[i];
}
//利用向下调整建小堆
//从第一个非叶子节点开始调整到根的位置
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kheap, k, i);
}
//比较N-K个数据
for (int i = k; i < N; i++)
{
if (kheap[0] < a[i])
{
kheap[0] = a[i];
AdjustDown(kheap, k, 0);
}
}
printf("最大的十个数为: ");
for (int i = 0; i < k; i++)
{
printf("%d ", kheap[i]);
}
}
void testTopK()
{
int k;
printf("请输入k值: ");
scanf("%d", &k);
int* arr =(int *)malloc(sizeof(int) * N);
if (arr == NULL)
{
perror("arr malloc fail");
return 0;
}
srand((time_t)time(0));
for (int i = 0; i < N; i++)
{
arr[i] = rand()%10000+i;
}
arr[10] = 1000000 + 1;
arr[11] = 1000000 + 2;
arr[12] = 1000000 + 3;
arr[13] = 1000000 + 4;
arr[14] = 1000000 + 5;
arr[15] = 1000000 + 6;
arr[16] = 1000000 + 7;
arr[17] = 1000000 + 8;
arr[18] = 1000000 + 9;
arr[19] = 1000000 + 10;
TopK(arr, k);
}
int main()
{
testTopK();
return 0;
}
既然看到这里了,不妨关注+点赞+收藏,感谢大家,若有问题请指正。

