堆与贪心辨析 — LeetCode 热门 100 题精讲(Java 向)

215、347、295 是堆的经典应用,763 是贪心的绝佳范例。四题对比,厘清何时用堆、何时用贪心

堆与贪心的本质区别

在刷题中,堆(Heap)和贪心(Greedy)常被并列提及,但它们的应用场景截然不同:

  • 堆(优先队列):适用于 维护动态数据集的极值,如求 TopK、中位数、动态排序等。堆本身是一种数据结构,不涉及决策逻辑,只提供高效的插入和获取极值操作。
  • 贪心算法:适用于具有 最优子结构贪心选择性质 的问题,每一步做出当前局部最优选择,最终累积为全局最优。贪心是一种算法思想,需要证明正确性。

LeetCode 热门 100 题中,有四题分别代表了这两类:215. 数组中的第K个最大元素347. 前K个高频元素295. 数据流的中位数 属于堆的经典应用;763. 划分字母区间 则是贪心的典型代表。下面逐一剖析。


1. 数组中的第K个最大元素(LeetCode 215)

题目:给定整数数组 nums 和整数 k,返回数组中第 k 个最大的元素。

堆解法:维护一个大小为 k 的小顶堆,堆顶即为第 k 大元素。时间复杂度 O(n log k),空间 O(k)。

public int findKthLargest(int[] nums, int k) {
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();
    for (int num : nums) {
        minHeap.offer(num);
        if (minHeap.size() > k) minHeap.poll();
    }
    return minHeap.peek();
}

关键点:使用小顶堆存储最大的 k 个数,堆顶是第 k 大。这不是贪心,因为最终结果依赖于全局数据,而非逐步局部决策。


2. 前K个高频元素(LeetCode 347)

题目:给定数组 nums 和整数 k,返回出现频率前 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);
    PriorityQueue<int[]> minHeap = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
    for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
        minHeap.offer(new int[]{e.getValue(), e.getKey()});
        if (minHeap.size() > k) minHeap.poll();
    }
    int[] res = new int[k];
    for (int i = 0; i < k; i++) res[i] = minHeap.poll()[1];
    return res;
}

关键点:同样基于全局统计,与贪心无关。


3. 数据流的中位数(LeetCode 295)

题目:设计数据结构,支持添加整数,并随时获取当前所有元素的中位数。

双堆解法:一个大顶堆存较小的一半,一个小顶堆存较大的一半,保持平衡,中位数为堆顶值。

class MedianFinder {
    private PriorityQueue<Integer> maxHeap; // 存较小的一半
    private PriorityQueue<Integer> minHeap; // 存较大的一半
    public MedianFinder() {
        maxHeap = new PriorityQueue<>(Collections.reverseOrder());
        minHeap = new PriorityQueue<>();
    }
    public void addNum(int num) {
        maxHeap.offer(num);
        minHeap.offer(maxHeap.poll());
        if (minHeap.size() > maxHeap.size()) {
            maxHeap.offer(minHeap.poll());
        }
    }
    public double findMedian() {
        if (maxHeap.size() > minHeap.size()) return maxHeap.peek();
        return (maxHeap.peek() + minHeap.peek()) / 2.0;
    }
}

关键点:中位数依赖于全部数据,堆只是高效维护有序性,并非贪心决策。


4. 划分字母区间(LeetCode 763)

题目:给定字符串 s,将字符串划分为尽可能多的片段,使得每个字母最多出现在一个片段中。返回每个片段的长度。

贪心思路

  • 先统计每个字符最后出现的位置 lastPos
  • 遍历字符串,维护当前片段的右边界 right = max(right, lastPos[s[i]])
  • i == right 时,说明当前片段已经结束,记录长度,并更新左边界。

为什么是贪心? 每一步我们都尽可能地延长当前片段的右边界,使当前片段包含所有已出现字符的最后位置,直到无法再延伸。这种"能延伸就延伸"的策略,保证了每段尽可能长,从而片段数尽可能多,是局部最优累积到全局最优的典型。

public List<Integer> partitionLabels(String s) {
    int[] last = new int[26];
    for (int i = 0; i < s.length(); i++) last[s.charAt(i) - 'a'] = i;
    List<Integer> res = new ArrayList<>();
    int start = 0, right = 0;
    for (int i = 0; i < s.length(); i++) {
        right = Math.max(right, last[s.charAt(i) - 'a']);
        if (i == right) {
            res.add(i - start + 1);
            start = i + 1;
        }
    }
    return res;
}

复杂度:O(n),空间 O(1)(固定 26)。

关键点:每一步更新当前片段的最终边界,当到达边界时切割,保证当前片段是满足条件的最短前缀,从而使得后续片段能尽可能多,是贪心选择性质的体现。


四题对比与启发

题目 核心技巧 算法类型 时间复杂度 空间复杂度
215. 第K大元素 小顶堆 O(n log k) O(k)
347. 前K高频 哈希 + 小顶堆 O(n log k) O(n)
295. 数据流中位数 双堆 O(log n) 插入 O(n)
763. 划分字母区间 贪心(最后位置) 贪心 O(n) O(1)

共同本质:前三题都是 数据筛选/排序类 问题,堆提供了高效的极值维护;最后一题是 区间划分类 问题,贪心利用"最后一个位置"的单调性,一次性确定切分点。


总结:何时用堆,何时用贪心?

  • :当问题中出现 “第K大/小”、“前K个”、“中位数”、“动态极值” 等关键词时,优先考虑堆。堆擅长处理流式数据或大规模数据的 TopK 筛选。
  • 贪心:当问题具有 “每一步选择局部最优,不影响后续决策可行性” 的性质时,考虑贪心。常见特征包括区间覆盖、跳跃游戏、活动选择、字符串划分等。


🤖