📌 题目链接:295. 数据流的中位数 - 力扣(LeetCode)
🔍 难度:困难 | 🏷️ 标签:设计、堆(优先队列)、数据流、双指针
⏱️ 目标时间复杂度:addNum: O(log n),findMedian: O(1)
💾 空间复杂度:O(n)
🎯 问题核心与面试价值
在实时数据处理系统(如股票价格监控、在线评分系统、网络流量分析)中,我们经常需要动态维护一个不断增长的数据集合,并高效查询当前中位数。这道题考察的是:
- 如何用两个堆(优先队列)巧妙维护有序性
- 对“中位数”定义的精准理解(奇偶长度差异)
- 动态平衡思想(类似 AVL 树的旋转思想)
- C++ STL 中
priority_queue的自定义比较器使用
这是高频系统设计+算法结合题,在 Google、Amazon、Meta 等大厂面试中多次出现,尤其适合考察候选人对数据结构组合使用的能力。
🧩 题目分析
我们需要设计一个类 MedianFinder,支持两种操作:
addNum(int num):向数据流中添加一个整数;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 时:
-
若
num ≤ queMin.top()→ 应放入queMin- 但插入后可能破坏 size 不变量(
queMin比queMax多超过 1 个) - → 把
queMin的堆顶(最大值)移到queMax
- 但插入后可能破坏 size 不变量(
-
否则 → 放入
queMax- 但若
queMax比queMin大 → 把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承担“多一个”的角色,简化逻辑。也可反过来,但需保持一致。
🧭 解题思路(分步拆解)
-
明确目标:动态维护中位数,避免每次排序。
-
选择数据结构:
- 数组?→ 插入 O(n),排序 O(n log n) → ❌
- 平衡二叉搜索树?→ 可行,但实现复杂(如
multiset+ 双指针,见官方方法二) - 双堆 → 插入 O(log n),查询 O(1) → ✅ 最优
-
设计不变量:
- 左堆(最大堆)存小的一半,右堆(最小堆)存大的一半
- 左堆大小 ≥ 右堆大小,且最多多1
-
处理插入:
- 先按值决定放哪边
- 再调整堆大小以维持不变量
-
查询中位数:直接取堆顶,按奇偶判断
📊 算法分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
addNum | O(log n) | O(n) | 堆插入+可能的一次弹出 |
findMedian | O(1) | — | 直接访问堆顶 |
| 总体 | — | O(n) | 存储所有数字 |
✅ 满足题目要求(5e4 次操作完全可行)
💡 面试延伸 & 进阶思考
Q1:为什么不用一个堆?
A:单个堆无法直接获取中位数。最大堆只能得最大值,最小堆只能得最小值。
Q2:能否用 multiset + 双指针?
A:可以(官方方法二),但:
multiset插入 O(log n),但移动迭代器需小心边界- 实际性能可能不如双堆(常数更大)
- 双堆更简洁、更常用
Q3:如果数据范围有限(如 0~100)?
A:可用计数排序 + 双指针(桶排序思想):
- 开数组
cnt[101]记录每个数出现次数- 维护总数量
total- 查询中位数时从 0 开始累加,找到第
total/2和total/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!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!