力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 17堆(优先队列):永远快速拿到最值

如果说哈希表解决了「快速查某个值存不存在」,那堆解决的是另一类常见问题:「快速拿到当前最大/最小的那个」。 比如「最大的 K 个数」「K 个有序链表怎么合并成一个有序链表」「数据流里实时维护中位数」。 这一章先把堆的物理结构讲清楚(它就是一棵特殊的完全二叉树),再带你用 4 道经典题把套路练熟,最后给你一张「Top K 到底选哪种解法」的选型表。

学习目标
掌握堆的三种典型用法
前置要求
Java 基础 · 专题 05 链表 · 专题 07 二叉树
本章产出
4 道 Hot 题 + TopK 选型表
建议学时
4–6 小时

17.1堆是什么,Java 里怎么用

堆(Heap)本质是一棵完全二叉树,并且满足一个关键性质:父节点的值 ≤ 子节点的值(小顶堆);反过来如果父 ≥ 子,就叫大顶堆。 「完全二叉树」的意思是:除了最后一层,上面各层都填满,最后一层从左往右排,中间不空。这个形状保证它可以用数组紧凑存储,不需要真的去建一棵链式树。

为什么这个性质有用?因为「父 ≤ 子」意味着整棵树最小的那个元素一定在根。所以无论是插一个元素还是删一个元素,只要维护好「堆性质」,我们就能在 O(log n) 时间拿到最值,而不用像数组那样扫一遍 O(n)。

小顶堆(父 ≤ 子)的样子:
        1
      /   \
     3     2
    / \   / \
   5   4 6   7
堆顶(根)= 1,永远是整棵树最小。

用数组存(下标从 0 开始,左孩子 2i+1,右孩子 2i+2,父亲 (i-1)/2):
  [1, 3, 2, 5, 4, 6, 7]
   i=0  1  2  3  4  5  6

关键点:堆只保证「根是最值」,不保证整体有序!
        比如 3 和 2 谁大堆不管,只管 1 比它们都小。

Java 里怎么用:PriorityQueue

Java 标准库没有叫「Heap」的类,但 PriorityQueue 就是堆(优先队列)的实现。它默认是小顶堆(队头是最小元素)。常用三个操作:

// 默认小顶堆:peek() 永远是最小元素
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.add(5); minHeap.add(1); minHeap.add(3);
System.out.println(minHeap.peek()); // 输出 1

// 想要大顶堆:传入比较器,让「大的」排到前面
// 写法一:lambda (a,b)->b-a(注意整数溢出风险,见 17.7)
PriorityQueue<Integer> maxHeap1 = new PriorityQueue<>((a, b) -> b - a);
// 写法二:更安全的 Comparator.reverseOrder()
PriorityQueue<Integer> maxHeap2 = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap2.add(5); maxHeap2.add(1); maxHeap2.add(3);
System.out.println(maxHeap2.peek()); // 输出 5
记忆法:堆就像一个「自动排序的候车室」——你随便塞人进去,它永远把最快上车的(最值)摆在门口。 poll() 就是放走门口那个人,peek() 是探头看一眼门口是谁,add() 是塞个人进去它自己排好位。

17.2堆的三种典型用法

刷题时堆几乎只干三件事,认准这三类你就知道该不该用堆:

① Top K 问题:维护一个固定大小为 K 的堆

比如「前 K 个最大的数」。思路是:建一个大小为 K 的堆,扫一遍数组,把元素往里塞;堆满了就弹出最值,最后堆里剩下的就是 Top K。 具体用大顶还是小顶,取决于你要「最大」还是「第 K 大」——这个最容易想反,17.3 会专门讲。

② 多路归并:K 个有序序列合并

比如 K 个已经排好序的链表/数组,要把它们合成一个有序序列。每次只需要从「K 个序列当前的头」里挑最小的接上去。 用堆装这 K 个头,poll 最小的,再把那个序列的下一个头补进来,循环即可。17.5 合并 K 个升序链表就是典型。

③ 动态最值:边进边出

数据流场景——元素不是一次性给你,而是一边来一边走,你随时要回答「当前最大值是多少」「中位数多少」。 堆能 O(log n) 维护最值,所以特别适合这种「持续变化、随时查询」的场景(如 347 的频次统计、703 数据流第 K 大等)。

三类用法一句话区分:
  Top K      —— 我只要「最突出的那几个」,其余不要
  多路归并   —— 好几路都排好序了,我要把它们「缝」成一路
  动态最值   —— 数据像水龙头一样流进来,我随时问「现在最值是谁」
选型一句话:只要题目里出现「最大的 K 个」「前 K 个」「随时拿最值」「多个有序序列合并」,优先想堆。 但注意:如果数据范围很小(比如分数只有 0~100),17.6 会告诉你计数排序可能更快。

17.3数组中的第 K 个最大元素:堆解法(中等,力扣 215)

中等 核心:维护大小为 K 的小顶堆

题意:给你一个整数数组 nums 和整数 k,返回数组中第 k 个最大的元素。 例如 nums = [3,2,1,5,6,4],k = 2,排序后是 [1,2,3,4,5,6],第 2 大是 5。注意不要求整体排序,只问第 k 大。

思路推导:为什么是小顶堆,而不是大顶堆?

最直觉的暴力:把数组排序,取倒数第 k 个,O(n log n)。能不能更好?堆可以做到 O(n log k)。

关键问题是:要第 k 大,应该维护什么堆?很多人第一反应是「维护大顶堆,弹出 k-1 次最大的,堆顶就是第 k 大」。这能 work,但要弹出 k-1 次,而且堆里其实装了所有 n 个元素。

更省空间、更优雅的写法是:维护一个大小为 k 的小顶堆。推理如下——

例子 nums=[3,2,1,5,6,4], k=2,维护大小为 2 的小顶堆:
  看 3:堆空,入 → [3]
  看 2:2<堆顶3,不换(还不够格进前2大)→ [3]   其实要入堆再弹,这里堆未满直接入 [3,2]
  看 1:堆满(2个),1<堆顶2 → 不入,跳过
  看 5:5>堆顶2 → 弹2入5 → [3,5]
  看 6:6>堆顶3 → 弹3入6 → [5,6]
  看 4:4<堆顶5 → 不入
  结束,堆=[5,6],堆顶=5 = 第2大 ✓

为什么是小顶而不是大顶?因为我们要找「前 k 大里最小的那个」,小顶堆的堆顶正好暴露这个最小值,方便和后面元素比较淘汰。若用大顶堆,堆顶是最大的,你没法知道「第 k 大」是谁。

参考代码

public int findKthLargest(int[] nums, int k) {
    // 小顶堆:堆顶是当前堆里最小的,也就是「前 k 大里最小的」
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();
    for (int num : nums) {
        minHeap.add(num);            // 先无条件入堆
        if (minHeap.size() > k) {    // 超过 k 个,把最小的(堆顶)扔掉
            minHeap.poll();          // 留下的始终是最大的 k 个数
        }
    }
    return minHeap.peek();           // 堆顶 = 第 k 大
}

复杂度

遍历 n 个元素,每个 add 和 poll 都是 O(log k)(堆大小始终 ≤ k),所以总时间 O(n log k),空间 O(k)。比排序的 O(n log n) 在 k 远小于 n 时更优。

也可以反过来用大小为 n-k+1 的大顶堆保留「最小的若干」,但最通用的记法是「求第 k 大用大小为 k 的小顶堆」。题目若改成「第 k 小」,对称地用大小为 k 的大顶堆即可。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 前 K 个高频元素(力扣 347,中等)——与本题共用「大小为 k 的小顶堆」技巧;差异在堆里放的不是数字本身,而是「数字+频次」的对象,比较器按频次比(提示:用 int[] 或自定义类,(a,b)->Integer.compare(a[1], b[1]))。
  • 数据流中的第 K 大元素(力扣 703,简单)——动态版堆:add() 每次来一个数就按本题逻辑维护大小为 k 的小顶堆,返回堆顶(提示:把本题循环改成类里持有一个 heap 成员,add 时执行同样逻辑)。
  • 最接近原点的 K 个点(力扣 973,中等)——与本题同框架;差异在「大小」的度量变成欧氏距离,比较器按距离比(提示:堆里放 int[] 点坐标,距离是 x*x+y*y)。

17.4前 K 个高频元素(中等,力扣 347)

中等 核心:哈希计数 + 按频次排序的堆

题意:给你一个整数数组 nums 和一个整数 k,返回出现频率前 k 高的元素。 例如 nums = [1,1,1,2,2,3],k = 2,频次是 1→3 次、2→2 次、3→1 次,前 2 高频是 [1,2](顺序不限)。

思路推导:两步拆开想

这道题其实是两个子问题的组合,拆开反而简单:

所以做法:把每个「数+频次」塞进一个大小为 k 的小顶堆,比较器按频次(不是按数)比。堆满就弹频次最小的,最后堆里就是前 k 高频。

参考代码

public int[] topKFrequent(int[] nums, int k) {
    // 第一步:哈希计数
    Map<Integer, Integer> freq = new HashMap<>();
    for (int num : nums) {
        freq.put(num, freq.getOrDefault(num, 0) + 1);
    }
    // 第二步:大小为 k 的小顶堆,按「频次」比较
    // 堆里放 int[]{数值, 频次},比较器比频次
    PriorityQueue<int[]> minHeap = new PriorityQueue<>(
        (a, b) -> Integer.compare(a[1], b[1])   // 频次小的在堆顶
    );
    for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
        minHeap.add(new int[]{e.getKey(), e.getValue()});
        if (minHeap.size() > k) {
            minHeap.poll();   // 弹掉频次最小的,留下前 k 高频
        }
    }
    // 取出结果
    int[] res = new int[k];
    for (int i = 0; i < k; i++) {
        res[i] = minHeap.poll()[0];   // 这里顺序无所谓
    }
    return res;
}

复杂度

计数 O(n);建堆时最多 n 个不同数,每个插入 O(log k),O(n log k);总 O(n log k),空间 O(n+k)。

另一种 O(n) 解法:桶排序。频次最大不超过 n,用 List<Integer>[] buckets 下标当频次,把数按频次丢进对应桶,再从高频桶往低频次取满 k 个。因为只扫桶不比较,能到 O(n)。但堆写法更通用好记,面试优先写堆。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 根据字符出现频率排序(力扣 451,中等)——与本题共用「哈希计数 + 堆/桶按频次排序」技巧;差异在输出是把字符按频次从高到低拼成字符串(提示:堆里放 char 和频次,poll 时重复 append 频次次)。
  • 前 K 个高频单词(力扣 692,中等)——同理但堆里是字符串,比较器先比频次、频次相同按字典序(提示:(a,b)->a[1]!=b[1]? a[1]-b[1] : b[0].compareTo(a[0]) 注意字典序小的反而优先)。
  • 数据流中的第 K 大元素(力扣 703,简单)——复用 17.3 堆框架,频次的变体(提示:无频次,直接按数值维护小顶堆)。

17.5合并 K 个升序链表(困难,力扣 23)

困难 核心:K 路归并,堆里放各链表的当前头

题意:给你一个含 k 个链表的数组,每个链表都已升序排列,请你把它们合并成一个升序链表并返回其头节点。 例如 lists = [[1,4,5],[1,3,4],[2,6]],合并后是 [1,1,2,3,4,4,5,6]。

思路推导:从「逐个合并」到「一起合并」

最容易想到的暴力:先合并第 1 和第 2 条成一条,再拿结果和第 3 条合并……这叫顺序合并。 假设每条平均 n 个节点,合并两条是 O(n);第 i 次合并要扫前面所有已合并的 i·n 个节点,总复杂度约 O(k²·n),很慢。

优化思路——多路归并(K 路归并):想象 k 个指针分别指向 k 条链表的头,每一步我们只要在「这 k 个当前头」里挑最小的接上去,然后把那个指针往后挪一位。 「从 k 个数里反复挑最小」正是堆的强项!用一个小顶堆(按节点值比较)装这 k 个当前头:

lists = [[1,4,5],[1,3,4],[2,6]],堆按节点值小顶:
初始化堆:[1(链1), 1(链2), 2(链3)]   括号标来自哪条
poll 1(链1) → 结果[1],补链1的next=4 → 堆[1(链2),2(链3),4(链1)]
poll 1(链2) → 结果[1,1],补链2的next=3 → 堆[2(链3),3(链2),4(链1)]
poll 2(链3) → 结果[1,1,2],补链3的next=6 → 堆[3,4,6]
poll 3 → 补4(链2) → 堆[4(链1),4(链2),6]
poll 4(链1) → 补5 → 堆[4(链2),5,6]
poll 4(链2) → 补null(不入) → 堆[5,6]
poll 5 → 补null → 堆[6]
poll 6 → 补null → 堆空
结果:[1,1,2,3,4,4,5,6] ✓

每个节点进堆出堆各一次,每次 O(log k),总 O(n log k),比 O(k²n) 好太多。注意用 专题 05 学的虚拟头节点(dummy)统一边界。

参考代码

public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) return null;
    // 小顶堆:堆里放节点,按节点值比较谁小谁在堆顶
    PriorityQueue<ListNode> minHeap = new PriorityQueue<>(
        (a, b) -> Integer.compare(a.val, b.val)
    );
    // 1. 把每条链表的头(非 null)入堆
    for (ListNode head : lists) {
        if (head != null) minHeap.add(head);
    }
    ListNode dummy = new ListNode(0);   // 虚拟头,统一边界
    ListNode tail = dummy;
    // 2. 循环取最小,补 next
    while (!minHeap.isEmpty()) {
        ListNode cur = minHeap.poll();  // 当前最小节点
        tail.next = cur;                // 接结果尾巴
        tail = tail.next;
        if (cur.next != null) {         // 这一路还有后续,补进来
            minHeap.add(cur.next);
        }
    }
    return dummy.next;
}

复杂度

共 n 个节点(n 为所有链表节点总数),每个节点入堆出堆 O(log k),时间 O(n log k)。空间 O(k)(堆里最多 k 个节点)。边界:lists 为空返回 null;某条链表为空要跳过(代码已处理)。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 合并两个有序链表(力扣 21,简单)——本题的 k=2 退化版,其实不用堆,双指针即可(提示:理解 K 路归并后,k=2 时直接两个指针比大小接即可,复杂度 O(n) 比堆的 O(n log 2) 还省)。
  • 合并 K 个升序数组(力扣 632,困难)——与本题同框架,把链表节点换成数组下标;差异在堆里要记录「值来自第几个数组、第几个下标」(提示:堆里放 int[]{数组索引, 元素下标, 值})。
  • 数据流的中位数(力扣 295,困难)——动态维护两堆(大顶+小顶),是堆在多路/动态最值的延伸(提示:左半用大顶堆、右半用小顶堆,平衡两堆大小)。

17.6Top K 三种解法对比

「前 K 大/高频」这类题,至少有三种解法都能做,面试时该选哪个?一张表讲清:

解法时间复杂度适用场景优缺点
堆(大小为 k 的小顶堆) O(n log k) 通用,尤其 n 很大、k 较小;数据流场景 逻辑清晰好写,空间 O(k);比快排慢一点常数
快速选择(quickselect) 平均 O(n),最坏 O(n²) 只要「第 k 个」不要求前 k 个全列出 平均最快,但最坏退化要注意随机化 pivot(见 专题 16)
计数/桶排序 O(n + 值域) 数据范围很小(如分数 0~100、字符 a~z) 值域小的时候最优 O(n);值域大则爆内存
选型口诀:
  数据范围小 + 要全列前 k 个  → 桶排序(最快,O(n))
  只问「第 k 个是谁」         → 快速选择(平均最快)
  通用 / 数据流 / 值域未知     → 堆(最稳,永远能写)
面试建议:堆解法是最「不会翻车」的默认选项,先写出来保证能过;如果面试官追问「还能更快吗」,再补快速选择或桶排序。不要一上来就炫快排导致 pivot 写错。

17.7本专题常见坑

  • 比较器写反(大顶写成小顶)。「求第 k 大」要用小顶堆,很多人记成「大」就用大顶,结果堆顶是最大不是第 k 大。记住:堆顶暴露的是「你用来淘汰的基准」,求第 k 大时基准是「前 k 大里最小的」→ 小顶。
  • (a,b)->b-a 的整型溢出。当 a、b 接近 Integer 边界(如 a=-2^31, b=1),b-a 会溢出变成正数,比较器结果错乱。务必用 Integer.compare(a, b) 或 Comparator.reverseOrder()。
  • 堆大小忘控制在 K。写 add 后必须判断 size() > k 再 poll,否则堆里装了全部 n 个元素,既浪费空间又没起到「只留 k 个」的作用。
  • 堆不支持随机删除。堆只能删堆顶。如果想删中间某个特定元素,得自己维护额外映射或换数据结构(如 TreeSet)。别想当然 heap.remove(x)(虽然 PriorityQueue 有但 O(n) 慢)。
  • 堆不保证整体有序。只保证堆顶最值。poll 依次取出才是升序,不能指望 peek 多个元素就是排序好的。

17.8自测题

Q1.(概念题)为什么「求数组中第 k 大的元素」用大小为 k 的小顶堆,而不是大顶堆?堆顶代表什么?

小顶堆的堆顶是「当前堆里最小的」,而堆里保留的是「目前已见过的最大的 k 个数」,堆顶就是这 k 个数里最小的那个——恰好是第 k 大的候选。每来更大的数就淘汰堆顶,遍历完堆顶即答案。大顶堆的堆顶是最大元素,无法直接定位「第 k 大」。

Q2.(概念题)Top K 的三种解法(堆 / 快速选择 / 桶排序)分别什么时候最优?各自复杂度?

堆 O(n log k) 通用最稳、支持数据流;快速选择平均 O(n) 只问「第 k 个」最快;桶排序 O(n+值域) 仅在数据范围小时最优。详见 17.6 表格。

Q3.(动手题)用大小为 k 的小顶堆思想,写一个类支持:不断 add(int num) 动态加入数字,并随时返回当前第 k 大的元素(力扣 703 思路)。先写框架再补细节。

类里持有一个 PriorityQueue<Integer> minHeap 和 int k。每次 add:minHeap.add(num); if (minHeap.size() > k) minHeap.poll(); return minHeap.peek();。初始化时若已有数组,逐个 add 即可。注意 k 可能大于已加入元素数,此时 peek 可能为 null,需按题意处理。
学完这章,你应该能回答:堆的物理结构是什么、为什么 peek/poll/add 是 O(log n);Top K 为什么用小顶堆;K 路归并怎么用堆把 O(k²n) 降到 O(n log k);以及堆 / 快选 / 桶排序三种 Top K 解法怎么选型。