Heap

堆(Heap)是一种完全二叉树结构,常用于快速取得当前的最大值或最小值。它不是完全排序,而是 partial ordered

堆只保证父子之间的局部有序,并不保证兄弟节点或不同子树之间有序。它和 binary search tree 的区别是:

正因为只维护这种局部顺序,堆可以高效支持:

在算法题里,堆通常作为 priority queue 使用,用来动态维护“当前最小/最大”的元素,比如:

由于堆是完全二叉树,所以通常直接用数组表示,而不需要真的定义树节点。

Build Heap with array

对 0-based array 来说,如果某个节点的下标是 k,那么:

为什么会是这个公式?

因为完全二叉树每一层都是从左到右紧密排列的,所以只要知道节点所在的层号,就能算出它在数组中的位置。设层号从 0 开始:

$$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$。所以:

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 过程

  1. 比较当前节点与左右子节点的值。
  2. 在三者中找到最小(小顶堆)或最大(大顶堆)的节点。
  3. 如果最小/最大节点不是当前节点,则交换它们,并对交换后的子节点递归执行 siftDown

堆构建流程可视化 (Min-Heap)

[7, 2, 5, 8, 1, 6, 4] 为例,堆构建从最后一个非叶子节点(index = 2, value = 5)开始:

D2 Diagram
qtopie.github.io

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 前面(优先级更高)”。

// 最小堆: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 接口:为什么必须自己实现

接口里的 PushPop 职责极小:

官方文档对这两个方法语焉不详,容易让人以为框架会统一实现。其实 h.Pop() 这个方法体绝对不能放进框架,有三个原因:

  1. 历史原因(泛型之前)container/heap 在 Go 1.0(2012)就有了,而泛型直到 Go 1.18(2022)才引入。接口方法只能用 any(当年的 interface{}),框架拿到的只是一个接口值,根本不知道底层切片是 []int[]string 还是 []*Cell,无法替你写出类型正确的截断语句 *h = old[:n-1]——这一步必须由你(唯一知道具体类型的人)来完成。

  2. 内存管理(防泄漏):如果堆里存的是指针或含指针的结构体,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 直接编译报错;是否需要清理、如何清理,只有你的业务场景知道。

  3. 固定逻辑其实已经在框架里了:真正的算法(交换、上浮、下沉)由包内函数实现。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:

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):

Top K 万能模板:不管题目问第 K 大还是前 K 高频,一律维护大小为 K 的堆——求第 K 用小顶堆(堆顶是被淘汰的“最差强者”,堆里始终留最大的 K 个);求第 K 用大顶堆。这样时间复杂度压到 $O(n \log K)$,避免全排序的 $O(n \log n)$。

Reference