1. 题目来源
链接:数据流中的中位数
来源:LeetCode——《剑指-Offer》专项
2. 题目说明
如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。
例如,
[2,3,4]
的中位数是 3
[2,3]
的中位数是 (2 + 3) / 2 = 2.5
设计一个支持以下两种操作的数据结构:
void addNum(int num)
从数据流中添加一个整数到数据结构中。double findMedian()
返回目前所有元素的中位数。
示例 1:
输入:
[“MedianFinder”,“addNum”,“addNum”,“findMedian”,“addNum”,“findMedian”]
[[],[1],[2],[],[3],[]]
输出:[null,null,null,1.50000,null,2.00000]
示例 2:
输入:
[“MedianFinder”,“addNum”,“findMedian”,“addNum”,“findMedian”]
[[],[2],[],[3],[]]
输出:[null,null,2.00000,null,2.50000]
限制:
- 最多会对
addNum
、findMedia
进行 50000 次调用。
3. 题目解析
方法一:最大/小堆+泛型算法+过程化思考+顶级解法
这是一道 Hard
问题,一开始感觉很简单啊,不就是求个中位数吗,事实上…一言难尽…
首先问题难点主要在数据是从一个数据流中读出来的,所以数据的数目随着函数调用次数而增加。所以解决问题的过程就是求取 动态中位数
的过程。有以下几个备选数据结构:
- 无序数组:如果数组没有排序,采用上篇博文基于快排思想的
Partition
函数找出数组的中位数,插入时间复杂度为 O ( 1 ) O(1) O(1),在无序数组中找中位数的时间复杂度为 O ( n ) O(n) O(n) - 有序数组:插入数据时让数组保持有序,即最坏情况下需要移动 O ( n ) O(n) O(n) 个数,故需要 O ( n ) O(n) O(n) 的时间复杂度用来插入,但是只需要 O ( 1 ) O(1) O(1) 的时间复杂度完成中位数查找
- 排序链表:思想及时间复杂度类似于有序数组,确定中位数位置时可以定义两个指针指向链表中间的节点,如果链表的节点数目为奇数,那么这两个指针指向同一个节点,这样得到中位数的时间复杂度达到 O ( 1 ) O(1) O(1)
BST
树:采用BST
树可将插入数据的平均时间下降到 O ( l o g n ) O(logn) O(logn)。但是,仍然在最坏情况下将退化为单支树,使得树形结构极度不平衡。故插入时间复杂度仍为 O ( n ) O(n) O(n),为了得到中位数需要在二叉树节点中添加一个表示子树节点数目的字段,有这个字段便能够在平均 O ( l o g n ) O(logn) O(logn) 的时间得到中位数,但是最差情况下仍需要 O ( n ) O(n) O(n) 时间AVL
树:对上面的BST
树做进化的话就是AVL
树了。将AVL
左右平衡因子改为左右子树节点数目之差,可在 O ( l o g n ) O(logn) O(logn) 时间往AVL
树中添加一个新节点,同时 O ( 1 ) O(1) O(1) 时间得到所有节点的中位数
但是 AVL
树并没有现成能用的版本,而自己在现场进行实现又过于繁杂,所有不得不再找其它方法。
在《剑指-Offer》中给出了十分精巧的算法思想,如下所示:
- 如果数据在容器中已经排序,那么中位数可以由
P1
和P2
指向的数得到。如果容器中数据的数目是奇数,那么P1
和P2
指向同一个数据。
我们注意到整个数据容器被分隔成了两部分。位于容器左边部分的数据比右边的数据小。另外, P1
指向的数据是左边部分最大的数,P2
指向的数据是左边部分最小的数。
如果能够保证数据容器左边的数据都小于右边的数据,这样即使左、右两边内部的数据没有排序,也可以 根据左边最大的数及右边最小的数得到中位数。如何快速从一 一个数据容器中找出最大数? 用最大堆实现这个数据容器,因为位于堆顶的就是最大的数据。同样,也可以快速从最小堆中找出最小数。
往堆中插入一个数据的时间复杂度为 O ( l o g n ) O(logn) O(logn),得到中位数的时间效率为 O ( 1 ) O(1) O(1)。
下图是《剑指-Offer》给出的这几种方法的复杂度对比:
问题到这已经确定了数据结构,并且达到了较优的时间复杂度,但是仍有细节需要讨论:
-
首先要保证数据平均分配到两个堆中,因此两个堆中数据的数目之差不能超过1。
- 为了实现平均分配,可以在数据的总数目是偶数时把新数据插入到最小堆中,否则插入
到最大堆中。.
- 为了实现平均分配,可以在数据的总数目是偶数时把新数据插入到最小堆中,否则插入
-
还要保证最大堆中里的所有数据都要小于最小堆中的数据。当数据的总数目是偶数时,按照前面的分配规则会把新的数据插入到最小堆中。如
果此时这个新的数据比最大堆中的一一些数据要小,怎么办呢?- 可以先把这个新的数据插入到最大堆中,接着把最大堆中的最大的数字拿出来插入到最小堆中。由于最终插入到最小堆的数字是原最大堆中最大的数字,这样就保证了最小堆中所有数字都大于最大堆中的数字。当需要把一个数据插入到最大堆中,但这个数据小于最小堆里的一些数据时,这个情形和前面类似,请大家自己分析。
采用 STL
泛型算法中的函数 push_heap pop_heap
及 vector
快速实现堆,并利用比较仿函数 less
和 greater
分别用来实现最大堆和最小堆。
优先队列实现堆也能做,题解区大佬还说可参考 设计模式 - 桥接模式 。
参见代码如下:
// 执行用时 :124 ms, 在所有 C++ 提交中击败了96.61%的用户
// 内存消耗 :45 MB, 在所有 C++ 提交中击败了100.00%的用户
class MedianFinder {
public:
/** initialize your data structure here. */
MedianFinder() {
}
void addNum(int num) {
if (((min.size() + max.size()) & 1) == 0) {
if (max.size() > 0 and num < max[0]) {
max.push_back(num);
push_heap(max.begin(), max.end(), less<double>());
num = max[0];
pop_heap(max.begin(), max.end(), less<double>());
max.pop_back();
}
min.push_back(num);
push_heap(min.begin(), min.end(), greater<double>());
}
else {
if (min.size() > 0 and min[0] < num) {
min.push_back(num);
push_heap(min.begin(), min.end(), greater<double>());
num = min[0];
pop_heap(min.begin(), min.end(), greater<double>());
min.pop_back();
}
max.push_back(num);
push_heap(max.begin(), max.end(), less<double>());
}
}
double findMedian() {
int size = min.size() + max.size();
if (size == 0) return 0;
double mid = 0;
if ((size & 1) == 1) mid = min[0];
else mid = (min[0] + max[0]) / 2;
return mid;
}
private:
vector<double> min;
vector<double> max;
};
/**
* Your MedianFinder object will be instantiated and called as such:
* MedianFinder* obj = new MedianFinder();
* obj->addNum(num);
* double param_2 = obj->findMedian();
*/