TOP K問題的解決方案

1.1 代碼實現

1.2 複雜度分析

2.快速排序法

2.1 代碼實現

2.2 複雜度分析

3.堆排序法

3.1 代碼實現

3.2 複雜度分析

4. 方法比較


       Top K是很常見的一種問題,是指在N個數的無序序列中找出最大的K個數,而其中的N往往都特別大,對於這種問題,最容易想到的辦法當然就是先對其進行排序,然後直接取出最大的K的元素就行了,但是這種方法往往是不可靠的,不僅時間效率低而且空間開銷大,排序是對所有數都要進行排序,而實際上,這類問題只關心最大的K個數,並不關心序列是否有序,因此,排序實際上是浪費了的很多資源都是沒必要的。本文主要介紹三種TopK算法:


1.類選擇排序法

       爲什麼叫類選擇排序法呢?因爲這種方法很像選擇排序,選擇排序是抽出序列中的最大或最小值放在一端,這裏也類似。算法思路:對目標序列N個數遍歷,取出其中最大的數最爲Top1;再次遍歷剩下的N-1個數,取出其中最大的數爲Top2;....再對剩下的N-K+1個數遍歷,取出其中最大的數爲TopK,這樣就可以找到最大的K個數了。

1.1 代碼實現

vector<int> TopKBySelect(vector<int>& nums,int k,int len)
{
    vector<int>res;
 
    vector<int>flag(len);
 
    for(int i=0;i<k;i++)
    {
        int maxIndex=0;   //保存最大數的索引
        int maxNum=nums[0];  //保存最大數
 
        for(int j=0;j<len;j++)
        {
            if(nums[j]>maxNum&&!flag[j])  //如果大於最大數並且沒有被取出來過
            {
                maxNum=nums[j];
                maxIndex=j;
 
            }
        }
        flag[maxIndex]=-1;    //將此次遍歷的最大數索引標記爲-1,放置再次被取出
        res.push_back(maxNum);  //存入該最大數
    }
 
    return res;
}

1.2 複雜度分析

        時間複雜度方面,要求TopK就需要進行K次遍歷,然後取出其中最大的數,因此算法平均時間複雜度爲O(N*K);

        空間複雜度方面,可以看到這種方法需要開闢一個輔助空間來對取出過的元素進行標記,因此空間複雜度爲O(N),除此之外,還需注意到的是,這種方法有效的前提是提前將所有數讀入,這樣如果一開始的N較大,那麼空間開銷是不可忽視的,而且,如果數據是動態的,即是可能會不停的增加新數據,那麼就還需要每插入一個新數據就將其與前面取出的TopK做比較,排除K+1個數中最小的,最後剩下的纔是TopK。


2.快速排序法

       快速排序法的原理這就不多說了,可見https://blog.csdn.net/qq_28114615/article/details/86064412

       在快速排序中,每一輪排序都會將序列一分爲二,左子區間的數都小於基準數,右子區間的數都大於基準數,而快速排序用來解決TopK問題,也是基於此的。N個數經過一輪快速排序後,如果基準數的位置被換到了i,那麼區間[0,N-1]就被分爲了[0,i-1]和[i+1,N-1],這也就是說,此時有N-1-i個數比基準數大,i個數比基準數小,假設N-1-i=X那麼就會有以下幾種情況:

①X=K。這種情況說明比基準數大的有K個,其他的都比基準數小,那麼就說明這K個比基準數大的數就是TopK了;

②X<K。這種情況說明比基準數大的數不到K個,但是這X肯定是屬於TopK中的TopX,而剩下的K-X就在[0,i]之間,此時就應當在[0,i]中找到Top(K-X),這就轉換爲了TopK的子問題,可以選擇用遞歸解決;

③X>K。這種情況說明比基準數大的數超過了K個,那麼就說明TopK必定位於[i+1,N-1]中,此時就應當繼續在[i+1,N-1]找TopK,這樣又成了TopK的一個子問題,也可以選擇用遞歸解決。

2.1 代碼實現

int getIndex(vector<int>& nums,int left,int right)  //快排獲取相遇點(基準數被交換後的位置)
{
    int base=nums[left];
    int start=left;
    while(left<right)
    {
        while(left<right&&nums[right]>=base)right--;
        while(left<right&&nums[left]<=base)left++;
 
        int temp=nums[right];
        nums[right]=nums[left];
        nums[left]=temp;
    }
 
    nums[start]=nums[left];
    nums[left]=base;
 
    return left;
}
int findTopKthIndex(vector<int>&nums,int k,int left,int right)
{
    int index=getIndex(nums,left,right);    //獲取基準數位置
 
    int NumOverBase=right-index;  //比基準數大的數的個數
 
    if(NumOverBase==k)return index;  //比基準數大的剛好有K個
 
    //比基準數大的多於K個,就在右邊子區間尋找TopK
    else if(NumOverBase>k)return findTopKthIndex(nums,k,index+1,right);
 
    //比基準數大的少於K個,就在左邊找剩下的
    return findTopKthIndex(nums,k-NumOverBase,left,index);
 
}
vector<int> TopKInQuick(vector<int>& nums,int k,int len)
{
    if(len==k)return nums;
 
    vector<int>res;
    vector<int>temp(nums.begin(),nums.end());  //TopK不對原數組改變
 
    int index=findTopKthIndex(temp,k,0,len-1);  //通過快排找到第K+1大的數的位置
 
    for(int i=len-1;i>index;i--)res.push_back(temp[i]);  //取出TopK返回
 
    return res;
}

2.2 複雜度分析

        這種方法是利用了快速排序中找分割點的方法,每次分割後的數組大小近似爲原數組大小的一半,因此這種方法的時間複雜度實際上是O(N)+O(N/2)+O(N/4)+……<O(2N),因此時間複雜度爲O(N),時間複雜度雖然低,但是這種方法也需要提前將N個數讀入,空間開銷是一筆負擔,並且對於動態的數據放入也是比較“死板”的。


3.堆排序法

       堆排序的分析可見https://blog.csdn.net/qq_28114615/article/details/86154057

       堆排序是通過維護大頂堆或者小頂堆來實現的。堆排序法來解決N個數中的TopK的思路是:先隨機取出N個數中的K個數,將這N個數構造爲小頂堆,那麼堆頂的數肯定就是這K個數中最小的數了,然後再將剩下的N-K個數與堆頂進行比較,如果大於堆頂,那麼說明該數有機會成爲TopK,就更新堆頂爲該數,此時由於小頂堆的性質可能被破壞,就還需要調整堆;否則說明這個數最多隻能成爲Top K+1 th,因此就不用管它。然後就將下一個數與當前堆頂的數作比較,根據大小關係如上面所述方法進行操作,知道N-K個數都遍歷完,此時還在堆中的K個數就是TopK了。

3.1 代碼實現

void adjustMinHeap(vector<int>& nums,int root,int len) //小頂堆結點調整
{
    int lch=2*root+1;  //左子結點
    int rch=lch+1;   //右子結點
    int index=root;  //較大結點
 
    if(rch<len&&nums[rch]<nums[index])index=rch; 
 
    if(lch<len&&nums[lch]<nums[index])index=lch;
 
    if(index!=root) //當前結點非最小結點
    {
        swap(nums[index],nums[root]);
        adjustMinHeap(nums,index,len);
    }
    return;
}
 
vector<int> TopKInHeap(vector<int>& nums,int k,int len)
{
    vector<int>res(nums.begin(),nums.begin()+k); //取出前k個數
 
    for(int i=k/2-1;i>=0;i--)  //根據前K個數建立一個小頂堆
    {
        adjustMinHeap(res,i,k);
    }
 
    //將剩下的數與堆頂做比較
    for(int i=k;i<len;i++)
    {
        if(nums[i]>res[0])  //當前數比堆頂數大
        {
            res[0]=nums[i]; //將堆頂更新爲該數
            adjustMinHeap(res,0,k); //重新調整堆
        }
    }
 
    return res;
}

3.2 複雜度分析

       根據堆排序的複雜度,不難得出,在該方法中,首先需要對K個元素進行建堆,時間複雜度爲O(K);然後對剩下的N-K個數對堆頂進行比較及更新,最好情況下當然是都不需要調整了,那麼時間複雜度就只是遍歷這N-K個數的O(N-K),這樣總體的時間複雜度就是O(N),而在最壞情況下,N-K個數都需要更新堆頂,每次調整堆的時間複雜度爲logK,因此此時時間複雜度就是NlogK了,總的時間複雜度就是O(K)+O(NlogK)≈O(NlogK)。空間複雜度是O(1)。值得注意的是,堆排序法提前只需讀入K個數據即可,可以實現來一個數據更新一次,能夠很好的實現數據動態讀入並找出TopK。


4. 方法比較

       綜合以上所說的,類選擇排序的平均時間複雜度爲O(N*K),快速排序的平均時間複雜度爲O(N),堆排序的平均時間複雜度爲O(NlogK),可以看出,快速排序的方法應當是最快的,堆排序的方法應當是優於類選擇排序的,實際上怎麼樣呢?我們來看一看:

N=50000,K=5,測試3次:

N=1000000,K=5,測試3次如下:

N=100000000,K=3,測試3次如下;

再測試一下K與logN接近的情況:

可以看到,和我們一開始的推測是有差別的,快速排序的方法並不是最快的,反而是堆排序的優勢更加明顯,這是爲什麼呢?

根據我的理解,在使用快速排序來查找TopN的過程中,每一次分割後都需要對一部分進行重新訪問,元素被訪問的步長並不都是1,並非一定是連續的,比如說前一次訪問到了最後一個元素,下一次訪問又要從分割點開始從頭訪問,這種訪問不連續的情況,就可能會影響到Cache的命中率,未命中的元素只能從效率更低的設備中獲取;而再看堆排序,在前K個元素建堆結束後,後面的元素都是按照地址連續訪問,這樣的訪問使得Cache的命中率比快速排序的Cache命中率更高,而本身當K比較小的時候O(NlogK)和O(N)的差距就不是很大,因此在這種情況下訪問Cache的命中率反而使得快速排序的效率更加低下,堆排序的方法效率更高。

不過這並不是絕對的,當數據量較小的情況下,快速排序方法的效率還是會稍優於堆排序方法的,尤其是K較大的情況下;但是如果數據量較大,這個時候不僅要考慮Cache的命中率問題,還要考慮內存大小的問題,而堆排序的方法很明顯可以支持“動態讀入”數據,因此不必一次性將所有數據都讀入,內存問題則迎刃而解,而快速排序方法和類選擇排序的方法卻都需要將所有數據一次性讀入,這樣顯然是不行的。

那這是否意味着數據量大的情況下堆排序就一定是最好的方法呢?數據量小的情況下快速排序的方法就是最好的方法呢?這都並不一定,比如說,數據量大的情況下可以多臺電腦分開計算,然後再合到一起,這樣的方法並不一定就比堆排序方法差;數據量小的情況,如果N個數據的範圍爲0~M,M的大小不算很大,那麼還可以用桶排序的方法來對每個數據進行計數,然後再從高到低將TopK倒出來,這種方法的複雜度僅僅爲O(N+K),這能說它就比快速排序的方法差嗎?當然不一定了。

當然,TopN的解決方案不僅僅限於文中幾種,但是算法不分好壞,不管採用什麼方法,還是應當結合各種算法的特點以及實際應用環境來選擇最合適的方法,而不是最好的方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章