登录
OAmaster
常用数据结构

堆与优先队列

从 LC 215 第 K 大出发,用大小 k 的最小堆把 O(n log n) 砍到 O(n log k)。

Medium预计阅读 45 分钟更新于 2026-05-23

1. 核心原理

堆解决的是这一类问题:"数据源源不断来、我只关心当前的最大或最小、要能持续插入新元素"。Top-K、流式中位数、任务调度、Dijkstra 最短路里的"取最近未访问点"、哈夫曼编码里的"合并最小两堆"——全都可以归结成同一个动作:快速看顶 + 快速换顶

排序也能做,但每来一个新元素就重新排一遍是 O(n log n),浪费在"把不关心的尾部也排好"上。堆只承诺三件事:

  • 看顶 O(1):当前的极值是谁
  • 插入 O(log n):放一个新元素进来,依然保证顶是极值
  • 弹顶 O(log n):取走极值,让次极值升上去

物理形状是一棵完全二叉树——除最后一层外每层都满、最后一层从左到右紧密排布。这种形状可以直接塞进数组:下标 i 的左孩子是 2i + 1、右孩子是 2i + 2、父亲是 (i - 1) / 2。插入和删除沿一条根到叶的路径上浮或下沉,路径长度 ≤ log n

Top-K 为什么用反向堆

第 k 大大小 k 的最小堆,求第 k 小大小 k 的最大堆。这条反直觉的规则每年面试都有人写错。

原因:堆顶是"随时被踢"的那个。求第 k 大时,候选集里最不该留的就是最小那个,把它放在堆顶才方便随手弹掉。每来一个新元素就和堆顶比一下——比堆顶还小就丢弃,否则替换掉堆顶。扫完一遍 n 次,每次 O(log k),合计 O(n log k)k 远小于 n 时比 O(n log n) 排序快不少。

各语言堆的默认方向

语言默认堆怎么变方向
Python heapq最小堆存负数 heappush(h, -x),取的时候 -h[0]
Java PriorityQueue最小堆new PriorityQueue<>(Comparator.reverseOrder())
Go container/heap无默认必须自己实现 Less(i, j) bool
C++ priority_queue最大堆(注意反过来)greater<int> 变最小堆

Python 的 heapq 没有最大堆,惯例做法是塞负数;C++ 的 priority_queue 默认是最大堆(和其他语言相反),别记反。

本章节奏

新写完 §2 三段通用模板后,按"静态数组 → 在线流 → 双流维护"推进,对应 LC 215 → 703 → 295。一条主线:堆顶就是答案候选

2. 通用堆操作模板

下面三段骨架覆盖了 90% 的堆题:标准三件套、Top-K 维护、双堆中位数。后面例题全是把这三套套到不同场景。

2.1 标准三件套:建堆 / push / pop

import heapq

# 建堆:O(n) 原地把无序数组变成最小堆
arr = [3, 1, 4, 1, 5, 9, 2, 6]
heapq.heapify(arr)                       # arr 现在满足堆性质

# push:O(log n)
heapq.heappush(arr, 0)

# 看顶:O(1),arr[0] 就是当前最小
top = arr[0]

# 弹顶:O(log n)
smallest = heapq.heappop(arr)

# heapreplace = pop + push 一步合成,只走一次下沉,比分开快
# 注意:先返回旧顶、再压入新元素
old_top = heapq.heapreplace(arr, 7)
import java.util.PriorityQueue;
import java.util.List;

// 建堆:传入 Collection 是 O(n);逐个 offer 是 O(n log n)
PriorityQueue<Integer> heap = new PriorityQueue<>(List.of(3, 1, 4, 1, 5, 9, 2, 6));

// push:O(log n)
heap.offer(0);

// 看顶:O(1)
int top = heap.peek();

// 弹顶:O(log n)
int smallest = heap.poll();

// Java 没有 heapreplace,等价写法是 poll() + offer() 两步
// 实际开销是两次 sift(一次下沉 + 一次上浮),略慢于 Python heapreplace
heap.poll();
heap.offer(7);
#include <queue>
#include <vector>
using namespace std;

// 默认是最大堆——要最小堆得显式 greater<int>
priority_queue<int, vector<int>, greater<int>> heap;

// 批量建堆:直接用 vector 构造,O(n)
vector<int> arr = {3, 1, 4, 1, 5, 9, 2, 6};
priority_queue<int, vector<int>, greater<int>> heap2(arr.begin(), arr.end());

// push:O(log n)
heap.push(0);

// 看顶:O(1)
int top = heap.top();

// 弹顶:O(log n)
heap.pop();

// 同样没有 heapreplace,要 pop 后 push
heap.pop();
heap.push(7);

三件套之外,heapifyO(n) 而不是 O(n log n)——叶子层占节点数一半且天然就是单元素堆不用动,倒数第二层每个节点最多下沉 1 步,往上每层翻倍但节点数减半,求和级数收敛到 O(n)。"一次性建堆 + pop k 次取 Top-K" 的总成本是 O(n + k log n),比"逐个 push 进堆"的 O(n log n) 更快。

2.2 Top-K:大小 k 的反向堆

import heapq

def top_k_largest(nums: list[int], k: int) -> list[int]:
    """返回前 k 大的元素(顺序不定);堆顶 = 第 k 大"""
    heap = []                                    # 最小堆
    for x in nums:
        if len(heap) < k:
            heapq.heappush(heap, x)              # 堆没满,直接放
        elif x > heap[0]:
            heapq.heapreplace(heap, x)           # 比门槛大,替换堆顶
    return heap                                  # 堆里就是 Top-K
import java.util.PriorityQueue;

int[] topKLargest(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();   // 默认最小堆
    for (int x : nums) {
        if (heap.size() < k) {
            heap.offer(x);
        } else if (x > heap.peek()) {
            heap.poll();
            heap.offer(x);                       // 没有 heapreplace,poll + offer
        }
    }
    int[] ans = new int[k];
    for (int i = 0; i < k; i++) ans[i] = heap.poll();
    return ans;
}
vector<int> topKLargest(vector<int>& nums, int k) {
    // 求第 k 大用最小堆——堆顶是 Top-K 里最小的"门槛"
    priority_queue<int, vector<int>, greater<int>> heap;
    for (int x : nums) {
        if ((int)heap.size() < k) {
            heap.push(x);
        } else if (x > heap.top()) {
            heap.pop();
            heap.push(x);
        }
    }
    vector<int> ans;
    while (!heap.empty()) { ans.push_back(heap.top()); heap.pop(); }
    return ans;
}

关键技巧:求 Top-k "大" 用最小堆——堆顶是候选集里最小的"门槛",新元素能不能进来就看比不比得过堆顶。求 Top-k "小" 完全对称,用最大堆。复杂度 O(n log k)

2.3 双堆维护中位数

import heapq

class MedianStream:
    def __init__(self):
        self.left = []                           # 最大堆(存 -x 模拟)
        self.right = []                          # 最小堆

    def add(self, x: int) -> None:
        # 三步走:先无脑放 left,再把 left 顶倒给 right,最后看是否回倒
        heapq.heappush(self.left, -x)
        heapq.heappush(self.right, -heapq.heappop(self.left))
        if len(self.right) > len(self.left):
            heapq.heappush(self.left, -heapq.heappop(self.right))

    def median(self) -> float:
        if len(self.left) > len(self.right):
            return -self.left[0]                 # 总数奇数:中位数在 left 顶
        return (-self.left[0] + self.right[0]) / 2
import java.util.PriorityQueue;
import java.util.Comparator;

class MedianStream {
    private final PriorityQueue<Integer> left;   // 最大堆
    private final PriorityQueue<Integer> right;  // 最小堆

    public MedianStream() {
        // 用 Comparator.reverseOrder() 避免 (a,b) -> b - a 的溢出
        left = new PriorityQueue<>(Comparator.reverseOrder());
        right = new PriorityQueue<>();
    }

    public void add(int x) {
        left.offer(x);
        right.offer(left.poll());
        if (right.size() > left.size()) {
            left.offer(right.poll());
        }
    }

    public double median() {
        if (left.size() > right.size()) {
            return left.peek();
        }
        return (left.peek() + right.peek()) / 2.0;
    }
}
class MedianStream {
    priority_queue<int> left;                                          // 默认最大堆
    priority_queue<int, vector<int>, greater<int>> right;              // 最小堆

public:
    void add(int x) {
        left.push(x);
        right.push(left.top()); left.pop();
        if (right.size() > left.size()) {
            left.push(right.top()); right.pop();
        }
    }

    double median() const {
        if (left.size() > right.size()) return left.top();
        return (left.top() + right.top()) / 2.0;
    }
};

双堆的核心是不变量 |left| - |right| ∈ {0, 1}——left 装较小的一半(最大堆,顶是这一半的最大值),right 装较大的一半(最小堆,顶是这一半的最小值)。"先无脑放 left → 倒给 right → 失衡再回倒"三步天然完成了排序,省得手写 if x <= left.peek() 的二选一分支。

自定义比较器小抄

  • Pythonheapq 不接受比较器,只能靠元组排序——(cnt, key)cnt 升序、cnt 相等时按 key 升序。要反向用负号 (-cnt, key)
  • Javanew PriorityQueue<>((a, b) -> Integer.compare(b, a)) 是最大堆。(a, b) -> b - a——b - ab 接近 Integer.MIN_VALUE 时会溢出。
  • C++:lambda 比较器要传 decltype(cmp) 给模板参数,写成 priority_queue<T, vector<T>, decltype(cmp)> pq(cmp)。注意比较器返回 true 的元素优先级低(和 sort 相反)。

3. 母题精讲:LC 215 数组中的第 K 个最大元素

链接:LeetCode 215

题意:给一个整数数组 nums 和整数 k,返回数组中第 k 大的元素。注意"第 k 大"不是去重后的第 k 大——比如 [3, 2, 1, 5, 6, 4] 第 2 大是 5,[3, 2, 3, 1, 2, 4, 5, 5, 6] 第 4 大是 4。

数据范围:

  • nums.length:1 到 10⁵
  • nums[i]:−10⁴ 到 10⁴
  • 1 <= k <= nums.length

示例:

输入:nums = [3, 2, 1, 5, 6, 4], k = 2
输出:5

输入:nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4
输出:4

暴力解:能 AC 但浪费

最直觉的写法是排序,倒着取下标 k - 1

class Solution:
    def findKthLargest_sort(self, nums: list[int], k: int) -> int:
        nums.sort()
        return nums[-k]
class Solution {
    public int findKthLargestSort(int[] nums, int k) {
        Arrays.sort(nums);
        return nums[nums.length - k];
    }
}
class Solution {
public:
    int findKthLargestSort(vector<int>& nums, int k) {
        sort(nums.begin(), nums.end());
        return nums[nums.size() - k];
    }
};

时间 O(n log n),空间 O(1)(或者 O(log n) 算上递归栈)。n = 10⁵ 时这是 10⁵ × 17 ≈ 1.7 × 10⁶ 次比较,能过,但浪费在哪?

我们其实只关心前 k 大那几个元素的相对顺序,剩下 n − k 个元素被排得明明白白却没人用。k = 2n = 10⁵ 时,花 n log n 的力气只为拿出 2 个值——杀鸡用了牛刀。

只维护当前的 Top-k 候选集

换个思路:扫描数组,始终维护一个大小恰好为 k 的"Top-k 候选集"。每来一个新元素 x,跟候选集里最小的那个比一下:

  • 如果 x 比最小的还小,丢掉它(它不可能是 Top-k)
  • 否则把最小的踢出来,把 x 加进去

扫完整个数组,候选集里就是真正的 Top-k,候选集里最小的那个就是第 k 大。

这个候选集本质上要支持三件事:

  1. 看一眼最小值
  2. 弹出最小值
  3. 插入一个新元素

排序数组能实现,但插入是 O(k)。能把这三件事都压到 O(log k) 的,就是(小顶堆)——这也是堆的"三件套"。

总成本:扫一遍 n 次,每次至多 O(log k),合计 O(n log k)k 远小于 n 时,这比 O(n log n) 快不少。

代入 n = 10⁵k = 100

  • 排序 O(n log n) ≈ 10⁵ × 17 = 1.7 × 10⁶
  • O(n log k) ≈ 10⁵ × 7 = 7 × 10⁵

差距不大;但 k = 2 时堆只要 n 次操作,比排序快一个量级。这就是堆的核心卖点:只关心"部分极值"时,别走全排序的弯路

下面这个交互演示走一遍 [3, 2, 1, 5, 6, 4]k = 2 下的过程。蓝色是已经决定不进 Top-2 的位置,红色是当前在堆中的位置:

findKthLargest([3, 2, 1, 5, 6, 4], k=2)

维护大小 2 的最小堆,堆顶 = 当前 Top-2 中的最小值 = 候选答案

Step 1 / 8初始:堆空,size = 0
index
012345
value
3
2
1
5
6
4
i
当前在堆中已被淘汰最终堆顶(答案)尚未扫到
01/08

多语言实现

直接套 §2.2 的 Top-K 模板,扫一遍数组、返回堆顶。

import heapq

class Solution:
    def findKthLargest(self, nums: list[int], k: int) -> int:
        heap = []
        for x in nums:
            if len(heap) < k:
                heapq.heappush(heap, x)
            elif x > heap[0]:
                heapq.heapreplace(heap, x)       # pop + push 一步合成
        return heap[0]
class Solution {
    public int findKthLargest(int[] nums, int k) {
        PriorityQueue<Integer> heap = new PriorityQueue<>();
        for (int x : nums) {
            if (heap.size() < k) {
                heap.offer(x);
            } else if (x > heap.peek()) {
                heap.poll();
                heap.offer(x);
            }
        }
        return heap.peek();
    }
}
class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        priority_queue<int, vector<int>, greater<int>> heap;
        for (int x : nums) {
            if ((int)heap.size() < k) {
                heap.push(x);
            } else if (x > heap.top()) {
                heap.pop();
                heap.push(x);
            }
        }
        return heap.top();
    }
};

复杂度:时间 O(n log k)、空间 O(k)

LC 215 还有个 O(n) 期望复杂度的快速选择(QuickSelect)——基于快排 partition 一次扔掉一半,平均 O(n),但最坏 O(n²)、常数大、代码长。面试里堆解一般够用,被追问"能不能更快"再补 QuickSelect。


4. 抽象:堆到底是什么?

堆的三件套

把堆想象成医院的分诊台——病人源源不断来,但每次叫号时窗口看一眼"谁优先级最高"就好,不需要把候诊队伍排得整整齐齐。堆就是这种"懒排序"结构,承诺三件事:

  • 看顶 O(1):当前的极值(最小或最大)是谁
  • 插入 O(log n):放一个新元素进来,依然保持极值在顶
  • 弹顶 O(log n):取走极值,让次极值升上去

关键词是"部分排序"——堆不会给你一个完全有序的序列,它只保证"顶部那个是极值"。要想拿到全序,得连续 pop n 次(每次 O(log n)),合计 O(n log n),这其实就是堆排序的来历。

但只要你只看顶、只取前 k 个、或者反复维护"动态最值"——堆比排序快得多。

为什么是 O(log n)?

堆物理上是一棵完全二叉树——除了最后一层外每层都满,最后一层从左到右紧密排布。这种形状有个好处:可以直接塞进数组里,下标 i 的左孩子是 2i + 1、右孩子是 2i + 2、父亲是 (i - 1) / 2

下图展示一个最小堆 [1, 3, 2, 7, 5, 4, 6] 同时以树形 + 数组形态出现,下标对应关系一目了然:

   树形(最小堆)              数组下标对应
        1 (0)                arr = [1, 3, 2, 7, 5, 4, 6]
       / \                          0  1  2  3  4  5  6
      3   2  (1, 2)
     /\\  /\\                 idx i  → left = 2i+1, right = 2i+2
    7  5 4  6 (3,4,5,6)              → parent = (i-1)/2

插入和删除靠"上浮"或"下沉"维护堆性质(父节点 ≤ 或 ≥ 它的孩子),每次操作只走一条根到叶的路径。

下面是 pop() 后的下沉演示——pop 把堆顶 1 弹掉、把末尾 6 提到根,然后从根开始 sift down 直到稳定:

Step 0: 把末尾 6 提到根(破坏堆序)
        6        ← 新堆顶,但 6 > 子节点 3 和 2,违反最小堆
       / \
      3   2
     / \\  /
    7   5 4

Step 1: 6 与较小的孩子 2 交换
        2
       / \
      3   6      ← 6 下沉一层
     / \\  /
    7   5 4

Step 2: 6 与较小的孩子 4 交换(也可以走另一条路;选小的)
        2
       / \
      3   4      ← 6 继续下沉
     / \\  /
    7   5 6

终态:堆性质恢复,pop 返回原堆顶 1,剩余堆 [2, 3, 4, 7, 5, 6]

下沉路径长度 ≤ 树高 = log n,所以 popO(log n)push 完全对称——把新元素塞到末尾,与父节点比较向上交换(sift up),同样 O(log n)。面试时不用手撕这套图,但这条心里要有数。

为什么 heapify 是 O(n) 而不是 O(n log n)

把一个无序数组一次性变成堆(heapq.heapify(arr) / Java PriorityQueue(List))的复杂度是 O(n),不是直觉上的 n log n。面试官追问时这是必懂点。

直觉:从下往上建堆。叶子层(占节点数一半)天然就是单元素堆,不用动。倒数第二层每个节点最多下沉 1 步,倒数第三层最多下沉 2 步,依此类推。把每层的"节点数 × 单次代价"加起来:

总代价 = Σ (h 从 0 到 log n)  [第 h 层节点数] × [最多下沉步数]
       = Σ h · (n / 2^(h+1))
       ≈ n · Σ h / 2^(h+1)
       ≤ n · 2 = O(n)

最后那个级数 Σ h / 2^h ≤ 2 是经典收敛结果(错位相减能算出来)。节省的关键是叶子层占一半且完全不动——朴素的"n 个元素逐个 push" 会让每个元素都走完整条 log n 路径,那才是 O(n log n)

工程意义:建堆 + pop k 次取 Top-K 是 O(n + k log n);当 k = n 退化为堆排序,整体 O(n log n)

这就是堆全部秘密——剩下的题都是怎么把"堆顶=答案"这套模板套到不同场景上。


5. 例题(三道)

下面三道按"静态数组 → 在线流 → 双流维护"递进,全是堆三件套的不同打开方式:一次性扫完、每次 add 都要返回结果、同时维护两个堆。

5.1 LC 215 数组中的第 K 个最大元素(速记)

§3 已经把 LC 215 拆透了。这里只是把它列在"例题三道"系列里做一个对照锚点——大小 k 的最小堆扫一遍,堆顶就是答案,时间 O(n log k)、空间 O(k)

Follow-up:上面是一次扫完所有元素。要是元素一个一个来、每来一个就要立刻返回当前的第 k 大呢?这就是下一题。


5.2 LC 703 数据流中的第 K 大元素

链接:LeetCode 703

题意:设计一个类 KthLargest

  • KthLargest(k, nums):用初始数组 nums 和参数 k 初始化
  • add(val):把 val 加入数据流,返回当前数据流中的第 k 大元素

数据范围:

  • 1 <= k <= 10⁴
  • 调用 add 的次数:1 到 10⁴
  • nums[i]val:−10⁴ 到 10⁴

示例:

KthLargest(3, [4, 5, 8, 2])
add(3)  → 4   // 流变成 [4, 5, 8, 2, 3],第 3 大 = 4
add(5)  → 5   // 流变成 [4, 5, 8, 2, 3, 5],第 3 大 = 5
add(10) → 5   // 流变成 [4, 5, 8, 2, 3, 5, 10],第 3 大 = 5
add(9)  → 8   // 流变成 [4, 5, 8, 2, 3, 5, 10, 9],第 3 大 = 8
add(4)  → 8

这就是 LC 215 的"在线版本"——把 LC 215 里那个"大小 k 的最小堆"持久化到对象里就完事了。初始化时跑一遍 nums,之后每次 add 只做一次 push(或者 push 之后 pop 一个)。堆顶永远是当前第 k 大,规则没变。

import heapq

class KthLargest:
    def __init__(self, k: int, nums: list[int]):
        self.k = k
        self.heap = []
        for x in nums:
            self.add(x)                          # 复用 add,省得写两遍

    def add(self, val: int) -> int:
        if len(self.heap) < self.k:
            heapq.heappush(self.heap, val)
        elif val > self.heap[0]:
            heapq.heapreplace(self.heap, val)
        return self.heap[0]
class KthLargest {
    private final int k;
    private final PriorityQueue<Integer> heap;

    public KthLargest(int k, int[] nums) {
        this.k = k;
        this.heap = new PriorityQueue<>();
        for (int x : nums) {
            add(x);
        }
    }

    public int add(int val) {
        if (heap.size() < k) {
            heap.offer(val);
        } else if (val > heap.peek()) {
            heap.poll();
            heap.offer(val);
        }
        return heap.peek();
    }
}
class KthLargest {
    int k;
    priority_queue<int, vector<int>, greater<int>> heap;

public:
    KthLargest(int k, vector<int>& nums) : k(k) {
        for (int x : nums) {
            add(x);                              // 复用 add,省得写两遍
        }
    }

    int add(int val) {
        if ((int)heap.size() < k) {
            heap.push(val);
        } else if (val > heap.top()) {
            heap.pop();
            heap.push(val);
        }
        return heap.top();
    }
};

复杂度:构造 O(n log k)、每次 add O(log k)、空间 O(k)

构造函数里直接循环调 add,省得把 push 逻辑写两份。这里有个坑:题面允许 nums.length < k(甚至空数组),所以"堆还没满"的分支不能省——这就是 if len(heap) < k 存在的原因,容易忽略。

Follow-up:上面这两道都是"找单极端值"。要是题目要的是中位数——把数据流劈成大小相等的两半再取中间——一个堆就不够用了。


5.3 LC 295 数据流的中位数

链接:LeetCode 295

题意:设计 MedianFinder 类:

  • addNum(num):把 num 加入数据流
  • findMedian():返回当前数据流的中位数(流长度为偶数时是中间两个的平均)

数据范围:

  • 调用 addNumfindMedian 总次数:1 到 5 × 10⁴
  • num:−10⁵ 到 10⁵

示例:

addNum(1)
addNum(2)
findMedian() → 1.5
addNum(3)
findMedian() → 2.0

朴素思路:把每个新数插入一个有序数组——addNumO(n)findMedianO(1)。但 addNum 调用 5 × 10⁴ 次最坏 (5 × 10⁴)² = 2.5 × 10⁹,妥妥 TLE。

中位数有个天生的性质:它把数据劈成两半,左半都 ≤ 它、右半都 ≥ 它。既然要劈两半,那就维护两个堆:

  • left:最大堆,存较小的一半(顶部是这一半的最大值)
  • right:最小堆,存较大的一半(顶部是这一半的最小值)

约束 |left| - |right| ∈ {0, 1}(left 最多比 right 多一个):

  • 总数偶数:两边一样多,中位数 = (left 顶 + right 顶) / 2
  • 总数奇数:left 多一个,中位数 = left 顶

每次 addNum 要做两件事:把 num 放进合适的一边、维持两堆大小平衡。最简洁的写法就一句话——"先无脑放 left,再把 left 顶倒给 right,最后看是否要把 right 顶倒回 left"。三段式三步内必恢复不变量,比手写 if/else 干净得多。

import heapq

class MedianFinder:
    def __init__(self):
        self.left = []                           # 最大堆(存 -num 模拟)
        self.right = []                          # 最小堆

    def addNum(self, num: int) -> None:
        # 三步走:先入 left,再把 left 顶丢给 right,最后平衡
        heapq.heappush(self.left, -num)
        heapq.heappush(self.right, -heapq.heappop(self.left))
        if len(self.right) > len(self.left):
            heapq.heappush(self.left, -heapq.heappop(self.right))

    def findMedian(self) -> float:
        if len(self.left) > len(self.right):
            return -self.left[0]                 # 奇数个,中位数在 left 顶
        return (-self.left[0] + self.right[0]) / 2
class MedianFinder {
    private final PriorityQueue<Integer> left;   // 最大堆
    private final PriorityQueue<Integer> right;  // 最小堆

    public MedianFinder() {
        left = new PriorityQueue<>(Comparator.reverseOrder());
        right = new PriorityQueue<>();
    }

    public void addNum(int num) {
        left.offer(num);
        right.offer(left.poll());
        if (right.size() > left.size()) {
            left.offer(right.poll());
        }
    }

    public double findMedian() {
        if (left.size() > right.size()) {
            return left.peek();
        }
        return (left.peek() + right.peek()) / 2.0;
    }
}
class MedianFinder {
    priority_queue<int> left;                                          // 默认最大堆
    priority_queue<int, vector<int>, greater<int>> right;              // 最小堆

public:
    MedianFinder() {}

    void addNum(int num) {
        // 三步走:先入 left,再把 left 顶丢给 right,最后平衡
        left.push(num);
        right.push(left.top());
        left.pop();
        if (right.size() > left.size()) {
            left.push(right.top());
            right.pop();
        }
    }

    double findMedian() {
        if (left.size() > right.size()) {
            return left.top();                   // 奇数个,中位数在 left 顶
        }
        return (left.top() + right.top()) / 2.0;
    }
};

复杂度:addNum O(log n)findMedian O(1)、空间 O(n)

为什么"先入 left、再倒给 right、再判断回倒"是对的?本质上这套动作天然完成了排序——num 进 left 后,弹出的 left 顶必然是"小半边里最大的",扔到 right 里恰好维持"右半全 ≥ 左半"的不变量。换一种写法 if num <= left.peek(): 进 left else: 进 right 也行,但还要单独加 size 平衡分支,代码量翻倍。


6. 边界、易错与复杂度

求第 k 大到底是最小堆还是最大堆?

"求第 k 大用最小堆、求第 k 小用最大堆"——这一条反直觉的设定每次面试都会有人写错。记忆法就一句话:堆顶是随时被踢的那个。求第 k 大时,候选集里只有真正大的能留下,"最该被踢"的就是最小那个,所以放在堆顶。

代码写不确定时,跑一个 size = 2 的 mental test:[1, 5, 3] 求第 2 大(答案是 3)。维护最小堆 size = 2 最后是 [3, 5],堆顶 = 3 ✓。如果错用最大堆,堆顶 = 5(这是第 1 大,不是第 2 大)。

Python 模拟最大堆别忘了正负号

Python 的 heapq 没有最大堆。模拟时三处都要写负号

  • push:heapq.heappush(h, -x)
  • pop:-heapq.heappop(h)
  • 看顶:-h[0]

最容易漏的是"看顶"——h[0] 是堆里的负数,直接拿来用会差一个符号。LC 295 双堆里 return -self.left[0] 那处尤其要小心。

Java 比较器溢出陷阱

Java 写最大堆常用 new PriorityQueue<>((a, b) -> b - a)。但当 ab 接近 Integer.MIN_VALUEMAX_VALUE 时,b - a 会溢出。安全写法是 (a, b) -> Integer.compare(b, a) 或者直接 Comparator.reverseOrder(),永远不会溢出。

LC 215 / 703 数据范围 nums[i] ∈ [-10⁴, 10⁴],没风险;但有些题(比如 LC 1167 连接棒材的最低费用)值域可达 10⁹,溢出就出 bug。

Java 没有 heapreplace

Python 的 heapq.heapreplace(h, x) 一步完成"弹顶 + 入新"且只走一次下沉,比 pop()push() 少一次上浮。Java PriorityQueue 没有等价 API,只能写成 heap.poll(); heap.offer(x);——两次 sift,常数略大。C++ 同样没有,写法相同。这在 LC 2233 K 次 +1 之类需要反复 replace 的场景里会有 1.5-2x 的常数差,但不影响渐近复杂度。

Go 的 heap.Interface 模板

Go 的 container/heap 不是开箱即用的容器,是接口包。一个堆需要实现五个方法:Len() intLess(i, j int) boolSwap(i, j int)Push(x any)Pop() any。其中:

  • Push / Pop 操作的是底层 slice,不是堆——堆操作要走 heap.Push(h, x) / heap.Pop(h)(注意大写、且要传 h 作为第一个参数)
  • Pop 返回 slice 末尾的元素(不是首位),因为 heap 包内部已经把要弹的元素 swap 到末尾了
  • Less< 是最小堆,写 > 是最大堆——这是 Go 里改方向的唯一开关

记住"小写 Push/Pop 是 slice 操作,大写 heap.Push/Pop 才是堆操作"——容易混。

堆顶可能不是答案:滞后删除

LC 295 之所以能干净维护"双堆 size 差 ≤ 1",是因为只增不删。很多题(比如 LC 480 滑动窗口中位数)要求"删除一个旧元素再加新元素"——heapq 不支持 O(log n) 删除任意元素,咋办?

工程上的标准套路叫滞后删除(lazy deletion):用一个 to_delete 计数器记录"需要从堆中清除的元素",每次看顶 / 弹顶时先检查顶部是不是"应该被删"的,是就丢弃接着看下一个:

from collections import Counter
import heapq

class LazyHeap:
    """支持 O(log n) push / O(log n) 摊还 pop / O(log n) 摊还 peek
    + O(1) 标记删除任意元素(必须确保该元素当前在堆中)"""

    def __init__(self):
        self.heap = []
        self.to_delete = Counter()
        self.size = 0  # 逻辑大小(已剔除标记删除)

    def push(self, x):
        heapq.heappush(self.heap, x)
        self.size += 1

    def remove(self, x):
        """标记删除 x;真正清理推迟到 peek/pop 时"""
        self.to_delete[x] += 1
        self.size -= 1

    def _clean_top(self):
        while self.heap and self.to_delete[self.heap[0]] > 0:
            self.to_delete[heapq.heappop(self.heap)] -= 1

    def peek(self):
        self._clean_top()
        return self.heap[0]

    def pop(self):
        self._clean_top()
        self.size -= 1
        return heapq.heappop(self.heap)
import java.util.*;

class LazyHeap {
    // 最小堆 + 延迟删除计数表
    private final PriorityQueue<Integer> heap = new PriorityQueue<>();
    private final Map<Integer, Integer> toDelete = new HashMap<>();
    private int size = 0;  // 逻辑大小(已剔除标记删除)

    public void push(int x) {
        heap.offer(x);
        size++;
    }

    /** 标记删除 x;真正清理推迟到 peek/pop 时。要求 x 当前在堆中 */
    public void remove(int x) {
        toDelete.merge(x, 1, Integer::sum);
        size--;
    }

    private void cleanTop() {
        while (!heap.isEmpty() && toDelete.getOrDefault(heap.peek(), 0) > 0) {
            int top = heap.poll();
            toDelete.merge(top, -1, Integer::sum);
        }
    }

    public int peek() {
        cleanTop();
        return heap.peek();
    }

    public int pop() {
        cleanTop();
        size--;
        return heap.poll();
    }

    public int size() { return size; }
}
#include <queue>
#include <unordered_map>

class LazyHeap {
    // 最小堆 + 延迟删除计数表
    priority_queue<int, vector<int>, greater<int>> heap;
    unordered_map<int, int> toDelete;
    int sz = 0;  // 逻辑大小(已剔除标记删除)

    void cleanTop() {
        while (!heap.empty() && toDelete[heap.top()] > 0) {
            toDelete[heap.top()]--;
            heap.pop();
        }
    }

public:
    void push(int x) { heap.push(x); sz++; }

    // 标记删除 x;真正清理推迟到 peek/pop 时。要求 x 当前在堆中
    void remove(int x) { toDelete[x]++; sz--; }

    int peek() { cleanTop(); return heap.top(); }

    int pop() {
        cleanTop();
        int x = heap.top();
        heap.pop();
        sz--;
        return x;
    }

    int size() const { return sz; }
};

每个元素至多被 push 一次、pop 一次、cleanTop 处理一次,摊还仍是 O(log n)。这是 LC 480 滑动窗口中位数、LC 1438 最大子数组、LC 1696 跳跃游戏 VI 的通解骨架——配合双堆做中位数,配合单堆做单端最值。

进阶专题("堆 + 滑窗"那一章)会展开"to_delete 在双堆上同步"的细节,本节先把模板立起来。

复杂度速查

时间空间
LC 215 第 K 大(堆解)O(n log k)O(k)
LC 215 第 K 大(QuickSelect)O(n) 期望 / O(n²) 最坏O(log n) 递归栈
LC 703 数据流第 K 大单次 O(log k)O(k)
LC 295 数据流中位数addNum O(log n)findMedian O(1)O(n)

Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新


8. 小结

堆就是个"部分排序"结构,三件套:O(log n) 插入、O(log n) 弹顶、O(1) 看顶。它的核心卖点一句话——只关心极值或前 k 个时,比全排序快。求第 k 大用大小 k 的最小堆 O(n log k),比 O(n log n) 排序更快。

进阶模板三招:大小 k 限制堆(LC 215 / 347)、双堆维护中位数(LC 295 / 480)、贪心 + 堆反复取极值(LC 502 / 2233 / 1642)。后面 字典树并查集树状数组线段树 这些章节也会反复用到堆,作为子结构出现。


对应灵神题单:常用数据结构 · 堆与优先队列

进一步阅读:灵茶山艾府《堆题单》;LeetCode 堆标签

On this page