引言
在学习堆排序时,你是否注意到有两种建堆方式:
- 从上往下:逐个插入元素,使用
heapInsert(上浮)操作 - 从下往上:从最后一个非叶子节点开始,使用
heapIfy(下沉)操作
更令人困惑的是,它们的时间复杂度竟然不同!本文将详细推导这两种方式的复杂度差异。
一、两种建堆方式的代码实现
1.1 从上往下建堆(O(NlogN))
// 从第 0 个元素开始,逐个插入到堆中
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i); // 上浮操作
}
1.2 从下往上建堆(O(N))
// 从最后一个非叶子节点开始,向前遍历
for (int i = (arr.length - 1) / 2; i >= 0; i--) {
heapIfy(arr, arr.length, i); // 下沉操作
}
思考:为什么起始位置是 (arr.length - 1) / 2?
- 在完全二叉树中,索引大于
(N-1)/2的节点都是叶子节点 - 叶子节点本身就是堆,不需要调整
- 约有一半的节点是叶子节点!
二、从上往下建堆的复杂度分析 - O(NlogN)
2.1 核心思想
每个新元素插入时,最坏情况是从底部一直上浮到顶部。
2.2 详细推导
完全二叉树的高度分布:
- 高度为 h 的层最多有
2^h个节点 - 树的总高度 H = log₂N
每个节点的上浮成本:
- 第 i 个节点插入时,浮动距离 = 当前节点的高度
- 越晚插入的节点,位置越深,上浮距离越长
总成本计算:
T(N) = Σ(每个节点的上浮成本)
= Σ(h=0 to logN) [2^h × h]
= 0×1 + 1×2 + 2×4 + 3×8 + ... + logN×N
这是一个等差×等比级数,求和结果为 O(NlogN)。
2.3 直观理解
- 前 N/2 个节点:较浅,上浮成本低
- 后 N/2 个节点:都在深层,每个都要上浮 logN 层
- 总成本 ≈ (N/2) × logN = O(NlogN)
三、从下往上建堆的复杂度分析 - O(N)
3.1 核心思想
从深层往浅层调整,利用"大部分节点已经在底层"的特点,大幅减少调整成本。
3.2 关键洞察
完全二叉树的节点分布规律:
假设 N = 2^H - 1(满二叉树)
高度分布:
- 高度为 0(叶子层):2^(H-1) 个节点 ≈ N/2
- 高度为 1:2^(H-2) 个节点 ≈ N/4
- 高度为 2:2^(H-3) 个节点 ≈ N/8
- ...
发现:约有一半的节点是叶子节点,它们不需要调整!
3.3 精确推导
下沉操作的成本:
- 高度为 h 的节点,最多下沉 h 层
- 成本 = O(h)
总成本计算:
T(N) = Σ(每个节点的下沉成本)
= Σ(h=1 to H-1) [高度为 h 的节点数 × h]
= Σ(h=1 to H-1) [2^(H-1-h) × h]
令 k = H-1-h:
T(N) = Σ(k=0 to H-2) [2^k × (H-1-k)]
= (H-1)×Σ(2^k) - Σ(k×2^k)
其中:
- Σ(k=0 to H-2) 2^k = 2^(H-1) - 1 ≈ N/2
- Σ(k=0 to H-2) k×2^k = (H-2)×2^(H-1) + 2
代入化简:
T(N) = (H-1)×(N/2) - [(H-2)×(N/2) + 2]
= (N/2)×[(H-1) - (H-2)] - 2
= N/2 - 2
= O(N)
3.4 具体示例
以 N=15 为例(H=4):
③ (1 个,下沉最多 3 层)
/ \
② ② (2 个,下沉最多 2 层)
/ \ / \
①① ①① (4 个,下沉最多 1 层)
/\ /\ /\ /\
L L L L... (8 个叶子,下沉 0 层)
总成本 = 1×3 + 2×2 + 4×1 + 8×0
= 3 + 4 + 4 + 0
= 11
而 N×logN = 15×4 = 60
实际成本远小于 NlogN!
3.5 无穷级数视角
当 N 很大时:
T(N) ≈ N × Σ(h=1 to ∞) [h / 2^(h+1)]
= N × (1/4 + 2/8 + 3/16 + 4/32 + ...)
= N × 1 (收敛常数)
= O(N)
四、核心差异对比
| 维度 | 从上往下(heapInsert) | 从下往上(heapIfy) |
|---|---|---|
| 起点 | 从根开始(i=0) | 从最后一个非叶子节点开始 |
| 操作 | 上浮 | 下沉 |
| 处理顺序 | 浅层→深层 | 深层→浅层 |
| 叶子节点 | 需要上浮 | 不需要调整(成本为 0) |
| 大部分节点成本 | 后期插入的 N/2 个节点成本高 | N/2 个叶子节点成本为 0 |
| 时间复杂度 | O(NlogN) | O(N) |
五、形象比喻
5.1 从上往下建堆
就像让所有人按身高排队,每来一个人就要从队尾走到合适的位置。后面的人要走很远。
5.2 从下往上建堆
先让最底层的人站好位置,然后一层层往上调整。大部分人已经在正确位置,只需要微调。
六、完整代码对比
6.1 慢版本:O(NlogN)
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) return;
// 建堆:O(NlogN)
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
// 排序:O(NlogN)
int heapSize = arr.length;
while (heapSize > 0) {
swap(arr, 0, --heapSize);
heapIfy(arr, heapSize, 0);
}
}
6.2 快版本:O(N) 建堆
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) return;
// 建堆:O(N) ⭐优化版本
for (int i = (arr.length - 1) / 2; i >= 0; i--) {
heapIfy(arr, arr.length, i);
}
// 排序:O(NlogN)
int heapSize = arr.length;
while (heapSize > 0) {
swap(arr, 0, --heapSize);
heapIfy(arr, heapSize, 0);
}
}
七、总结
7.1 关键结论
- 从上往下:大部分节点在树的深层,上浮距离长 → O(NlogN)
- 从下往上:一半节点是叶子(成本为 0),只有少量高层节点需要下沉 → O(N)
- 完全二叉树的性质:约有一半的节点是叶子节点,这是优化的关键!
7.2 实际应用
虽然两种方式的整体堆排序时间复杂度都是 O(NlogN)(因为排序阶段也是 O(NlogN)),但:
- O(N) 建堆在实际运行中更快
- 面试中常被问到这个复杂度差异
- 理解这个差异有助于深入掌握堆的性质
7.3 思考题
- 如果数组已经是大根堆,两种建堆方式各需要多少时间?
- 为什么堆排序是不稳定排序?
- 如何修改代码实现小根堆排序?
参考文献:
- 《算法导论》第 6 章 - 堆排序
- 《数据结构与算法分析》- 优先队列章节
希望这篇博客能帮你彻底理解堆排序建堆的复杂度差异!🎯

3410

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



