数据流中的第 K 大元素
295. Find Median from Data Stream
这是一道经典的数据结构运用题。
在数据流中,数据会不断涌入结构中,那么也就面临着需要多次动态调整以获得中位数。 因此实现的数据结构需要既需要快速找到中位数,也需要做到快速调整。
首先能想到就是二叉搜索树,在平衡状态下,树顶必定是中间数,然后再根据长度的奇偶性决定是否取两个数。
此方法效率高,但是手动编写较费时费力。
对顶堆解法
根据只需获得中间数的想法,可以将数据分为左右两边,一边以最大堆的形式实现,可以快速获得左侧最大数, 另一边则以最小堆的形式实现。其中需要注意的一点就是左右侧数据的长度差不能超过1。 这种实现方式的效率与AVL平衡二叉搜索树的效率相近,但编写更快。
具体的,我们可以使用两个优先队列(堆)来维护整个数据流数据,令维护数据流左半边数据的优先队列(堆)为 l,维护数据流右半边数据的优先队列(堆)为r。
显然,为了可以在 \(O(1)\)的复杂度内取得当前中位数,我们应当令 l 为大根堆,r 为小根堆,并人为固定l和 r之前存在如下的大小关系:
当数据流元素数量为偶数:l和 r 大小相同,此时动态中位数为两者堆顶元素的平均值;
当数据流元素数量为奇数:l 比 r 多一,此时动态中位数为 l 的堆顶原数。
为了满足上述说的奇偶性堆大小关系,在进行 addNum 时,我们应当分情况处理:
- 插入前两者大小相同,说明插入前数据流元素个数为偶数,插入后变为奇数。我们期望操作完达到「
l的数量为r多一,同时双堆维持有序」,进一步分情况讨论:
- 如果
r为空,说明当前插入的是首个元素,直接添加到l即可; - 如果
r不为空,且num <= r.peek(),说明 num 的插入位置不会在后半部分(不会在 r 中),直接加到l即可; - 如果
r不为空,且num > r.peek(),说明 num 的插入位置在后半部分,此时将r的堆顶元素放到l中,再把 num 放到r(相当于从r中置换一位出来放到l中)。
- 插入前两者大小不同,说明前数据流元素个数为奇数,插入后变为偶数。我们期望操作完达到「
l和r数量相等,同时双堆维持有序」,进一步分情况讨论(此时 l 必然比 r 元素多一):
- 如果
num >= l.peek(),说明 num 的插入位置不会在前半部分(不会在 l 中),直接添加到r即可。 - 如果
num < l.peek(),说明 num 的插入位置在前半部分,此时将l的堆顶元素放到 r 中,再把 num 放入l中(相等于从l中替换一位出来当到r中)。
class MedianFinder {
public:
priority_queue<int, vector<int>, less<int>> queMin;//大顶堆
priority_queue<int, vector<int>, greater<int>> queMax;
MedianFinder() {}
void addNum(int num) {
if (queMin.empty() || num <= queMin.top()) {
//注意这里是queMin.top(),因为可能queMax()是空的,所以用大顶堆比较一样的!!!
queMin.push(num);
if (queMax.size() + 1 < queMin.size()) {
queMax.push(queMin.top());
queMin.pop();
}
} else {
queMax.push(num);
if (queMax.size() > queMin.size()) {
queMin.push(queMax.top());
queMax.pop();
}
}
}
double findMedian() {
if (queMin.size() > queMax.size()) {
return queMin.top();
}
return (queMin.top() + queMax.top()) / 2.0;
}
};Follow up:
If all integer numbers from the stream are in the range [0, 100], how would you optimize your solution?
可以使用建立长度为 101 的桶,每个桶分别统计每个数的出现次数,同时记录数据流中总的元素数量,每次查找中位数时,先计算出中位数是第几位,从前往后扫描所有的桶得到答案。
这种做法相比于对顶堆做法,计算量上没有优势,更多的是空间上的优化。
对顶堆解法两个操作中耗时操作复杂度为 \(O(\log{n})\),\(\log\)操作常数不会超过 3,在极限数据 \(10^7\)情况下计算量仍然低于耗时操作复杂度为 \(O(C)\)(C 固定为 101)桶计数解法。
If 99% of all integer numbers from the stream are in the range [0, 100], how would you optimize your solution?
和上一问解法类似,对于 1% 采用哨兵机制进行解决即可,在常规的最小桶和最大桶两侧分别维护一个有序序列,即建立一个代表负无穷和正无穷的桶。
上述两个进阶问题的代码如下,但注意由于真实样例的数据分布不是进阶所描述的那样(不是绝大多数都在 [0,100]范围内),所以会 TLE。
class MedianFinder {
TreeMap<Integer, Integer> head = new TreeMap<>(), tail = new TreeMap<>();
int[] arr = new int[101];
int a, b, c;
public void addNum(int num) {
if (num >= 0 && num <= 100) {
arr[num]++;
b++;
} else if (num < 0) {
head.put(num, head.getOrDefault(num, 0) + 1);
a++;
} else if (num > 100) {
tail.put(num, tail.getOrDefault(num, 0) + 1);
c++;
}
}
public double findMedian() {
int size = a + b + c;
if (size % 2 == 0) return (find(size / 2) + find(size / 2 + 1)) / 2.0;
return find(size / 2 + 1);
}
int find(int n) {
if (n <= a) {
for (int num : head.keySet()) {
n -= head.get(num);
if (n <= 0) return num;
}
} else if (n <= a + b) {
n -= a;
for (int i = 0; i <= 100; i++) {
n -= arr[i];
if (n <= 0) return i;
}
} else {
n -= a + b;
for (int num : tail.keySet()) {
n -= tail.get(num);
if (n <= 0) return num;
}
}
return -1; // never
}
}