一、为什么需要最小堆
假设你在写一个任务调度器,每个任务有优先级,系统需要不断取出优先级最高(数值最小)的任务来执行。最直觉的做法是把任务存进数组,每次取最小值时遍历整个数组——O(n) 的时间,任务一多就扛不住。
换一种思路:维护一个有序数组,插入时保持排序,取最小值只需看第一个元素。插入变成了 O(n),因为要找到正确位置并把后面的元素全部后移。
能不能两全其美?插入和删除最小值都是 O(log n),查看最小值是 O(1)?最小堆就是答案。它用数组存储一棵完全二叉树,保证根节点始终是最小值,通过「上浮」和「下沉」两个操作维护堆序性,插入和删除都只需 O(log n)。
二、现实类比
急诊室分诊台。病人按严重程度排队,最危重的永远排第一。新来了一个病人,分诊护士根据症状判断严重程度,把 Ta 插到队列中的合适位置——不需要把所有人都重新排一遍,只需要从下往上逐级调整。最前面的人被叫走后,队里剩下的病人再从上往下调整一次,确保新的最危重病人浮到最前面。
三、核心思想
最小堆是一棵完全二叉树,每个父节点的值都小于等于其子节点的值。它用扁平数组存储,不需要指针:父节点在索引 i,左子在 2i+1,右子在 2i+2,子节点的父在 (i-1)/2。这种紧凑排列使堆具有极好的缓存局部性。
3.1 两个核心操作
上浮(Sift Up):插入新元素时,先将它放到数组末尾,然后不断与父节点比较,如果比父小就交换,直到到达根或不再比父小。这条路劲最长为树高 log n。
下沉(Sift Down):删除堆顶时,先将根与末尾元素交换,弹出末尾(即原堆顶),然后把新的根不断与两个子节点中较小的那个比较,如果比子大就交换,直到到达叶或不再比子大。
3.2 复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查看最小值 Peek | O(1) | 始终在索引 0 |
| 插入 Push | O(log n) | 上浮最多走树高 |
| 弹出最小值 Pop | O(log n) | 下沉最多走树高 |
| 建堆 Heapify | O(n) | 从最后一个非叶节点向前下沉 |
| 空间 | O(n) | 扁平数组,无指针开销 |
四、变体与对比
| 模式 | 核心思路 | 适用场景 | 不适用场景 |
|---|---|---|---|
| 最小堆 | 数组存储的完全二叉树,根为最小 | 优先队列、任务调度、Top-K | 需要删除任意元素 |
| 最大堆 | 根为最大值 | 堆排序、Top-K(保留最大的 K 个) | 需要取最小值 |
| 有序数组 | 插入时保持排序 | 小规模数据、需要遍历 | 高频插入删除 |
| 红黑树 | 自平衡二叉搜索树 | 需要删除任意节点、范围查询 | 只需要取极值时开销过大 |
| 跳表 | 多层链表实现有序结构 | 需要并发安全的有序结构 | 只需要优先队列语义 |
堆 vs 红黑树是最关键的对比。Linux 内核的 CFS 调度器用红黑树而不用堆,原因是 CFS 需要频繁删除任意进程(进程睡眠、退出时从调度队列中移除),堆只能高效地删除根节点,删除中间节点是 O(n) 的查找 + O(log n) 的调整。而 React 调度器几乎只从堆顶弹出最高优先级任务,所以选择了堆——更简单、更紧凑。
五、多语言实现
5.1 Go 实现
package minheap
// MinHeap 是泛型最小堆,要求元素满足 constraints.Orderedtype MinHeap[T interface{ Less(T) bool }] struct { data []T}
func New[T interface{ Less(T) bool }]() *MinHeap[T] { return &MinHeap[T]{data: make([]T, 0)}}
// Push 插入元素func (h *MinHeap[T]) Push(val T) { h.data = append(h.data, val) h.siftUp(len(h.data) - 1)}
// Pop 弹出最小元素func (h *MinHeap[T]) Pop() (T, bool) { if len(h.data) == 0 { var zero T return zero, false } root := h.data[0] last := h.data[len(h.data)-1] h.data = h.data[:len(h.data)-1] if len(h.data) > 0 { h.data[0] = last h.siftDown(0) } return root, true}
// 上浮:与父节点比较,比父小则交换func (h *MinHeap[T]) siftUp(i int) { for i > 0 { parent := (i - 1) / 2 if !h.data[i].Less(h.data[parent]) { break } h.data[i], h.data[parent] = h.data[parent], h.data[i] i = parent }}
// 下沉:与较小子节点比较,比子大则交换func (h *MinHeap[T]) siftDown(i int) { n := len(h.data) for { left, right := 2*i+1, 2*i+2 smallest := i if left < n && h.data[left].Less(h.data[smallest]) { smallest = left } if right < n && h.data[right].Less(h.data[smallest]) { smallest = right } if smallest == i { break } h.data[i], h.data[smallest] = h.data[smallest], h.data[i] i = smallest }}5.2 TypeScript 实现
// 最小堆,元素按 sortIndex 排序,id 用于 tie-breakinterface HeapNode { sortIndex: number; id: number;}
class MinHeap { private heap: HeapNode[] = []; private nextId = 0;
push(sortIndex: number): void { const node: HeapNode = { sortIndex, id: this.nextId++ }; this.heap.push(node); this.siftUp(this.heap.length - 1); }
pop(): HeapNode | null { if (this.heap.length === 0) return null; const top = this.heap[0]; const last = this.heap.pop()!; if (this.heap.length > 0) { this.heap[0] = last; this.siftDown(0); } return top; }
peek(): HeapNode | null { return this.heap[0] ?? null; }
get size(): number { return this.heap.length; }
private siftUp(i: number): void { while (i > 0) { const parent = (i - 1) >>> 1; if (compare(this.heap[i], this.heap[parent]) >= 0) break; [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; i = parent; } }
private siftDown(i: number): void { const n = this.heap.length; while (true) { const left = 2 * i + 1; const right = 2 * i + 2; let smallest = i; if (left < n && compare(this.heap[left], this.heap[smallest]) < 0) smallest = left; if (right < n && compare(this.heap[right], this.heap[smallest]) < 0) smallest = right; if (smallest === i) break; [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; i = smallest; } }}
// 先比 sortIndex,相同则比 id(FIFO 顺序)function compare(a: HeapNode, b: HeapNode): number { return a.sortIndex !== b.sortIndex ? a.sortIndex - b.sortIndex : a.id - b.id;}上述 TypeScript 实现与 React 的 SchedulerMinHeap.js 风格一致——sortIndex 是排序键,id 作为第二排序字段保证 FIFO 顺序。React 调度器正是用这种方式管理定时任务。
六、生产验证
6.1 React 调度器 — 任务优先级队列
React 的调度器(Scheduler)用最小堆管理待执行的任务。每个任务有一个 sortIndex(通常是过期时间),堆顶始终是过期最早的任务。调度器每帧从堆顶取任务执行,如果执行超时就交还主线程,下一帧继续。整个最小堆实现只有约 75 行代码,简洁且高效。
- 项目:facebook/react
- 源码:
packages/scheduler/src/SchedulerMinHeap.js push、pop、siftUp、siftDown四个函数(第 15-75 行),sortIndex+id双键排序
6.2 Linux 内核 CFS — 进程调度
虽然 CFS 用的不是最小堆而是红黑树,但对比很有启发意义。CFS 需要频繁地移除任意进程(进程阻塞时),红黑树支持 O(log n) 的任意节点删除,而堆只擅长删除根。选择红黑树是为了满足「删除任意节点」的需求,不是因为堆不够快。
- 项目:torvalds/linux
- 源码:
kernel/sched/fair.c pick_next_task_fair从红黑树最左节点取下一个任务(第 5000 行附近)
6.3 Node.js libuv — 定时器堆
Node.js 的事件循环底层由 libuv 驱动,libuv 用最小堆管理定时器。每次循环迭代检查堆顶定时器是否到期,到期就执行回调。因为只需要取「最近到期的定时器」,堆是最自然的选择。
- 项目:libuv/libuv
- 源码:
src/timer.c uv_timer_start插入堆,uv__run_timers从堆顶取出到期定时器
七、小结
适合使用最小堆的场景:
- 优先队列——频繁取最小(或最大)值,插入和删除都要快
- 任务调度器、定时器管理——堆顶永远是最紧急的任务
- Top-K 问题——用大小为 K 的最小堆在 O(n log K) 时间内找出最大的 K 个元素
- 图算法——Dijkstra 最短路径、Prim 最小生成树都需要优先队列
不适合使用最小堆的场景:
- 需要频繁删除任意元素,堆只能高效删除根节点
- 需要有序遍历所有元素,堆的数组表示不是全局有序的
- 数据规模很小(< 20 个元素),线性扫描可能更快且更简单
- 需要稳定的排序结果,标准堆不保证相同键值的元素保持原始顺序
八、参考资料
- React Scheduler 源码 - React 调度器的最小堆实现,约 75 行
- libuv 定时器实现 - Node.js 底层定时器堆的 C 实现
- Binary Heap - Wikipedia - 二叉堆的定义、操作与复杂度证明
- Heaps and Priority Queues - 堆与优先队列的完整教程
- Linux CFS 调度器设计 - CFS 为什么选红黑树而非堆的设计考量
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






