跳到正文
第 295 题困难

数据流的中位数

Find Median from Data Stream

实现 MedianFinder:不断往数据流里加数,随时返回当前所有数的中位数。

在力扣中练习

题目描述

中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。

  • 例如 arr = [2,3,4] 的中位数是 3 。
  • 例如 arr = [2,3] 的中位数是 (2 + 3) / 2 = 2.5 。

实现 MedianFinder 类:

  • MedianFinder() 初始化 MedianFinder 对象。

  • void addNum(int num) 将数据流中的整数 num 添加到数据结构中。

  • double findMedian() 返回到目前为止所有元素的中位数。与实际答案相差 10-5 以内的答案将被接受。

示例 1:

输入
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
输出
[null, null, null, 1.5, null, 2.0]

解释
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1);    // arr = [1]
medianFinder.addNum(2);    // arr = [1, 2]
medianFinder.findMedian(); // 返回 1.5 ((1 + 2) / 2)
medianFinder.addNum(3);    // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0

提示:

  • -105 <= num <= 105
  • 在调用 findMedian 之前,数据结构中至少有一个元素
  • 最多 5 * 104 次调用 addNum 和 findMedian

解题思路

主解法

对顶堆 · 双堆

左半用大顶堆,右半用小顶堆,中位数就在两个堆顶

每来一个数都重新排序太慢,中位数又只依赖有序序列的中间位置。把较小一半放在大顶堆,把较大一半放在小顶堆,两个堆顶就能守住分界。

自拟数据流依次加入 [6,-2,8,4,4,10],每次加入后查询,结果是 [6,2,6,5,4,5]。负数、重复值和无序到达都不会改变两个不变量。

分步图解

addNum(-2):插入、搬移并恢复数量平衡,图中是真实堆结构。

每次插入后的查询结果
6
左 · 大顶堆(1)左子树 · 右子树
-2堆顶
右 · 小顶堆(1)左子树 · 右子树
6堆顶

左侧所有值 ≤ 右侧所有值;左堆与右堆等长,或只多一个。

1 / 4
插入后维护两个不变量:左值不大于右值,左堆最多多一个。

首先左侧所有值不大于右侧所有值;其次左堆与右堆等长,或只比右堆多一个。代码先插入左堆,再把左堆最大值交给右堆,必要时搬回一个恢复数量平衡。

奇数个元素取左堆顶,偶数个取两个堆顶平均,除以 2.0 保留小数。图中按真实父子堆序排列,不把堆冒充完全排好序的数组;除堆顶外,兄弟节点之间不要求有序。

时间 addNum O(log n),findMedian O(1)空间 O(n)

易错点

  • 直接把 num 塞进较短的那个堆,会破坏「左堆所有数 ≤ 右堆所有数」,中位数就错了。
  • findMedian 里写 (left.top() + right.top()) / 2,整数除法会把 1.5 变成 1。
  • 只在 findMedian 里检查平衡是不够的:连续 add 几次后 right 会比 left 多出很多,堆顶早就偏离中间了。
对照解法

有序数组 · 插入

对照版:插入 O(n),取中位数 O(1)

结果正确,但数组插入需要 O(n);用于对照双堆如何将插入降为 O(log n)。

把数据全部存进一个升序数组,思路最直白:

  • addNum:lower_bound 找到第一个大于等于 num 的位置,insert 进去;
  • findMedian:下标直接取中间。

这个写法的查询很快,O(1),但每次插入都要把后面一半元素整体后移,最坏 O(n)。数据流长度到 5×10⁴ 时,总代价是 O(n²)。

它适合两种场合:数据规模很小,或者插入远少于查询。放到这道题里,它正好反衬出双堆的价值 —— 双堆牺牲了一点常数,把插入压到 O(log n)。

顺带一提,lower_bound 要求区间有序,所以数组必须时刻保持升序 —— insert 插在第一个不小于 num 的位置,插完依然有序。

  1. 1维护一个升序 vector。
  2. 2addNum:lower_bound 找到插入点,insert 进去。
  3. 3findMedian:n 为奇数取 sorted[n/2]。
  4. 4n 为偶数取中间两个的平均。
时间 addNum O(n),findMedian O(1)空间 O(n)

易错点

  • 用 push_back 后忘记排序,lower_bound 的前提就不成立了。
  • 偶数长度时下标写成 n/2 和 n/2+1,会越界且取错数。
  • 数据量大时 insert 的搬移会让总耗时退化成 O(n²)。

代码与演示

主解法配有图解;对照解法用于比较思路、复杂度或实现方式。

演示输入依次加入 [6,-2,8,4,4,10],每次加入后查询中位数

左侧维护较小一半的大顶堆,右侧维护较大一半的小顶堆。

每次插入后的查询结果
[ ]
左 · 大顶堆(0)左子树 · 右子树

空树 · null

右 · 小顶堆(0)左子树 · 右子树

空树 · null

左侧所有值 ≤ 右侧所有值;左堆与右堆等长,或只多一个。

1 / 14
查看 C++ 代码

延伸阅读

  • 双堆是「动态求第 k 大 / 中位数」的标准结构:把「有序」这个强条件换成「左右两半各自只维护一个端点」。
  • 滑动窗口中位数(480)同样是双堆,只是元素会过期,需要配合哈希表做惰性删除。
  • priority_queue 默认是大顶堆;小顶堆要写 priority_queue<int, vector<int>, greater<int>>,greater 来自 functional 头文件。