目录
- 堆是什么:完全二叉树 + 堆序性
- 为什么用数组:紧凑存储 + O(1) 寻亲
- 两个灵魂操作:siftUp 与 siftDown
- 三大基础操作:push / pop / peek
- 堆化(heapify)的 O(n) 秘籍
- 最佳实现:基于比较器的统一抽象
- 小顶堆 vs 大顶堆:一行之差
- 三大实战应用
- 复杂度分析
- 决策表
- 一句话总结
1. 堆是什么:完全二叉树 + 堆序性
堆(Heap) 是满足两个性质的 完全二叉树:
| 性质 | 含义 |
|---|---|
| 完全性 | 除了最后一层,其他层都"塞满";最后一层从左到右连续填充,不能有"洞" |
| 堆序性 | 父节点和子节点之间有固定的"大小关系"(不关心左右子树之间的顺序) |
按堆序性的方向分两种:
| 类型 | 性质 | 根节点 |
|---|---|---|
| 小顶堆(Min-Heap) | 每个父节点 ≤ 它的孩子 | 最小值 |
| 大顶堆(Max-Heap) | 每个父节点 ≥ 它的孩子 | 最大值 |
样例数据 [3, 1, 4, 1, 5, 9, 2, 6] 自底向上堆化后的小顶堆:
1 ← 根 = 最小值
/ \
1 2
/ \ / \
3 5 9 4
/
6
关键提醒:堆 不保证 左右子树之间的顺序。上图里左子树有 1,右子树有 2,这不是堆的要求。堆只关心 父子,不管 兄弟。
这也意味着:堆不是 BST。BST 的左 < 根 < 右 是全序关系;堆只要求父子有序。
2. 为什么用数组:紧凑存储 + O(1) 寻亲
完全二叉树最爽的地方:没有空洞,可以紧凑存到数组里,连指针都不需要。
把上图的小顶堆写成数组(按层序 BFS 填进去):
索引: 0 1 2 3 4 5 6 7
值: 1 1 2 3 5 9 4 6
索引公式(这是堆的"灵魂",请背下来):
parent(i) = (i - 1) >> 1 // i > 0 时
left(i) = i << 1 | 1 // 或 2i + 1
right(i) = i << 1 | 2 // 或 (i + 1) << 1
验证一下(索引 5,值 9):
parent(5) = (5-1) >> 1 = 2→ 索引 2,值2✓(9 的父节点是 2)left(5) = 5<<1|1 = 11→ 越界(9 是叶子)✓right(5) = 5<<1|2 = 12→ 越界 ✓
为什么用位移 >>1 和 <<1|1 而不是 /2 和 *2+1?
位运算快一丢丢,但更重要的是 强调"这就是二进制上的事"。生产环境两种写法都对,写哪个看团队风格。
对比指针实现:
| 数组版 | 指针版 | |
|---|---|---|
| 空间 | 只存值 | 还要存 left/right 两个指针 |
| Cache 友好 | ✓ 连续内存 | ✗ 节点散在堆里 |
| 寻亲复杂度 | O(1) 算一下 | O(1) 但要走指针 |
| 工业首选 | ✓ | 教学偶尔用 |
结论:数组是最佳实现方式。
3. 两个灵魂操作:siftUp 与 siftDown
堆的所有操作都围绕 两个动作 展开。掌握这两个动作,堆就掌握了一大半。
siftUp(向上浮)
新元素插入到末尾后,沿着 父节点方向 一路往上,遇到比自己"差"(小顶堆里更大)的父节点就交换,直到满足堆序为止。
function siftUp(i):
while i > 0 且 heap[i] < heap[parent(i)]: # 大顶堆改成 >
swap(heap[i], heap[parent(i)])
i = parent(i)
siftDown(向下沉)
堆顶被取出后,把末尾元素挪到堆顶,沿着 两个孩子中更优先的那个 一直往下交换,直到满足堆序为止。
function siftDown(i):
while true:
left = left(i), right = right(i)
best = i
if left < n 且 heap[left] < heap[best]: best = left # 大顶堆改 >
if right < n 且 heap[right] < heap[best]: best = right
if best == i: break # 已经到位
swap(heap[i], heap[best])
i = best
为什么 siftUp 给 push 用,siftDown 给 pop 用?
| 操作 | 破坏点 | 修复方向 |
|---|---|---|
push(x) | 末尾多了 x,只在路径末尾违反堆序 | siftUp:从末尾向上修 |
pop() | 堆顶被取出,把末尾元素挪到堆顶,只在堆顶违反堆序 | siftDown:从堆顶向下修 |
每条路径最多 log n 步,每步 O(1) → 单次操作 O(log n)。
为什么 siftDown 要和两个孩子里"更优先"的那个交换? 因为我们要找的是"违反堆序的那个孩子",跟它换;如果跟另一个孩子换,新换上去的元素可能依然不满足堆序,还得再来一次。
3.1 具体例子:siftUp(左孩子情形)
初始小顶堆 [3, 5, 8, 7],插入 1(落到索引 4,是 5 的左孩子):
3
/ \
5 8
/
7
第 1 步(i=4,p=1):
arr[4]=1vsarr[1]=5→ 1 更小,交换- 数组 →
[3, 1, 8, 7, 5] i = p = 1← 新插入的 1 现在坐在了原父节点 5 的位置上
第 2 步(i=1,p=0):
arr[1]=1vsarr[0]=3→ 1 更小,交换- 数组 →
[1, 3, 8, 7, 5] i = 0,循环退出
祖节点是会考虑的,靠的就是 i = p 这一行:每比较+交换一次,i 就往上爬一层,下一轮的 p = (i-1)/2 取出的自然就是上一轮的"祖节点"。直到 i = 0 爬到根,循环结束。
不管插入的节点是左孩子(
2p+1)还是右孩子(2p+2),逻辑完全一样——下标算出来的p就是它的父节点,循环条件只看i > 0,跟左右无关。
3.2 具体例子:siftDown(顶部往下冒)
数组 [10, 5, 2, 1, 8, 3, 4](n=7),从索引 0 开始 siftDown:
10
/ \
5 2
/ \ / \
1 8 3 4
第 1 步(i=0):
- 左右孩子:arr[1]=5, arr[2]=2
- 找最小:5 < 10 → best=1;2 < 5 → best=2(取更小的那个孩子)
- 10 > 2,交换 →
[2, 5, 10, 1, 8, 3, 4],i = best = 2
第 2 步(i=2):
- 左右孩子:arr[5]=3, arr[6]=4
- 找最小:3 < 10 → best=5;4 < 3 不成立 → best=5
- 10 > 3,交换 →
[2, 5, 3, 1, 8, 10, 4],i = best = 5
第 3 步(i=5):
- l=11, r=12 都越界(n=7),没孩子
- best 还是 5,break
最终 [2, 5, 3, 1, 8, 10, 4],每个父节点 ≤ 它的孩子,合法小顶堆 ✓
3.3 siftUp 与 siftDown 对照
| siftUp | siftDown | |
|---|---|---|
| 算索引 | p = (i-1) >> 1 | l = 2i+1, r = 2i+2 |
| 比较对象 | 子 vs 父 | 自己 vs 两孩子的最小 |
i 跳到哪 | i = p(往上爬一层) | i = best(往下掉一层) |
| 停的条件 | i === 0 或父更小 | 没孩子,或都比自己大 |
4. 三大基础操作:push / pop / peek
class MinHeap {
constructor() { this.heap = []; }
peek() { // O(1)
return this.heap[0];
}
push(val) { // O(log n)
this.heap.push(val); // 1. 放到末尾
this._siftUp(this.heap.length - 1); // 2. 往上冒
}
pop() { // O(log n)
if (this.heap.length === 0) return undefined;
const top = this.heap[0]; // 1. 记下堆顶(要返回的)
const last = this.heap.pop(); // 2. 取出末尾
if (this.heap.length > 0) {
this.heap[0] = last; // 3. 末尾元素顶到堆顶
this._siftDown(0); // 4. 往下沉
}
return top;
}
_siftUp(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.heap[i] < this.heap[p]) {
[this.heap[i], this.heap[p]] = [this.heap[p], this.heap[i]];
i = p;
} else break;
}
}
_siftDown(i) {
const n = this.heap.length;
while (true) {
const l = 2 * i + 1, r = 2 * i + 2;
let s = i;
if (l < n && this.heap[l] < this.heap[s]) s = l;
if (r < n && this.heap[r] < this.heap[s]) s = r;
if (s === i) break;
[this.heap[i], this.heap[s]] = [this.heap[s], this.heap[i]];
i = s;
}
}
}
pop() 的 4 步走一遍(假设小顶堆 [1, 2, 3, 4, 5]):
初始: [1, 2, 3, 4, 5] 堆顶 1,要返回
1. top = 1
2. last = 5, 数组变 [1, 2, 3, 4]
3. 末尾元素 5 顶到堆顶 → [5, 2, 3, 4]
4. siftDown(0): 5 vs 2、3,较小的 2 上来 → [2, 5, 3, 4]
5 vs 4,4 上来 → [2, 4, 3, 5]
5 是叶子,结束
返回 1,堆变 [2, 4, 3, 5] ← 仍然是小顶堆 ✓
为什么 pop 不直接 siftDown(0)? 因为要先取出堆顶的值(要返回)。如果直接覆盖数组然后 siftDown,下标 0 已经是新元素,没问题 —— 关键是 要先取出旧堆顶的值,否则就丢了。
5. 堆化(heapify)的 O(n) 秘籍
把一个 乱序数组 变成堆有两种思路:
| 思路 | 方法 | 复杂度 |
|---|---|---|
| 自顶向下 | 从前到后,一个一个 push | O(n log n) |
| 自底向上 ★ | 从后往前(从 n/2-1 开始),每个节点 siftDown | O(n) |
为什么自底向上是 O(n)?
直觉:
- 叶子节点(占 n/2 个)天然就是"堆",零操作
- 越靠近根的节点越少,但每个节点的 siftDown 路径越长
- 两者相乘,总和是 收敛级数,结果是 O(n)
数学:
高度为 h 的节点有 n / 2^(h+1) 个,每个最多下沉 h 步
总工作 = Σ (n / 2^(h+1)) * h
= n * Σ h / 2^(h+1)
< n * 2
= O(n)
最佳实现:永远用自底向上 heapify。
static heapify(arr) {
const h = new MinHeap();
h.heap = arr.slice();
// 从最后一个非叶子节点(n/2 - 1)开始,往前到 0
for (let i = Math.floor(h.heap.length / 2) - 1; i >= 0; i--) {
h._siftDown(i);
}
return h;
}
为什么从 n/2 - 1 开始?
索引 ≥ n/2 的节点都是叶子(没有孩子),siftDown 不做任何事,跳过。
性能对比(100 万个元素的随机数组):
| 方式 | 时间 |
|---|---|
| 自顶向下(一个一个 push) | ~80 ms |
| 自底向上 heapify | ~15 ms |
差距 5 倍以上。这是面试常考点。
5.1 具体例子
把 [3, 1, 4, 1, 5, 9, 2, 6](n=8)变成小顶堆。最后一个非叶子 = floor(8/2) - 1 = 3,依次处理 3、2、1、0。
初始: siftDown(3) siftDown(2) siftDown(1) siftDown(0)
3 3 3 3 1
/ \ / \ / \ / \ / \
1 4 1 2 1 2 1 2 1 2
/ \ / \ / \ / \ / \ / \ / \ / \ / \ / \
1 5 9 2 1 5 9 4 1 5 9 4 3 5 9 4 3 5 9 4
/ / / / \ / \
6 6 6 6 1 6 1
逐步走一遍:
| 步骤 | i | 数组 | 说明 |
|---|---|---|---|
| 初始 | - | [3,1,4,1,5,9,2,6] | - |
| 1 | 3 | [3,1,4,1,5,9,2,6] | 值 1 vs 左孩子 6,1 < 6 不换 |
| 2 | 2 | [3,1,2,1,5,9,4,6] | 值 4 vs 9、2,最小是 2 → 4 ↔ 2,4 落到索引 6(叶子,停) |
| 3 | 1 | [3,1,2,1,5,9,4,6] | 值 1 vs 1、5,1 ≤ 1 不换 |
| 4a | 0 | [3,1,2,1,5,9,4,6] | 值 3 vs 1、2,最小是 1 → 3 ↔ 1 |
| 4b | 1 | [1,3,2,1,5,9,4,6] | 值 3 vs 1、5,最小是 1 → 3 ↔ 1 |
| 4c | 3 | [1,1,2,3,5,9,4,6] | i=3 是叶子,停 |
| 最终 | - | [1,1,2,3,5,9,4,6] | ✓ |
6. 最佳实现:基于比较器的统一抽象
最佳实现的核心思想:用比较器(comparator)把"小顶"和"大顶"统一成同一份代码。
class Heap {
constructor(cmp = (a, b) => a - b) { // 默认小顶
this.heap = [];
this.cmp = cmp; // 返回负数表示 a 优先级更高
}
static min() { return new Heap((a, b) => a - b); }
static max() { return new Heap((a, b) => b - a); }
peek() { return this.heap[0]; }
size() { return this.heap.length; }
push(val) {
this.heap.push(val);
this._siftUp(this.heap.length - 1);
}
pop() {
if (!this.heap.length) return undefined;
const top = this.heap[0];
const last = this.heap.pop();
if (this.heap.length) {
this.heap[0] = last;
this._siftDown(0);
}
return top;
}
static heapify(arr, cmp = (a, b) => a - b) {
const h = new Heap(cmp);
h.heap = arr.slice();
for (let i = Math.floor(h.heap.length / 2) - 1; i >= 0; i--) {
h._siftDown(i);
}
return h;
}
_higher(a, b) { // a 优先级是否高于 b
return this.cmp(a, b) < 0;
}
_siftUp(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this._higher(this.heap[i], this.heap[p])) {
[this.heap[i], this.heap[p]] = [this.heap[p], this.heap[i]];
i = p;
} else break;
}
}
_siftDown(i) {
const n = this.heap.length;
while (true) {
const l = 2 * i + 1, r = 2 * i + 2;
let best = i;
if (l < n && this._higher(this.heap[l], this.heap[best])) best = l;
if (r < n && this._higher(this.heap[r], this.heap[best])) best = r;
if (best === i) break;
[this.heap[i], this.heap[best]] = [this.heap[best], this.heap[i]];
i = best;
}
}
}
精彩之处:同一份代码,既是小顶堆也是大顶堆。
const minH = Heap.min(); // 小顶堆
const maxH = Heap.max(); // 大顶堆
minH.push(3); minH.push(1); minH.push(4);
minH.pop(); // 1(最小值)
maxH.push(3); maxH.push(1); maxH.push(4);
maxH.pop(); // 4(最大值)
自定义比较器威力:
// 任务调度:deadline 最早的先出去
const scheduler = new Heap((a, b) => a.deadline - b.deadline);
// Dijkstra 算法里的"当前最短距离"
const dist = new Heap((a, b) => a.dist - b.dist);
// 按"距离原点"排,最远的先出去
const farFirst = new Heap((a, b) =>
Math.hypot(b.x, b.y) - Math.hypot(a.x, a.y)
);
7. 小顶堆 vs 大顶堆:一行之差
| 小顶堆 | 大顶堆 | |
|---|---|---|
| 比较器 | (a, b) => a - b | (a, b) => b - a |
| 堆顶 | 最小值 | 最大值 |
_higher(a, b) 等价 | a < b | a > b |
| 典型应用 | Dijkstra、合并 K 路、Top-K 最小 | 堆排序、Top-K 最大、优先级队列取最大 |
它们是同一个算法,只是比较方向反了。所以学完一个,另一个自动会。
完整跑一遍验证:
const arr = [3, 1, 4, 1, 5, 9, 2, 6];
const minH = Heap.heapify(arr);
const asc = [];
while (minH.size()) asc.push(minH.pop());
console.log(asc); // [1, 1, 2, 3, 4, 5, 6, 9]
const maxH = Heap.heapify(arr, (a, b) => b - a);
const desc = [];
while (maxH.size()) desc.push(maxH.pop());
console.log(desc); // [9, 6, 5, 4, 3, 2, 1, 1]
一招变型:"最小 K 个"问题,把上面的"比较器反转" + "改成小顶堆"组合即可。
8. 三大实战应用
8.1 堆排序(Heap Sort)
function heapSort(arr, desc = false) {
const h = Heap.heapify(
arr,
desc ? (a, b) => b - a : (a, b) => a - b
);
const out = [];
while (h.size()) out.push(h.pop());
return out;
}
复杂度:建堆 O(n) + n 次 pop 每次 O(log n) = O(n log n),空间 O(1)(in-place)。
但实测:堆排序的常数因子比快排大,cache 也差(跳着访问内存),实际速度往往比快排慢 2-5 倍。所以:
- 面试考:堆排序怎么写、复杂度多少、为什么 in-place
- 实战用:优先快排,归并
- 唯一会赢的场景:需要 最坏 O(n log n) 保证(vs 快排最坏 O(n²))
8.2 Top-K 问题
Top-K 最大(返回 K 个最大元素)—— 用小顶堆:
function topK(arr, k) {
const heap = []; // 小顶堆:堆顶 = 当前 K 候选里最差的 = 门槛
for (const x of arr) {
if (heap.length < k) {
heap.push(x); // 没满就加
siftUp(heap, heap.length - 1);
} else if (x > heap[0]) { // 满了,新人 > 最差的?
heap[0] = x; // 替换最差的
siftDown(heap, 0, heap.length);
}
// 否则:x 不够格,跳过
}
return heap;
}
为什么用小顶堆? 小顶堆的堆顶是当前 K 个里 最小的,新元素如果比它还小/等,根本进不来(因为它会被立刻 pop 掉,等于没进);如果新元素比它大,挤掉它。这样维护代价 O(log K),整体 O(n log K)。
| Top-K 最大 | 用 | 堆顶作用 |
|---|---|---|
| ✓ | 小顶堆 | 挡板:比堆顶小的进不来 |
| 大顶堆 | 每次把最大的踢出去,留下 K 个最小的,方向反了 |
Top-K 最小反过来用大顶堆。
复杂度对比(n = 10⁷, K = 10):
| 方法 | 时间 |
|---|---|
| 全排序取前 K | ~3 秒(O(n log n)) |
| 小顶堆 Top-K | ~0.4 秒(O(n log K)) |
类比(简历筛选):想象你从 100 万份简历里挑 top 3。不是给 100 万份都排名,而是维护一份"当前 top 3 名单":
- 名单没满 → 直接加进去
- 名单满了 → 新简历 x 比名单里最差的那个还强?是 → 踢掉最差的、加 x;否 → 跳过
要快速找到"名单里最差的"——小顶堆的堆顶就是。所以"维护 top K 候选 = 小顶堆 + 堆顶当门槛"。
具体例子。[3, 1, 4, 1, 5, 9, 2, 6],K=3。预期 top 3 = [9, 6, 5](堆内顺序):
| i | x | 堆顶 | 操作 | 堆 |
|---|---|---|---|---|
| 0 | 3 | - | 没满,push | [3] |
| 1 | 1 | - | 没满,push | [1,3] |
| 2 | 4 | 1 | 没满,push | [1,3,4] |
| 3 | 1 | 1 | 1 > 1? 否,跳过 | [1,3,4] |
| 4 | 5 | 1 | 5 > 1? 是,替换堆顶 → siftDown | [3,5,4] |
| 5 | 9 | 3 | 9 > 3? 是,替换 → siftDown | [4,5,9] |
| 6 | 2 | 4 | 2 > 4? 否,跳过 | [4,5,9] |
| 7 | 6 | 4 | 6 > 4? 是,替换 → siftDown | [5,6,9] |
最终 heap = [5, 6, 9],top 3 = [9, 6, 5]。✓
口诀:前 K 大,用小顶堆。堆顶 = K 候选里最小的门槛,新数过门槛就替。
两个变体:
- 前 K 小:用大顶堆 of size K,堆顶当门槛,淘汰最大的
- 第 K 大(LeetCode 215):
topKMax(arr, K).heap[0](小顶堆里堆顶就是第 K 大)
差距 ~7 倍。
8.3 优先级队列 / 任务调度
这是堆最自然的用途:
class TaskScheduler {
constructor() { this.queue = Heap.min(); }
add(deadline, task) {
this.queue.push({ deadline, task });
}
next() {
return this.queue.pop()?.task;
}
}
const s = new TaskScheduler();
s.add(10, '写报告');
s.add(5, '修 bug');
s.add(8, '开会');
s.next(); // '修 bug'(deadline=5)
s.next(); // '开会'(deadline=8)
s.next(); // '写报告'(deadline=10)
JS 标准库至今没有 PriorityQueue(2026 仍没有),手写堆是日常操作。Node.js 里有 import { PriorityQueue } from 'mnemonist/priority-queue' 可以直接用。
8.4 中位数(两个堆的经典组合)
class MedianFinder {
constructor() {
this.maxH = []; // 大顶堆,存较小一半,堆顶 = 小半最大值
this.minH = []; // 小顶堆,存较大一半,堆顶 = 大半最小值
}
add(num) { return addMedian(num, this.maxH, this.minH); }
find() { return findMedian(this.maxH, this.minH); }
}
两个堆 + 巧妙平衡 = O(log n) 维护中位数。面试高频题。
⚠️ 上面的
find()是简化版。当两堆等大时(中位数为两堆顶的平均值),会少算一半。修正版见下。
分工与不变量
| 堆 | 类型 | 存什么 | 堆顶的含义 |
|---|---|---|---|
maxH | 大顶堆 | 较小一半 | 小半中的最大值 |
minH | 小顶堆 | 较大一半 | 大半中的最小值 |
不变量(每次 add() 后都必须满足):
maxH.length >= minH.lengthsize差 ≤ 1
由此推出 median 的取值规则:
maxH.length > minH.length→ 中位数 =maxH[0](奇数个,小半多一个)size相等 → 中位数 =(maxH[0] + minH[0]) / 2(偶数个,平均)
插入流程
function addMedian(num, maxH, minH) {
// 1. 决定进哪个堆
if (maxH.length === 0 || num <= maxH[0]) {
maxH.push(num);
siftUpMax(maxH, maxH.length - 1); // 大顶堆 siftUp
} else {
minH.push(num);
siftUpMin(minH, minH.length - 1); // 小顶堆 siftUp
}
// 2. 平衡:size 差不能超过 1
if (maxH.length > minH.length + 1) {
minH.push(popTop(maxH, true)); // maxH 太大,挪堆顶过去
siftUpMin(minH, minH.length - 1);
} else if (minH.length > maxH.length) {
maxH.push(popTop(minH, false)); // minH 太大,挪堆顶过来
siftUpMax(maxH, maxH.length - 1);
}
}
function findMedian(maxH, minH) {
if (maxH.length > minH.length) return maxH[0];
return (maxH[0] + minH[0]) / 2;
}
function popTop(heap, isMax) { // 标准 pop:取堆顶,末尾挪到顶,siftDown
const top = heap[0];
const last = heap.pop();
if (heap.length) {
heap[0] = last;
if (isMax) siftDownMax(heap, 0, heap.length);
else siftDownMin(heap, 0, heap.length);
}
return top;
}
为什么用 if/else 分流? 看着比"先 push 再搬运"啰嗦,但意图清晰:num ≤ 小半最大值 → 留在小半;否则 → 去大半。搬运逻辑(popTop)跟前面学过的 pop 一模一样,复用已有概念。
具体例子:数据流 [1, 5, 2, 8, 3, 9]
走一遍验证:排序后 [1, 2, 3, 5, 8, 9],中位数 = (3 + 5) / 2 = 4。
| 步骤 | 操作 | maxH(大顶堆) | minH(小顶堆) | size 差 | median |
|---|---|---|---|---|---|
| 1 | add(1) | [1] | [] | 1 | 1 |
| 2 | add(5) | [1] | [5] | 0 | (1+5)/2 = 3 |
| 3 | add(2) | [2,1] | [5] | 1 | 2 |
| 4 | add(8) | [2,1] | [5,8] | 0 | (2+5)/2 = 3.5 |
| 5 | add(3) | [3,2,1] | [5,8] | 1 | 3 |
| 6 | add(9) | [3,2,1] | [5,8,9] | 0 | (3+5)/2 = 4 ✓ |
逐行解释步骤 6(最难的一步):
add(9)→minH.push(9)?不对,是先走 if 判断:9 > maxH[0]=3→ 进minH,siftUpMin 到[5,8,9]- 平衡判断:
maxH.length(3) > minH.length(3) + 1?否;minH.length(3) > maxH.length(3)?否 → 不挪
最终 maxH 顶 = 3(较小一半的最大),minH 顶 = 5(较大一半的最小),中位数 = (3+5)/2 = 4。
复杂度
| 操作 | 时间 | 说明 |
|---|---|---|
add(num) | O(log n) | 最多 2 次 push + 1 次 pop,每次 log n |
find() | O(1) | 只读两个堆顶 |
| 空间 | O(n) | 两个堆各存一半 |
和"全排序再取中间"的对比: 全排序每次插入 O(n),中位数 O(1);两个堆反过来,插入 O(log n),中位数 O(1)。两个堆的总成本更优,且适合数据流(边来边算,不用回头排序)。
一句话口诀
小半用大顶堆,大半用小顶堆;堆顶就是候选中位数;size 差 ≤ 1,永远保持平衡。
9. 复杂度分析
| 操作 | 时间 | 空间 | 说明 |
|---|---|---|---|
peek() | O(1) | O(1) | 直接读数组头 |
push(x) | O(log n) | O(1) | siftUp 路径长 ≤ ⌈log₂ n⌉ |
pop() | O(log n) | O(1) | siftDown 路径长 ≤ ⌈log₂ n⌉ |
heapify(arr) | O(n) | O(n) | 自底向上 siftDown |
| 堆排序 | O(n log n) | O(1) | in-place(不算输出数组) |
| Top-K(n 个取 K 个) | O(n log K) | O(K) | 用大小为 K 的堆 |
| 中位数维护 | O(log n) 单次 | O(n) | 两个堆 |
为什么 heapify 是 O(n) 而不是 O(n log n)?
直觉:叶子节点(n/2 个)天然是堆,零操作;靠近根的节点少但路径长。两者相乘是 收敛级数,总和 = O(n)。
T(n) = n/2 * 0 + n/4 * 1 + n/8 * 2 + n/16 * 3 + ...
< n/2 * (0 + 1/2 + 1/4 + 1/8 + ...)
= n/2 * 2 = n
为什么 siftUp 和 siftDown 都是 O(log n)? 树高 = ⌈log₂ n⌉。siftUp 从叶子往上走,最坏走到根;siftDown 从根往下走,最坏走到叶子。两种路径都是树高。
10. 决策表
| 场景 | 选择 | 理由 |
|---|---|---|
| 经常取最小/最大 | 堆 | O(1) peek |
| 经常插入 + 删除最大 | 堆 | O(log n) 双向 |
| 只取一次最大/最小 | 排序更快 | O(n log n) 一次 vs O(n) 建堆 + O(n log n) 取 |
| 需要稳定排序 | 归并 | 堆排不稳定 |
| 需要最坏 O(n log n) 保证 | 堆排序 | 快排最坏 O(n²) |
| Top-K(K << n) | 堆 | O(n log K),比全排快 K/log K 倍 |
| Top-K(K 接近 n) | 排序 | O(n log n) vs O(n log n) 接近 |
| 中位数 / 滑动窗口中位数 | 两个堆 | O(log n) 维护 |
| 合并 K 个有序流 | 堆 | 天然支持,O(n log K) |
| 实时调度(deadline / 优先级) | 堆 | 标准模式 |
11. 一句话总结
堆 = 完全二叉树 + 堆序性,用数组存; siftUp 给 push 用,siftDown 给 pop 用; 比较器把"小顶"和"大顶"统一成一份代码; 自底向上 heapify 是 O(n) 的秘籍; 三大应用:堆排序、Top-K、优先级队列。