- 問題一
- 解法一
- 解法二
- 解法三
- 解法四
- 解法五
- 解法六
- 解法七
- 解法八
- 問題二
- 寫在最後
問題一
在不同應用領域,經常涉及到top-K的問題,請給出不同策略在一系列數中返回Top-K元素,並分析你的策略。
解法一
我會排序!
考慮對給定序列進行排序,取前 \(k\) 大即可。
使用基於比較的排序演算法,時間複雜度 \(O(n\log n)\) 級別。
解法二
當 \(k\) 較小時,將整個序列排序有些太浪費了。
考慮進行 \(k\) 輪氣泡排序或選擇排序,僅使 \(k\) 大值有序即可。
時間複雜度 \(O(nk)\) 級別,當 \(k < n\) 時較優。
解法三
當序列中的元素均為整數且值域較小時,可以考慮進行計數排序,並在順序列舉值域時取列舉到的前 \(k\) 大即可。
若值域為 \(O(m)\) 級別,則時間複雜度為 \(O(n + m)\) 級別。
解法四
我學過快排!
考慮快排每輪進行分治時進行的操作:選擇基準元素,將小於/大於基準元素的元素分別劃分到基準元素的兩側。則此時根據基準元素左側的元素數量就可以得到基準元素的排名 \(r\):
- 若 \(r = k\),說明基準元素及其左側元素即為前 \(k\) 大元素,停止演算法。
- 若 \(r > k\),說明前 \(k\) 大元素均小於基準元素,問題轉化為求基準元素左側的所有元素的第 \(k\) 大,遞迴進行即可。
- 若 \(r < k\),說明不大於基準元素的均為前 \(k\) 大,可以直接加入答案中,問題轉化為求基準元素右側的所有元素的前 \(k - r\) 大值,遞迴進行即可。
比起分治更像是一種減治。
與快速排序複雜度分析類似地,若基於隨機選擇基準元素,上述演算法時間複雜度期望 \(O(n)\) 級別,但是時間複雜度上限為 \(O(n^2)\) 級別。
但需要注意與解法一二不同的是,此解法僅能將前 \(k\) 大值選出而不能保證前 \(k\) 大值的內部的有序性。
該演算法即為 C++ STL 中的 nth_elements
內部實現的大致思路,另外 nth_elements
在區間長度小於 3 時會轉化為插入排序,很酷!
//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
int a[kN];
//=============================================================
int kth_elements(int l_, int r_, int k_) {
int p = rand() % (r_ - l_ + 1) + l_, i = l_, j = l_;
std::swap(a[p], a[r_]);
while (j < r_) {
if (a[j] >= a[r_]) std::swap(a[i], a[j]), ++ i;
++ j;
}
std::swap(a[i], a[j]);
if (r_ == k_ + i - 1) return a[i];
if (r_ > k_ + i - 1) return kth_elements(i + 1, r_, k_);
return kth_elements(l_, i - 1, k_ - (r_ - i + 1));
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
srand(time(0));
int n, k; std::cin >> n >> k;
for (int i = 1; i <= n; ++ i) std::cin >> a[i];
int ak = kth_elements(1, n, k);
for (int i = 1; i <= k; ++ i) std::cout << a[i] << " ";
return 0;
}
解法五
我會分治!
考慮將序列分塊,每 \(m\) 個連續元素被分為一塊,則共有 \(O\left(\left\lceil \frac{n}{m} \right\rceil\right)\) 塊。
對於每塊套用解法四 \(O(m)\) 地求出其中的前 \(k\) 大值,然後對所有塊的有序的前 \(k\) 大值歸併即得整體的前 \(k\) 大值。
歸併的複雜度為 \(O(k)\) 級別,總時間複雜度 \(O\left(\left\lceil \frac{n}{m} \right\rceil\times m + k\right) = O(n)\) 級別。
解法四同階但是常數更大了上,感覺多此一舉!
但是注意到分解後的子問題可以分散式地執行後再合併,不缺算力的情況下推薦使用。
解法六
BFPRT 演算法,又稱中位數的中位數演算法,一種對解法四的最佳化,可保證複雜度上限為 \(O(n)\) 級別。
以發明者 Blum、Floyd、Pratt、Rivest、Tarjan 的首字母命名。怎麼都是熟人、、、又見到了我最喜歡的 LCT 的 Tarjan 大神怎麼哪都有你
為什麼解法四會被卡到 \(O(n^2)\) 級別?因為無法保證每次劃分選擇基準元素時均能選取到中位數,使基準元素兩側元素數量級不平衡,但是又會減治遞迴到元素數量較多一側,從而使最壞情況下遞迴次數變為 \(O(n)\) 級別。BFPRT 演算法在演算法四的基礎上,對選取基準元素的過程進行了最佳化,使得基準元素能更加接近中位數,從而避免了上述最壞情況的出現。
具體地,在選擇基準元素時,首先將整個序列每 5 個相鄰元素進行分塊,求得每塊中的中位數,再將求得的所有中位陣列成一個序列後,遞迴呼叫 BFPRT
演算法求該序列的中位數即為基準元素。
可以證明求得的基準元素一定被限制在整個序列的 \(30\% \sim 70\%\) 範圍內,避免了最壞情況的發生。
在找基準元素僅僅首先遍歷了整個序列,然後遞迴呼叫了 BFPRT
演算法,則時間複雜度不變仍為 \(O(n)\) 級別。透過遞迴分析可知演算法總時間複雜度為 \(O(n)\) 級別。
時間複雜度分析詳見:BFPRT——Top k問題的終極解法 - 知乎。
程式碼實現時考慮了重複出現的基準元素的影響。
//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
std::vector <int> a;
//=============================================================
int bfprt(std::vector <int> &a_, int l_, int r_, int k_);
int medianOfMedians(std::vector<int> &a_, int l_, int r_) {
int len = r_ - l_ + 1, bnum = len / 5 + (len % 5 > 0);
std::vector <int> temp1, temp2;
for (int i = 1; i <= bnum; ++ i) {
int bl = l_ + 5 * (i - 1), br = std::min(l_ + 5 * i - 1, r_), blen = br - bl + 1;
for (int j = bl, k = 1; j <= br; ++ j, ++ k) temp1.push_back(a_[j]);
std::sort(temp1.begin(), temp1.end());
temp2.push_back(temp1[blen / 2]);
}
std::sort(temp2.begin(), temp2.end());
return bfprt(temp2, 0, bnum - 1, bnum / 2);
}
std::vector<int> partition(std::vector <int> &a_, int l_, int r_, int pivot_) {
int greater = l_ - 1, equal = 0, temp;
for (int i = l_; i <= r_; ++ i) {
if (a_[i] > pivot_) {
++ greater;
temp = a_[greater], a_[greater] = a_[i];
if (equal > 0) {
a_[i] = a_[greater + equal];
a_[greater + equal] = temp;
} else {
a_[i] = temp;
}
} else if (a_[i] == pivot_) {
++ equal;
temp = a_[i];
a_[i] = a_[greater + equal];
a_[greater + equal] = temp;
}
}
return std::vector<int> {greater + 1, greater + equal};
}
int bfprt(std::vector <int> &a_, int l_, int r_, int k_) {
if (l_ == r_) return a_[l_];
int pivot = medianOfMedians(a_, l_, r_);
std::vector<int> p = partition(a_, l_, r_, pivot);
if (l_ + k_ >= p[0] && l_ + r_ <= p[1]) return a_[p[0]];
if (l_ + k_ < p[0]) return bfprt(a_, l_, p[0] - 1, k_);
return bfprt(a_, p[1] + 1, r_, k_ + l_ - p[1] - 1);
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
int n, k; std::cin >> n >> k;
for (int i = 1; i <= n; ++ i) {
int x; std::cin >> x;
a.push_back(x);
}
int ak = bfprt(a, 0, n - 1, k);
for (int i = 0; i < k; ++ i) std::cout << a[i] << " ";
return 0;
}
解法七
我會使用資料結構!
考慮使用支援求解最大元素的資料結構進行維護。
考慮在遍歷序列元素時維護一個元素個數上限為 \(k\) 的小根堆,當列舉到元素 \(a_i(1\le i\le n)\) 時:
- 若堆中元素個數不大於 \(k\),則直接入堆。
- 若堆中元素個數為 \(k\),則比較堆頂元素與 \(a_i\) 的大小關係,若 \(a_i\) 大於堆頂元素則令堆頂元素彈出,並將 \(a_i\) 入堆。
遍歷完成後堆中的 \(k\) 個元素即為序列的前 \(k\) 大,且透過不斷彈出直至堆空可使序列的前 \(k\) 大處於有序狀態。
堆中元素個數上限為 \(k\),則單次入堆/出堆操作的時間複雜度上限為 \(O(\log k)\) 級別,則總時間複雜度 \(O(n\log k)\) 級別,在能夠保證前 \(k\) 大有序的情況下,時間複雜度優於解法一二。
偷懶寫優先佇列哈哈
//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
int a[kN];
//=============================================================
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
int n, k; std::cin >> n >> k;
std::priority_queue <int, std::vector <int>, std::greater<int> > q;
for (int i = 1; i <= n; ++ i) {
std::cin >> a[i];
if (i <= k) q.push(a[i]);
else if (a[i] > q.top()) q.pop(), q.push(a[i]);
}
while (!q.empty()) {
std::cout << q.top() << " ";
q.pop();
}
return 0;
}
解法八
我會使用資料結構!
既然能用堆,那也能用二叉搜尋樹!
與解法六類似地,考慮維護一棵元素個數上限為 \(k\) 的二叉搜尋樹,當列舉到元素 \(a_i(1\le i\le n)\) 時:
- 若樹中元素個數不大於 \(k\),則直接插入。
- 若樹中元素個數為 \(k\),則比較樹中最小元素與 \(a_i\) 的大小關係,若 \(a_i\) 大於最小元素則刪除最小元素,並插入 \(a_i\)。
遍歷完成後對二叉搜尋樹進行中序遍歷即得有序的前 \(k\) 大值。
時間複雜度也為 \(O(n\log k)\) 級別,與解法七同階並且結果一致,但是常數較大而且平衡樹也很難寫,與解法七相比沒有什麼競爭力。
懶得寫平衡樹了程式碼略。
問題二
調研學習排序演算法 CubeSort,體會分治思想的使用。
不是我要笑死了這 b 排序、、、
考慮將待排序序列每 \(\operatorname{C}\) 個連續元素分為一段(稱為一個 Cube
),對每段分別呼叫其他排序方法排序後,再將所有段歸併即得有序序列。
一般 \(\operatorname{C}\) 取一個較小的值,如 8, 16
。時間複雜度依賴於呼叫的其他排序方法,但總時間複雜度與直接呼叫該排序方法同階。
若呼叫的其他排序方法是穩定的,則 Cubesort 是穩定的。
Cubesort 的優勢在於可以分散式地處理不同段的排序,得到結果後再歸併即可,適合算力充足的情況。
並且當 \(\operatorname{C}\) 較小時,可以使用在較小資料範圍時近似線性的插入排序,使排序的平均複雜度遠低於上限,實際使用時表現很優秀。
#include <iostream>
#include <algorithm>
const int CUBE_SIZE = 8;
// function to sort the cube
void cubeSort(int arr[], int n)
{
// divide the array into cubes of size CUBE_SIZE
for (int i = 0; i < n; i += CUBE_SIZE)
{
std::sort(arr + i, arr + std::min(i + CUBE_SIZE, n));
}
// merge the cubes
int temp[n];
for (int i = 0; i < n; i += CUBE_SIZE)
{
std::merge(arr + i, arr + std::min(i + CUBE_SIZE, n),
arr + std::min(i + CUBE_SIZE, n),
arr + std::min(i + 2*CUBE_SIZE, n),
temp + i);
}
// copy the result from temp[] back to arr[]
for (int i = 0; i < n; i++)
arr[i] = temp[i];
}
// main function
int main()
{
// input array
int arr[] = {3, 6, 7, 1, 5, 2, 8, 4};
int n = sizeof(arr) / sizeof(arr[0]);
// call cubeSort
cubeSort(arr, n);
// print the sorted array
for (int i = 0; i < n; i++)
std::cout << arr[i] << " ";
return 0;
}
寫在最後
參考:
- 推薦閱讀:BFPRT——Top k問題的終極解法 - 知乎
- 排序(下):如何用快排思想在O(n)內查詢第K個大元素? - 知乎
- 【每日演算法Day 82】面試經典題:求第K大數,我寫了11種實現,不來看看嗎? - 知乎
- Cubesort in C++ Code of Code