經典Top-K問題最優解決辦法以及C++程式碼實現
問題描述:
Top-K問題是一個十分經典的問題,一般有以下兩種方式來描述問題:在10億的數字裡,找出其中最大的100個數;或者在一個包含n個整數的陣列中,找出最大的100個數。
前邊兩種問題描述稍有區別,但都是說的Top-K問題,前一種描述方式是說這裡也許沒有足夠的空間儲存大量的數字或其他東西,我們最好可以在一邊輸入資料,一邊求出結果,而不需要儲存資料;後一種說法則表示可以儲存資料,這種情況下,最簡單直觀的想法就是對陣列進行排序,取後100個數即為所求。
解法思想:
①首先說第一種情況的思路,這種情況下,關鍵在於不能消耗太大的記憶體,無法通過陣列的簡單排序來求取最大的K個數,於是我們應該想到堆排序,求最大的K個數,就採用大小為K的最小二叉堆來實現;我們知道二叉堆可以看作是一顆近似的完全二叉樹,其根節點正好就是K個數中最小的一個。
具體演算法:先輸入K個數,建立一個大小為K的最小二叉堆,接著每輸入一個數,與堆的根節點進行比較,如果比根節點還小,說明不可能為最大的K個數之一,如果比根節點大,那麼替換根節點的值,接著下沉根節點,維護二叉堆的性質。這樣到成功輸入所有資料後,最小二叉堆裡剩下的就為最大的K個數。(如果求最小的K個數,同理換成最大二叉堆即可)。
時間複雜度:由於演算法主要涉及對二叉堆結構的操作,所以總體時間複雜度為O(nlgK)。
②第二種情況,這種情況,由於可以操作儲存資料的陣列,所以我們採用排序的方式進行求解,但一般的排序時間複雜度也挺高,題目只求最大的K個數,不需要完全排序;於是我們想到可以借用快排思想來進行求解。
這個解法源於快排(Quick Sort),所以也叫Quick Select,主要基於快排中Partition函式(對堆排和快排不熟悉的可以參照演算法導論第6,7章)。
具體演算法:我們知道,每執行一次Partition函式都會確定一個數m的最終位置,且小於m的數均在其左邊,大於m的數都在其右邊,所以我們的目的就是當數m的右邊正好有K-1個數的時候停止演算法,得到答案。每次執行Partition函式時,根據前邊上述性質,若
- K<右邊陣列長度,那麼要找的K個數一定在m右邊,對m右邊的陣列執行Partition函式;
- K=右邊陣列長度+1,那麼正好找到最大的K個數,為數m以及其右邊陣列,停止演算法;
- 其他情況,最大的K個數不僅僅在m數右邊陣列中,對m左邊陣列執行Partition函式。
時間複雜度:與快排類似,Quick Select同樣也是不穩定的演算法,最壞情況下時間複雜度會達到O(n2),但平均效能也與快排類似,時間複雜度一般可認為為O(n)。
兩種情況的具體C++程式碼實現如下:
/*第一種情況——二叉堆C++程式碼實現*/
/*
*程式碼採用STL中的最小優先佇列實現,由於STL中自帶最小優先佇列,其底層就是二叉堆實現,
*所以就不再手寫二叉堆了。最小優先佇列頂層元素總是佇列中最小的元素,也就是二叉堆堆頂。
*/
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
/*由於STL自帶優先佇列是預設最大優先的,所以自己寫了一個比較函式,將其改為最小優先*/
struct cmp1 {
bool operator ()(int &a, int &b) {
return a>b; //最小值優先
}
};
int main() {
//這裡用來測試,輸入格式:先輸入需要求的最大K個數中的K值,再依次輸入資料流
int K = 0;
cin >> K;
int tmp = 0;
int i = 0;
priority_queue<int,vector<int>,cmp1> minHeap; //建立最小優先佇列
while (cin >> tmp) { //迴圈輸入資料流
if (i < K) { //先建立一個K個大小的優先佇列,也就是K大小的二叉堆
minHeap.push(tmp);
}
else { //演算法實現
if (tmp <= minHeap.top())
continue;
else if (tmp > minHeap.top()) {
minHeap.pop();
minHeap.push(tmp);
}
}
i++;
}
while (!minHeap.empty()) { //輸出最大的K個數
cout << minHeap.top() << endl;
minHeap.pop();
}
return 0;
}
/*第二種情況——Quick Select C++程式碼實現*/
/*Quick Select*/
#include <iostream>
#include <vector>
using namespace std;
int Partition(vector<int> &vec, int p, int r) { //實現快排中Partition函式,輸入原陣列引用,以及需要執行的左右下標
if (p >= r) //非法輸入,Partition具體思想參照快排詳解
return r;
int tmp = vec[r];
int i = p;
int j = p;
while (i < r) {
if (vec[i] <= tmp) {
int temp = vec[i];
vec[i] = vec[j];
vec[j] = temp;
i++;
j++;
}
else if (vec[i] > tmp) {
i++;
}
}
vec[r] = vec[j];
vec[j] = tmp;
return j;
}
int main() {
int K = 0; //測試部分,輸入需要求的K值大小,然後再依次輸入陣列元素
cin >> K;
int tmp = 0;
vector<int> vec;
while (cin >> tmp)
vec.push_back(tmp);
int size = vec.size();
if (size == 0 || k>size) return vector<int>();
if (size== k) return input;
int p = 0;
int r = vec.size() - 1;
int index = Partition(vec, p, r);
while (index != size - K) { //當Partition返回值及右邊部分不是K大小時,繼續迴圈
int sizeOfRight = size - index - 1; //記錄index右邊陣列長度大小
if (K <= sizeOfRight) {
index = Partition(vec, index + 1, r);
}
else if (K == sizeOfRight + 1) //這一步好像有點多餘,while迴圈保證了這點,但為了對應部落格文字描述就加上了
continue;
else if (K > sizeOfRight + 1) {
index = Partition(vec, p, index - 1);
}
}
for (int i = index; i < size; i++) { //測試部分,輸出需要求的K個數
cout << vec[i] << endl;
}
return 0;
}