LeetCode Hot 100 题解 · 堆篇

LeetCode Hot 100 题解 · 堆篇

  1. 本专题收录 LeetCode Hot 100 中所有 堆 相关题目。
  2. 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。

📑 目录

题号 题目 难度 核心思路
215 215.数组中的第K个最大元素 🟠中等 四种写法层层递进:直接排序 取下标 n − k n-k n−k( O ( n log ⁡ n ) O(n \log n) O(nlogn),基线);大顶堆 装下全部数、连弹 k 次堆顶( O ( n + k log ⁡ n ) O(n + k \log n) O(n+klogn));小顶堆 养大小为 k 的候选池,堆超 k 就弹堆顶,扫完堆顶就是第 k 大( O ( n log ⁡ k ) O(n \log k) O(nlogk),Top-K 通吃模板);快速选择 随机挑基准做三路划分,等于基准的一整批同时归位 ,只递归答案所在的那一区,期望 O ( n ) O(n) O(n)。
347 347.前K个高频元素 🟠中等 先用哈希表数出每个数的频率,再套 215Top-K 模板 :按频率养一个大小为 k 的小顶堆,堆顶是候选里频率最低的、随时被换掉,扫完堆里就是前 k 高频的数, O ( n log ⁡ k ) O(n \log k) O(nlogk)。更快的桶排序:频率直接当桶下标 ,从高频桶往低频桶收满 k 个,免比较免堆, O ( n ) O(n) O(n)。
295 295.数据流的中位数 🔴困难 中位数就是有序数组切成等长两半的切点 ------双堆对顶:大顶堆装较小的一半、小顶堆装较大的一半,中位数永远只在两个堆顶。插入拿 num 和大顶堆堆顶比------不超过堆顶进小半边、比堆顶大进大半边(保证小半边整体 ≤ 大半边),再 balance:哪边多出 2 个就搬堆顶过去,维护两堆大小差至多 1。addNum O ( log ⁡ n ) O(\log n) O(logn),findMedian O ( 1 ) O(1) O(1)。

前置:PriorityQueue 的创建和常用 API

做题前先把工具摸熟。堆在 Java 里的实现是 PriorityQueue(优先队列),它只保证一件事:每次 poll/peek 拿到的都是当前堆内最"小"的那个元素 ("小"由你给的比较器定义)。内部是二叉堆的数组实现,不保证整体有序------遍历它得到的顺序是乱的,这是新手最常踩的坑。

创建堆的几种写法

写法 得到的堆 说明
new PriorityQueue<>() 小顶堆 无参构造就是自然序小顶堆,默认是小顶堆,要大顶堆必须自己动手
new PriorityQueue<>((a, b) -> a - b) 小顶堆 lambda 写全的默认序,和上面完全等价
new PriorityQueue<>((a, b) -> b - a) 大顶堆 lambda 反向比较:b - a 让大的浮上去
new PriorityQueue<>(Comparator.reverseOrder()) 大顶堆 JDK 自带倒序比较器,语义直白、不怕减法溢出
new PriorityQueue<>(Comparator.comparingInt(x -> -x)) 大顶堆 按相反数排,整数场景可用
new PriorityQueue<>(Comparator.comparing(e -> e.freq)) 自定义 按对象的某个字段比较,347.前K个高频元素 里就是按频率比

一个坑:比较器里写 a - b 在元素值域大时可能整型溢出(比如 a = Integer.MIN_VALUE),稳妥写法是 Integer.compare(a, b)。做题值域小无所谓,工程里要留心。

常用 API

方法 作用 复杂度 备注
offer(e) / add(e) 入堆 O ( log ⁡ n ) O(\log n) O(logn) 两个是同一个动作,add 满了会抛异常(无界堆碰不到)
poll() 弹出并返回堆顶 O ( log ⁡ n ) O(\log n) O(logn) 拿走当前最"小"的元素,堆会自动调整
peek() 只看堆顶不弹 O ( 1 ) O(1) O(1) 空堆返回 null,别 NPE
size() 元素个数 O ( 1 ) O(1) O(1) 本篇判断"堆里养了几个"全靠它
isEmpty() 是否为空 O ( 1 ) O(1) O(1) 弹之前看一眼,防止空堆 poll 返回 null 拆箱炸掉

本篇三道题的套路就一句话:offer 进、poll 出,堆顶永远是分界值------第 k 大是堆顶(小顶堆养 k 个),中位数是两个堆顶夹出来的。


215.数组中的第K个最大元素

题目链接215.数组中的第K个最大元素

题目描述 :给一个整数数组 nums 和整数 k,返回其中k 个最大的元素 (按排序后的名次算,重复元素各占一个名次,不是第 k 个不同的数)。

Follow-up追问 :希望把时间做到 O ( n ) O(n) O(n)。


思路一:直接排序(把数组升序排好,下标 n-k 处就是答案)

核心思想

  1. 最朴素的出发点:"第 k 大"就是升序排序后下标 n - k 处的那个数。排完直接取,没有一点弯路。
  2. 它是所有解法的基线:后面三种写法全在回答同一个问题------怎么少排序一点。只排到"够定位一个数"就停,是本题所有优化的共同方向。
  3. 笔试时间紧、数据规模小(n 不超过几千)时,这版写起来最快、最不容易出错,直接拿分。

时间复杂度 : O ( n log ⁡ n ) O(n \log n) O(nlogn),其中 n 为数组长度,排序主导。

空间复杂度 : O ( log ⁡ n ) O(\log n) O(logn),Arrays.sort 对基本类型是双轴快排,递归栈深度 log ⁡ n \log n logn 级别。

java 复制代码
class Solution {
    public int findKthLargest(int[] nums, int k) {
        Arrays.sort(nums);              // 升序排完,第 k 大固定在下标 n-k
        return nums[nums.length - k];   // k=1(最大值)落在末位,正好对得上
    }
}

思路二:大顶堆(全部数入堆,连弹 k 次堆顶)

核心思想

  1. 大顶堆的堆顶永远是当前堆内最大值 ------想拿"第 1 大",弹一次堆顶就是;连弹 k 次,第 k 次弹出的就是第 k 大
  2. 这是最"贴着题面"的堆写法:堆顶按从大到小的顺序出队,弹到第 k 个收工。
  3. 代价在堆要装下全部 n 个数 :建堆 O ( n ) O(n) O(n) 之后每次弹出 O ( log ⁡ n ) O(\log n) O(logn),总共 O ( n + k log ⁡ n ) O(n + k \log n) O(n+klogn);k 接近 n 时就是 O ( n log ⁡ n ) O(n \log n) O(nlogn),没比全排序省。改进方向就是思路三------既然只关心前 k 个,堆里凭什么留 k 个以外的数。

时间复杂度 : O ( n + k log ⁡ n ) O(n + k \log n) O(n+klogn),其中 n 为数组长度。建堆 O ( n ) O(n) O(n),k 次弹出各 O ( log ⁡ n ) O(\log n) O(logn);k 与 n 同阶时退化为 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

空间复杂度 : O ( n ) O(n) O(n),堆装下全部元素。

java 复制代码
class Solution {
    public int findKthLargest(int[] nums, int k) {
        // 大顶堆:堆顶永远是当前最大值,出队顺序就是从大到小
        // 三种等价写法,任选其一:
        // 1. Comparator.reverseOrder() ------ JDK 自带的倒序比较器
        // 2. lambda:(a, b) -> b - a ------ b-a 正序反过来就是大顶堆
        // 3. lambda:Comparator.comparingInt(x -> -x) ------ 按相反数排
        PriorityQueue<Integer> heap =
                new PriorityQueue<>(Comparator.reverseOrder());
        // PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> b - a);
        for (int num : nums) {
            heap.offer(num);
        }
        int ans = 0;
        for (int i = 0; i < k; ++i) {
            ans = heap.poll(); // 弹 k 次:第 1 次是第 1 大,第 k 次就是第 k 大
        }
        return ans;
    }
}

思路三:小顶堆(养一个大小为 k 的候选池,堆顶就是第 k 大)

核心思想

  1. "第 k 大"换个说法就是:前 k 个最大的数里,最小的那一个 。既然要的是这 k 个数里最小的,那用小顶堆最顺------小顶堆的堆顶永远是堆内最小值,恰好替我们盯着这个"守门员"。
  2. 扫数组,每个数都入堆;堆大小一旦超过 k,立刻弹掉堆顶。被弹掉的数是当前候选里最小的,它进不了前 k 大,以后也回不来,弹得毫无牵挂。
  3. 扫完时堆里恰好剩下前 k 大的数,堆顶就是第 k 大 。和思路二的区别就在堆只养 k 个:log 因子从 log ⁡ n \log n logn 降到 log ⁡ k \log k logk,空间从 O ( n ) O(n) O(n) 降到 O ( k ) O(k) O(k)。这个"大小为 k 的小顶堆"是所有 Top-K 问题的通用模板,347.前K个高频元素 换个比较维度就能复用。

时间复杂度 : O ( n log ⁡ k ) O(n \log k) O(nlogk),其中 n 为数组长度。每个元素一次入堆、至多一次弹出,堆大小始终不超过 k,单次是 O ( log ⁡ k ) O(\log k) O(logk)。

空间复杂度 : O ( k ) O(k) O(k),堆里最多 k 个数。

java 复制代码
class Solution {
    public int findKthLargest(int[] nums, int k) {
        // 小顶堆:堆顶永远是堆内最小值,专门守"前 k 大"的门
        // 无参构造默认就是小顶堆(自然序),写全就是 new PriorityQueue<>((a, b) -> a - b)
        PriorityQueue<Integer> heap = new PriorityQueue<>();
        for (int num : nums) {
            heap.offer(num);
            if (heap.size() > k) {
                heap.poll(); // 弹掉目前最小的:它进不了前 k 大,以后也用不上
            }
        }
        return heap.peek(); // 堆里剩 k 个数,堆顶恰好是第 k 大
    }
}

思路四:快速选择(三路划分,等于基准的一整批同时归位)

核心思想

  1. 完整排序是 O ( n log ⁡ n ) O(n \log n) O(nlogn),但我们只关心一个位置 的数。快速选择每次随机挑一个基准 pivot,把当前数集划成三区:big(> pivot)、equal(= pivot)、small(< pivot),等于 pivot 的一整批同时归位------big 排最前、equal 紧随其后、small 垫底,它们的名次区间整体确定,答案落进 equal 直接收工。
  2. 三区怎么和 k 对上:k <= big.size() 说明答案在 big 区,带着原 k 递归;排除 big 和 equal 后名额不够(nums.size() - small.size() < k,即 big+equal 的个数 < k)说明答案在 small 区,递归时 k 要减掉前面已确定排掉的 big+equal 个数;两边都不满足,答案恰好落在 equal 区,返回 pivot。
  3. 相比二路 partition 的"一次归位一个",三路划分多一个硬好处:重复元素多时不退化 ------全相等的数组一次划分直接出答案。期望复杂度 O ( n ) O(n) O(n)(每次随机划分,处理量几何级数下降)。代价是每次递归新建三个 List 装元素 ,空间 O ( n ) O(n) O(n),换到的是"不动原数组"。

时间复杂度 :期望 O ( n ) O(n) O(n),其中 n 为数组长度;最坏 O ( n 2 ) O(n^2) O(n2)(每次都极不均匀地划分),随机化后实际几乎碰不到。

空间复杂度 : O ( n ) O(n) O(n),每层递归新建的 big/equal/small 总元素数不超过 n,期望几层就收敛。

java 复制代码
public class Solution {
    private int quickSelect(List<Integer> nums, int k) {
        // 随机选择基准数:防有序/重复用例把划分次次打偏,退化成 O(n^2)
        Random rand = new Random();
        int pivot = nums.get(rand.nextInt(nums.size()));
        // 将大于、小于、等于 pivot 的元素划分至 big, small, equal 中
        List<Integer> big = new ArrayList<>();
        List<Integer> equal = new ArrayList<>();
        List<Integer> small = new ArrayList<>();
        for (int num : nums) {
            if (num > pivot)
                big.add(num);
            else if (num < pivot)
                small.add(num);
            else
                equal.add(num); // 和 pivot 相同的一整批,名次区间整体确定
        }
        // 第 k 大元素在 big 中,递归划分:big 全是前 |big| 名,k 不用改
        if (k <= big.size())
            return quickSelect(big, k);
        // 第 k 大元素在 small 中,递归划分:k 减去前面已确定排掉的 big+equal 个数
        if (nums.size() - small.size() < k)
            return quickSelect(small, k - nums.size() + small.size());
        // 第 k 大元素在 equal 中,直接返回 pivot
        return pivot;
    }

    public int findKthLargest(int[] nums, int k) {
        List<Integer> numList = new ArrayList<>();
        for (int num : nums) {
            numList.add(num);
        }
        return quickSelect(numList, k); // 全程只读 List,不动原数组
    }
}

四种写法一张表收束(n 为数组长度):

写法 时间 空间 一句话定位
直接排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( log ⁡ n ) O(\log n) O(logn) 基线,笔试最稳的保底
大顶堆 O ( n + k log ⁡ n ) O(n + k \log n) O(n+klogn) O ( n ) O(n) O(n) 最贴题面:从大到小连弹 k 次
小顶堆 O ( n log ⁡ k ) O(n \log k) O(nlogk) O ( k ) O(k) O(k) Top-K 模板,k 越小越划算
快速选择 期望 O ( n ) O(n) O(n) O ( n ) O(n) O(n) Follow-up 的正解,三路划分对重复元素友好

347.前K个高频元素

题目链接347.前K个高频元素

题目描述 :给整数数组 nums 和整数 k,返回出现频率前 k 的元素,任意顺序都算对。题目保证答案唯一(不存在频率并列挤占名额的情况),且 1 <= k <= 不同元素的个数,可以不排序直接返回。


思路一:哈希计数 + 小顶堆(按频率养候选池,堆顶随时让位)

核心思想

  1. "前 k 高频"的第一步永远是把频率数出来------哈希表一次扫描,每个数出现几次存几次。
  2. 数完之后问题退化成纯 Top-K:和 215.数组中的第K个最大元素 的模板一模一样,只是比较对象从"数值"换成"频率"------按频率养一个大小为 k 的小顶堆,堆顶是候选里频率最低的。
  3. 扫哈希表逐个入堆,堆超过 k 就弹堆顶:被弹的元素频率进不了前 k 高,后面也不会有它的记录再进来。扫完堆里剩下的就是答案。

时间复杂度 : O ( n log ⁡ k ) O(n \log k) O(nlogk),其中 n 为数组长度。计数 O ( n ) O(n) O(n),哈希表至多 n 个不同元素,每个一次入堆、至多一次弹出。

空间复杂度 : O ( n ) O(n) O(n),哈希表和堆。

java 复制代码
class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> cnt = new HashMap<>();
        for (int num : nums) {
            cnt.put(num, cnt.getOrDefault(num, 0) + 1); // 计数:没见过从 0 起加
        }

        // int[] 的第一个元素表示值,第二个表示次数
        // PriorityQueue<int[]> q = new PriorityQueue<>(new Comparator<int[]>() { // 小顶堆
        //     public int compare(int[] a, int[] b) {
        //         return a[1] - b[1];
        //     }
        // });

        PriorityQueue<int[]> q = new PriorityQueue<>(
                // (a, b) -> a[1] - b[1]
                (a,b) -> Integer.compare(a[1],b[1]) // 不做减法只比大小,比较器里防溢出的稳妥写法
        );
        for (Map.Entry<Integer, Integer> entry : cnt.entrySet()) {
            int num = entry.getKey(), count = entry.getValue();
            q.offer(new int[] { num, count });
            if (q.size() > k) {
                q.poll(); // 弹掉频率最低的:它进不了前 k 高频
            }
        }

        int ans[] = new int[k];
        for (int i = 0; i < k; ++i) {
            ans[i] = q.poll()[0]; // 堆里剩下的 k 个就是前 k 高频元素
        }
        return ans;
    }
}

思路二:桶排序(频率直接当桶下标,免比较免堆)

核心思想

  1. 堆的 log ⁡ \log log 因子来自"比较"。但频率有个哈希没有的性质:它的取值范围被 n 封顶------一个数最多出现 n 次。取值范围小且是整数,正对上桶排序的口味。
  2. n + 1 个桶,下标就是频率 :出现 3 次的数全挂进 buckets[3]。放桶不用比较, O ( 1 ) O(1) O(1) 定位。
  3. 从最高频的桶往下收,收满 k 个停。频率高的桶先被扫到,天然有序。

时间复杂度 : O ( n ) O(n) O(n),计数一遍 + 放桶一遍 + 收桶最多扫一遍。

空间复杂度 : O ( n ) O(n) O(n),桶数组。

java 复制代码
class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        int n = nums.length;
        Map<Integer, Integer> freq = new HashMap<>();
        for (int num : nums) {
            freq.merge(num, 1, Integer::sum);
        }

        // buckets[f] = 所有出现 f 次的数;频率封顶是 n,桶开 n+1 个
        List<Integer>[] buckets = new List[n + 1];
        for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
            int f = e.getValue();
            if (buckets[f] == null) {
                buckets[f] = new ArrayList<>();
            }
            buckets[f].add(e.getKey());
        }

        int[] ans = new int[k];
        int idx = 0;
        for (int f = n; f >= 1 && idx < k; --f) { // 从高频桶往低频桶收
            if (buckets[f] == null) {
                continue; // 没有出现 f 次的数,跳过
            }
            for (int num : buckets[f]) {
                ans[idx++] = num;
                if (idx == k) {
                    break; // 收满 k 个,够数了
                }
            }
        }
        return ans;
    }
}

295.数据流的中位数

题目链接295.数据流的中位数

题目描述 :实现 MedianFinder 类:addNum(int num) 往数据流里加一个数;findMedian() 返回目前所有已加数的中位数------总数为奇数时是正中间那个,偶数时是中间两个的平均数。数据会一直加,两个操作都要求高效。


思路一:双堆对顶(大顶堆存小的一半,小顶堆存大的一半,中位数永远在堆顶)

核心思想

  1. 中位数的本质:把有序数组切成等长两半 ,切点位置就是答案。静态数组好说,数据流的难点在于每次插入后切点会动 ------重新排序是 O ( n log ⁡ n ) O(n \log n) O(nlogn),扛不住。
  2. 换个角度:我们其实不需要全局有序,只需要知道较小那一半的最大值较大那一半的最小值 。"动态维护一堆数的最大/最小"正是堆的主场------大顶堆装较小的一半,小顶堆装较大的一半,两个堆顶就夹住了切点。
  3. 于是查中位数变成 O ( 1 ) O(1) O(1):总数奇数时中位数在"多一个"的那堆的堆顶,偶数时取两堆顶平均。
  4. 插入怎么保证"两半之间有序"(小半边整体 ≤ 大半边)?拿 num 和大顶堆堆顶比:num ≤ 堆顶就进小半边,比堆顶大才进大半边 ------进小半边的数不超过小半边最大值,进大半边的数超过它,两边永远不交叉。之后交给 balance 收尾:哪边多出 2 个,就把那边的堆顶搬一个去对面 ,维护"两堆大小差至多 1"。奇数时多出来的可能是任意一边,findMedian 谁多取谁。

时间复杂度addNum O ( log ⁡ n ) O(\log n) O(logn)(至多三次堆操作);findMedian O ( 1 ) O(1) O(1),只读两个堆顶。其中 n 为当前已加的数字个数。

空间复杂度 : O ( n ) O(n) O(n),两个堆合起来装着全部数字。

java 复制代码
class MedianFinder {

    private PriorityQueue<Integer> maxHeap; // 大顶堆存较小的一半元素

    private PriorityQueue<Integer> minHeap; // 小顶堆存较大的一半元素

    public MedianFinder() {
        maxHeap = new PriorityQueue<>((a, b) -> b - a); // lambda 写大顶堆:b-a 让大的浮上去
        minHeap = new PriorityQueue<>((a, b) -> a - b); // lambda 写小顶堆:默认序,写全就这么写
    }

    public void addNum(int num) {
        // num 不超过小半边最大值就进小半边;比它大才进大半边------两边永远不交叉
        if (maxHeap.isEmpty() || maxHeap.peek() >= num) { // 我默认先加大的
            maxHeap.add(num);
        } else {
            minHeap.add(num);
        }
        balance(); // 调整两个堆
    }

    public double findMedian() {
        if (maxHeap.size() == minHeap.size()) { // 偶数个元素
            return (double) (maxHeap.peek() + minHeap.peek()) / 2;
        } else { // 奇数个:多的一边不定,谁多取谁的堆顶
            return maxHeap.size() > minHeap.size() ? maxHeap.peek() : minHeap.peek();
        }
    }

    private void balance() {
        // 每次只加一个数,差最多到 2:到 2 就搬堆顶过去,回到差 1
        if (Math.abs(maxHeap.size() - minHeap.size()) == 2) {
            if (maxHeap.size() > minHeap.size()) {
                minHeap.add(maxHeap.poll()); // 小半边多了:搬最大值去大半边
            } else {
                maxHeap.add(minHeap.poll()); // 大半边多了:搬最小值回小半边
            }
        }
    }

}

/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder obj = new MedianFinder();
 * obj.addNum(num);
 * double param_2 = obj.findMedian();
 */

流程图:

text 复制代码
num ≤ maxHeap.peek() ──> 进 maxHeap(小半边)
num >  maxHeap.peek() ──> 进 minHeap(大半边)
                     │
                     └──> balance:两堆差 2 就搬多的一边的堆顶过去

不变量:maxHeap 所有元素 ≤ minHeap 所有元素,且两堆大小差至多 1
查中位数:偶数取两堆顶平均;奇数取"多一个"那边的堆顶

总结:堆篇三道题全是同一套模板------要"前 K"就养一个大小为 K 的小顶堆,堆顶永远是那个分界值215.数组中的第K个最大元素 四种写法从 O ( n log ⁡ n ) O(n \log n) O(nlogn) 一步步磨到期望 O ( n ) O(n) O(n):排序是基线,大顶堆贴题面,小顶堆是 Top-K 模板,快速选择 partition 归位后只递归目标那一半;347.前K个高频元素 先哈希计数再套同一个模板,频率封顶是 n 时可以换桶排序做到 O ( n ) O(n) O(n);295.数据流的中位数 是 Top-K 的对称版------双堆对顶各扛一半,中位数永远夹在两个堆顶。核心都是:不追求全局有序,只用堆动态维护"关心的那一截"的边界。

相关推荐
手写码匠1 小时前
华为云Flexus+DeepSeek征文|DeepSeek 应用监控告警体系实战:从“用户投诉才知道“到“故障前就发现“
人工智能·深度学习·算法·aigc
shehuiyuelaiyuehao2 小时前
算法29,前缀和,除自身以外的数组的乘积
java·数据结构·算法
智购科技自动售货机厂家2 小时前
2026自动售货机设备清洁效果自动验证:从图像比对到评分算法的工程实践~YH
人工智能·算法·计算机视觉
小刘在重生~2 小时前
Java常用类|String类详解 + BigDecimal精准计算(含面试题)
java·开发语言·python
随遇而安zx2 小时前
【多线程】---JMM 与 volatile 深度解析
java·多线程
姜穆澜2 小时前
机器学习实战指南:从算法原理到工程落地
人工智能·算法·机器学习
邪修king2 小时前
Re:Linux系统篇(十九):进程篇(八): 进程等待详解:wait/waitpid,僵尸进程到底该如何回收
java·开发语言
啵啵啵鱼3 小时前
DS-顺序表
java·数据结构·笔记·后端·链表·obsidian