堆详解(最大堆、最小堆、优先队列)
二叉堆把「完全二叉树」和「数组」两件武器叠在一起:完全树保证高度恒为 O(log n),数组存储让父子跳转变成 O(1) 的下标运算,于是「反复取极值」这一高频需求被压到取堆顶 O(1)、插入删除 O(log n)。它是优先队列的事实标准实现。
它解决什么问题
设想一类场景:数据源源不断地到来,而你反复需要当前的最大值或最小值——任务调度取优先级最高者、定时器找最近到期者、排行榜维护前 K 名。用无序数组,取极值要 O(n) 扫一遍;用有序数组,取极值 O(1) 但每次插入要 O(n) 挪动元素腾位置;用平衡二叉搜索树,两头都是 O(log n),但实现重、常数大,而且杀鸡用了牛刀——你根本不需要全序,只需要知道「谁是老大」。
堆精准命中这个缺口:只维护「父不弱于子」的偏序关系,放弃兄弟之间、跨子树之间的顺序承诺。约束越弱,维护越便宜。需要注意一个常见的以讹传讹:堆在概念上并不要求是完全二叉树(斐波那契堆、二项堆就不是),只是二叉堆选择用完全二叉树实现,而完全树又恰好能无损地拍平进数组——下面说的堆,默认指二叉堆。
核心原理
堆序性质:只管父子,不管全局
- 大顶堆:任意节点的值不小于其孩子;
- 小顶堆:任意节点的值不大于其孩子。
性质是递归定义的,因此整棵树的最大(小)值必然在堆顶,取堆顶是 O(1)。但也仅此而已:左右孩子谁大没有约定,同层之间没有约定,所以堆不是有序结构——对堆做一次层序遍历得到的序列没有单调性,想「搜一个任意值」只能 O(n) 扫描。认清「堆 = 偏序」是避免一切误用的前提。
完全二叉树与数组的天作之合
完全二叉树「每层填满、最后一层靠左连续」的形状,让它可以按层序直接写入数组,不留任何空洞。根存下标 0 时,下标换算全是位运算级别的算术:
- 节点
i的左孩子:2i + 1,右孩子:2i + 2; - 节点
i的父节点:(i - 1) / 2(整数除法)。
没有指针字段,没有内存碎片,连续存储还缓存友好。树高恒为 ⌊log n⌋,这是完全树的形状白送的性质,堆所有操作 O(log n) 的底气也正源于此。
深入机制
siftUp 与 siftDown:维护堆序的两个原子动作
插入:新元素追加到数组末尾(完全树的下一个空位),然后与父节点比较,若破坏堆序则交换,一路向上,直到不弱于父节点或到达堆顶。这个过程叫上浮(siftUp),最多走树高那么远。
删除堆顶:直接把根删掉会在树顶留个洞,难以收拾。标准做法是用数组末尾元素覆盖堆顶、数组长度减一,再让这个「空降兵」与两个孩子中更强者比较交换,一路向下,直到不弱于两个孩子或成为叶子。这叫下沉(siftDown)。为什么选「更强」的孩子交换?因为换上去的那个必须同时压得住另一个孩子,否则交换后堆序仍被破坏。
void siftUp(int[] a, int i) { |
两个操作都沿单条路径移动,代价 O(log n)。
建堆为什么能做到 O(n)
把无序数组整理成堆有两条路线。直觉路线是逐个插入:n 次上浮,O(n log n)。更优的 Floyd 路线是自底向上批量下沉:叶子天然满足堆序,无需处理;从最后一个非叶节点(下标 n/2 - 1)开始,逆序对每个节点做 siftDown。直觉上这似乎也是 O(n log n),但精确算账后答案是 O(n),关键在于绝大多数节点离底部很近:
- 高度为 h 的节点至多有
⌈n / 2^(h+1)⌉个,每个最多下沉 h 层; - 总代价上界为
Σ h · n / 2^(h+1)(h 从 1 到 log n); - 级数
Σ h / 2^h收敛于常数 2,故总代价 ≤ n · 常数 = O(n)。
一句话版直觉:底部节点数量占大头但只需走一两步,顶部节点要走得远但数量屈指可数,移动总量被几何级数压住,与「逐个插入」时每个新节点都可能从底部一路浮到顶的处境完全不同。
堆排序:原地、O(n log n)、不稳定
流程只有两步:
- 用 Floyd 法把数组建成大顶堆,O(n);
- 反复执行「堆顶与堆区末尾交换 → 堆区长度减一 → 对新堆顶做 siftDown」,每轮把当前最大值钉进最终位置,n-1 轮共 O(n log n)。
交换堆顶与末尾是个一石二鸟的设计:既「取出」了最大值,又让末尾元素自动补位到根,删除堆顶的前置动作就此免掉。总复杂度 O(n log n)、原地 O(1) 额外空间。但相等元素的相对次序可能被「堆顶-末尾」的远距离交换打乱,所以堆排序不稳定;且其访问模式局部性差,实践中通常跑不过快速排序,只在需要稳定最坏上界或额外空间受限时出场。
工程实践
- 优先队列:Java 的
PriorityQueue默认小顶堆,构造大顶堆传PriorityQueue<>((a, b) -> Integer.compare(b, a))。切记不要写成b - a,整数溢出会让比较结果出错,这是真实出现过的事故级 bug。 - Top-K:维护容量为 K 的小顶堆,堆顶是当前候选中的「守门员」,新元素超过它则替换之,O(n log k) 时间、O(k) 空间,且天然适配流式数据。数据流求中位数则是「大顶堆存左半 + 小顶堆存右半」的对顶结构。
- 定时器与延迟队列:以触发时间为键的小顶堆,堆顶即最近到期的任务,线程只需睡到堆顶时刻;Kafka 的 DelayedOperationPurgatory、大量 RPC 框架的超时管理都基于这一结构(配合时间轮分摊竞争)。
- 修改任意元素优先级:普通堆不支持高效定位,方案有二——维护「元素 → 下标」的索引堆,或放任旧记录留在堆里、弹出时按版本号丢弃(懒惰删除)。
对比与权衡
| 操作 | 无序数组 | 有序数组 | 平衡 BST | 二叉堆 |
|---|---|---|---|---|
| 取极值 | O(n) | O(1) | O(log n) | O(1) |
| 插入 | O(1) | O(n) | O(log n) | O(log n) |
| 删除极值 | O(n) | O(1) | O(log n) | O(log n) |
| 查找任意值 | O(n) | O(log n) | O(log n) | O(n) |
| 建堆/初始化 | — | O(n log n) | O(n log n) | O(n) |
堆在「取极值 + 增删」组合上做到了综合最优,代价是放弃了任意查找与全序输出。Top-K 若要求线性时间则应改用快速选择(期望 O(n)),堆解法的胜场在数据流与增量场景。
面试视角
为什么自底向上建堆是 O(n) 而不是 O(n log n)? 下沉的代价取决于节点高度而非深度:占节点总数约一半的叶子高度为 0,四分之一的节点高度为 1,逐层减半。按 Σ h·n/2^(h+1) 求和,级数收敛,总移动次数被 n 的常数倍封住。与之对照,逐个插入时每个元素都可能上浮全程,才是 O(n log n)。
Top-K 第 K 大为什么用小顶堆而不是大顶堆? 小顶堆的堆顶是当前 K 个候选中最弱的门槛,新元素只需与它比一次就能决定去留;若用大顶堆,堆顶是全局最大值,无法回答「我是否属于前 K」。方向选反是最常见的实现错误。
堆排序为什么不稳定? 「堆顶与堆区末尾交换」会把末尾元素隔空提到堆顶,再靠下沉落位,相等元素在数组中的原始相对顺序可能因此被颠倒,而排序过程没有任何机制保护和恢复这种顺序。
如何合并 K 个有序链表? 把各链表的头节点放入容量 K 的小顶堆,每轮弹出最小节点接到结果尾部,并把该节点的后继补入堆中,总代价 O(N log K),远优于两两顺序合并的 O(NK)。
小结
- 堆是偏序结构:只承诺父与子的强弱关系,堆顶即极值,别指望它有序或支持快速任意查找。
- 完全二叉树的形状给了它 O(log n) 的高度,数组存储给了它 O(1) 的下标寻路,两者叠加才有堆的全部价值。
- siftUp 管插入、siftDown 管删顶与建堆;Floyd 建堆 O(n) 的本质是「多数节点路短、少数节点路长」的几何级数收敛。
- 堆排序原地且最坏 O(n log n) 但不稳定;工程上堆的主场是优先队列、Top-K 与定时器,选型时牢记它换取效率的代价是放弃全序。

