目录
一.引言
在众多排序算法中,堆排序(Heap Sort) 凭借其稳定的 O(NlogN) 时间复杂度和 O(1) 的空间复杂度,在学习和实际应用中占据着重要的地位。
但你真的了解堆排序的核心—— 建堆 过程吗?建堆方法看似简单,却隐藏着一个巨大的时间复杂度陷阱!本篇博客将深入剖析两种建堆方法(向上和向下),并通过严谨的复杂度分析,为你揭示 O(N) 建堆的奥秘,最终带你理解堆排序和它在 TopK 问题中的强大应用。
二.堆排序基础:堆与调整算法
堆(Heap) 本质上是一种特殊的完全二叉树。它满足堆的性质:
-
小堆(Min-Heap): 任意父节点的值都小于等于其子节点的值。
-
大堆(Max-Heap): 任意父节点的值都大于等于其子节点的值。

堆排序主要依赖两个核心操作:向上调整(AdjustUp)和 向下调整(AdjustDown)。
2.1 向上调整算法(AdjustUp)
向上调整 主要用于在堆中插入新元素时维护堆的性质。新元素通常作为完全二叉树的最后一个叶子节点加入,然后与它的父节点进行比较,若不满足堆的性质,则交换位置,直到根节点或满足性质为止。
我们通常用向上调整来实现向上建堆。

void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustUp(int* arr, int child)
{
// 通过数组下标关系找到父节点
int parents = (child - 1) / 2;
while (child > 0)
{
// 假设建小堆:如果子节点小于父节点,则交换
if (arr[child] < arr[parents])
{
Swap(&arr[child], &arr[parents]);
// 继续向上调整
child = parents;
parents = (child - 1) / 2;
}
else
{
break; // 满足堆性质,停止调整
}
}
}
2.2 向下调整算法(AdjustDown)
向下调整 主要用于在 删除堆顶元素或 建堆 时维护堆的性质。它从一个节点开始,将该节点与它的子节点比较,选择合适的子节点(大堆选大的,小堆选小的)进行交换,并向下递归,直到到达叶子节点或满足堆性质为止。
向下调整是实现向下建堆和 堆排序 的核心。

void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* arr, int n, int parents)
{
// 假设左孩子是最小的,n是数组的有效元素个数
int child = (parents * 2) + 1;
// 建小堆:谁小谁上去
while (child < n)
{
// 找出左右孩子中较小的那个
if ((child + 1) < n && arr[child + 1] < arr[child])
{
child++;
}
// 如果孩子比父节点小,则交换
if (arr[child] < arr[parents])
{
Swap(&(arr[child]), &(arr[parents]));
// 继续向下调整
parents = child;
child = (parents * 2) + 1;
}
else
{
break; // 满足堆性质,停止调整
}
}
}
三.核心比较:两种建堆方法的效率分析
3.1 向上建堆(O(NlogN))
思路: 假设初始数组为空堆,从第二个元素(下标 i=1)开始,依次将其插入到堆中,每次插入都利用 AdjustUp 向上调整。
// 向上调整算法建堆:复杂度O(NlogN)
for (int i = 1;i < n;i++)
{
AdjustUp(a, i); // 每次插入都进行一次O(logN)的调整
}
时间复杂度分析:因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。

结论:借助于 向上调整算法 实现的 向上建堆,虽然从思路上好理解,但是由于越靠近底层的节点,其位于的层数越高,调整次数越多,时间复杂度就较高。
3.2 向下建堆(O(N))
思路: 将初始数组视为一棵完全二叉树,从第一个非叶子节点(下标为 (N - 1 - 1) / 2)开始,向前遍历到根节点,对每一个节点都执行一次 AdjustDown 向下调整。
// 向下算法建堆:复杂度O(N)
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
{
AdjustDown(a, n, i); // 对所有非叶子节点进行向下调整
}
时间复杂度分析:因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。

结论: 借助于 向下调整算法 实现的 向下建堆,时间复杂度为 O(N),相比向上建堆的 O(NlogN) 有了显著的效率提升,是建堆的首选方法。
四.堆排序的实现(O(NlogN))
堆排序主要分为两个阶段:
-
建堆: 使用 O(N) 的向下调整算法将初始数组建成一个堆。
-
排序: 循环执行以下操作 N−1 次:
-
将堆顶元素(最大或最小)与数组末尾元素交换。
-
将堆的大小减一。
-
对新的堆顶元素执行 O(logN) 的 向下调整算法,恢复堆的性质。
-


重点:
①排序的过程类似于堆删除的思想。
②建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
③堆排序的机制是每次取出堆顶元素(大堆取最大值,小堆取最小值)放到数组末尾,为了最终实现升序排列,你需要不断取出最大值(建大堆)放到后面;反之,要实现降序排列,你需要不断取出最小值(建小堆)放到后面。
void HeapSort(int* a, int n)
{
// 阶段一:O(N) 向下调整算法建堆(此处建小堆)
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
{
AdjustDown(a, n, i);
}
// 阶段二:O(NlogN) 排序过程
int end = n - 1;
while (end > 0)
{
// 1. 交换堆顶(最小元素)和当前有效数组的最后一个元素
Swap(&a[0], &a[end]);
// 2. 有效数组长度减一
// 3. 对新的堆顶元素进行向下调整(O(logN))
AdjustDown(a, end, 0);
end--;
}
}
时间复杂度分析:堆排序在排序阶段的时间复杂度为 O(N log N),因为虽然每次“向下调整”最多需要 O(log N) 时间,但各层节点数不同、调整次数分布与向上建堆类似(节点多的层调整次数多,节点少的层调整次数少),整体求和仍为 O(N log N)。
五.TopK 问题
题目描述:从 N 个数据中找出最大的 K 个数,或最小的 K 个数。
问题分析:如果我们使用完整的排序算法(如归并或快排),时间复杂度为 O(NlogN)。如果我们对所有数据建堆,再依次借助堆删除的操作执行K次,那空间复杂度为O(N)。这样做效果都不好。
解决方法:若找最大的 K 个数,则建立一个容纳 K 个元素的小堆(若找最小,则建大堆)。
-
遍历 N 个数据,如果堆未满,直接插入。
-
如果堆已满,将当前数据与堆顶(堆中最小的元素)比较:
-
如果数据大于堆顶,则弹出堆顶,并插入新数据,然后进行 O(logK) 的调整。
-
如果数据小于等于堆顶,则忽略。
-
-
最终,这个小堆中剩下的 K 个元素就是最大的 K 个数。
void FindTopK(int* a, int n, int k)
{
if (k <= 0 || k > n)
{
// K 值不合法
return;
}
// 1. 创建大小为 K 的数组作为 TopK 堆
int* topKHeap = (int*)malloc(sizeof(int) * k);
if (topKHeap == NULL) return;
// 2. 初始化堆:将原始数组的前 K 个元素放入 TopK 堆
for (int i = 0; i < k; i++)
{
topKHeap[i] = a[i];
}
// 3. 构建初始小堆 (O(K)):用于存储当前最大的 K 个元素
// 注意:AdjustDown(arr, size, parent)
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(topKHeap, k, i);
}
// 4. 遍历剩余的 N-K 个元素 (O((N-K) log K))
for (int i = k; i < n; i++)
{
// 如果当前元素比堆顶(K个元素中最小的那个)大
if (a[i] > topKHeap[0])
{
// 替换堆顶
topKHeap[0] = a[i];
// 重新向下调整,恢复小堆性质
AdjustDown(topKHeap, k, 0);
}
}
// 5. 最终 topKHeap 中存储的就是最大的 K 个元素
printf("最大的 %d 个元素是: ", k);
for (int i = 0; i < k; i++)
{
printf("%d ", topKHeap[i]);
}
printf("\n");
free(topKHeap);
}
时间复杂度:O(NlogK)
-
遍历 N 个元素,最多对堆进行 N 次操作(插入或替换)。
-
每次操作(堆的调整)的时间复杂度是 O(logK),因为堆的大小始终保持为 K。
-
总时间复杂度为 O(NlogK)。
空间复杂度:O(K)
-
算法只需要额外的空间来存储一个大小为 K 的堆。
-
与 O(N) 的输入规模相比,这是一个非常高效的常数级空间开销。
感谢各位读者阅读,大家多多点赞、收藏、+++关注!!!
800

被折叠的 条评论
为什么被折叠?



