第一次刷到 LeetCode 295 这道题的时候,我差点被题目描述骗过去——不就是求中位数吗,sort 一下不就完了?但“数据流”三个字才是真正藏考点的地方:数据会源源不断地流入,每次插入后你都得快速回答“现在的中位数是多少”。这就是 C++ 双堆法大显身手的场景,也是这道题能常驻 Hot 100、面试官百问不腻的根本原因。
这篇文章我想从一道题出发,把“双堆”这个数据结构组合讲透:为什么不能用排序硬扛、两个堆怎么分工、C++ 里用 priority_queue 怎么写出简洁不出错的代码,以及面试官在进阶要求里真正想考察的是什么。不管你是刚刷 Hot 100 的新手,还是准备二刷的进阶选手,这篇题解都能给你一些参考。
1. 排序法的复杂度陷阱:数据流问题的核心矛盾
1.1 数据流场景和普通数组排序最大的区别
如果题目只问你“给定一个数组,求中位数”,那最朴素的做法就是 sort 一下,然后按下标取中间值或中间两数的平均值,完事。但 LeetCode 295 不是这么设计的,它要求你实现一个类,支持两个操作:
addNum(int num):从数据流中添加一个整数。findMedian():返回目前所有元素的中位数。
这个设计意味着什么问题呢?数据不是一次性给你的,你也不知道后面会来什么数。每次调用 findMedian() 时,它都必须基于“当前已经插入的所有元素”给出正确答案。如果你每次都重新完整排序,那 addNum 是 O(1)(就 push 进 vector),findMedian 却变成 O(n log n)。如果数据流很大,或者 findMedian 被频繁调用,性能很快就崩了。
另一种直觉做法是“维护一个始终有序的数组”:每次 addNum 的时候用二分查找找到插入位置,然后 insert 进去。这样 findMedian 是 O(1),但 addNum 变成了 O(n),因为 vector 在中间插入元素需要把后续元素全部后移。当数据流规模到达百万级,一次插入几万次移动,同样不现实。
这就是数据流问题的核心矛盾:“在线”和“动态”两个属性,要求你在插入和查询之间做一个时间复杂度的平衡。你不能把所有成本都压在查询上,也不能把所有成本都压在插入上。
1.2 为什么平衡树在这里也未必好用
很多有经验的同学会第一时间想到平衡二叉搜索树,比如 C++ 的 std::multiset。理论上它支持 O(log n) 插入,也能维护有序性。但是问题来了:怎么在 O(1) 时间内拿到中位数?
multiset 的迭代器不是随机访问迭代器,你没法直接“跳”到第 n/2 个元素。要找到中位数,你得从 begin() 开始一步一步走 n/2 步,这是 O(n) 的操作。虽然插入快,但查询中位数依旧慢,整体还是不满足要求。
除非你用一棵加强版的平衡树,在每个节点额外维护子树大小,这样才能 O(log n) 找到第 k 大元素。但实现复杂度比“两个堆”高太多了,面试现场手写一棵带 size 的平衡树,大概率翻车。
| 方案 | addNum 复杂度 | findMedian 复杂度 | 总评 |
|---|---|---|---|
| 每次重新排序 | O(1) | O(n log n) | 查询频繁时不可用 |
| 维护有序数组 | O(n) | O(1) | 插入频繁时不可用 |
| multiset + 迭代器遍历 | O(log n) | O(n) | 查询瓶颈严重 |
| 带 size 的平衡树 | O(log n) | O(log n) | 实现复杂,不够实用 |
| 双堆法 | O(log n) | O(1) | 均衡且实现简单 |
双堆法之所以是这道题的经典解,就是因为它用非常轻量的数据结构,同时拿到了“插入 O(log n)”和“查询 O(1)”这两个关键指标。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双堆的分工逻辑:两个堆怎么配合出中位数
2.1 中位数的本质:只关心分界线附近的两个数
先想一个问题:中位数到底在数学上是什么?把当前所有元素从小到大排好,它的位置在序列正中间。这就意味着,如果我们能维护两个集合——较小的一半和较大的一半——中位数就只取决于这两个集合在“边界处”的两个元素。
这个思路的关键在于,我根本不需要知道完整的有序序列,我只需要知道:
- 较小那半边的最大值是什么。
- 较大那半边的最小值是什么。
恰好,堆这种数据结构就是为“维护极值”而生的:
- 大顶堆能在 O(1) 时间给出堆内最大值,也就是较小一半的“中心侧边界”。
- 小顶堆能在 O(1) 时间给出堆内最小值,也就是较大一半的“中心侧边界”。
所以,双堆法的第一步是建立分工:
low,一个大顶堆,存放较小的那一半元素,堆顶是这半边最大的数。high,一个小顶堆,存放较大的那一半元素,堆顶是这半边最小的数。
2.2 堆顶就是中位数的分界线
当两个堆里的元素个数保持一个约定关系时,中位数就能直接从堆顶算出来。
比如当前总共有 2k 个元素,如果 low 有 k 个、high 有 k 个,那么排序后整个序列的中间两个数,恰好就是 low 的最大值和 high 的最小值,中位数就是这两个数的平均值。
如果当前有 2k + 1 个元素,我们规定 low 有 k + 1 个、high 有 k 个,多出来的那一个正好落在“较小一半”的最大值位置,也就是 low.top(),那中位数就是 low.top()。
这个思路的本质是:把我对“完整有序数组”的维护需求,降级成对“左右两半边界”的维护需求。堆比有序数组便宜得多——它不需要维护全序,只要维护堆顶这个极值就够了。
我会在后面的代码实现里专门讲怎么保证两个堆的“有序性”和“个数平衡”,因为这两个约束只要破坏任意一个,结果就不会对。很多人的代码看起来像双堆,但写出来是错的,就是因为只维护了数量平衡,没维护顺序关系。
3. C++ 代码实现:priority_queue 的两次转移平衡法
3.1 完整可提交的代码
用 C++ 的 priority_queue 实现两个堆,逻辑非常清爽:
cpp复制class MedianFinder {
private:
// 大顶堆,存储较小的一半元素
priority_queue<int> low;
// 小顶堆,存储较大的一半元素
priority_queue<int, vector<int>, greater<int>> high;
public:
MedianFinder() {}
void addNum(int num) {
// 第一步:先进入 low
low.push(num);
// 第二步:把 low 的最大值“转移”到 high
high.push(low.top());
low.pop();
// 第三步:调整数量平衡,保证 low.size() >= high.size()
if (low.size() < high.size()) {
low.push(high.top());
high.pop();
}
}
double findMedian() {
if (low.size() > high.size()) {
return low.top();
}
return (static_cast<double>(low.top()) + high.top()) / 2.0;
}
};
这段代码短但包含的信息量很大,三个步骤缺一不可。我从后往前拆开讲。
3.2 addNum 里的三次操作到底在干嘛
很多讲双堆的文章会给你一个更直观的版本:如果 num 比 low 堆顶小,就放进 low;否则放进 high;然后调整数量。这种写法当然可以,但边界条件多,容易漏判。我更喜欢上面这种“先插入,再转移,再平衡”的写法,因为它用一个统一的流程把顺序约束自动保证了。
为什么这么说?看前两步:
cpp复制low.push(num);
high.push(low.top());
low.pop();
这三行合起来的意图是:先把新元素丢进 low,然后把 low 里当前最大的那个数拎出来放进 high。由于 low.top() 是较小一半里的最大值,把它移动到 high,就能保证 high 里所有元素始终大于等于 low 里所有元素吗?严格来说是“大于等于 low 其余元素,且此前的 high 元素本来就不小于 low 的任何元素”,整个“low 的所有元素 <= high 的所有元素”的全局顺序就保住了。
如果你只用“num 和堆顶比较后直接插到某一侧”的写法,相等的元素、堆为空的情况、以及连续插入多个同值元素时,很容易破坏这个顺序约束。而“先入 low,再转移”这个流程天然维护了顺序,不需要额外判断。
第三步则是在维护数量约束:
cpp复制if (low.size() < high.size()) {
low.push(high.top());
high.pop();
}
这个判断保证 low 永远不会比 high 小。如果 high 多了一个,就把 high 里最小的那个(也就是分界线右侧最小的)搬回 low。整体约束最终是:
- 当元素总数为偶数时,
low.size() == high.size()。 - 当元素总数为奇数时,
low.size() == high.size() + 1。
这样就锁定了 findMedian 里“low 更大看 low,相等看平均值”的逻辑。
3.3 使用 static_cast 避免整数除法坑
findMedian 里有一个看起来不起眼但特别容易犯错的点:
cpp复制return (low.top() + high.top()) / 2.0;
两个 int 相加,如果直接除以 2,结果会被截断成整数。很多人在自己本地测试奇偶边界的时候没有覆盖偶数长度用例,就会漏掉这个错误。我习惯在相加前先 static_cast<double>(low.top()),保证后续运算是浮点除法。这一点在面试手写代码时也值得主动提出来,算一个加分细节。
4. 边界条件与手测推演:从奇怪答案到稳定 AC
4.1 空堆时的防御性处理
先说一个现实中可能遇到的边界:如果流里还没有任何元素,直接调用 findMedian(),low.top() 是未定义行为。LeetCode 的测试用例不会这样调用,但如果你把这段代码用在项目里或扩展成其他工具类的底层,最好加一个防御:
cpp复制double findMedian() {
if (low.empty()) {
return 0.0;
}
// ...
}
一行防御,能省去未来很多排查时间。
4.2 重复元素会不会搞乱两个堆
有人担心重复元素会不会破坏堆之间的顺序。比如连续插入三个 1:
- 第一次插入 1:
low= {1},high= {} - 第二次插入 1:先
low.push(1),再high.push(low.top()),也就是 high 拿到 1,low变成 {} - 此时
low.size() < high.size(),第三步把 high 的堆顶 1 搬回 low,最终low= {1},high= - 第三次插入 1:先
low.push(1),low= {1, 1},high.push(1),high= {1, 1},low=
你看,最终 low 是两个 1,high 是一个 1,中位数是 low.top() = 1,正确。重复值在“元素相等”时的搬运不会造成错误的失衡,因为堆只按值比较,不区分先后顺序。
4.3 一个完整的手测推演过程
我拿一个稍微复杂一点的序列来推演:[5, 2, 3, 8, 1, 9, 7]。
| 插入 | 操作过程 | low(大顶堆) | high(小顶堆) | findMedian |
|---|---|---|---|---|
| 5 | low.push(5),high.push(5),low.pop()后 low={},high={5},第三步搬回 | {} | 5 | |
| 2 | low.push(2),low={5,2},high.push(5),low拿到2,high={5},low.size()==high.size() | 3.5 | ||
| 3 | low.push(3),low={3,2},high.push(3),low={2},high={5,3},low.size()<high.size(),high.top()=3搬回low | 3 | ||
| 8 | low.push(8),low={8,3,2},high.push(8),low={3,2},high={5,8},数量相等 | 4 | ||
| 1 | low.push(1),low={3,2,1},high.push(3),low={2,1},high={5,8,3},low.size()<high.size(),high.top()=3搬回low | 3 | ||
| 9 | low.push(9),low={9,3,2,1},high.push(9),low={3,2,1},high={5,8,9},low.size()<high.size(),high.top()=5搬回low | 5 | ||
| 7 | low.push(7),low={7,5,3,2,1},high.push(7),low={5,3,2,1},high={8,9,7},low.size()<high.size(),high.top()=7搬回low | 7 |
每次插入后的 low.top() 和 high.top() 恰好就是排序后数组中间位置附近的两个元素,整个过程的正确性一目了然。
这个推演我也建议你自己在纸上画一遍,尤其是关注“为什么每轮执行完,low.top() 一定 <= high.top()”。理解了这一点,双堆法就彻底拿下了。
4.4 溢出风险:low.top() + high.top() 可能超 int
这一条平时不太会踩到,但值得留意。如果数据流里的值非常接近 INT_MAX,两个堆顶相加就溢出了。LeetCode 的测试数据一般不会这么极端,但如果你把这个类用在长尾数据、传感器数据等真实场景里,最好一开始就往 double 方向走。我们的 findMedian 已经用 static_cast<double> 转了一个数,两个 double 相加就不会有 int 溢出问题。
5. 进阶追问:数值范围受限时的计数优化方案
LeetCode 的题目描述里特意留了两个进阶要求,很多人刷题时会直接略过,但面试官往往会拿它们做追问:
如果数据流中所有整数都在 0 到 100 范围内,你将如何优化?
如果数据流中 99% 的整数都在 0 到 100 范围内,你将如何优化?
这两个问题的答案,能把“能用双堆”变成“真正懂优化”。
5.1 范围在 [0, 100]:计数数组就够了
当所有整数取值范围被限制在 [0, 100],我们就不需要用堆了。直接维护一个长度为 101 的计数数组 cnt,再加上一个总数 total:
cpp复制class MedianFinder {
private:
int cnt[101] = {0};
int total = 0;
public:
void addNum(int num) {
cnt[num]++;
total++;
}
double findMedian() {
int mid = (total + 1) / 2; // 第 mid 小的元素位置
int sum = 0;
for (int i = 0; i <= 100; i++) {
sum += cnt[i];
if (sum >= mid) {
if (total % 2 == 1) {
return i;
}
// 偶数时,还需要找下一个位置的最小值
if (sum >= mid + 1) {
return i;
}
for (int j = i + 1; j <= 100; j++) {
if (cnt[j] > 0) {
return (i + j) / 2.0;
}
}
}
}
return 0.0;
}
};
这个方案的本质是桶计数。因为值域只有 101 个桶,findMedian 时从桶 0 开始累加,找到中位数落在哪个桶里,最多遍历 101 次,相当于 O(1) 级别。而 addNum 是真正的 O(1),比双堆的 O(log n) 还要快。
5.2 99% 数据在 [0, 100]:分段处理更优雅
第二个进阶问题更刁钻:99% 的数在 0 到 100 之间,但剩下的 1% 在范围外,可能是负数,也可能大于 100。这时候你没法只用计数数组,因为值域被撑大了。
优化的思路是分段:
- 小于 0 的数:用一个最大堆
left存。 - 大于 100 的数:用一个最小堆
right存。 - 位于 [0, 100] 的数:用计数数组
cnt存。
查询中位数时,先根据 total 的奇偶性和位置,判断中位数落在左堆、计数数组、还是右堆。如果落在计数数组范围内,就按 5.1 的方法在 101 个桶里找;如果落在左堆/右堆里,就处理对应的堆。
这个方案的实质,是把“大范围数据”拆成“窄区间计数 + 极值堆”的组合。面试官看到你能主动拆解问题,而不是一股脑塞进堆里,好感度会明显不一样。
5.3 两种优化背后的共同方法论
这两个进阶问题真正想考察的,是你拿到“数据范围”这个新约束之后,能不能跳出常规的数据结构,联想到桶、计数、分段这些更贴近底层的手段。数组计数之所以高效,是因为它把“维护顺序”这个任务变成了“维护频次”——在值域小的时候,频次表本身就是天然有序的。
这类思想在真实系统里也很常见:比如统计 TP99、TP999 延迟,如果延迟范围已知,直接上直方图;如果延迟分布很宽,再用直方图 + 尾巴二分。算法题和工程实践并不割裂,代码里的直方图就是 LeetCode 这道题计数数组的放大版。
6. 变体与面试延伸:双堆思想的通用性
6.1 如果要求支持删除任意数值
面试官有时候会在你答完 295 之后追加一个问题:如果我想删除某个已经插入的数,双堆还能用吗?
直接用堆删除任意元素是不行的,堆只支持删除堆顶。一个工业级的技巧是延迟删除(lazy deletion):用另一个哈希表记录“待删除但还没真正删除”的数字,当堆顶元素命中了待删除记录时,才真正把它 pop 出去。这个思路在实现滑动窗口中位数时几乎是标配,像 LeetCode 480 就是在双堆基础上套了延迟删除。
6.2 滑动窗口中位数:双堆的实战升级
LeetCode 480 要求你在一个不断滑动的窗口中求中位数,每次窗口右移时,要同时处理“一个新数进入窗口”和“一个旧数离开窗口”。这正好是双堆 + 延迟删除的经典应用场景,因为离开窗口的那个数不一定在堆顶。
这道题我曾写过一篇专门的题解,核心就是维护两个堆的平衡关系,并在每次窗口移动后清理两个堆中“已失效”的堆顶元素。掌握了 295 的插入平衡逻辑,再去看 480 会轻松不少。
6.3 双堆思想的通用场景
双堆不只是“求中位数”的专用工具。凡是需要动态维护“分位数”、或者需要同时获取“两侧边界极值”的问题,都可以往双堆上靠:
- 动态数据流求第 k 大/第 k 小,可以用一个大小为 k 的堆维护。
- 实时统计请求耗时分布,比如找 P95/P99,本质上就是维护分位数。
- 多路归并时的堆选最小/最大,也是同一类思路。
我自己的感受是:LeetCode 295 的价值不在于代码本身,而在于它用一道题教会你——当你需要快速获取全局中间位置信息时,完整排序往往是浪费的,两个堆各管一半,反而更优雅。
最后再分享一个小技巧:如果你在面试现场写这个类,可以在写完 addNum 后立刻说一句“这个流程已经保证了 low 中所有元素都小于等于 high 中所有元素”,面试官基本都会点头。然后你再顺势解释两步转移的顺序约束,这一题就稳稳过了。我当年第一次面大厂时,就是靠这道题把算法面试环节拖进了“聊方案”而不是“死写代码”的节奏,后来拿到 offer 回头看,这题确实值得认真吃透。
