Heap
堆(Heap)是一种完全二叉树结构,常用于快速取得当前的最大值或最小值。它不是完全排序,而是 partial ordered:
- 在大顶堆(max-heap)中,每个父节点都大于等于它的子节点,因此根节点一定是最大值。
- 在小顶堆(min-heap)中,每个父节点都小于等于它的子节点,因此根节点一定是最小值。
堆只保证父子之间的局部有序,并不保证兄弟节点或不同子树之间有序。它和 binary search tree 的区别是:
- heap:父节点和子节点之间满足大小关系,因此只能快速拿到堆顶(全局最大或最小)。
- BST:左子树 < 根 < 右子树,维护的是更强的全局有序关系,所以中序遍历会得到有序序列。
- heap 适合 priority queue;BST 更适合查找某个具体值、范围查询、顺序遍历。
正因为只维护这种局部顺序,堆可以高效支持:
- 插入一个元素:$O(\log n)$
- 删除堆顶元素:$O(\log n)$
- 查看堆顶元素:$O(1)$
- 从无序数组建堆:$O(n)$
在算法题里,堆通常作为 priority queue 使用,用来动态维护“当前最小/最大”的元素,比如:
- Top K
- Dijkstra / A*
- merge k sorted lists
- streaming median
由于堆是完全二叉树,所以通常直接用数组表示,而不需要真的定义树节点。
Build Heap with array
对 0-based array 来说,如果某个节点的下标是 k,那么:
- left child =
2k + 1 - right child =
2k + 2 - parent =
(k - 1) / 2
为什么会是这个公式?
因为完全二叉树每一层都是从左到右紧密排列的,所以只要知道节点所在的层号,就能算出它在数组中的位置。设层号从 0 开始:
- 第 $i$ 层恰好有 $2^i$ 个节点。
- 前面所有层的节点数是等比数列求和(这就是 $1 + 2 + 4$ 的来源):
$$1 + 2 + 4 + \cdots + 2^{i-1} = 2^i - 1$$
设某节点位于第 $i$ 层、且是这一层从左往右第 $d$ 个节点($d$ 从 0 开始)。那么它在数组中的下标,等于前面所有层的节点总数 + 本层已经排过的节点数:
$$k = \underbrace{(2^i - 1)}_{\text{前面 } i \text{ 层共 } 2^i-1 \text{ 个}} + d$$
它的左孩子位于下一层(第 $i+1$ 层)。左孩子是本层第 $2d$ 个节点——因为前面的 $d$ 个节点每个都有 2 个孩子。代入公式:
$$\text{left} = (2^{i+1} - 1) + 2d = 2\big((2^i - 1) + d\big) + 1 = 2k + 1$$
右孩子紧邻左孩子之后:
$$\text{right} = \text{left} + 1 = 2k + 2$$
反过来,已知孩子下标 $k$,父节点下标为 $\left\lfloor \dfrac{k-1}{2} \right\rfloor$。这也解释了 siftUp / siftDown 里的 (i-1)/2。
为什么建堆从 size/2 - 1 开始
下标为 $i$ 的节点是叶子的条件是它没有左孩子,即 $2i + 1 \ge n$,也就是 $i \ge \lfloor n/2 \rfloor$。所以:
- 第一个叶子节点的下标是 $\lfloor n/2 \rfloor$。
- 最后一个非叶子节点下标是 $\lfloor n/2 \rfloor - 1$,它就是
buildHeap中siftDown的起始位置(叶子节点没有孩子,无需下沉)。
func buildHeap(nums []int) {
size := len(nums)
// k, 2k+1, 2k+2; k = (i-1)/2
for i := size/2 - 1; i >= 0; i++ {
siftDown(nums, i)
}
}
func pop(heap []int) int {
// switch to end and sift down
item := heap[0]
heap[0] = heap[len(heap)-1]
heap = heap[:len(heap)-1]
siftDown(heap, 0)
return item
}
func push(heap []int, item int) []int {
// append to end and sift up
heap = append(heap, item)
siftUp(heap, len(heap)-1)
// here we created new slice and return it
return heap
}
func siftUp(nums []int, i int) {
if i <= 0 {
return
}
parent := (i - 1) / 2
if nums[parent] > nums[i] {
nums[i], nums[parent] = nums[parent], nums[i]
siftUp(nums, parent)
}
}
func siftDown(nums []int, i int) {
size := len(nums)
smallest := i
l, r := 2*i+1, 2*i+2
if l < size && nums[l] < nums[smallest] {
smallest = l
}
if r < size && nums[r] < nums[smallest] {
smallest = r
}
if smallest != i {
nums[i], nums[smallest] = nums[smallest], nums[i]
siftDown(nums, smallest)
}
}
Heap Building & Sift Process
堆的构建通常采用 siftDown(下沉)操作,从最后一个非叶子节点开始,自底向上进行堆化。其时间复杂度为 $O(n)$。
SiftDown 过程
- 比较当前节点与左右子节点的值。
- 在三者中找到最小(小顶堆)或最大(大顶堆)的节点。
- 如果最小/最大节点不是当前节点,则交换它们,并对交换后的子节点递归执行
siftDown。
堆构建流程可视化 (Min-Heap)
以 [7, 2, 5, 8, 1, 6, 4] 为例,堆构建从最后一个非叶子节点(index = 2, value = 5)开始:
Go Heap lib
container/heap 包本身不存储任何数据,它要求你为自定义类型实现一个 Interface,然后由包内的 heap.Init / heap.Push / heap.Pop 完成堆算法:
type Interface interface {
sort.Interface // Len() int; Less(i, j int) bool; Swap(i, j int)
Push(x any) // 向末尾追加一个元素
Pop() any // 取出并返回末尾元素(下标 Len()-1)
}
Less 决定最小堆还是最大堆
堆的“排序语义”完全由 Less(i, j int) bool 决定:它回答“元素 i 是否应该排在元素 j 前面(优先级更高)”。
h[i] < h[j]:越小的元素优先级越高,堆顶是最小值 → 最小堆h[i] > h[j]:越大的元素优先级越高,堆顶是最大值 → 最大堆
// 最小堆:height 小的优先
func (pq PriorityQueue) Less(i, j int) bool { return pq[i].height < pq[j].height }
// 最大堆:height 大的优先(把 < 改成 > 即可)
func (pq PriorityQueue) Less(i, j int) bool { return pq[i].height > pq[j].height }
Push / Pop 接口:为什么必须自己实现
接口里的 Push 和 Pop 职责极小:
Push(x any):把元素追加到切片末尾。Pop() any:取出最后一个元素、缩短切片、并返回它(只负责“截断”)。
官方文档对这两个方法语焉不详,容易让人以为框架会统一实现。其实 h.Pop() 这个方法体绝对不能放进框架,有三个原因:
历史原因(泛型之前):
container/heap在 Go 1.0(2012)就有了,而泛型直到 Go 1.18(2022)才引入。接口方法只能用any(当年的interface{}),框架拿到的只是一个接口值,根本不知道底层切片是[]int、[]string还是[]*Cell,无法替你写出类型正确的截断语句*h = old[:n-1]——这一步必须由你(唯一知道具体类型的人)来完成。内存管理(防泄漏):如果堆里存的是指针或含指针的结构体,
old[:n-1]只是缩短了切片的长度,底层数组仍然引用着被弹出的对象,GC 无法回收它。正确做法是显式置空:func (h *PriorityQueue) Pop() any { old := *h n := len(old) item := old[n-1] old[n-1] = nil // 防内存泄漏:让 GC 能回收弹出的对象(仅指针类型需要) *h = old[:n-1] return item }框架不能强制写
old[n-1] = nil:如果存的是int,赋nil直接编译报错;是否需要清理、如何清理,只有你的业务场景知道。固定逻辑其实已经在框架里了:真正的算法(交换、上浮、下沉)由包内函数实现。
heap.Pop(h)的执行顺序是:交换堆顶与末尾 → 从堆顶下沉调整 → 最后一步才调用你写的h.Pop(),它只是被用来取走那个已经被交换到末尾的元素:// container/heap 包内的核心实现(GOROOT 源码) func Pop(h Interface) any { n := h.Len() - 1 h.Swap(0, n) // ① 固定:堆顶与末尾交换 down(h, 0, n) // ② 固定:从根节点下沉,维护堆序 return h.Pop() // ③ 你写的:截断切片,返回末尾元素 } func Push(h Interface, x any) { h.Push(x) // ① 你写的:追加到末尾 up(h, h.Len()-1) // ② 固定:从末尾上浮,维护堆序 }
一句话总结:框架负责“上浮/下沉”的算法,你只负责“如何从自己的具体切片里拿走/放进最后一个元素”。代价是每次使用都要手写
Len/Less/Swap/Push/Pop五个样板方法。
完整示例:
import (
"container/heap"
"fmt"
)
type Cell struct {
dx int
dy int
height int
}
type PriorityQueue []Cell
func (pq PriorityQueue) Len() int {
return len(pq)
}
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i].height < pq[j].height
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
}
func (pq *PriorityQueue) Push(x any) {
item := x.(Cell)
*pq = append(*pq, item)
}
func (pq *PriorityQueue) Pop() any {
n := len(*pq)
item := (*pq)[n-1]
*pq = (*pq)[0 : n-1]
return item
}
func main() {
cells := [][3]int{
{0, 0, 7},
{0, 1, 2},
{0, 2, 5},
}
pq := make(PriorityQueue, 3)
for i := 0; i < 3; i++ {
pq[i] = Cell{cells[i][0], cells[i][1], cells[i][2]}
}
heap.Init(&pq)
for pq.Len() > 0 {
val := heap.Pop(&pq)
fmt.Println(val)
}
}
值类型 vs 指针类型:上面的
PriorityQueue存的是Cell值而非*Cell指针。值类型元素在切片底层的连续数组里紧挨着排列,内存局部性好、缓存命中率高,也不需要做Pop时的置空防泄漏。指针类型适合元素体积大(超过一个缓存行 / 结构体巨大)或需要共享同一对象的场景,但会引入指针跳转(cache miss)与 GC 压力。默认优先用值类型,除非确实需要共享可变对象。
heap.go 源码:https://pkg.go.dev/container/heap(GOROOT 中 src/container/heap/heap.go)
Java PriorityQueue 对照
java.util.PriorityQueue 把堆完整封装进了标准库:offer(e) 入堆、poll() 出堆、peek() 看堆顶,不用像 Go 一样手写接口方法。唯一需要配置的同样是“比较规则”,它对应 Go 的 Less。
最小堆(默认)
不传比较器时使用元素的自然顺序(natural ordering),值越小优先级越高:
PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // 默认 = 最小堆
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
minHeap.peek(); // 1,堆顶最小
最大堆
传入 Comparator.reverseOrder(),把比较方向反过来:
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.offer(5);
maxHeap.offer(1);
maxHeap.offer(3);
maxHeap.peek(); // 5,堆顶最大
自定义比较器(对应 Go 的 Less)
用 Lambda 或 Comparator.comparingInt 指定按哪个字段、什么方向排序:
class Cell {
int dx, dy, height;
Cell(int dx, int dy, int height) {
this.dx = dx;
this.dy = dy;
this.height = height;
}
}
// 按 height 升序 → 最小堆
// 等价于 Go 的 pq[i].height < pq[j].height
PriorityQueue<Cell> minByHeight =
new PriorityQueue<>(Comparator.comparingInt(c -> c.height));
// 按 height 降序 → 最大堆
// 等价于 Go 的 pq[i].height > pq[j].height
PriorityQueue<Cell> maxByHeight =
new PriorityQueue<>((a, b) -> b.height - a.height);
对比:Go 用
Less(i, j)回答“i 是否应该排在 j 前面”,Java 用compare(a, b)返回负数表示 a 优先级更高,两者语义完全等价,都决定了堆是最小堆还是最大堆。区别只在 Go 需要你手动实现接口,Java 则直接传一个比较器。
典型应用场景
堆的核心价值是 $O(1)$ 看堆顶、$O(\log n)$ 增删。所有场景都围绕这句话展开:
| 场景 | 思路 | 复杂度 |
|---|---|---|
| Top K / 第 K 大(小) | 维护一个大小为 K 的堆,堆顶就是第 K 大/小 | $O(n \log K)$ |
| 数据流中位数 | 双堆:大顶堆放较小的一半,小顶堆放较大的一半,堆顶即候选值 | $O(\log n)$ / 查询 $O(1)$ |
| 合并 K 个有序序列 | 每个序列的头入堆,每次弹出最小再补位 | $O(N \log K)$ |
| 前 K 高频 | 哈希统计频率 + 大小为 K 的堆 | $O(n \log K)$ |
| 滑动窗口最大/最小 | 堆可解 $O(n \log k)$,但单调队列 $O(n)$ 更优 | — |
| Dijkstra / 任务调度 | 每次取当前最优(距离最小 / 优先级最高) | $O(E \log V)$ |
数据流中位数(双堆法)
动态插入、随时查询中位数,是堆最经典的场景之一。思路:用大顶堆维护数组较小的一半,小顶堆维护较大的一半,并保证两堆大小差不超过 1:
- 大顶堆(
maxHeap)的堆顶 = 较小一半的最大值; - 小顶堆(
minHeap)的堆顶 = 较大一半的最小值; - 若两堆大小相等 → 中位数 = 两个堆顶的平均值;否则 → 中位数 = 较大堆的堆顶。
Go 实现(LeetCode 295):
type MaxHeap []int
func (h MaxHeap) Len() int { return len(h) }
func (h MaxHeap) Less(i, j int) bool { return h[i] > h[j] } // 大顶堆
func (h MaxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MaxHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *MaxHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
type MinHeap []int
func (h MinHeap) Len() int { return len(h) }
func (h MinHeap) Less(i, j int) bool { return h[i] < h[j] } // 小顶堆
func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MinHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *MinHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
type MedianFinder struct {
lo *MaxHeap // 较小的一半(大顶堆)
hi *MinHeap // 较大的一半(小顶堆)
}
func Constructor() MedianFinder {
return MedianFinder{lo: &MaxHeap{}, hi: &MinHeap{}}
}
func (mf *MedianFinder) AddNum(num int) {
// 先放进较小的一半,再把最大值移到较大的一半,保证 lo <= hi
heap.Push(mf.lo, num)
heap.Push(mf.hi, heap.Pop(mf.lo).(int))
// 平衡:保持 lo 不比 hi 小(大小相等或 lo 多一个)
if mf.lo.Len() < mf.hi.Len() {
heap.Push(mf.lo, heap.Pop(mf.hi).(int))
}
}
func (mf *MedianFinder) FindMedian() float64 {
if mf.lo.Len() > mf.hi.Len() {
return float64((*mf.lo)[0])
}
return (float64((*mf.lo)[0]) + float64((*mf.hi)[0])) / 2
}
Java 对应(LeetCode 295):
class MedianFinder {
PriorityQueue<Integer> lo = new PriorityQueue<>((a, b) -> b - a); // 大顶堆
PriorityQueue<Integer> hi = new PriorityQueue<>(); // 小顶堆
public void addNum(int num) {
lo.offer(num);
hi.offer(lo.poll());
if (lo.size() < hi.size()) lo.offer(hi.poll());
}
public double findMedian() {
return lo.size() > hi.size() ? lo.peek() : (lo.peek() + hi.peek()) / 2.0;
}
}
LeetCode Hot 150 中的堆题目
| 题号 | 题目 | 考察点 |
|---|---|---|
| 215 | 数组中的第 K 个最大元素 | Top K 模板:维护大小为 K 的小顶堆 |
| 347 | 前 K 个高频元素 | 哈希统计频率 + 大小为 K 的小顶堆 |
| 23 | 合并 K 个升序链表 | 多路归并:K 个头节点入堆 |
| 295 | 数据流的中位数 | 双堆模板(见上) |
| 239 | 滑动窗口最大值 | 堆 $O(n \log k)$;单调队列 $O(n)$ 是更优解 |
| 4 | 寻找两个正序数组的中位数 | 双堆/二分(堆不是最优,仅作理解) |
扩展练习(经典但不在 Hot 150):
- 703 数据流中的第 K 大元素 —— 维护大小为 K 的最小堆,堆顶即答案。
- 264 丑数 II —— 小顶堆按序生成丑数(或用三指针)。
- 378 有序矩阵中第 K 小的元素 —— 多路归并(每行头节点入堆)。
- 1046 最后一块石头的重量 —— 大顶堆反复取两个最大值。
- 767 重构字符串 —— 大顶堆 + 贪心,避免相邻相同。
Top K 万能模板:不管题目问第 K 大还是前 K 高频,一律维护大小为 K 的堆——求第 K 大用小顶堆(堆顶是被淘汰的“最差强者”,堆里始终留最大的 K 个);求第 K 小用大顶堆。这样时间复杂度压到 $O(n \log K)$,避免全排序的 $O(n \log n)$。