Hot 100 --- 数据流的中位数
本文概览:本文讲解数据流的中位数的核心思路:大顶堆存小的一半 + 小顶堆存大的一半,两个堆顶就是中位数,O(log n) 动态维护
一、题目

二、题目分析
1. 题目要求
要求实现一个类 MedianFinder:
MedianFinder():初始化addNum(num):从数据流中添加一个整数到数据结构中findMedian():返回目前所有元素的中位数
例子:
1 | addNum(1) addNum(2) → findMedian() = 1.5 |
核心难点:数字是随机持续添加的,中位数要能随时快速查询。这不是一次性的计算,而是"边加边查",所以需要一个能动态维护中位数的数据结构。
2. 暴力思路:有序数组 + 二分插入
第一时间的想法肯定是维护一个有序数组:新元素用二分查找找到应插入的位置,然后插进去。
- 二分找位置:O(log n)
- 插入后移:插入位置后面的所有元素都要往后挪,最坏 O(n)
所以整体是 O(n),瓶颈不在找位置,在挪元素。
3. 关键观察:我们不需要完全有序
既然我们只是需要中位数,其实并不需要数组完全有序,只需要知道中间值是多少就好了。
换个角度想:把所有元素分成两半,其中一半完全小于另一半:
1 | 小的一半 大的一半 |
- 总长度为偶数时:中位数 = (小的一半的最大值 + 大的一半的最小值) / 2,即 (7 + 9) / 2 = 8
- 总长度为奇数时:设立一个规则——小的那一半可以多存一个元素,中位数就是小的一半的最大值
这样只要随时拿到"小的一半的最大值"和"大的一半的最小值"这两个数,中位数就出来了。
4. 用什么维护两半的最大最小值?
普通数组拿最大最小值都要 O(n) 扫一遍,不够快。
- 小的一半要快速拿最大值 → 最大堆(堆顶就是这一半的最大值)
- 大的一半要快速拿最小值 → 最小堆(堆顶就是这一半的最小值)
堆的插入/弹出都是 O(log n),正好满足"比 O(n) 快的维护"。
最大堆、最小堆的详细介绍可以看我另一篇题解(物理数组 + 逻辑完全二叉树、上浮、下沉的完整推导),这里不再重复:
三、思路概览
1 | class MedianFinder { |
思路简要说明:
- 两个堆分工:maxHeap(最大堆)存小的一半,minHeap(最小堆)存大的一半
- 数量规则:总元素为奇数时,maxHeap 比 minHeap 多存一个(中位数取 maxHeap 堆顶)
- 平衡插入:每来一个新元素都过一遍两个堆(先加进一个堆、弹出堆顶、给另一个堆),确保两半的大小关系和数量规则始终成立
- **查询 O(1)**:偶数取两堆顶平均,奇数取 maxHeap 堆顶
- **时间复杂度 O(log n)**:addNum 涉及 3 次堆操作,每次 O(log n)
四、思路详解
第一步:为什么"分两半"就能拿到中位数?
中位数的定义:所有元素排序后处在正中间的值。
- 偶数个:正中间两个数的平均值
- 奇数个:正中间那一个数
而"小的一半完全小于大的一半"这个划分,恰好把正中间的两个数暴露在分界线上:
1 | 排序后:[1, 3, 5, | 7, 9, 11] ← 偶数 6 个 |
所以只要"分界线两侧的最大值/最小值"随时可查,中位数就随时可查,其余元素怎么排根本不用关心。
第二步:为什么要约定"奇数时小的一半多存一个"?
奇数个元素没法平分,两半必然一边多一个,必须提前定死规则,查询时才知道取谁的堆顶。
约定"小的那一半多一个"的好处:findMedian 里奇数分支直接 return maxHeap.peek(),一个堆顶就是答案。
(反过来约定"大的一半多一个"也完全可行,查询时取 minHeap 堆顶即可,本文代码采用前者。)
第三步:addNum 的插入逻辑——最巧妙的点
先看代码:
1 | public void addNum(int num) { |
为什么不能直接往对应的堆里塞?
因为有个铁律必须满足:minHeap 的所有元素 > maxHeap 的所有元素(大的一半完全大于小的一半)。直接塞新元素,可能破坏这条性质——万一新元素很小,却塞进了大的一半呢?
解决方式:让新元素"过一遍对面那个堆"再落位。
分两种情况推演(注意数量规则:maxHeap 比 minHeap 多 0 或 1 个):
情况一:两堆相等 → 这次要让 maxHeap 多 1 个
1 | 直接塞 maxHeap 的风险:num 可能很大,比 minHeap 的某些元素还大 → 违反铁律 |
关键:minHeap.poll() 弹出的元素,是"大的一半"里最小的,它必然 ≤ minHeap 里剩下的所有元素,把它给 maxHeap,铁律必然保持。新元素无论大小,都会被自动路由到正确的半区。
情况二:maxHeap 多 1 个 → 这次要让 minHeap 补 1 个(回到相等)
1 | 直接塞 minHeap 的风险:num 可能很小,比 maxHeap 的某些元素还小 → 违反铁律 |
总结这个设计的巧妙之处:
- 新元素不直接落位,先进入对面的堆"排队",由堆顶机制筛出该跨过分界线的那个元素
- 3 次堆操作(offer + poll + offer),换来"数量规则 + 大小铁律"同时被维护,不需要任何 if 判断新元素的大小
- 条件极简:只看两堆 size 是否相等,不用比较 num 和堆顶
第四步:findMedian 的取值
1 | public double findMedian() { |
对应第二步的规则约定:
- 偶数:小一半的 max(maxHeap 堆顶)+ 大一半的 min(minHeap 堆顶),除以 2.0
- 奇数:maxHeap 多一个,堆顶即中位数
第五步:能否优化到 1 次堆操作?
上面的写法每次 addNum 固定 3 次堆操作。其实有些情况下新元素可以直接落位,省到 1 次:
1 | 情况A: max.size()==min.size() && num < min.peek() |
思路:先拿 num 和堆顶比一比,如果 num 天然属于目标半区(比大一半的最小值还小 / 比小一半的最大值还大),直接塞进去就行,不用绕对面一圈。
但是不建议这样写:
- 四个条件分支很容易写错、写漏(比如边界用
<还是<=) - 省下的只是常数时间(O(log n) 不变),换来的是正确性风险
- 对称的 3 次操作写法无脑且必然正确,面试和实战都更稳
大家感兴趣可以自己完成这部分代码,本文不提供。
完整执行过程
依次添加 [1, 2, 3, 4, 5],每步列出两堆状态(括号标注堆顶):
1 | 初始:maxHeap = [],minHeap = [] |
复杂度分析
时间复杂度:
addNum:O(log n),固定 3 次堆操作(offer + poll + offer),每次 O(log n)findMedian:O(1),只 peek 两个堆顶
**空间复杂度 O(n)**:两个堆合计存全部元素。
对比有序数组方案:addNum O(n)(挪元素)+ findMedian O(1)。堆方案把插入的 O(n) 压到 O(log n),这正是动态数据流场景下需要的。
五、总结
数据流的中位数的核心思路:
- 分两半:小的一半 + 大的一半,一半完全小于另一半,不追求完全有序
- 两堆分工:小的一半用最大堆(堆顶 = 小一半 max),大的一半用最小堆(堆顶 = 大一半 min)
- 数量规则:奇数时 maxHeap 多存一个,中位数 = maxHeap 堆顶
- 平衡插入:新元素先 offer 进对面堆、poll 出堆顶给目标堆,3 次堆操作同时维护数量规则和大小铁律
- **查询 O(1)**:偶数两堆顶平均 / 2.0,奇数取 maxHeap 堆顶
对顶堆的适用场景:
- 动态数据流的中位数
- 滑动窗口中位数
- 任何"把数据分成一大一小两部分,随时要拿中间分界值"的场景
这个套路叫对顶堆:两个堆顶相对而立,中间那条缝就是分界线。

