【LeetCode Hot100 刷题日记 (76/100)】295. 数据流的中位数 —— 双堆(优先队列)维护动态中位数 🧠

10 阅读5分钟

📌 题目链接:295. 数据流的中位数 - 力扣(LeetCode)

🔍 难度:困难 | 🏷️ 标签:设计、堆(优先队列)、数据流、双指针

⏱️ 目标时间复杂度:addNum: O(log n),findMedian: O(1)

💾 空间复杂度:O(n)


🎯 问题核心与面试价值

实时数据处理系统(如股票价格监控、在线评分系统、网络流量分析)中,我们经常需要动态维护一个不断增长的数据集合,并高效查询当前中位数。这道题考察的是:

  • 如何用两个堆(优先队列)巧妙维护有序性
  • 对“中位数”定义的精准理解(奇偶长度差异)
  • 动态平衡思想(类似 AVL 树的旋转思想)
  • C++ STL 中 priority_queue 的自定义比较器使用

这是高频系统设计+算法结合题,在 Google、Amazon、Meta 等大厂面试中多次出现,尤其适合考察候选人对数据结构组合使用的能力。


🧩 题目分析

我们需要设计一个类 MedianFinder,支持两种操作:

  1. addNum(int num):向数据流中添加一个整数;
  2. findMedian():返回当前所有数的中位数。

关键约束:

  • 数据是动态流入的,不能预先知道全部数据;
  • 每次 findMedian 必须高效(理想 O(1));
  • 数据规模可达 5 × 10⁴ 次操作,暴力排序(O(n log n) 每次)会超时 ❌。

✅ 正确思路:不显式维护整个有序数组,而是用两个堆分别维护“较小的一半”和“较大的一半”


🧠 核心算法及代码讲解:双堆(Two Heaps)法

📌 算法思想

我们维护两个堆:

  • queMin最大堆(C++ 默认 priority_queue<int> 是最大堆),存储较小的一半数字,堆顶是这部分的最大值;
  • queMax最小堆(用 greater<int> 实现),存储较大的一半数字,堆顶是这部分的最小值。

💡 不变量(Invariant)

  • queMin.size() == queMax.size()queMin.size() == queMax.size() + 1
  • 所有 queMin 中的元素 ≤ 所有 queMax 中的元素

这样,中位数就是:

  • 若总个数为奇数 → queMin.top()(因为 queMin 多一个)
  • 若为偶数 → (queMin.top() + queMax.top()) / 2.0

🔄 插入逻辑(关键!)

当插入新数字 num 时:

  1. num ≤ queMin.top() → 应放入 queMin

    • 但插入后可能破坏 size 不变量(queMinqueMax 多超过 1 个)
    • → 把 queMin 的堆顶(最大值)移到 queMax
  2. 否则 → 放入 queMax

    • 但若 queMaxqueMin 大 → 把 queMax 的堆顶(最小值)移到 queMin

✅ 这样始终保证 queMin 存“左半部分”,queMax 存“右半部分”,且大小差 ≤1。

💻 C++ 代码详解(带行注释)

class MedianFinder {
public:
    // queMin: 最大堆,存较小的一半;queMax: 最小堆,存较大的一半
    priority_queue<int, vector<int>, less<int>> queMin;      // 默认就是 less,可省略
    priority_queue<int, vector<int>, greater<int>> queMax;   // greater<int> 实现最小堆

    MedianFinder() {}

    void addNum(int num) {
        // 情况1:queMin 为空(首次插入)或 num <= queMin 的最大值
        if (queMin.empty() || num <= queMin.top()) {
            queMin.push(num);  // 先加入左半部分
            // 检查是否左半部分比右半部分多出超过1个
            if (queMax.size() + 1 < queMin.size()) {
                queMax.push(queMin.top());  // 把左半部分的最大值移到右边
                queMin.pop();
            }
        } 
        // 情况2:num > queMin.top(),应加入右半部分
        else {
            queMax.push(num);
            // 注意:这里只允许 queMin 比 queMax 多1,不允许 queMax 更大!
            if (queMax.size() > queMin.size()) {
                queMin.push(queMax.top());  // 把右半部分的最小值移到左边
                queMax.pop();
            }
        }
    }

    double findMedian() {
        // 奇数个:queMin 多一个,中位数是 queMin.top()
        if (queMin.size() > queMax.size()) {
            return queMin.top();
        }
        // 偶数个:取两个堆顶平均
        return (queMin.top() + queMax.top()) / 2.0;
    }
};

🔍 为什么 queMin 允许多一个,而 queMax 不允许?
这是设计选择!统一让 queMin 承担“多一个”的角色,简化逻辑。也可反过来,但需保持一致。


🧭 解题思路(分步拆解)

  1. 明确目标:动态维护中位数,避免每次排序。

  2. 选择数据结构

    • 数组?→ 插入 O(n),排序 O(n log n) → ❌
    • 平衡二叉搜索树?→ 可行,但实现复杂(如 multiset + 双指针,见官方方法二)
    • 双堆 → 插入 O(log n),查询 O(1) → ✅ 最优
  3. 设计不变量

    • 左堆(最大堆)存小的一半,右堆(最小堆)存大的一半
    • 左堆大小 ≥ 右堆大小,且最多多1
  4. 处理插入

    • 先按值决定放哪边
    • 再调整堆大小以维持不变量
  5. 查询中位数:直接取堆顶,按奇偶判断


📊 算法分析

操作时间复杂度空间复杂度说明
addNumO(log n)O(n)堆插入+可能的一次弹出
findMedianO(1)直接访问堆顶
总体O(n)存储所有数字

✅ 满足题目要求(5e4 次操作完全可行)


💡 面试延伸 & 进阶思考

Q1:为什么不用一个堆?

A:单个堆无法直接获取中位数。最大堆只能得最大值,最小堆只能得最小值。

Q2:能否用 multiset + 双指针?

A:可以(官方方法二),但:

  • multiset 插入 O(log n),但移动迭代器需小心边界
  • 实际性能可能不如双堆(常数更大)
  • 双堆更简洁、更常用

Q3:如果数据范围有限(如 0~100)?

A:可用计数排序 + 双指针(桶排序思想):

  • 开数组 cnt[101] 记录每个数出现次数
  • 维护总数量 total
  • 查询中位数时从 0 开始累加,找到第 total/2total/2+1 个数
  • 时间复杂度:addNum O(1),findMedian O(100)=O(1) → 极优!

Q4:如何扩展到求第 k 小/大元素?

A:可用 QuickSelect(期望 O(n)) ,但不支持动态插入。
动态场景下可用 Order Statistic Tree(GNU pbds) ,但非标准库。


💻 完整可运行代码

C++ 版本

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class MedianFinder {
public:
    priority_queue<int, vector<int>, less<int>> queMin;
    priority_queue<int, vector<int>, greater<int>> queMax;

    MedianFinder() {}

    void addNum(int num) {
        if (queMin.empty() || num <= queMin.top()) {
            queMin.push(num);
            if (queMax.size() + 1 < queMin.size()) {
                queMax.push(queMin.top());
                queMin.pop();
            }
        } else {
            queMax.push(num);
            if (queMax.size() > queMin.size()) {
                queMin.push(queMax.top());
                queMax.pop();
            }
        }
    }

    double findMedian() {
        if (queMin.size() > queMax.size()) {
            return queMin.top();
        }
        return (queMin.top() + queMax.top()) / 2.0;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    MedianFinder medianFinder;
    medianFinder.addNum(1);    // arr = [1]
    medianFinder.addNum(2);    // arr = [1, 2]
    cout << fixed << setprecision(5) << medianFinder.findMedian() << "\n"; // 输出 1.5
    medianFinder.addNum(3);    // arr = [1, 2, 3]
    cout << fixed << setprecision(5) << medianFinder.findMedian() << "\n"; // 输出 2.0

    return 0;
}

JavaScript 版本(使用 MinPriorityQueue / MaxPriorityQueue)

⚠️ LeetCode JS 环境已内置 @datastructures-js/priority-queue

var MedianFinder = function() {
    this.queMin = new MaxPriorityQueue(); // 较小的一半,最大堆
    this.queMax = new MinPriorityQueue(); // 较大的一半,最小堆
};

MedianFinder.prototype.addNum = function(num) {
    if (this.queMin.isEmpty() || num <= this.queMin.front().element) {
        this.queMin.enqueue(num);
        if (this.queMax.size() + 1 < this.queMin.size()) {
            this.queMax.enqueue(this.queMin.dequeue().element);
        }
    } else {
        this.queMax.enqueue(num);
        if (this.queMax.size() > this.queMin.size()) {
            this.queMin.enqueue(this.queMax.dequeue().element);
        }
    }
};

MedianFinder.prototype.findMedian = function() {
    if (this.queMin.size() > this.queMax.size()) {
        return this.queMin.front().element;
    }
    return (this.queMin.front().element + this.queMax.front().element) / 2.0;
};

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!