295. 数据流的中位数 - 力扣(LeetCode)
都是数据流,数据动态更新,意味着如果使用排序的话,每次插入一个数据都要重新排序,显然是不可接受的。那这题其实也是堆的应用。
先说一下整体思路:
我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。假设有 n 个数据:
如果n 是偶数,从小到大排序后的前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中。这样,要找的中位数就是两个对顶元素的均值。
如果 n 是奇数,大顶堆就存储 n/2+1 个数据,小顶堆中就存储 n/2 个数据,中位数就是大顶堆的堆顶元素。
也就是大顶堆允许存储的元素最多比小顶堆多一个。
因为我们的两个堆一开始就是空的,所以第一个数据直接插入大顶堆,后面的数据,如果小于等于大顶堆的堆顶元素,就插入到大顶堆;否则,插入到小顶堆。
那么在数据插入之后,两个堆中的元素个数可能就不满足要求了,所以要根据元素总数的奇偶进行调整:从一个堆中将堆顶元素移动到另一个堆。
好吧,整体思路其实挺简单的:
class MedianFinder {
public:
/** initialize your data structure here. */
MedianFinder():count(0){}
void adjust(){
if(count % 2){//count为奇数,大顶堆元素个数count/2 + 1,小顶堆元素个数count/2
if(Maxq.size() > count/2+1){
Minq.push(Maxq.top());
Maxq.pop();
}else if(Maxq.size() < count/2+1){
Maxq.push(Minq.top());
Minq.pop();
}
}else{//count为偶数,大、小顶堆内元素个数各为count/2
if(Maxq.size() > count/2){
Minq.push(Maxq.top());
Maxq.pop();
}else if(Maxq.size() < count/2){
Maxq.push(Minq.top());
Minq.pop();
}
}
}
void addNum(int num) {
if(Maxq.empty() || num <= Maxq.top()) Maxq.push(num);
else Minq.push(num);
++count;
adjust();
}
double findMedian() {
if(count % 2) return Maxq.top();
return (Maxq.top() + Minq.top())/2.0;
}
private:
int count;//记录数据流中的总数据个数
priority_queue, less> Maxq;//大顶堆
priority_queue, greater> Minq;//小顶堆
};
可以发现adjust函数处的最外层if-else
很相似,可以做一下合并:
class MedianFinder {
public:
MedianFinder():count(0){}
void adjust(){
auto tmp = count % 2;
if(Maxq.size() > count/2+tmp){
Minq.push(Maxq.top());
Maxq.pop();
}else if(Maxq.size() < count/2+tmp){
Maxq.push(Minq.top());
Minq.pop();
}
}
void addNum(int num) {
if(Maxq.empty() || num <= Maxq.top()) Maxq.push(num);
else Minq.push(num);
++count;
adjust();
}
double findMedian() {
if(count % 2) return Maxq.top();
return (Maxq.top() + Minq.top())/2.0;
}
private:
int count;//记录数据流中的总数据个数
priority_queue, less> Maxq;//大顶堆
priority_queue, greater> Minq;//小顶堆
};
那么我们获取中位数的时间复杂度是O(1)的,而插入数据时维护两个堆的时间复杂度则是O(logn)的,n即为插入时的元素数据流中的元素个数。
另外官方题解里面数据流的中位数 - 数据流的中位数 - 力扣(LeetCode),关于两个堆的平衡策略,其实是不够好的(虽然更加简洁),但是有很多多余的push操作,简单说每次都插入大顶堆会导致大顶堆的不必要的堆化操作,而且说实话可读性稍弱。
普通排序复杂度必然是过高的,但是使用插入排序会有所好转,因为插入前原本的数据是有序的,我们只需要在有序序列中插入一个数据,然后将该插入点之后的数据统统往后移动一位就可以了。
至于插入点的选择,可以遍历,但是最好使用二分法: 二分查找(下),这样的话时间复杂度和空间复杂都是和维护两个堆的方法相当的。
结合泛型算法会更简单:
class MedianFinder {
public:
MedianFinder(){}
void addNum(int num) {
if(a.empty()) a.push_back(num);
//lower_bound:返回指向第一个不小于等于num的元素的迭代器
else a.insert(lower_bound(a.begin(), a.end(), num), num);
}
double findMedian() {
int n = a.size();
return n % 2 ? a[n/2] : (a[n/2 -1] + a[n/2])*0.5;
}
private:
vector a;
};
不过插入排序的整体效率是比不上第一种方式的。
emmm…
这个就单纯是官方题解里的思路了,一开始还真没想到。
知道平衡二叉搜索树具备快速插入删除,以及获取中位数的特点,但是官方题解说多数语言模拟这种行为的是 multiset 类,这个是学到了。
那使用multiset的话就会思路也很简单,因为中位数总是会在二叉树的根节点或者根节点的子树上(二叉搜索树的性质),那重点就在于我们如何定位到这个中位数。
题解的思路是,保持两个指针:一个用于中位数较低的元素,另一个用于中位数较高的元素。当元素总数为奇数时,两个指针都指向同一个中值元素(因为在本例中只有一个中值)。当元素数为偶数时,指针指向两个连续的元素,其平均值就是我们需要的中位数。
那么在插入数据的时候,需要怎样操作呢?
1、容器为空,只需插入 num 并设置两个指针指向这个元素。
2、容器当前包含奇数个元素。这意味着两个指针当前都指向同一个元素:
3、容器当前包含偶数个元素。这意味着指针当前指向连续的元素:
而中位数就是两个指针所指元素的平均值。
class MedianFinder {
public:
MedianFinder() : left(data.end()), right(data.end()){}
void addNum(int num) {
const size_t n = data.size();//插入之前的size
data.insert(num);
if(!n) left = right = data.begin();
else if(n % 2 ==1){ //奇数个元素,left,right指向同一个元素
if(num < *left) --left;
else ++right;
}else{ //偶数个元素,left, right指向不同元素(连续)
//插入之后两个指针指向同一个元素(总数变为奇数)
if(num > *left && num < *right) {
++left;--right;
}
else if(num >= *right) right = ++left; //确保指向同一个元素
else left = --right;
}
}
double findMedian() {
return (*left + *right)*0.5;
}
private:
multiset data;
multiset::iterator left, right;
};
换种写法,只用一个指针也行:
class MedianFinder {
public:
MedianFinder() : mid(data.end()) {}
//如果元素个数是奇数,则mid指向中间数
//如果为偶数,mid指向右中位数
void addNum(int num) {
const size_t n = data.size();//插入之前的size
data.insert(num);
if(!n) mid = data.begin();
else if(num < *mid){
mid = n%2 ? mid : prev(mid);
}else{
mid = n%2 ? next(mid) : mid;
}
}
double findMedian() {
const size_t n = data.size();
return (*mid + *next(mid, n % 2 - 1)) * 0.5;
}
private:
multiset data;
multiset::iterator mid;
};
不过此处需要注意,只有vector和deque插入可能会使得原有的迭代器失效,set,map,unordered_set,unordered_map以及multiset插入元素都不会使原有的迭代器失效。
1、如果数据流中所有整数都在 0 到 100 范围内,你将如何优化你的算法?
创建一个大小为101的数组,数组下标对应数字0~100,数组值全部初始化为0,插入元素时对应下标处加1,总数加1,g获取中位数时遍历该数组,一直计算前缀和直到到达总个数的一半,此时相应的下标就对应中位数了。
或者使用桶排序的思路也是可以的。
2、如果数据流中 99% 的整数都在 0 到 100 范围内,你将如何优化你的算法?
我理解的意思是中位数几乎肯定会落在0 ~ 100区间内部,那么我们只需要两个变量记录不在0 ~ 100区间的元素个数:一个记录小于0的,一个记录大于100的,再结合上一题的数组就能很轻松的寻找到中位数了。