写在前面
上一篇我们已经完成了二叉堆的完整实现,从完全二叉树的数组存储出发,学习了 HeapAdjustUp、HeapAdjustDown、HeapPush、HeapPop,并进一步实现了建堆与堆排序。
当时我们直接得到了两个很重要的结论: 向上调整建堆=O(N logN)向下调整建堆=O(N);
但这里其实很容易产生一个疑问:
HeapAdjustUp和HeapAdjustDown单次最坏不都是沿完全二叉树走一条路径,复杂度都是 O(log N) 吗?为什么拿来建堆以后,一个是 O(N logN),另一个却变成了 O(N)?
所以这一篇先不急着继续增加堆接口,而是把这两个复杂度真正推导一遍,使用等比数列和错位相减理解两种建堆方式为什么会产生差距。
证明完复杂度以后,再把堆放到一个更实际的问题里:
如果有 1 亿个整数,只需要找出最大的 10 个,而且内存非常有限,甚至无法一次性存放全部数据,该怎么办?
这也是堆相比"完整排序"更能体现价值的地方。
代码仓库
本篇全部测试代码已上传至 Gitee 仓库。项目复用之前实现的二叉堆模块,不改动原有 Heap.h、Heap.c,新增独立测试文件 Heap_TopK_Test.c。
文件中包含测试数据集生成函数 GenerateData,可以输出二进制文件模拟海量数据;TestTopK 实现流式读取,基于小根堆完成 Top‑K 查找,同时内置自动化结果校验,复现文中内存受限场景下的算法逻辑。
数据结构/Heap_TopK_Test · Luminous/Code_2026 - 码云 - 开源中国
一、先明确:单次调整为什么是 O(log N)
堆在逻辑上是一棵完全二叉树。
对于包含 (N) 个节点的完全二叉树,它的高度大约为:

向上调整的过程,是从当前节点沿父节点不断向根靠近:
当前节点
↑
父节点
↑
父节点
↑
根
向下调整则正好相反:
根
↓
孩子节点
↓
孩子节点
↓
叶子
最坏情况下,两种调整都只会经过一条根到叶子的路径,因此:HeapAdjustUp=O(log N) HeapAdjustDown=O(log N)
但这里需要区分:
单次调整的复杂度 和利用调整函数完成整个建堆过程的复杂度并不是一回事。
真正决定两种建堆方式差异的,是不同层节点的数量以及这些节点最多需要移动多少层。
二、向上调整建堆模拟不断插入元素
上一篇代码中保留了这种建堆方式:
cpp
for (int i = 1; i < n; i++)
{
HeapAdjustUp(a, i);
}
它的思想和连续执行 HeapPush 基本一致:
前面的元素已经组成一个堆
↓
把下一个元素看成新插入节点
↓
向上调整
↓
继续插入下一个元素
例如数组:
12 45 7 89 23 56
先把 12 看成一个只有根节点的堆,再依次把 45、7、89、23、56 加进来,每加入一个元素,都对新节点执行一次 HeapAdjustUp。
问题就在于:
越靠近底层的节点数量越多,而这些节点理论上能够向上移动的距离也越长。
三、向上建堆的复杂度证明
为了方便推导,假设是一棵高度为 h 的满二叉树,并把根所在深度记为 0。
节点总数为: N=2^{h-1}
各层节点数量和最多向上移动的距离如下:
| 深度 | 节点数量 | 最多向上调整 |
|---|---|---|
| 0 | 1 | 0 |
| 1 | 2 | 1 |
| 2 | 2^2 | 2 |
| 3 | 2^3 | 3 |
| ... | ... | ... |
| h-1 | 2^{h-1} | h-1 |
最坏情况下,总调整次数可以写成: 
即:

3.1 使用错位相减
原式:

两边乘 2:

用 (2S-S) 做错位相减:

括号中是等比数列:

代入:

化简:

因为:

所以:

同时:

代入后,最高阶项就是:

因此最终得到:

四、为什么向上建堆=O(N logN)比较"吃亏"
从树形结构来看会更加直观:
●
/ \
● ●
/ \ / \
● ● ● ●
/ \ / \ / \ / \
● ● ● ● ● ● ● ● ● ● ● ●
越往下:
- 节点数量越多;
- 这些节点距离根越远;
- 最坏情况下需要执行的交换次数越多。
也就是说:
节点最多的位置,恰好也是向上调整成本最高的位置。
因此所有调整成本累积起来,最终达到 O(N logN)。
这也是为什么,当完整数组已经摆在面前时,一般不会优先选择(向上建堆=O(N logN)。
五、向下调整建堆从最后一个非叶子节点开始
上一篇实际使用的是:
cpp
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
HeapAdjustDown(a, n, i);
}
和(向上建堆=O(N logN))最大的不同是:
叶子节点根本不需要调整。
因为叶子没有孩子,无论它是多少,都天然满足"父子大小关系"。
所以只需要从:
最后一个非叶子节点
↓
一直调整到根节点
即可。
例如:
12
/ \
45 7
/ \ /
89 23 56
最底层的:
89 23 56
都是叶子,直接跳过。
真正需要处理的只有:
7
45
12
六、向下建堆复杂度证明
仍然假设高度为 (h) 的满二叉树。
这一次从底部向上统计。
| 距离叶子的高度 | 节点数量 | 最多向下调整 |
|---|---|---|
| 1 | 2^{h-2} | 1 |
| 2 | 2^{h-3} | 2 |
| 3 | 2^{h-4} | 3 |
| ... | ... | ... |
| h-1 | 1 | h-1 |
因此最坏情况下:

写成:

6.1 再用一次错位相减
两边乘 2:

然后计算 (2S-S),得到:

其中:

因此:

化简:

因为:

所以:

忽略低阶项:

七、为什么向下建堆反而更快
观察节点分布:
● ← 能向下很多层,但只有1个
/ \
● ● ← 调整较多,但节点很少
/ \ / \
● ● ● ● ← 调整很短
/ \ / \ / \ / \
● ● ● ● ● ● ● ● ● ● ● ● ← 数量最多,但叶子不调整
向下建堆正好和向上建堆反过来了:
- 数量最多的叶子节点调整次数为 0;
- 靠近底部的非叶子节点数量很多,但通常只向下走 1 层;
- 真正可能走很多层的节点靠近根部,而这些节点数量非常少。
因此所有节点的调整成本加起来只有 (O(N))。
所以完整数组建堆时:
优先使用向下调整。
八、两种建堆方式对比
| 对比 | 向上调整建堆 | 向下调整建堆 |
|---|---|---|
| 思想 | 模拟不断 Push | 从最后非叶子节点开始 |
| 调整方向 | 子 → 父 | 父 → 子 |
| 单次调整 | O(log N) | O(log N) |
| 整体建堆 | O(N logN) | O(N) |
| 适用场景 | 元素逐个到达 | 已有完整数组 |
因此可以简单记成:
动态插入用向上调整,完整数组建堆用向下调整。
但比记结论更重要的是理解:
两者差距来自完全二叉树不同层节点数量与调整距离的分布。
九、问题升级:1 亿个数据只找最大的 10 个
前面堆排序解决的是:
把所有数据完整排序。
例如:
HeapSort(arr, n);
如果排序完成后只取:
arr[0] ~ arr[9]
当然可以得到前 10 大。
但如果数据量变成:
100000000
也就是 1 亿个整数呢?
假设一个 int 占 4 字节,仅原始数组就需要:100000000*4=400000000Bytes
约为: 381MiB
如果可用内存只有 1MB,那么连这些数据都无法一次性读进内存。
这时候第一反应可能是:
把数据分组,每组排序,取每组最大的几个,再继续筛选。
这种方法能做,但仔细想一下:
最终只要 10 个最大值,有必要把每一组数据全部排好吗?
答案显然是否定的。
所以可以进一步优化为:
从始至终只维护 10 个候选元素。
这就是经典的 Top-K 问题。
十、找前 K 大为什么使用小根堆
假设:
K = 10
先读取前 10 个数据,建立一个小根堆。
这里很容易记反:
找最大的数据,为什么反而用小根堆?
因为小根堆的堆顶,是当前 10 个候选值中最小的一个。
它刚好就是 Top-10 的准入门槛。
假设当前堆顶为:
72
新读到:
60
因为:
60 <= 72
说明 60 连当前第 10 大都比不过,直接丢弃。
如果新读到:
100
因为:
100 > 72
它就应该进入 Top-10。
直接:
cpp
topK[0] = 100;
HeapAdjustDown(topK, 10, 0);
替换掉当前最小候选,再执行一次向下调整恢复小根堆。
因此整个过程只有:
读取前K个
↓
建立小根堆
↓
后续每个数据和堆顶比较
↓
更大 → 替换堆顶 → 向下调整
↓
否则直接忽略
十一、Top-K复杂度
前 (K) 个元素建堆: O(K)
后面的 (N-K) 个元素,每个至少比较一次。
如果进入 Top-K,则执行一次 O(log K) 向下调整。
最坏情况下: O(N\log K)
空间只需要: O(K)
当:
K = 10
是固定常数时,log K也可以看作常数,因此整个过程基本就是 O(N) 的一次扫描。
如果是前 10 大:
前10大 → 小根堆
如果是前 10 小:
前10小 → 大根堆
本质都是让:
堆顶成为当前候选集合中最应该被淘汰的元素。
十二、简单测试:随机生成 10 万个数据
理论理解后,用 10 万个数据简单验证即可,没有必要真的生成 1 亿个数据。
测试分成两个函数:
cpp
GenerateData();
TestTopK();
生成和测试分别运行,在 main 中注释掉其中一个即可。
为了让每次生成的数据不同,随机数生成前加入:
cpp
srand((unsigned int)time(NULL));
使用当前时间作为随机种子。
普通数据写成:
cpp
rand() % 1000000 + i
这里加上循环变量 i 只是一个很常见的小处理,让不同位置的数据再多一点变化,一定程度上减少重复值即可,并不保证所有数据完全不同,也没有必要保证。
普通数据最大约为: 999999+99999<1100000
然后在数据中随机放入 10 个明显更大的值:
10000000
20000000
...
100000000
这样最终 Top-10 的正确答案就是确定的。
十三、生成测试数据
cpp
#include "Heap.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define DATA_COUNT 100000
#define TOP_K 10
#define RANDOM_RANGE 1000000
#define SPECIAL_STEP 10000000
void GenerateData(void)
{
FILE* pf = fopen("data.bin", "wb");
if (pf == NULL)
{
perror("fopen fail");
return;
}
srand((unsigned int)time(NULL));
int specialPos[TOP_K];
/*
10万数据分成10段,
每段随机选一个位置存特殊值。
这样位置随机,同时天然不会重复。
*/
for (int i = 0; i < TOP_K; i++)
{
specialPos[i] =
i * (DATA_COUNT / TOP_K)
+ rand() % (DATA_COUNT / TOP_K);
}
for (int i = 0; i < DATA_COUNT; i++)
{
/*
加上i只是让不同位置多一点变化,
减少重复概率,不保证完全无重复。
*/
int value = rand() % RANDOM_RANGE + i;
for (int j = 0; j < TOP_K; j++)
{
if (i == specialPos[j])
{
value = (j + 1) * SPECIAL_STEP;
break;
}
}
fwrite(&value, sizeof(int), 1, pf);
}
fclose(pf);
printf("已生成%d个测试数据\n", DATA_COUNT);
}
这里的特殊值最小为:
10000000
而普通值最大不到:
1100000
所以可以确定这 10 个特殊值就是全局最大的 10 个。
十四、流式读取并测试 Top-10
测试阶段不把 10 万个整数重新装进数组,而是直接从文件逐个读取。
这也更符合前面讨论的:
数据量大时不一次性全部加载到内存。
cpp
int CompareDesc(const void* p1, const void* p2)
{
int x = *(const int*)p1;
int y = *(const int*)p2;
if (x < y)
return 1;
else if (x > y)
return -1;
return 0;
}
void TestTopK(void)
{
FILE* pf = fopen("data.bin", "rb");
if (pf == NULL)
{
perror("fopen fail");
return;
}
HPDataType topK[TOP_K];
// 先读前10个作为初始候选
if (fread(topK,
sizeof(HPDataType),
TOP_K,
pf) != TOP_K)
{
printf("测试数据不足\n");
fclose(pf);
return;
}
// 前10个数据建立小根堆
for (int i = (TOP_K - 2) / 2; i >= 0; i--)
{
HeapAdjustDown(topK, TOP_K, i);
}
HPDataType x;
// 后续数据逐个读取
while (fread(&x, sizeof(HPDataType), 1, pf) == 1)
{
if (x > topK[0])
{
topK[0] = x;
HeapAdjustDown(topK, TOP_K, 0);
}
}
fclose(pf);
/*
此时topK中已经保存正确的前10大,
但它只是一个小根堆,并没有整体有序。
为了方便查看,只对这10个结果排序。
*/
qsort(
topK,
TOP_K,
sizeof(HPDataType),
CompareDesc
);
printf("Top-%d:\n", TOP_K);
for (int i = 0; i < TOP_K; i++)
{
printf("%d ", topK[i]);
}
printf("\n");
// 简单验证结果
int pass = 1;
for (int i = 0; i < TOP_K; i++)
{
int expected =
(TOP_K - i) * SPECIAL_STEP;
if (topK[i] != expected)
{
pass = 0;
break;
}
}
if (pass)
printf("TEST PASS\n");
else
printf("TEST FAIL\n");
}
十五、main 中生成与测试分别运行
第一次运行,先生成数据:
cpp
int main(void)
{
GenerateData();
//TestTopK();
return 0;
}
生成:
data.bin
之后改为:
cpp
int main(void)
{
//GenerateData();
TestTopK();
return 0;
}
进行测试。
正确结果应该是:
100000000
90000000
80000000
70000000
60000000
50000000
40000000
30000000
20000000
10000000
最后输出:
TEST PASS
就说明这 10 个事先放入随机位置的大数都被正确找了出来。
十六、完整排序和 Top-K 的区别
假设有:N=100000000个数据,只需要:K=10个最大值。
完整排序解决的是:
所有数据之间的顺序关系。
时间复杂度: O(N logN)
而 Top-K 只关心:
哪些元素属于最大的 K 个。
因此: O(N logK)
空间只需要:O(K)
当
时,差距会非常明显。
尤其数据来自:
- 文件;
- 网络;
- 日志;
- 数据流;
这些场景时,可以边读边处理,完全没有必要把所有数据同时放进内存。
写在最后
上一篇主要解决的是:
堆怎么写、向上调整和向下调整怎么实现。
这一篇则继续追问:
为什么两种建堆方式复杂度会不同?
通过节点数量、调整距离以及错位相减可以看到:向上建堆=O(N logN), 而:向下建堆=O(N);
两者虽然都使用单次 O(log N) 的调整函数,但不同层节点数量和实际调整距离不同,最终导致了完全不同的整体复杂度。
再往后看 Top-K,会更明显地发现:
堆真正有价值的地方,并不是一定要把所有数据排序。
如果 1 亿个数据只需要最大的 10 个,那么剩下 99999990 个数据之间谁大谁小,其实根本不重要。
这时候只维护一个 10 个元素的小根堆,就可以把空间从 O(N) 降到 O(K),同时避免完整排序的大量无效工作。
从完整排序到 Top-K,也是对数据结构作用的一次更直观理解:优化不只是让原来的步骤执行得更快,有时候更重要的是先判断哪些步骤根本没有必要执行。