【LeetCode 热题 100】295. 数据流的中位数——最大堆和最小堆
Problem: 295. 数据流的中位数
中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。
例如 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 以内的答案将被接受。
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:
- 空间复杂度:O(N)
整体思路
这段代码旨在解决一个经典的数据结构设计问题:数据流中的中位数 (Find Median from Data Stream)。问题要求设计一个数据结构,它能支持两个操作:addNum
(从数据流中添加一个整数)和 findMedian
(返回当前所有数字的中位数)。
该实现采用了一种非常经典且高效的 双堆(Dual Heaps) 方法。它巧妙地利用两个优先队列(堆)来动态维护数据流的中位数。
-
核心思想:分割数据流
- 算法将所有已添加的数字逻辑上分为两部分:
- 较小的一半:存储在一个 最大堆 (Max Heap)
left
中。 - 较大的一半:存储在一个 最小堆 (Min Heap)
right
中。
- 较小的一半:存储在一个 最大堆 (Max Heap)
- 通过这种方式,
left
堆的堆顶元素是“较小一半”中的最大值,而right
堆的堆顶元素是“较大一半”中的最小值。这两个堆顶元素就构成了整个数据流的“中心”。
- 算法将所有已添加的数字逻辑上分为两部分:
-
维护两个不变量:
- 不变量1(数值关系):
left
堆中的所有元素都小于或等于right
堆中的所有元素。 - 不变量2(数量关系):
left
堆的大小总是等于或比right
堆大 1。即left.size() == right.size()
或left.size() == right.size() + 1
。
- 不变量1(数值关系):
-
addNum(int num)
方法的实现:addNum
方法的核心任务是在添加新元素num
后,依然维持上述两个不变量。- 当两堆大小相等时 (
left.size() == right.size()
):- 我们期望最终
left
堆比right
堆多一个元素。 - 为了维护数值关系,不能直接将
num
加入left
。一个巧妙的操作是:先将num
加入right
堆,然后从right
堆中弹出最小值(即right.poll()
),再将这个最小值加入left
堆。 - 这个“中转”操作确保了新加入
left
堆的元素一定是“较大一半”中的最小值,从而维持了left
所有元素 <=right
所有元素的不变量。
- 我们期望最终
- 当
left
比right
多一个元素时 (left.size() > right.size()
):- 我们期望最终两堆大小相等。
- 类似地,先将
num
加入left
堆,然后从left
堆中弹出最大值(left.poll()
),再将这个最大值加入right
堆。 - 这个操作确保了新加入
right
堆的元素一定是“较小一半”中的最大值,维持了不变量。
-
findMedian()
方法的实现:findMedian
的实现非常简单,直接利用了双堆的结构和不变量。- 如果数据总数为奇数:
left
堆会比right
堆多一个元素。此时,中位数就是left
堆的堆顶元素left.peek()
。 - 如果数据总数为偶数:
left
堆和right
堆大小相等。此时,中位数是left
堆的堆顶(“较小一半”的最大值)和right
堆的堆顶(“较大一半”的最小值)的平均值。
完整代码
class MedianFinder {// left: 一个最大堆,用于存储数据流中较小的一半元素。// Java的PriorityQueue默认是最小堆,通过自定义比较器 (a, b) -> b - a 实现最大堆。private final PriorityQueue<Integer> left = new PriorityQueue<>((a, b) -> b - a);// right: 一个最小堆,用于存储数据流中较大的一半元素。// 默认构造函数创建的就是最小堆。private final PriorityQueue<Integer> right = new PriorityQueue<>();/** 构造函数,无需特殊操作。*/public MedianFinder() {}/*** 向数据结构中添加一个整数。* @param num 要添加的数字*/public void addNum(int num) {// 目标:维持 left.size() == right.size() 或 left.size() == right.size() + 1// 当前总数为偶数,添加后将变为奇数。目标是让 left 比 right 多一个。if (left.size() == right.size()) {// 为了维持 left 中所有元素 <= right 中所有元素的不变量:// 1. 先将 num 加入 right 堆。right.offer(num);// 2. 从 right 堆中弹出最小值,并将其加入 left 堆。// 这样保证了新加入 left 的元素是正确的。left.offer(right.poll());} else { // 当前总数为奇数,添加后将变为偶数。目标是让两堆大小相等。// 类似地,为了维持不变量:// 1. 先将 num 加入 left 堆。left.offer(num);// 2. 从 left 堆中弹出最大值,并将其加入 right 堆。right.offer(left.poll());}}/*** 返回当前数据流的中位数。* @return 中位数*/public double findMedian() {// 如果总数为奇数,left 堆会多一个元素,中位数就是 left 堆顶。if (left.size() > right.size()) {return left.peek();}// 如果总数为偶数,两堆大小相等,中位数是两个堆顶的平均值。// 注意要除以 2.0 来确保结果是浮点数。return (left.peek() + right.peek()) / 2.0;}
}/*** Your MedianFinder object will be instantiated and called as such:* MedianFinder obj = new MedianFinder();* obj.addNum(num);* double param_2 = obj.findMedian();*/
时空复杂度
假设已经向数据结构中添加了 N
个数字。
时间复杂度:
-
addNum(int num)
: O(log N)- 该方法主要包含堆的插入 (
offer
) 和删除 (poll
) 操作。 PriorityQueue
在Java中是基于二叉堆实现的。对于一个大小为k
的堆,插入和删除操作的时间复杂度都是 O(log k)。- 在我们的实现中,
left
堆和right
堆的大小都约等于N/2
。 - 因此,
addNum
方法执行的操作(如right.offer
,right.poll
,left.offer
)的时间复杂度都是 O(log(N/2)),这等价于 O(log N)。
- 该方法主要包含堆的插入 (
-
findMedian()
: O(1)- 该方法只需要访问两个堆的堆顶元素 (
peek
)。 - 访问堆顶元素是一个常数时间操作。
- 因此,
findMedian
的时间复杂度是 O(1)。
- 该方法只需要访问两个堆的堆顶元素 (
空间复杂度:O(N)
- 主要存储开销:算法的主要空间开销来自于两个优先队列
left
和right
。 - 空间大小:这两个堆共同存储了所有已添加的
N
个数字。 - 综合分析:
left
堆存储约N/2
个元素,right
堆存储约N/2
个元素。- 因此,总的空间复杂度与已添加的数字数量
N
成线性关系,即 O(N)。
参考灵神