mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1824 字
5 分钟
最小堆(Min Heap)
2026-06-13

一、为什么需要最小堆#

假设你在写一个任务调度器,每个任务有优先级,系统需要不断取出优先级最高(数值最小)的任务来执行。最直觉的做法是把任务存进数组,每次取最小值时遍历整个数组——O(n) 的时间,任务一多就扛不住。

换一种思路:维护一个有序数组,插入时保持排序,取最小值只需看第一个元素。插入变成了 O(n),因为要找到正确位置并把后面的元素全部后移。

能不能两全其美?插入和删除最小值都是 O(log n),查看最小值是 O(1)?最小堆就是答案。它用数组存储一棵完全二叉树,保证根节点始终是最小值,通过「上浮」和「下沉」两个操作维护堆序性,插入和删除都只需 O(log n)。

二、现实类比#

急诊室分诊台。病人按严重程度排队,最危重的永远排第一。新来了一个病人,分诊护士根据症状判断严重程度,把 Ta 插到队列中的合适位置——不需要把所有人都重新排一遍,只需要从下往上逐级调整。最前面的人被叫走后,队里剩下的病人再从上往下调整一次,确保新的最危重病人浮到最前面。

三、核心思想#

最小堆是一棵完全二叉树,每个父节点的值都小于等于其子节点的值。它用扁平数组存储,不需要指针:父节点在索引 i,左子在 2i+1,右子在 2i+2,子节点的父在 (i-1)/2。这种紧凑排列使堆具有极好的缓存局部性。

flowchart TD A["数组表示\n[2, 5, 3, 8, 7, 9, 4]"] --> B["树形表示"] B --> C[" 2\n / \\\n 5 3\n / \\ / \\\n 8 7 9 4"] C --> D{"核心操作"} D -->|"push: 尾部插入 → 上浮"| E["新元素逐层与父比较\n比父小则交换,直到满足堆序"] D -->|"pop: 根与末尾交换 → 下沉"| F["末尾移到根,逐层与较小子比较\n比子大则交换,直到满足堆序"]

3.1 两个核心操作#

上浮(Sift Up):插入新元素时,先将它放到数组末尾,然后不断与父节点比较,如果比父小就交换,直到到达根或不再比父小。这条路劲最长为树高 log n。

下沉(Sift Down):删除堆顶时,先将根与末尾元素交换,弹出末尾(即原堆顶),然后把新的根不断与两个子节点中较小的那个比较,如果比子大就交换,直到到达叶或不再比子大。

3.2 复杂度#

操作时间复杂度说明
查看最小值 PeekO(1)始终在索引 0
插入 PushO(log n)上浮最多走树高
弹出最小值 PopO(log n)下沉最多走树高
建堆 HeapifyO(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.Ordered
type 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-break
interface 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;
}
Note

上述 TypeScript 实现与 React 的 SchedulerMinHeap.js 风格一致——sortIndex 是排序键,id 作为第二排序字段保证 FIFO 顺序。React 调度器正是用这种方式管理定时任务。

六、生产验证#

6.1 React 调度器 — 任务优先级队列#

React 的调度器(Scheduler)用最小堆管理待执行的任务。每个任务有一个 sortIndex(通常是过期时间),堆顶始终是过期最早的任务。调度器每帧从堆顶取任务执行,如果执行超时就交还主线程,下一帧继续。整个最小堆实现只有约 75 行代码,简洁且高效。

6.2 Linux 内核 CFS — 进程调度#

虽然 CFS 用的不是最小堆而是红黑树,但对比很有启发意义。CFS 需要频繁地移除任意进程(进程阻塞时),红黑树支持 O(log n) 的任意节点删除,而堆只擅长删除根。选择红黑树是为了满足「删除任意节点」的需求,不是因为堆不够快。

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 个元素),线性扫描可能更快且更简单
  • 需要稳定的排序结果,标准堆不保证相同键值的元素保持原始顺序

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

最小堆(Min Heap)
https://blog.souloss.com/posts/programming/data-structures/data-structures-min-heap/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时