LeetCode第 295 题:数据流中的中位数(C++)

295. 数据流的中位数 - 力扣(LeetCode)

文章目录

      • 两个堆
      • 插入排序
      • multiset和双指针
      • 进阶

和这题算是稍微有一点点类似吧: LeetCode第 703 题:数据流中的第K大元素(C++)

两个堆

都是数据流,数据动态更新,意味着如果使用排序的话,每次插入一个数据都要重新排序,显然是不可接受的。那这题其实也是堆的应用

先说一下整体思路:

我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。假设有 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;
};

不过插入排序的整体效率是比不上第一种方式的。

multiset和双指针

emmm…
这个就单纯是官方题解里的思路了,一开始还真没想到。

知道平衡二叉搜索树具备快速插入删除,以及获取中位数的特点,但是官方题解说多数语言模拟这种行为的是 multiset 类,这个是学到了。

那使用multiset的话就会思路也很简单,因为中位数总是会在二叉树的根节点或者根节点的子树上(二叉搜索树的性质),那重点就在于我们如何定位到这个中位数。

题解的思路是,保持两个指针:一个用于中位数较低的元素,另一个用于中位数较高的元素。当元素总数为奇数时,两个指针都指向同一个中值元素(因为在本例中只有一个中值)。当元素数为偶数时,指针指向两个连续的元素,其平均值就是我们需要的中位数。

那么在插入数据的时候,需要怎样操作呢?

1、容器为空,只需插入 num 并设置两个指针指向这个元素。

2、容器当前包含奇数个元素。这意味着两个指针当前都指向同一个元素:

  • 如果 num 不等于当前的中位数元素,则 num 将位于元素的任一侧。无论哪一边,该部分的大小都会增加,因此相应的指针会更新。例如,如果 num 小于中位数元素,则在插入 num 时,输入的较小半部分的大小将增加 1。
  • 如果 num 等于当前的中位数元素,那么所采取的操作取决于 num 是如何插入数据的。

3、容器当前包含偶数个元素。这意味着指针当前指向连续的元素:

  • 如果 num 是两个中值元素之间的数字,则 num 成为新的中值,两个指针都指向它。
  • 否则,num 会增加较小或较高的那部分的大小,相应地更新指针。必须记住,两个指针现在必须指向同一个元素(变为奇数)。

而中位数就是两个指针所指元素的平均值。

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的,再结合上一题的数组就能很轻松的寻找到中位数了。

你可能感兴趣的:(leetcode,leetcode)