bfprt 演算法 (陣列中第K 小問題問題)
一:背景介紹 在一堆數中求其前 k 大或前 k 小的問題,簡稱 TOP-K 問題。而目前解決 TOP-K 問題最有效的演算法即是 BFPRT 演算法,又稱為中位數的中位數演算法,該演算法由 Blum、Floyd、Pratt、Rivest、Tarjan 提出,最壞時間複雜度為(n) 。
在首次接觸 TOP-K 問題時,我們的第一反應就是可以先對所有資料進行一次排序,然後取其前 k 即可,但是這麼做有兩個問題: 快速排序的平均複雜度為 (nlogn),但最壞時間複雜度為(n^2),不能始終保證較好的複雜度; 我們只需要前 k 大的,而對其餘不需要的數也進行了排序,浪費了大量排序時間。
除這種方法之外,堆排序也是一個比較好的選擇,可以維護一個大小為 k 的堆,時間複雜度為。
那是否還存在更有效的方法呢?我們來看下 BFPRT 演算法的做法。
在快速排序的基礎上,首先通過判斷主元位置與 k 的大小使遞迴的規模變小,其次通過修改快速排序中主元的選取方法來降低快速排序在最壞情況下的時間複雜度。
下面先來簡單回顧下快速排序的過程,以升序為例: 選取主元; 以選取的主元為分界點,把小於主元的放在左邊,大於主元的放在右邊; 分別對左邊和右邊進行遞迴,重複上述過程。
二:演算法過程及程式碼
BFPRT 演算法步驟如下: 選取主元; 1.1. 將 n 個元素按順序分為 [5 / n]個組,每組 5 個元素,若有剩餘,捨去; 1.2. 對於這[5 / n]個組中的每一組使用插入排序找到它們各自的中位數; 1.3. 對於 1.2 中找到的所有中位數,呼叫 BFPRT 演算法求出它們的中位數,作為主元; 以 1.3 選取的主元為分界點,把小於主元的放在左邊,大於主元的放在右邊; 判斷主元的位置與 k 的大小,有選擇的對左邊或右邊遞迴。
上面的描述可能並不易理解,先看下面這幅圖: BFPRT() 呼叫 GetPivotIndex() 和 Partition() 來求解第 k 小,在這過程中,GetPivotIndex() 也呼叫了 BFPRT(),即 GetPivotIndex() 和 BFPRT() 為互遞迴的關係。
下面為程式碼實現,其所求為前 k 小的數:
#include <iostream> #include <algorithm> using namespace std; int InsertSort(int array[], int left, int right); int GetPivotIndex(int array[], int left, int right); int Partition(int array[], int left, int right, int pivot_index); int BFPRT(int array[], int left, int right, int k); int main() { int k = 8; // 1 <= k <= array.size int array[20] = { 11,9,10,1,13,8,15,0,16,2,17,5,14,3,6,18,12,7,19,4 }; cout << "原陣列:"; for (int i = 0; i < 20; i++) cout << array[i] << " "; cout << endl; // 因為是以 k 為劃分,所以還可以求出第 k 小值 cout << "第 " << k << " 小值為:" << array[BFPRT(array, 0, 19, k)] << endl; cout << "變換後的陣列:"; for (int i = 0; i < 20; i++) cout << array[i] << " "; cout << endl; return 0; } /** * 對陣列 array[left, right] 進行插入排序,並返回 [left, right] * 的中位數。 */ int InsertSort(int array[], int left, int right) { int temp; int j; for (int i = left + 1; i <= right; i++) { temp = array[i]; j = i - 1; while (j >= left && array[j] > temp) array[j + 1] = array[j--]; array[j + 1] = temp; } return ((right - left) >> 1) + left; } /** * 陣列 array[left, right] 每五個元素作為一組,並計算每組的中位數, * 最後返回這些中位數的中位數下標(即主元下標)。 * * @attention 末尾返回語句最後一個引數多加一個 1 的作用其實就是向上取整的意思, * 這樣可以始終保持 k 大於 0。 */ int GetPivotIndex(int array[], int left, int right) { if (right - left < 5) return InsertSort(array, left, right); int sub_right = left - 1; // 每五個作為一組,求出中位數,並把這些中位數全部依次移動到陣列左邊 for (int i = left; i + 4 <= right; i += 5) { int index = InsertSort(array, i, i + 4); swap(array[++sub_right], array[index]); } // 利用 BFPRT 得到這些中位數的中位數下標(即主元下標) return BFPRT(array, left, sub_right, ((sub_right - left + 1) >> 1) + 1); } /** * 利用主元下標 pivot_index 進行對陣列 array[left, right] 劃分,並返回 * 劃分後的分界線下標。 */ int Partition(int array[], int left, int right, int pivot_index) { swap(array[pivot_index], array[right]); // 把主元放置於末尾 int partition_index = left; // 跟蹤劃分的分界線 for (int i = left; i < right; i++) { if (array[i] < array[right]) { swap(array[partition_index++], array[i]); // 比主元小的都放在左側 } } swap(array[partition_index], array[right]); // 最後把主元換回來 return partition_index; } /** * 返回陣列 array[left, right] 的第 k 小數的下標 */ int BFPRT(int array[], int left, int right, int k) { int pivot_index = GetPivotIndex(array, left, right); // 得到中位數的中位數下標(即主元下標) int partition_index = Partition(array, left, right, pivot_index); // 進行劃分,返回劃分邊界 int num = partition_index - left + 1; if (num == k) return partition_index; else if (num > k) return BFPRT(array, left, partition_index - 1, k); else return BFPRT(array, partition_index + 1, right, k - num); }
執行如下:
原陣列:11 9 10 1 13 8 15 0 16 2 17 5 14 3 6 18 12 7 19 4 第 8 小值為:7 變換後的陣列:4 0 1 3 2 5 6 7 8 9 10 12 13 14 17 15 16 11 18 19