题目一:295. 数据流的中位数
问题描述:
中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。例如,[2,3,4] 的中位数是 3、[2,3] 的中位数是 (2 + 3) / 2 = 2.5。设计一个支持以下两种操作的数据结构:
- void addNum(int num) - 从数据流中添加一个整数到数据结构中。
- double findMedian() - 返回目前所有元素的中位数。
示例:
- addNum(1)
- addNum(2)
- findMedian() -> 1.5
- addNum(3)
- findMedian() -> 2
进阶:
- 如果数据流中所有整数都在 0 到 100 范围内,你将如何优化你的算法?
- 如果数据流中 99% 的整数都在 0 到 100 范围内,你将如何优化你的算法?
算法思路:
由于奇数长度和偶数长度的中位数定义不同,判断「从数据流中读出的数的个数的奇偶性」很重要,这是因为长度的奇偶性决定了中位数的个数。当从数据流中读出的数的个数为奇数的时候,中位数只有 1 个;当从数据流中读出的数的个数为偶数的时候,中位数有 2 个。我们不妨分别称它们为「左中位数」和「右中位数」。
如果数据流中每读出 1 个数后都排一次序,「中位数」就位于这些数的「中间」。「中位数」把它们分为两个部分,一部分是「前有序数组」,另一部分是「后有序数组」。我们发现如下事实:
- 当从数据流中读出的数的个数为奇数的时候,中位数是「前有序数组」中的最大值,如下左图所示;
- 当从数据流中读出的数的个数为偶数的时候,左中位数是「前有序数组」中的最大值,右中位数是「后有序数组」中的最小值,如下右图所示。
由于我们只关心这两个 有序数组 中的最值,有一个数据结构可以帮助我们快速找到这个最值,这就是 优先队列 。具体来说:
- 前有序数组由于只关注最大值,可以 动态地 放置在一个最大堆中;
- 后有序数组由于只关注最小值,可以 动态地 放置在一个最小堆中。
- 当从数据流中读出的数的个数为偶数的时候,我们想办法让两个堆中的元素个数相等,两个堆顶元素的平均值就是所求的中位数(如下左图);
- 当从数据流中读出的数的个数为奇数的时候,我们想办法让最大堆的元素个数永远比最小堆的元素个数多 11 个,那么最大堆的堆顶元素就是所求的中位数(如下右图)。
为了得到所求的中位数,在任何时刻,两个堆应该始终保持的性质如下:
- 最大堆的堆顶元素,小于或者等于最小堆的堆顶元素;
- 最大堆的元素个数或者与最小堆的元素个数相等,或者多 11 。
具体可以进行如下操作:
- 情况 1: 当两个堆的元素个数之和为偶数(例如一开始的时候),为了让最大堆中多 1 个元素,采用这样的流程:「最大堆 → 最小堆 → 最大堆」;
- 情况 2: 当两个堆的元素个数之和为奇数,此时最小堆必须多 11 个元素,这样最大堆和最小堆的元素个数才相等,采用这样的流程:「最大堆 → 最小堆」 即可。
因此无论两个堆的元素个数之和是奇数或者是偶数,都得先「最大堆」 再「最小堆」 ,而当加入一个元素之后,元素个数为奇数的时候,再把最小堆的堆顶元素拿给最大堆就可以了。将元素放入优先队列以后,优先队列会自行调整(以对数时间复杂度),把最优值放入堆顶,是这道问题思路的关键。
总结:
脑子里建立如下动态的过程:为了找到添加新数据以后,数据流的中位数,我们让新数据在最大堆和最小堆中都走了一遍。而为了让最大堆的元素多 1 个,我们让从最小堆中又拿出堆顶元素「送回」给最大堆;将元素放入优先队列以后,优先队列会以对数时间复杂度自行调整,把「最优值」调整到堆顶,这是使用优先队列解决这个问题的原因。如果不太熟悉优先队列的朋友们,请复习一下优先队列的相关知识,包括基本操作、理解上浮和下沉。这道题使用 Java 编码看起来思路更清晰一些,在 Python 中的堆只有最小堆,在构造最大堆的时候,要绕一个弯子,具体请看如下参考代码。
参考代码:
class MedianFinder {
int count;
PriorityQueue<Integer> maxHeap;
PriorityQueue<Integer> minHeap;
/** initialize your data structure here. */
public MedianFinder() {
count = 0;
maxHeap = new PriorityQueue<>((x, y) -> y - x);
minHeap = new PriorityQueue<>();
}
public void addNum(int num) {
count += 1;
maxHeap.offer(num);
minHeap.offer(maxHeap.poll());
if(count%2!=0)
maxHeap.offer(minHeap.poll());
}
public double findMedian() {
if(count%2!=0)
return (double)maxHeap.peek();
else
return (double)(maxHeap.peek()+minHeap.peek())/2;
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* MedianFinder obj = new MedianFinder();
* obj.addNum(num);
* double param_2 = obj.findMedian();
*/
复杂度分析:
时间复杂度:O(logN),优先队列的出队入队操作都是对数级别的,数据在两个堆中间来回操作是常数级别的,综上时间复杂度是 O(logN) 级别的;
空间复杂度:O(N),使用了三个辅助空间,其中两个堆的空间复杂度是O( 2N),一个表示数据流元素个数的计数器 count,占用空间 O(1),综上空间复杂度为 O(N)。
题目二:480. 滑动窗口中位数
问题描述:
给你一个数组 nums,有一个长度为 k 的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1 位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。
示例:
给出 nums = [1,3,-1,-3,5,3,6,7],以及 k = 3,返回该滑动窗口的中位数数组 [1,-1,-1,3,5,6]。
窗口位置 中位数
--------------- -----
[1 3 -1] -3 5 3 6 7 1
1 [3 -1 -3] 5 3 6 7 -1
1 3 [-1 -3 5] 3 6 7 -1
1 3 -1 [-3 5 3] 6 7 3
1 3 -1 -3 [5 3 6] 7 5
1 3 -1 -3 5 [3 6 7] 6
算法思路:
本题考查动态维护数组的中位数。我们思考中位数的性质:如果一个数是中位数,那么在这个数组中,大于中位数的数目和小于中位数的数目,要么相等,要么就相差一。因此,我们采用对顶堆的做法,控制所有小于等于的数字放到一个堆中,控制所有比中位数大的数字放到另一个堆中,并且保证两个堆的数目相差小于等于1。这样就可以保证每一次查询中位数的时候,答案一定出于两个堆的堆顶元素之一。因此选定数据结构:优先队列。因为优先队列采用的是堆结构,正好符合我们的需求。我们将所有小于等于中位数的元素放到大顶堆,将所有大于中位数的元素放到小顶堆。
1)初始化方法如下:
- 将前K个元素全部插入到大顶堆中。从大顶堆中弹出K/2个元素到小顶堆中。
- 这样,当K为奇数,则大顶堆元素比小顶堆元素多1;当K为偶数,两个堆元素相等。
2)取中位数的操作:
- 我们的插入操作可以保证每次优先插入到大顶堆中,因此大顶堆中的元素个数大于等于小顶堆的元素个数。
- 当K为奇数时候,中位数是元素数量较多的大顶堆 堆顶元素。
- 当K为偶数时候,中位数是大顶堆和小顶堆的堆顶元素平均值。
3)窗口滑动过程中的操作:
- 假定在上一次滑动之后,已经有大顶堆和小顶堆元素数目相差小于等于1.
- 设置当前的滑动时,balance = 0。balance表示大顶堆元素数目减去小顶堆元素个数。
- 删除窗口左侧的元素:由于堆无法直接删除掉某个指定元素,先欠下这个账,等某次元素出现在堆顶的时候,再删除他。mp记录这个元素欠账的个数。mp[left]++;虽然没有真的在堆数据结构中删除窗口最左侧的元素,但是在我们的心中已经删掉他了。堆两侧的平衡性发生了变化。如果left<=大顶堆.top(),就说明删掉的元素在大顶堆中,我们让balance--;否则,就说明删掉的元素在小顶堆中,让balance++;
- 添加进来窗口右侧的元素。如果right<=大顶堆.top(),就应该让这个元素放到samll堆里面,balance++;否则放到小顶堆里,balance--。
- 经过上面的操作,balance要么为0,要么为2,要么为-2。我们需要经过调整使得balance为0。
- 如果balance为0,在这次窗口滑动之前已经是平衡的,这次调整也没有让两堆的数目变化,所以不用调整两边的堆。
- 如果balance为2,就说明small堆的元素比big堆的元素多了两个。从small堆减少一个,big堆里增加一个,就可以让两边相等。big.push(small.top());small.pop();
- 如果balance为-2,就说明big堆的元素比small堆的元素多了两个。从big堆减少一个,small堆里增加一个,就可以让两边相等。small.push(big.top());big.pop();
- 调整完了,现在该欠债还钱了。不能让那些早该删除的元素涉及到中位数的运算。
- 分别检查两边的堆顶元素,如果堆顶元素欠着债,则弹出堆顶元素,直到堆顶元素没有欠债为止。有朋友问了:堆顶下面也有欠债的怎么办呢?我们之前说过,取中位数的时候只与堆顶元素有关,至于那些堆顶下面欠着债的,欠着就欠着吧,等他们到堆顶的时候再弹出去就好了。
- 最后,添加中位数即可。
上段文字出自:https://leetcode-cn.com/problems/sliding-window-median/solution/feng-xian-dui-chong-shuang-dui-dui-ding-hq1dt/
参考代码:
class Solution {
public double[] medianSlidingWindow(int[] nums, int k) {
DualHeap dh = new DualHeap(k);
for (int i = 0; i < k; ++i) {
dh.insert(nums[i]);
}
double[] ans = new double[nums.length - k + 1];
ans[0] = dh.getMedian();
for (int i = k; i < nums.length; ++i) {
dh.insert(nums[i]);
dh.erase(nums[i - k]);
ans[i - k + 1] = dh.getMedian();
}
return ans;
}
}
class DualHeap {
// 大根堆,维护较小的一半元素
private PriorityQueue<Integer> small;
// 小根堆,维护较大的一半元素
private PriorityQueue<Integer> large;
// 哈希表,记录「延迟删除」的元素,key 为元素,value 为需要删除的次数
private Map<Integer, Integer> delayed;
private int k;
// small 和 large 当前包含的元素个数,需要扣除被「延迟删除」的元素
private int smallSize, largeSize;
public DualHeap(int k) {
this.small = new PriorityQueue<Integer>(new Comparator<Integer>() {
public int compare(Integer num1, Integer num2) {
return num2.compareTo(num1);
}
});
this.large = new PriorityQueue<Integer>(new Comparator<Integer>() {
public int compare(Integer num1, Integer num2) {
return num1.compareTo(num2);
}
});
this.delayed = new HashMap<Integer, Integer>();
this.k = k;
this.smallSize = 0;
this.largeSize = 0;
}
public double getMedian() {
return (k & 1) == 1 ? small.peek() : ((double) small.peek() + large.peek()) / 2;
}
public void insert(int num) {
if (small.isEmpty() || num <= small.peek()) {
small.offer(num);
++smallSize;
} else {
large.offer(num);
++largeSize;
}
makeBalance();
}
public void erase(int num) {
delayed.put(num, delayed.getOrDefault(num, 0) + 1);
if (num <= small.peek()) {
--smallSize;
if (num == small.peek()) {
prune(small);
}
} else {
--largeSize;
if (num == large.peek()) {
prune(large);
}
}
makeBalance();
}
// 不断地弹出 heap 的堆顶元素,并且更新哈希表
private void prune(PriorityQueue<Integer> heap) {
while (!heap.isEmpty()) {
int num = heap.peek();
if (delayed.containsKey(num)) {
delayed.put(num, delayed.get(num) - 1);
if (delayed.get(num) == 0) {
delayed.remove(num);
}
heap.poll();
} else {
break;
}
}
}
// 调整 small 和 large 中的元素个数,使得二者的元素个数满足要求
private void makeBalance() {
if (smallSize > largeSize + 1) {
// small 比 large 元素多 2 个
large.offer(small.poll());
--smallSize;
++largeSize;
// small 堆顶元素被移除,需要进行 prune
prune(small);
} else if (smallSize < largeSize) {
// large 比 small 元素多 1 个
small.offer(large.poll());
++smallSize;
--largeSize;
// large 堆顶元素被移除,需要进行 prune
prune(large);
}
}
}