建堆的两种算法:O(N) 和 O(N log N) 差在哪里
结论可以背。但要在两种建堆之间做选择,得知道代价落在了谁头上。
为什么需要
上一节已经会写向下调整建堆了,也背下了那个结论:建堆的时间复杂度是 O(N)。
但这个结论是反直觉的。建堆要对大约 N/2 个结点各做一次调整,每次调整最坏走 log N 层------按乘法直觉,乘积不就该是 O(N log N) 吗?
于是出现了两种错误认知,它们都很自然,也都很常见:
- 错误一:认为向下调整建堆是 O(N log N),O(N) 是记错了;
- 错误二:认为向上调整建堆也是 O(N),因为"建堆是 O(N)"这句话被当成了普适结论。
真实情况是:两个算法做的事情一样,复杂度不一样;而在平均输入下,它们的复杂度又一样。 这一篇把两个推导都摊开算清楚,再用一个可运行的程序验证一遍。
两个算法摆在一起
c
// A. 向上调整建堆:模拟插入
void BuildByUp(int* a, int n)
{
for (int i = 1; i < n; ++i) // 从前到后
AdjustUp(a, i);
}
// B. 向下调整建堆:从最后一个非叶子结点倒着调
void BuildByDown(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; --i) // 从后到前
AdjustDown(a, n, i);
}
两段循环都只遍历"非根"或"非叶子"的结点,工作量看起来同级。唯一的差别是遍历方向相反。
而这个方向,恰好决定了代价落在哪一批结点头上。这就是全部的秘密。
核心机制:代价落在谁头上
先约定两棵完全二叉树上的量:
- 结点的深度 d :从根往下数,根是第 0 层。向下调整不用它。
- 结点的高度 h :从该结点往下到最远叶子的边数,叶子高度为 0。向上调整不用它。
注意这两个量是反着来的:越靠近根的结点深度小、高度大;越靠近底的结点深度大、高度小。
向上调整建堆:代价 = 深度
下标为 i 的结点,深度是 ⌊log₂(i+1)⌋。向上调整最坏情况下要把这个结点一路送到根,也就是走满它的深度那么多层。
而完全二叉树第 d 层有 2^d 个结点(d 从 0 起),所以:
S_up = Σ(d=0 → H) d · 2^d
= (H-1) · 2^(H+1) + 2
又有 n ≈ 2^(H+1),代入:
S_up ≈ n · (H - 1) ≈ n · log₂n
最贵的活被分配给了最多的结点。 底层那大约 n/2 个结点,每一个都可能一路走到根。
向下调整建堆:代价 = 高度
高度为 h 的结点,向下调整最坏沉 h 层。而完全二叉树中高度为 h 的结点大约有 n / 2^(h+1) 个:
S_down = Σ(h=0 → H) h · (n / 2^(h+1))
= n · Σ(h=0 → ∞) h / 2^(h+1)
右边那个级数是收敛的。把它逐项摊开看:
| h | 该项 h/2^(h+1) |
累计 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 0.25 | 0.25 |
| 2 | 0.25 | 0.50 |
| 3 | 0.1875 | 0.6875 |
| 4 | 0.125 | 0.8125 |
| 5 | 0.078125 | 0.890625 |
| 6 | 0.046875 | 0.9375 |
| 7 | 0.02734375 | 0.96484375 |
| 8 | 0.015625 | 0.98046875 |
| 9 | 0.0087890625 | 0.989257... |
| ⋯ | ⋯ | → 1 |
S_down ≈ n · 1 = n
最贵的活被分配给了最少的结点。 底层那大约 n/2 个结点是叶子,叶子本身就是合法的堆,一层都不用走。贵的工作只留给了根附近那 O(1) 个结点。
一张表总结
| 向上调整建堆 | 向下调整建堆 | |
|---|---|---|
| 遍历方向 | 从前到后,i = 1 ... n−1 | 从后到前,i = (n−2)/2 ... 0 |
| 代价由什么决定 | 结点的深度 | 结点的高度 |
| 底部约 n/2 个结点 | 每个付最多 log n 层 | 每个付 0 层 |
| 靠近根的少数结点 | 付得很少 | 付得最多 |
| 总代价 | Σ d·2^d ≈ n log₂n |
n · Σ h/2^(h+1) ≈ n |
| 最坏复杂度 | O(N log N) | O(N) |
一句话概括这个反转:
✶ Insight ─────────────────────────────────────
- 两个算法做的事完全一样,区别只在谁承担了最贵的路径。向上调整把 log n 层的跋涉压给了 n/2 个底层结点;向下调整把这份跋涉留给了根附近的 O(1) 个结点,底层全部零成本。
- 递归/分治结构里,"代价随深度增长"而"结点数随深度指数增长"时,让深层的多数结点做轻活永远划算。归并排序的递归树、快速排序的平均情况分析,用的是同一套算术。
Σ h/2^(h+1) = 1这个收敛级数是整个 O(N) 结论的唯一来源。背结论不如记住这一行。
─────────────────────────────────────────────────
手算一遍:错位相减法
前面两条推导都停在 ≈ n 和 ≈ n log₂n。这个精度足够判断复杂度量级,但不够回答"到底差多少倍"。把求和式精确算出来,差距会变成一个具体的数字。
约定:树有 h 层 (根在第 1 层,叶子在第 h 层),满二叉树时 n = 2^h − 1。
向下调整建堆
从下往上数第 k 层的结点有 2^(h−k) 个,它们的高度是 k−1,所以最坏要往下沉 k−1 层:
T_down = Σ(k=1→h) 2^(h−k) · (k−1)
令 j = k−1,把公因子 2^(h−1) 提出来:
T_down = Σ(j=1→h−1) 2^(h−1−j) · j
= 2^(h−1) · Σ(j=1→h−1) j / 2^j
里面的和式有闭式:Σ(j=1→m) j/2^j = 2 − (m+2)/2^m。代入 m = h−1:
T_down = 2^(h−1) · [ 2 − (h+1)/2^(h−1) ]
= 2^h − (h + 1)
再用 n = 2^h − 1 换掉 2^h:
T_down = (n + 1) − h − 1 = n − h = n − log₂(n+1)
一个严丝合缝的 n − log₂(n+1)。 不是"约等于 n",而是精确地比 n 少了整整一个树高。
向上调整建堆
从上往下数第 k 层的结点有 2^(k−1) 个,深度是 k−1,最坏要往上浮 k−1 层:
T_up = Σ(k=1→h) 2^(k−1) · (k−1)
同样令 j = k−1:
T_up = Σ(j=1→h−1) 2^j · j
这个和式用错位相减法------两式错开一位再相减:
S = 1·2 + 2·4 + 3·8 + ⋯ + (h−1)·2^(h−1)
2S = 1·4 + 2·8 + ⋯ + (h−2)·2^(h−1) + (h−1)·2^h
──────────────────────────────────────────────────── 上下相减
−S = (2 + 4 + 8 + ⋯ + 2^(h−1)) − (h−1)·2^h
−S = (2^h − 2) − (h−1)·2^h
S = (h−1)·2^h − 2^h + 2
S = (h − 2)·2^h + 2
再用 2^h = n + 1:
T_up = (h − 2)(n + 1) + 2 = (n + 1)·log₂(n+1) − 2n
错位相减这一步是整个推导的关键:它把"等差 × 等比"的求和式压成了一个闭式,不用再跟级数打交道。
现在可以算倍率了
两条精确公式摆在一起,比值直接写成:
T_up / T_down = [ (n+1)·log₂(n+1) − 2n ] / [ n − log₂(n+1) ]
n 很大时分子约等于 (h−2)·n,分母约等于 n,于是比值 ≈ h − 2 ≈ log₂n − 2:
| n | h | T_up | T_down | 比值 |
|---|---|---|---|---|
| 1 023 | 10 | 8 194 | 1 013 | 8.09 |
| 32 767 | 15 | 425 986 | 32 752 | 13.01 |
| 524 287 | 19 | 8 912 898 | 524 268 | 17.00 |
| 1 048 575 | 20 | 18 874 370 | 1 048 555 | 18.00 |
比值从 8 爬到 18,一步一台阶,台阶高度正好是 1------因为它跟着 h 走。"差一个数量级"这个说法太保守了:n 到百万级时已经差 20 倍,而且这个倍数还在随 log n 增长。
公式被机器逐位确认
上面两个式子不是估算,是精确值。在一个真实程序里用升序数组(两种建堆的最坏输入)跑一遍,把实测的交换次数和公式并排:
n h | T_up公式 T_down公式 | 实测up 实测down
-----------|------------------------------|--------------------
1023 10 | 8194 1013 | 8194 1013
4095 12 | 40962 4083 | 40962 4083
32767 15 | 425986 32752 | 425986 32752
524287 19 | 8912898 524268 | 8912898 524268
1048575 20 | 18874370 1048555 | 18874370 1048555
4194303 22 | 83886082 4194281 | 83886082 4194281
h 从 10 一直取到 22,13 组数据,公式与实测逐位相等,一个数都不差。
这一下就把"O(N)"从一个量级判断坐实成了一个可预测的数字 。取 n = 4194303:
- 向下调整建堆交换 4 194 281 次------比结点数少 22 次,不多不少,正好是树高;
- 向上调整建堆交换 83 886 082 次;
- 比值 20 倍。
⚠️ 想复现这张表,输入必须是严格升序。任何一点数据扰动都会让它不再是"最坏情况",实测值就会掉到公式之下------公式描述的是上界,不是下界。
代码演示:亲手数一数
推导可以信,也可以自己跑一遍。下面这个程序给两种建堆各加一个交换计数器,同时校验建出来的确实是合法大堆------防止"两个都错但都很快"。
c
#include <stdio.h>
#include <stdlib.h>
static long g_swaps = 0;
static void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
++g_swaps; // 计数器:交换次数就是调整代价
}
static void AdjustUp(int* a, int child) /* 大堆向上调整 */
{
int parent = (child - 1) / 2;
while (child > 0) // 用 child > 0,别依赖整除
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
break;
}
}
static void AdjustDown(int* a, int n, int parent) /* 大堆向下调整 */
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
++child; // 找大的那个孩子
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
static long BuildByUp(int* a, int n)
{
g_swaps = 0;
for (int i = 1; i < n; ++i)
AdjustUp(a, i);
return g_swaps;
}
static long BuildByDown(int* a, int n)
{
g_swaps = 0;
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
AdjustDown(a, n, i);
return g_swaps;
}
/* 校验:每个父结点都不小于自己的孩子 */
static int IsMaxHeap(const int* a, int n)
{
for (int i = 0; i < n; ++i)
{
int l = 2 * i + 1, r = 2 * i + 2;
if (l < n && a[l] > a[i]) return 0;
if (r < n && a[r] > a[i]) return 0;
}
return 1;
}
static unsigned int g_seed = 20260916u;
static int NextRand(void) /* 线性同余,避免 RAND_MAX 太小 */
{
g_seed = g_seed * 1103515245u + 12345u;
return (int)((g_seed >> 1) & 0x7FFFFFFF);
}
static void FillAscending(int* a, int n) { for (int i = 0; i < n; ++i) a[i] = i; }
static void FillRandom(int* a, int n) { for (int i = 0; i < n; ++i) a[i] = NextRand(); }
int main(void)
{
const int ns[] = { 1000, 10000, 100000, 1000000 };
const int cnt = (int)(sizeof(ns) / sizeof(ns[0]));
const char* names[] = { "升序数组(最坏)", "随机数组(平均)" };
void (*fillers[])(int*, int) = { FillAscending, FillRandom };
for (int f = 0; f < 2; ++f)
{
printf("=== 输入: %s ===\n", names[f]);
printf("%9s %13s %13s %9s %9s\n",
"n", "AdjustUp", "AdjustDown", "比值", "建堆正确");
for (int t = 0; t < cnt; ++t)
{
int n = ns[t];
int* a = (int*)malloc(sizeof(int) * n);
int* b = (int*)malloc(sizeof(int) * n);
if (!a || !b) { printf("malloc failed\n"); return 1; }
fillers[f](a, n);
for (int i = 0; i < n; ++i) b[i] = a[i]; // 同一组数据各建一次
long up = BuildByUp(a, n);
long down = BuildByDown(b, n);
int ok = IsMaxHeap(a, n) && IsMaxHeap(b, n);
printf("%9d %13ld %13ld %9.2f %9s\n",
n, up, down, (double)up / (double)down, ok ? "yes" : "NO!");
free(a);
free(b);
}
printf("\n");
}
return 0;
}
为什么选升序数组当"最坏输入" :排大堆时,新元素要往上走,条件是它比父结点大。升序数组里每个新元素都比前面所有元素大,所以每一次插入都会被一路送到根------正好触发向上调整的最大代价。同理,向下调整在升序数组里每个内部结点都会沉到自己的最大高度。
编译运行(gcc -O2 -o bench bench.c)的结果:
=== 输入: 升序数组(最坏) ===
n AdjustUp AdjustDown 比值 建堆正确
1000 7987 992 8.05 yes
10000 113631 9992 11.37 yes
100000 1468946 99990 14.69 yes
1000000 17951445 999988 17.95 yes
=== 输入: 随机数组(平均) ===
n AdjustUp AdjustDown 比值 建堆正确
1000 1290 752 1.72 yes
10000 12648 7427 1.70 yes
100000 128402 74408 1.73 yes
1000000 1280419 744263 1.72 yes
三个值得停下来看的现象
① 升序输入时,AdjustDown 的次数几乎精确等于 n。
992 / 9992 / 99990 / 999988 ------ 分别是 1000、10000、100000、1000000 的 0.99 倍左右。这就是 S_down ≈ n · 1 = n 的直接证据。那些常数 Σ h/2^(h+1) = 1,在真实运行里就是这么一副样子。
② 比值随 log n 增长,与推导吻合。
log₂1000 ≈ 9.97,log₂1000000 ≈ 19.93。实测比值从 8.05 涨到 17.95 ------ 与推导的 S_up ≈ n·log₂n(系数约 0.9)同步。
顺便验算一下 n = 10⁶ 的理论值:它对应的满树 H = 19,代入 (H-1)·2^(H+1) + 2 = 18 × 1048576 + 2 = 18,874,370,实测 17,951,445。差 5%,原因是 n = 10⁶ 的最后一层没有填满(满树应有 1,048,575 个结点)。理论值和实测对得上。
③ 代价高度集中在最深几层。
在 n = 10⁶ 的升序用例里,按 depth(i) = ⌊log₂(i+1)⌋ 逐层统计:
| 深度 | 结点数 | 该层贡献 |
|---|---|---|
| 19(最深) | 475,713 | 9,038,547 |
| 18 | 262,145 | 4,718,610 |
| 17 | 131,072 | 2,228,224 |
| 16 | 65,536 | 1,048,576 |
| 15 | 32,768 | 491,520 |
最深三层就占了总代价的 89%。 这就是 Σ d·2^d 里那个"2^d 增长压过 d 增长"的具体形态------不是抽象结论,是算得出来的账。
一个反直觉的事实:随机输入下两个都是 O(N)
看第二张表:比值恒为 1.72,与 n 完全无关。
也就是说,随机数据上,向上调整建堆也是线性的。实测每插入一个元素平均只交换约 1.28 次(1,280,419 ÷ 999,999),这是个常数,所以总量是 O(N)。
原因不难理解:新元素要往上走 h 层,必须比到根路径上一连 h 个祖先都大。而祖先本身已经满足堆序------父结点是它所在子树里最大的那个------"再胜过它一次"这件事每上一层都要重新赌一把,期望上升层数因此收敛到一个常数,不会随 n 增长。严格的分析证明超出本文范围,但实测数据摆在那里。
所以诚实的结论是:
| 输入 | 向上调整建堆 | 向下调整建堆 |
|---|---|---|
| 平均(随机) | O(N) | O(N) |
| 最坏 | O(N log N) | O(N) |
选向下调整建堆的理由是最坏情况,不是平均情况。 工程上你没法保证输入是随机的------如果排序前的数据本来就接近有序(很常见),向上调整建堆就会实打实地退化。
常见误区
误区一:以为向下调整建堆是 O(N log N)
这是最普遍的一条。它的来源是"对 N/2 个结点各做一次 O(log N) 调整"这个乘法直觉。错在**"各做一次 O(log N)"高估了**------绝大多数的调整代价远小于 log N,底部一半结点甚至是 0。求和之后上界收紧到 O(N)。
误区二:以为"建堆是 O(N)"对两种算法都成立
"建堆 = O(N)"这个结论只属于向下调整建堆。向上调整建堆在最坏情况下是 O(N log N),本文的实测表第一行就是证据。
两句话长得像,含义完全不同:"堆排序列的建堆这一步是 O(N)"是对的;"用插入的方式建堆是 O(N)"是错的。
误区三:向下调整建堆的循环写反了方向
c
// 错误:从根往后走
for (int i = 0; i <= (n - 2) / 2; ++i)
AdjustDown(a, n, i);
// 正确:从最后一个非叶子结点倒着走
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
AdjustDown(a, n, i);
向下调整的前提是"左右子树已经是堆"。从根往后走,调根的时候左右子树还是乱的,前提不成立------建出来的不是堆。
倒着走才对,因为走到某个结点时,它下面两层已经被调好了。方向不是风格问题,是前提问题。
顺带一个无害但绕的写法:起点写 n / 2 或 (n - 1) / 2 也能跑,因为 n/2 就是第一个叶子,AdjustDown 进去发现孩子越界立刻退出,白跑一趟而已。但既然要写"最后一个非叶子结点",就写对:最后一个结点下标是 n-1,它的父结点是 ((n-1) - 1) / 2 = (n - 2) / 2。
误区四:以为"用满二叉树证明"是敷衍
教材用满二叉树来证明建堆复杂度,并且专门注明:"时间复杂度本来看的就是近似值,多几个结点不影响最终结果。"
这不是敷衍。完全二叉树的结点数夹在同高度的满二叉树和它的一半之间,两者只差常数倍。O 记号本来就要丢掉常数因子,所以用满二叉树得到的结论对完全二叉树同样成立。如果这里的"多几个结点"能让量级变化,那说明推错了。
误区五:Top-K 里 AdjustDown 的 n 传错
c
// 用前 k 个元素建堆,边界就是 k
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
AdjustDown(a, k, i); // 不是 n
这个参数是调整范围的上界 。传成 n 的话,AdjustDown 会一路沉到 a[n-1],把数组里还没参与筛选的元素当成堆的一部分------筛选逻辑直接垮掉。
误区六:以为"最坏 O(N)"等于"每次都交换 N 次"
T_down = n − log₂(n+1) 描述的是上界,而且只在最坏输入(升序)下取到。同一份代码换一组随机数据,结果差出三成:
| 输入 | n = 10⁶ 时的交换次数 | 占 n 的比例 |
|---|---|---|
| 升序(最坏) | 999 988 | 99.999% |
| 随机(平均) | 744 263 | 74.4% |
O(N) 说的是"不超过 n 的某个常数倍",不是"每次都恰好 n 次"。上界与实际值之间可以差出一个可观的比例------只要量级没变,O 记号就不管这个差距,这是它好用的地方,也是它粗糙的地方。
反过来用:如果实测值比公式大了,那才是真有问题------要么代码写错了,要么公式记错了。
该选哪个
把两个算法放回各自的适用场景:
| 场景 | 选择 | 理由 |
|---|---|---|
| 一批数据已在内存,要从头建堆 | 向下调整建堆 | O(N) 最坏保证,且与后续操作共用 AdjustDown |
| 数据流式到达,拿不到完整数组 | 向上调整 | 唯一选择:新元素只能从尾部上浮 |
| 已有一个堆,往里面插入一个元素 | 向上调整 | 只破坏了根到新叶子的一条路径 |
| 已有一个堆,删除堆顶 | 向下调整 | 破坏的是根到叶子的路径 |
| Top-K(取前 k 个最大) | 向下调整建堆(边界传 k) | 与第二阶段"替换堆顶 + 下沉"共用同一图元 |
| 堆排序 | 向下调整建堆 | 建堆和排序两个阶段都只需要 AdjustDown |
规律很清楚:一次性处理一批数据,选向下调整;数据一条条来,只能选向上调整。
注意最后两行------堆排序和 Top-K 的第二阶段都只用到 AdjustDown。这意味着整个堆排序只需要理解一个图元:把数据倒着调一遍建好堆,再把堆顶换到末尾、下沉。向上调整只需要在"流式插入"这个场景里出场。
📖 参考:《数据结构知识库》第四节 · 堆 ------ "自底向上建堆是 O(n)(不是 O(n log n))";第七节常见误区把"误以为建堆是 O(n log n)"单列为一条。
本节要点
- 两个建堆算法做的事一样,区别只在遍历方向;而方向决定了代价落在哪批结点头上。
- 向上调整的代价由结点的深度 决定,
Σ d·2^d ≈ n log₂n------最贵的活给了最多的结点。 - 向下调整的代价由结点的高度 决定,
Σ h·(n/2^(h+1)) = n·1 = n------最贵的活给了最少的结点。 Σ h/2^(h+1) = 1这个收敛级数是 O(N) 结论的唯一来源。- 精确值 (满二叉树
n = 2^h − 1):向下调整T_down = 2^h − h − 1 = n − log₂(n+1);向上调整T_up = (h−2)·2^h + 2 = (n+1)·log₂(n+1) − 2n。后者靠错位相减法得到闭式。 - 两者比值 ≈ h − 2 ≈ log₂n − 2 :n = 1023 时差 8 倍,n = 4194303 时差 20 倍。差距随 log n 缓慢增大,不是固定倍数。
- 两个精确公式已在满二叉树上逐位验证:h = 10...22,13 组数据,公式与实测完全相等。前提是输入严格升序,否则不再是最坏情况。
- 最坏情况:向上调整建堆 O(N log N),向下调整建堆 O(N)。
- 平均情况:两者都是 O(N)(实测比值恒为 1.72)。所以选向下调整的理由是最坏情况保证,不是平均性能。
- 实测升序输入:比值从 8.05 涨到 17.95,随 log₂n 增长;
AdjustDown的次数几乎精确等于 n。 - 实测 n = 10⁶ 时,最深三层贡献了向上调整总代价的 89%。
- 向下调整建堆的循环必须倒着走------这不是风格,是"左右子树必须已经是堆"这个前提的要求。
- 批量数据选向下,流式数据选向上;堆排序和 Top-K 全程只用
AdjustDown。
📖 参考:《数据结构知识库》第四、七节
KIRACRIMSON