堆

一句话说明

堆不是拿来“排序所有元素”的,而是拿来高效维护“当前最小值 / 最大值”的。

先记住它最重要的性质

  • 最小堆:父节点永远不大于子节点
  • 最大堆:父节点永远不小于子节点

所以:

  • 看最小值 / 最大值:O(1)
  • 插入:O(log n)
  • 删除堆顶:O(log n)

数组下标关系

如果下标从 0 开始:

parent(i) = (i - 1) / 2
left(i)   = 2*i + 1
right(i)  = 2*i + 2

这就是为什么堆通常直接用数组实现,不需要真的建树节点。

Go 代码:手写最小堆

type MinHeap struct {
    data []int
}
 
func (h *MinHeap) Len() int {
    return len(h.data)
}
 
func (h *MinHeap) Peek() int {
    return h.data[0]
}
 
func (h *MinHeap) Push(x int) {
    h.data = append(h.data, x)
    h.siftUp(len(h.data) - 1)
}
 
func (h *MinHeap) Pop() int {
    n := len(h.data)
    top := h.data[0]
    h.data[0] = h.data[n-1]
    h.data = h.data[:n-1]
    if len(h.data) > 0 {
        h.siftDown(0)
    }
    return top
}
 
func (h *MinHeap) siftUp(i int) {
    for i > 0 {
        p := (i - 1) / 2
        if h.data[p] <= h.data[i] {
            break
        }
        h.data[p], h.data[i] = h.data[i], h.data[p]
        i = p
    }
}
 
func (h *MinHeap) siftDown(i int) {
    n := len(h.data)
    for {
        smallest := i
        left := 2*i + 1
        right := 2*i + 2
 
        if left < n && h.data[left] < h.data[smallest] {
            smallest = left
        }
        if right < n && h.data[right] < h.data[smallest] {
            smallest = right
        }
        if smallest == i {
            break
        }
 
        h.data[i], h.data[smallest] = h.data[smallest], h.data[i]
        i = smallest
    }
}

Go 标准库里怎么用

实际做题时,Go 一般直接用 container/heap。

type IntHeap []int
 
func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
 
func (h *IntHeap) Push(x any) {
    *h = append(*h, x.(int))
}
 
func (h *IntHeap) Pop() any {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}

最小堆默认这样写;如果想做最大堆,就把 Less 反过来。

上浮和下沉到底在干什么

插入后上浮

新元素先丢到数组末尾,然后一路和父节点比较:

新元素太小,就往上换
直到堆性质恢复

删除堆顶后下沉

把最后一个元素拿来补到根,再一路往下和更小的孩子交换:

根太大,就往下换
直到堆性质恢复

建堆为什么是 O(n)

很多人第一反应会以为是 n 次插入,所以 O(n log n)。
但真正高效的建堆是:

  • 从最后一个非叶子节点开始
  • 逐个向下 siftDown
func (h *MinHeap) Heapify(nums []int) {
    h.data = append([]int(nil), nums...)
    for i := len(h.data)/2 - 1; i >= 0; i-- {
        h.siftDown(i)
    }
}

这个过程总复杂度是 O(n)。

经典题型

第 K 大元素

核心思路不是“建最大堆全弹出来”,而是:

  • 维护一个大小为 k 的最小堆
  • 堆顶就是当前第 k 大
func findKthLargest(nums []int, k int) int {
    h := &IntHeap{}
    heap.Init(h)
 
    for _, num := range nums {
        if h.Len() < k {
            heap.Push(h, num)
            continue
        }
        if num > (*h)[0] {
            heap.Pop(h)
            heap.Push(h, num)
        }
    }
 
    return (*h)[0]
}

合并 K 个有序链表

本质是:

  • 堆里永远只放每条链表当前最小的候选节点

数据流中位数

本质是双堆:

  • 一个最大堆维护较小一半
  • 一个最小堆维护较大一半

堆和优先队列的关系

堆 = 实现优先队列的一种经典方式

题目里说“优先队列”,你脑子里通常就该先想到堆。

什么时候该想到堆

  • 反复取最小值 / 最大值
  • 动态维护 Top K
  • 多路归并
  • 带权图最短路 / 最小生成树
  • 实时调度

易错点

  • 堆不是完全有序结构,只保证堆顶最优。
  • 想查任意元素是否存在,堆并不擅长。
  • container/heap 里 Pop 和 Push 操作的是切片末尾,真正的堆调整由标准库调用。
  • 写最大堆时别忘了把比较函数反过来。

相关主题


返回:数据结构 | 算法学习导航