← 返回题目列表

如何用堆维护数据流中的第 K 大元素?

高频 中等 第 8 / 28 题 更新于 2026/07/29
数据流第K大小顶堆

简化版

用一个大小为 K 的小顶堆维护当前最大的 K 个元素。堆顶就是这 K 个元素里最小的,也就是整个数据流目前的第 K 大;新元素比堆顶大才有资格进入堆。

详细版

数据流第 K 大和一次性数组 Top K 很像,但它的重点是“边到边查询”:每次 add(x) 后都要马上返回当前第 K 大。

核心做法:

  1. 维护一个小顶堆 heap
  2. 堆大小小于 K 时,直接放入新元素。
  3. 堆大小等于 K 时,只在 x > heap.peek() 时替换堆顶。
  4. 堆顶 heap.peek() 始终是当前第 K 大。

复杂度:每次插入最多一次 poll + offer,时间 O(log K);堆里最多 K 个元素,空间 O(K)。如果数据流还不足 K 个,通常按题意返回当前堆顶、空值或等待到满 K 后再返回。

class KthLargest {
    private final int k;
    private final PriorityQueue<Integer> heap = new PriorityQueue<>();

    KthLargest(int k, int[] nums) {
        this.k = k;
        for (int x : nums) add(x);
    }

    int add(int val) {
        if (heap.size() < k) heap.offer(val);
        else if (val > heap.peek()) {
            heap.poll();
            heap.offer(val);
        }
        return heap.peek();
    }
}

完整版教学

一、这题为什么不是每次排序

数据流的特点是元素不断到来,查询也不断发生。假设已经来了 n=1,000,000 个元素,每次新增后都全量排序,单次代价接近 O(n log n),连续查询会非常浪费。第 K 大并不要求你知道所有元素的完整顺序,只需要知道“当前最大的 K 个元素”以及其中最小的那一个。

堆正好适合维护这个边界。小顶堆的堆顶是堆内最小值,如果堆里放的是当前最大的 K 个元素,那么堆顶自然就是第 K 大。这个设计把“大问题的全排序”变成“维护 K 个候选者”。

目标:第 3 大
当前最大的 3 个: [10, 9, 8]
小顶堆堆顶:8  -> 当前第 3 大
其他元素:7, 5, 2  都不影响答案

二、为什么求第 K 大要用小顶堆

这道题最容易反直觉:求“大”的东西,却用“小顶堆”。原因是堆顶承担的是“淘汰门槛”,不是最终最大值。维护最大的 K 个元素时,最该被淘汰的是这 K 个候选者中最小的那个,所以要让它能被 O(1) 看到。

例如 K=3,堆里是 [8, 10, 9],堆顶为 8。新元素 7 连门槛都达不到,丢弃;新元素 11 超过门槛,说明它应该进入 Top 3,于是淘汰 8。如果用大顶堆,堆顶是 1011,你反而拿不到“当前最弱候选者”,替换判断会变复杂。

需求保留集合合适堆堆顶含义
第 K 大 / 最大 K 个当前最大的 K 个小顶堆第 K 大,淘汰门槛
第 K 小 / 最小 K 个当前最小的 K 个大顶堆第 K 小,淘汰门槛
只要最大值全部候选大顶堆最大值

三、用一个数字例子走完整过程

K=3,初始数据流依次到来:4, 5, 8, 2, 10, 9。前 3 个元素先填满堆,之后每个元素都和堆顶比较。堆内部数组顺序不代表排序结果,只要堆顶正确即可。

add 4: heap=[4]          不足 3 个
add 5: heap=[4,5]        不足 3 个
add 8: heap=[4,5,8]      第 3 大 = 4
add 2: 2 <= 4, 丢弃      第 3 大 = 4
add 10: 10 > 4, 替换     heap 存 {5,8,10}, 第 3 大 = 5
add 9: 9 > 5, 替换       heap 存 {8,9,10}, 第 3 大 = 8

这个过程说明堆不是保存所有历史数据,而是保存“仍可能影响第 K 大答案”的候选集合。小于等于堆顶的元素不可能进入当前最大的 K 个,直接丢弃是安全的。

四、代码不变式与边界处理

实现时要始终维护两个不变式:第一,堆大小不超过 K;第二,堆里保存的是当前最大的 K 个元素。只要这两个条件成立,堆顶就是当前第 K 大。判断逻辑可以写成“先入堆再裁剪”,也可以写成“满了才比较”,两种都可以。

int add(int x) {
    heap.offer(x);
    if (heap.size() > k) {
        heap.poll(); // 弹掉候选集合中最小的
    }
    return heap.peek();
}

这种写法更短,但会把明显不够格的元素也先插入一次,仍然是 O(log K)。如果 K=0 通常是非法输入,应在构造时拒绝;如果数据量暂时小于 K,要按题目约定处理,不能默认存在第 K 大。

五、复杂度为什么是 O(log K) 而不是 O(log n)

堆操作的复杂度取决于堆大小。这里堆最多只有 K 个元素,所以每次 offerpollO(log K),不是 O(log n)。当 K=100、数据流来了 n=10^9 个元素时,单次操作仍然只和 100 的对数相关。

每个元素最多:
  比较堆顶:O(1)
  插入或替换:O(log K)
总处理 n 个元素:O(n log K)
额外空间:O(K)

和快速选择相比,堆法适合流式数据,因为它不需要拿到完整数组,也不会修改原数据。快速选择平均 O(n) 很强,但它是一次性离线算法,不适合每来一个元素就返回答案。

六、常见误区与追问

记忆钩子:第 K 大维护“最大的 K 个”,堆顶不是最大,而是进入 Top K 的门槛。

  • 误区:求第 K 大应该用大顶堆。 大顶堆能快速拿最大值,但不能快速找到 Top K 里最该淘汰的最小候选者。
  • 误区:堆里的数组顺序就是从小到大排序。 堆只保证父子关系,不能把内部数组当有序数组使用。
  • 误区:每次 add 都必须保存所有历史元素。 只要题目只问第 K 大,保存 K 个候选者就够了。
  • 追问:新元素等于堆顶要不要替换? 不需要,替换后第 K 大不变;除非题目要求保留元素身份或稳定性。
  • 追问:数据不足 K 个怎么办? 这取决于接口定义,可以返回当前最小值、空值或等到满 K 后再提供答案。
  • 追问:为什么复杂度是 O(log K)? 因为堆大小被限制为 K,堆高约为 log K

七、加强记忆

数据流第 K 大的核心不是“排序”,而是“守住门槛”。把当前最大的 K 个元素放进小顶堆,堆顶就是这 K 个候选者中最弱的一个,也就是第 K 大。新元素只有超过堆顶才会改变候选集合,否则直接丢弃。记忆时抓住三句话:保留 K 个候选者,小顶堆顶当门槛,每次更新 O(log K)。这样无论题目问第 K 大、实时排行榜还是流式分数线,都能自然想到同一套模型。