TopK问题

913 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第18天,点击查看活动详情

topK

topK没有第0大,都是第一大或者第一小开始。 第一小/大就是对应有序数组的arr[0]/arr[N-1]

最小的k个数

基于快排的方法

参考quicksort,先将数组排序,再取出前k个数。这个方法是就地执行,会改变数组本身。

注意:选择第K小的元素,使用快排的选择算法,而这道题是求取最小的k个数,需要先对他们进行排序。

 class Solution {
 public:
     vector<int> getLeastNumbers(vector<int>& arr, int k) {
         if(arr.empty() || k<=0 || arr.size() < k) 
             return resultSet_;
          
         for(int lo=0, hi=arr.size()-1;lo < hi;) {  
             int L=lo, R=hi;
             int pivot = arr[lo];
 ​
             while(L < R) { 
                 while(L < R && pivot <= arr[R]) --R; arr[L] = arr[R]; //将大于轴点数子换到左侧
                 while(L < R && arr[L]<= pivot)  ++L; arr[R] = arr[L];
             } //L == R
             // 每次经过一轮循环,pivot左侧的元素都不大于它,右侧的元素都不小于它
             arr[L] = pivot;
             
             if(L <= k) lo = L+1; // 进入右侧
             if(L >= k) hi = L-1; // 进入左侧
         } // lo == hi 时 lo <=k<=hi,因此 lo == hi==k
 ​
         while(k) 
         { 
             --k;
             resultSet_.push_back(arr[k]);
         }
 ​
         return resultSet_;
     }
 ​
 private:
     std::vector<int> resultSet_;
 };

基于最大堆的方法

维持一个大小为k的大顶堆,使得堆顶元素最大。元素个数不足k就直接放入堆中,超过k个元素,和堆顶元素进行比较,小于堆顶就弹堆顶,放入当前元素。

时间复杂度是O(n*logk),空间复杂度是O(k)

这个优点在于:不用一次性的将全部数据加载进入内存,适合海量数据。

 class Solution {
 public:
     vector<int> getLeastNumbers(vector<int>& arr, int k) {
         if(arr.empty() || k<=0 || arr.size() < k) return std::vector<int>{ };
         std::vector<int> resultSet_(k);
         std::priority_queue<int> big_;  // 优先级队列
         
         for(int& num : arr) { 
             if(big_.size() < k) { 
                 big_.push(num);
             }
             else { 
             // 满了 
             // 将大的元素取出来,将小的元素压入
                 if(big_.top() > num) { 
                     big_.pop();
                     big_.push(num);
                 }
             }
         }
         
         while(k) { 
             resultSet_[k-1] =big_.top();
             big_.pop();
             
             --k;
         }
             
         return resultSet_;
     }
 };
 ​

上面这两种解法各有优缺点, 各自适用于不同的场合, 因此应聘者仵动手做题之前要先问消楚题目的要求, 包括输入的数据揽有多大、能否-次性载入内存、是否允许交换输入数据中数字的顺序等

两个有序数组的中位数

 给定两个大小为 m 和 n 的有序数组 nums1 和 nums2。请你找出这两个有序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
 ​
 你可以假设 nums1 和 nums2 不会同时为空。 

topK

利用求topK问题的思路。假设有数据:

 A: 1, 3, 4, 9
 B: 1, 2, 3, 4, 5, 6, 7, 8
 k = 7 

A的中位数是k/2=3。表示的是第3个,即下标为2 = k/2-1A[2] > B[2],因此B[2]前面面的元素不可能是中位数,比如B[1]最多也只是第4大。 而A中都是有可能的。比如A[0]只要比B[5]大,那么就是第7大,A[0]只要比B[2]大,那么A[3]就是第7大。下一次比较:

 A: 1, 3, 4, 9
 B: 4, 5, 6, 7, 8

引理

一般地, 两个有序数组 A[0], A[1], A[2] ... A[k/2-1], A[k/2] ... A[n]B[0], B[1], B[2] ... B[k/2-1], B[k/2]...B[m] 。如果 A[k/2-1] < B[k/2-1] ,那么 A[0], A[1], A[2] ... A[k/2-1] 都不可能是第 k 小的数字。

A 数组中比 A[k/2-1] 小的数有 k/2-1个。B数组中,B[k/2-1]前面有k/2-1。在最好的情况下B[k/2-1]前面最大的一个元素B[k/2-2] < A[0],那么此时A[k/2-1]也只是第k/2-1 + k/2小,即最好的情况下,A[k/2-1]也是只是第k-1小。因此 A[k/2-1] 前面的元素都不可能满足条件。

因此,根据这个结论,可以在每次从两个有序数组中选出中位数,且比较出大小后,较小的中位数及其所在数组前面的部分都可以抛弃(减而治之)。比如,上面A[0] ~ A[k/2-1]都可以抛弃。直接从A[k/2]再次寻找。此时k将会减少k/2,即从剩余的数组中查找 k = k - k/2 小的元素。

  • A[k/2-1] = B[k/2-1]时,就可以直接返回了,此时两个数就是原问题的第K小的数。

  • 某个数组到头了。比如A序列到达A[n],而B还没到达B[n]

    如果A[n] < B[k/2-1],那么根据上面的结论,A[n]及其前面可以全部抛弃,A剩余长度就是0。然后 k = k - k/2 那么最终的结果肯定就是在B中,此时可以直接访问得到B得到。 如果A[n] > B[k/2-1],自然抛弃B[k/2]前面的所有元素。下次对比时:

       A: A[n]
       B: B[k/2], ..., B[m]
    

    注意:每次k在抛弃一段之后都是会减少一半的。

根据topK求中位数

有了求 topK 的算法。可以利用 topk 来求解中位数,第k小的元素对应的下标是k-1

  • 当两个数组长度和是奇数时, k = length /2 + 1 =(2 * n + 1) /2 + 1 = n + 1

  • 当两个数组长度和是偶数时, k 是上中位数和下中位数和的平均数: 上中位数:left = length /2 = n 下中位数:right = length /2 +1 = n +1;

    为统一这两个写法:

    • left = (length +1) / 2
    • right = (length +2) / 2

代码实现

 class Solution {
 public:
     double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
 ​
         int n = nums1.size(); 
         int m = nums2.size(); 
 ​
         int length = n + m;
         int left  = (length +1) /2;
         int right = (length +2) /2;   
 ​
       return  static_cast<double>(__getTopK(nums1, 0, n, nums2, 0, m, left) + 
                                   __getTopK(nums1, 0, n, nums2, 0, m, right)) /2;
     }
 ​
     int __getTopK(std::vector<int>& nums1, int s1, int e1, 
                   std::vector<int>& nums2, int s2, int e2, 
                   int k) 
     { 
       int len1 = e1 - s1, len2 = e2- s2;
 ​
       if(len1 ==0) { return nums2[s2 +k-1]; }
       if(len2 ==0) { return nums1[s1 +k-1]; }
 ​
       if(k==1) return std::min(nums1[s1], nums2[s2]);
 ​
       // 求取此时的中点索引
       // 有可能 s1 + k/2直接越界了,因此要么到达 nums1 的中点,要么到达 nums1 的终点
       int i = s1 + std::min(len1, k/2)-1; 
       int j = s2 + std::min(len2, k/2)-1;
       
       int result = 0;
       
       if(nums1[i] < nums2[j]) 
       { 
         // 要抛弃的长度就是 [s1, i], 因此 k - (i+1 -s1)
         result =__getTopK(nums1, i+1, e1, nums2, s2, e2, k - (i+1 - s1)); 
       }
       else 
       {
         result = __getTopK(nums1, s1, e1, nums2, j+1, e2, k-(j+1 - s2));
       }
 ​
       return result;
     }
 };

无序数据流的中位数

 题目描述:
 ​
 如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。

分析

整个数据可以看作被中位数分为两部分:左边的不大于中位数,右边的不小于中位数。用一个数据结构保存中位数两边的数据,使得左边的数据都小于右边的数据

大顶堆和小顶堆:因为大顶堆头部最大,尾部最小,因为可以让小于中位数的存在大顶堆,反之大于中位数的位于小顶堆。使得数据流中的数据均匀分布在两个堆中。

问题是怎么分布均匀,两队数据个数之差不超过1? 让数据流中的数,先进入大顶堆,然后将大顶堆首部的元素移到小顶堆,就能使得左边的永远不大于右边的。如果导致小顶堆元素个数多于大顶堆,就从小顶堆顶取出一个给大顶堆,仍保持总体的有序性。

  • 如果总数个数是奇数,大顶堆个数是m,那么小顶堆个数是m-1。大顶堆顶即中位数。
  • 如果总数个数是偶数,大顶堆个数是m,那么小顶堆个数是m。两堆顶和的平均数就是中位数。

代码实现

class MedianFinder {
public:    
    void addNum(int num) {
        //每次都先加入大顶堆
        big.push(num);

        small.push(big.top());
        big.pop();

        if(big.size() < small.size()) {  
            big.push(small.top());
            small.pop();
        }
    }
    
    double findMedian() {
        return big.size() > small.size() ? 
                    static_cast<double>(big.top()) :
                    static_cast<double>(big.top() + small.top()) /2 ;
    }
private:
    std::priority_queue<int> big;
    std::priority_queue<int, std::vector<int>, std::greater<int>> small;
};

前k个高频数

还是利用堆实现:达到 O(n*log(k)) 的时间复杂度,并在空间复杂度为O(n),因为还有个map使用了空间。

比较无语的是比较器:先比较频率,频率相同时,字符小的优先级别高。

 class Solution {
 public:
     typedef std::vector<std::string> vectorStr;
 ​
     vectorStr topKFrequent(vectorStr& words, int k) {
         if(words.empty() || k<=0) return resultSet_; 
        
         for(const auto& word : words) ++map_[word];
 ​
         for(const auto& entry: map_) { 
             if(heap_.size() < k) 
             { 
               heap_.push({entry.second, entry.first});
             }
             else 
             {   
               if(entry.second < heap_.top().first || 
                 (entry.second == heap_.top().first && entry.first > heap_.top().second)) 
                 continue;
 ​
               heap_.pop();
               heap_.push({entry.second, entry.first});
             }
         }
 ​
         resultSet_.resize(k);
         while(k--) 
         { 
             resultSet_[k] = heap_.top().second;
             heap_.pop();
         }
         return resultSet_;
     }
 private:
     struct Comparator { 
       bool operator()(const std::pair<int, std::string>& lhs, 
                       const std::pair<int, std::string>& rhs) const
       { 
         return lhs.first == rhs.first ? 
                lhs.second < rhs.second:   // 频率一致,字母顺序小的 优先级更高
                lhs.first  > rhs.first;    // 先按照频率比较
       }  
    };
 ​
     std::priority_queue<std::pair<int, std::string>,
                         std::vector<std::pair<int, std::string>>,
                         Comparator> heap_;
     std::unordered_map<std::string, int> map_;
     vectorStr resultSet_;
 };