算法面试突击手册 —— 堆(优先队列)


一、概念与特点

大白话 + 类比

堆 = 急诊室分诊台

急诊室不按先来后到排队,而是按病情严重程度。护士(堆)不断接收病人,每次叫号(poll)都叫最危急的那个。新来的病人如果特别严重,会被排到前面。

  • 小根堆(最小堆):堆顶是最小元素,越小的优先级越高
  • 大根堆(最大堆):堆顶是最大元素,越大的优先级越高

Java 中 PriorityQueue 默认是小根堆

堆 vs 其他方法的选择策略

这是面试中的高频决策点:

问题其他方法怎么选
第 K 大(静态数组)O(n log k)快速选择 O(n) 平均求稳用堆,追求最优用快选
Top K 频率O(n log k)桶排序 O(n)k 小时堆好,数据范围确定时桶更好
数据流中位数只能用堆双堆是唯一解
合并 K 个链表O(N log k)归并 O(N log k)堆更直观,归并空间更优
滑动窗口最大值O(n log n)单调队列 O(n)堆更易想到,单调队列更优

核心特征(什么时候该想到用它)

特征说明
”第 K 大/小”小根堆维护前 K 大,大根堆维护前 K 小
”Top K”不需要全排序,只需要前 K 个
动态中位数双堆:大根堆 + 小根堆
多路归并K 个有序序列,每次取最小
数据流不断有新数据进来,需要实时知道某统计量

识别信号词

  • “第 K 个最大/最小”、“前 K 个” → 堆(或快速选择)
  • “数据流”、“动态”、“中位数” → 双堆
  • “合并 K 个” → 堆多路归并
  • “滑动窗口” + “最大值” → 堆(存值和下标,清理过期)

Java PriorityQueue 速查

// 默认小根堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>();

// 大根堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

// 自定义比较器(例如按频率排序)
PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> freqMap.get(a) - freqMap.get(b));

minHeap.offer(x);    // 插入 O(log n)
minHeap.poll();      // 弹出堆顶(最小/最大)O(log n)
minHeap.peek();      // 查看堆顶(不删除)O(1)
minHeap.size();      // 大小
minHeap.remove(x);   // 删除指定元素 O(n)——尽量别用

二、Hot 100 精讲题目


215. Kth Largest Element in an Array 数组中的第K个最大元素

📌 找数组中第 K 个最大元素(不是排序后第 K 个,而是从大到小第 K 个)。如 [3,2,1,5,6,4], k=2 → 5(最大是 6,第二大是 5)

🔍 解题分析

堆解法思路:维护一个大小为 K 的小根堆。为什么用小根堆?

  • 小根堆的堆顶是堆中最小的元素
  • 堆中保存的是”到目前为止的前 K 大”元素
  • 堆顶就是这些前 K 大中最小的那个 → 即第 K 大

核心思路(步骤化)

  1. 遍历数组每个元素
  2. 元素入小根堆
  3. 如果堆大小 > K,弹出堆顶(把当前 K+1 大中最小的丢掉)
  4. 遍历完,堆顶就是第 K 大

为什么小根堆存”前 K 大”而不是大根堆:大根堆的堆顶是最大值,如果我们要找第 K 大,需要把所有元素都入堆再弹出 K 次。而小根堆只保留 K 个——因为堆顶是堆中最小的,如果堆里有 K 个元素,堆顶自然就是这些元素里的第 K 大。

堆 vs 快速选择

快速选择
时间O(n log k)O(n) 平均,O(n²) 最坏
空间O(k)O(log n)(递归栈)
优点稳,支持数据流更快,理论最优
面试必会加分项

✅ 完整 Java 实现

public int findKthLargest(int[] nums, int k) {
    // 小根堆:堆顶是堆里最小的,用于维护"前 K 大"
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();

    for (int num : nums) {
        // 每个元素先进堆
        minHeap.offer(num);

        // 堆大小超过 K 时,把最小的弹掉
        // 这样堆里始终保留着最大的 K 个
        if (minHeap.size() > k) {
            minHeap.poll();
        }
    }

    // 堆里是前 K 大,堆顶(最小的)就是第 K 大
    return minHeap.peek();
}

手算验证nums = [3, 2, 1, 5, 6, 4], k = 2

步骤入堆堆内容size > k?弹出堆最终
13[3][3]
22[2, 3][2, 3]
31[1, 3, 2]1[2, 3]
45[2, 5, 3]2[3, 5]
56[3, 5, 6]3[5, 6]
64[4, 6, 5]4[5, 6]

堆顶 = 5 = 第 2 大 ✓

⏱ 复杂度分析

  • 时间复杂度:O(n log k) —— n 次操作,每次入堆/出堆 O(log k)
  • 空间复杂度:O(k) —— 堆中最多 k 个元素

💡 记忆口诀

“小根堆装前 K 大,超了 K 个弹最小的,堆顶就是第 K 大”


347. Top K Frequent Elements 前 K 个高频元素

📌 找出出现频率前 K 高的元素。如 [1,1,1,2,2,3], k=2[1,2]

🔍 解题分析

这道题的堆解法和 215 本质一样:维护一个按频率排序的小根堆,保持堆大小为 K。不同之处在于比较器的定义。

核心思路(步骤化)

  1. HashMap 统计每个元素的出现频率
  2. 遍历 HashMap 的 keySet,用小根堆维护频率前 K 高的元素
  3. 堆的比较器:(a, b) -> freqMap.get(a) - freqMap.get(b),频率低的在堆顶
  4. 堆大小超过 K 时弹出频率最低的
  5. 将堆中元素倒序取出

堆 vs 桶排序

堆(O(n log k))桶排序(O(n))
优点空间 O(k),k 小时很快线性时间
缺点log k 开销需要 O(n) 额外空间
选择k << n 时用堆数据量大、k 也大时用桶

关键边界条件:比较器写成 freqMap.get(a) - freqMap.get(b) 而不是 b - a,确保是小根堆(频率低的在堆顶优先被弹出)。

✅ 完整 Java 实现

public int[] topKFrequent(int[] nums, int k) {
    // 1. 统计每个元素出现的频率
    // key = 元素, value = 出现次数
    Map<Integer, Integer> freqMap = new HashMap<>();
    for (int num : nums) {
        freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
    }

    // 2. 小根堆:按频率从小到大排列
    // 堆顶是频率最小的,方便被弹出
    PriorityQueue<Integer> minHeap = new PriorityQueue<>(
        (a, b) -> freqMap.get(a) - freqMap.get(b) // 按频率升序
    );

    for (int num : freqMap.keySet()) {
        minHeap.offer(num);          // 入堆
        if (minHeap.size() > k) {
            minHeap.poll();          // 弹掉频率最低的
        }
    }

    // 3. 取出结果(从堆中按任意顺序取出即可,或者倒序保证从高到低)
    int[] result = new int[k];
    for (int i = k - 1; i >= 0; i--) {
        result[i] = minHeap.poll();
    }
    return result;
}

⏱ 复杂度分析

  • 时间复杂度:O(n log k) —— n 是 HashMap 不同 key 的数量
  • 空间复杂度:O(n) —— HashMap + O(k) 堆

💡 记忆口诀

“频率当权重,小根堆按频率筛;频率低的先弹,留下的就是 Top K”


295. Find Median from Data Stream 数据流的中位数

📌 设计数据结构,支持不断添加整数,并能随时返回当前所有数的中位数。

🔍 解题分析

为什么必须用双堆

数据流意味着数据不停进来,如果每次都用排序求中位数,代价 O(n log n)。双堆可以做到插入 O(log n),查询 O(1)。

双堆设计

把数据流切成”左半”和”右半”两半:

  • 大根堆(leftMaxHeap):存较小的一半数。堆顶是左半的最大值(离中位数最近的那个小值)
  • 小根堆(rightMinHeap):存较大的一半数。堆顶是右半的最小值(离中位数最近的那个大值)

平衡规则:两个堆大小差 ≤ 1,且让左堆(大根堆)可以多一个。这样:

  • 奇数个元素:中位数 = 左堆堆顶
  • 偶数个元素:中位数 = (左堆堆顶 + 右堆堆顶) / 2

addNum 操作流程(步骤化)

  1. 新数先入左堆(大根堆)
  2. 把左堆的最大值移到右堆(保证右堆的数都比左堆大)
  3. 如果右堆比左堆大 → 把右堆最小值移回左堆(保证左堆 size ≥ 右堆 size)

这个”先放左边,把最大的给右边,再平衡大小”的三步保证了两个堆的性质始终正确。

类比:想象你面前有两排书架。左边的书都比右边薄(左边是大根堆,最厚的在最右边;右边是小根堆,最薄的在最左边)。新来一本书,先放左边。如果左边最厚的比右边最薄的还厚,就把左边最厚的移到右边。然后调整两边数量相等。

关键边界条件/易错点

易错点说明
初始为空新元素进左堆后,左堆→右堆 再 右堆→左堆,最终左堆多一个
大根堆的比较器(a, b) -> b - a 不能忘记
中位数用 double偶数时要除 2.0,不是 2

✅ 完整 Java 实现

class MedianFinder {
    // 左半:大根堆,存较小的一半数,堆顶是左半最大值
    private PriorityQueue<Integer> leftMaxHeap;

    // 右半:小根堆,存较大的一半数,堆顶是右半最小值
    private PriorityQueue<Integer> rightMinHeap;

    public MedianFinder() {
        // 大根堆:传入比较器反转顺序
        leftMaxHeap = new PriorityQueue<>((a, b) -> b - a);
        // 小根堆:默认就是小根堆
        rightMinHeap = new PriorityQueue<>();
    }

    public void addNum(int num) {
        // 步骤1:新元素先放入左半(大根堆)
        leftMaxHeap.offer(num);

        // 步骤2:把左半的最大值移到右半
        // 这保证了右半的所有数都 ≥ 左半的所有数
        rightMinHeap.offer(leftMaxHeap.poll());

        // 步骤3:平衡两个堆的大小
        // 保持 leftMaxHeap.size() >= rightMinHeap.size()
        // 且两者差距不超过 1
        if (leftMaxHeap.size() < rightMinHeap.size()) {
            leftMaxHeap.offer(rightMinHeap.poll());
        }
    }

    public double findMedian() {
        // 奇数个元素:左堆比右堆多一个,中位数在左堆堆顶
        if (leftMaxHeap.size() > rightMinHeap.size()) {
            return leftMaxHeap.peek();
        }
        // 偶数个元素:中位数 = (左堆堆顶 + 右堆堆顶) / 2
        return (leftMaxHeap.peek() + rightMinHeap.peek()) / 2.0;
    }
}

手算验证:依次插入 [3, 1, 5, 2]

步骤操作左堆 (大根)右堆 (小根)中位数
add(3)3入左, 左→右, 右<左不调[3][]3.0
add(1)1入左→[3,1], 左→右(3), 左=[1],右=[3][1][3](1+3)/2=2.0
add(5)5入左→[5,1], 左→右(5), 左=[1],右=[5,3], 右>左→调, 左=[3,1],右=[5][3,1][5]3.0
add(2)2入左→[3,1,2], 左→右(3), 左=[2,1],右=[3,5][2,1][3,5](2+3)/2=2.5

⏱ 复杂度分析

  • 时间复杂度:addNum O(log n) —— 最多 3 次堆操作;findMedian O(1) —— 只看堆顶
  • 空间复杂度:O(n) —— 存储所有元素

💡 记忆口诀

“大根堆管左半(小的那边),小根堆管右半(大的那边);新数先入左,把最大的给右,再平衡左右;中位数在两个堆顶之间”


239. Sliding Window Maximum 滑动窗口最大值(堆解法)

📌 详见 03-滑动窗口。此题最优解是单调队列 O(n),但堆解法更直观。

🔍 解题分析

堆解法核心:大根堆同时存 (值, 下标),按值降序。每次窗口移动后:

  1. 新元素入堆
  2. 检查堆顶是否还在窗口内(下标 > i - k 说明还在)
  3. 不在就弹出,直到堆顶在窗口内
  4. 堆顶就是当前窗口的最大值

堆 vs 单调队列

单调队列
时间O(n log n)O(n)
空间O(n)O(k)
思路自然直观需要理解单调性
面试优先想到更优解,加分项

为什么堆中有过期元素也没问题:我们只在需要答案时才清理堆顶。这叫做”懒删除”——不是每个过期元素都立即删,而是只删那些挡在堆顶的过期元素。

✅ 完整 Java 实现

public int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    int[] result = new int[n - k + 1];

    // 大根堆:int[]{值, 下标},按值降序排列
    PriorityQueue<int[]> maxHeap = new PriorityQueue<>(
        (a, b) -> b[0] - a[0] // b[0]-a[0] > 0 则 b 在前,即值大的在前
    );

    for (int i = 0; i < n; i++) {
        // 新元素入堆
        maxHeap.offer(new int[]{nums[i], i});

        // 窗口形成(前 k 个已入堆)后开始记录答案
        if (i >= k - 1) {
            // 懒删除:清理堆顶中已经滑出窗口的元素
            // 窗口范围是 [i-k+1, i],下标 ≤ i-k 的都是过期的
            while (maxHeap.peek()[1] <= i - k) {
                maxHeap.poll();
            }
            // 清理后的堆顶就是当前窗口最大值
            result[i - k + 1] = maxHeap.peek()[0];
        }
    }

    return result;
}

⏱ 复杂度分析

  • 时间复杂度:O(n log n) —— n 次入堆/出堆操作,每次 O(log n)
  • 空间复杂度:O(n) —— 堆中可能堆积很多过期元素

💡 记忆口诀

“大根堆存 (值, 下标),每次查答案前先弹走过期的老大”


23. Merge k Sorted Lists 合并 K 个升序链表(堆解法)

📌 详见 08-链表。堆解法比归并更直观。

🔍 解题分析

堆多路归并:把所有链表的头节点放入小根堆(按节点值排序)。每次弹出最小的节点,接到结果链表尾部。如果弹出的节点有后继,把后继也入堆。重复直到堆空。

为什么堆更适合:K 路归并的核心操作是”每次找 K 个中的最小值”。堆正好擅长”动态维护一组元素的最小值”。

堆 vs 归并

归并(分治)
实现难度直观,代码短需要注意分治边界
时间复杂度O(N log k)O(N log k)
空间复杂度O(k)(堆)O(log k)(递归栈)
适合场景K 很大时K 较小时

关键细节:遍历 lists 数组时,空链表(head==null)要跳过,不入堆。pop 最小节点后,如果它的 next 不为 null,要把 next 也入堆——这是不同于普通堆操作的地方。

✅ 完整 Java 实现

public ListNode mergeKLists(ListNode[] lists) {
    // 小根堆:按节点值升序,堆顶是最小的节点
    PriorityQueue<ListNode> minHeap = new PriorityQueue<>(
        (a, b) -> a.val - b.val
    );

    // 将每个非空链表的头节点入堆
    for (ListNode head : lists) {
        if (head != null) {
            minHeap.offer(head);
        }
    }

    // 哨兵节点,统一插入逻辑
    ListNode dummy = new ListNode(0);
    ListNode cur = dummy; // cur 始终指向结果链表的最后一个节点

    while (!minHeap.isEmpty()) {
        // 弹出当前最小的节点
        ListNode minNode = minHeap.poll();
        cur.next = minNode;
        cur = cur.next;

        // 【关键】如果弹出的节点有后继,把后继入堆
        // 这样每条链表的下一个候选节点始终在堆中
        if (minNode.next != null) {
            minHeap.offer(minNode.next);
        }
    }

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(N log k) —— N 是总节点数,每个节点入堆出堆各一次,每次 O(log k)
  • 空间复杂度:O(k) —— 堆中最多同时存放 k 个节点

💡 记忆口诀

“所有头节点入小根堆,每次弹最小接上去,有后继继续入堆”


🔜 上一章:13-图 | 下一章:15-矩阵