在 n 個數當中找第k小元素 (BFPRT演算法,最壞情況為線性時間的選擇問題)
題目描述
問題描述:
在 n 個數當中找第k小元素。
輸入:
第一行輸入n的值,第二行輸入n個數,第三行輸入k的值。
輸出:
n 個數中的第k小元素。
要求:
你的演算法最壞情況下應該線上性時間內完成。
示例1:
輸入:
5
8 1 3 6 9
3
輸出: 6
示例2:
輸入:
10
72 6 57 88 60 42 83 73 48 85
5
輸出: 60
思路分析
對於常規解法,我們隨機在陣列中選擇一個數作為劃分值(pivot),然後進行快排的partation過程(將小於pivot的數放到陣列左邊,大於pivot的數放到陣列右邊),劃分完之後pivot的下標為i,然後判斷k與等於i的相對關係,如果k正好在等於i,那麼陣列第k小的數就是pivot,如果k小於i,那麼我們遞迴對左邊再進行上述過程,如果k大於i,那我們遞迴對右邊再進行上述過程。
對於最好的情況:每次所選的pivot劃分之後正好在陣列的正中間,那麼遞迴方程為T(n) = T(n/2) + n,解得T(n) = O(n),所以此時此演算法是O(n)線性複雜度的。
對於最壞情況:每次所選的pivot劃分之後都好在陣列最邊上,那麼時間複雜度為O(n2)。
BFPRT演算法就是在這個pivot上做文章,BFPRT演算法能夠保證每次所選的pivot劃分之後在陣列的中間位置,那麼時間複雜度就是O(n)。
BFPRT演算法流程
這題規定了要線上性時間內完成第k小元素的選擇,在演算法導論這本書裡面的第九章有講解過這種問題,演算法的基本思想是修改快速排序演算法中的主元選取方法,降低演算法在最壞情況下的時間複雜度。
下述步驟來自《演算法導論(第3版)》第9.3節。
在快速排序中,我們始終選擇第一個元素或者最後一個元素作為pivot,而在此演算法中,每次選擇五分中位數的中位數作為pivot,這樣做的目的就是使得劃分比較合理,從而避免了最壞情況的發生。通過執行下列步驟,演算法Select可以確定一個有個不同元素的輸入陣列中第i小的元素:
(1) 將n個元素劃為組,每組5個,至多隻有一組由剩下的n mod 5個元素組成。
(2) 尋找這個組中每一個組的中位數,這個過程可以用插入排序,然後確定每組有序元素的中位數。
(3) 對第2步中找出的箇中位數,重複步驟1和步驟2,遞迴下去,直到剩下一個數字。
(4) 最終剩下的數字即為主元pivot,用快速排序的劃分思想,把小於pivot的數全放左邊,大於它的數全放右邊。跟快速排序不同的是,這裡只是劃分,並沒有排序。
(5) 判斷pivot的位置與k的大小,有選擇的對左邊或右邊遞迴。
1 #include <iostream> 2 #include <string.h> 3 #include <stdio.h> 4 #include <time.h> 5 #include <algorithm> 6 7 using namespace std; 8 9 //插入排序 10 void InsertSort(int a[], int l, int r) 11 { 12 for(int i = l + 1; i <= r; i++) 13 { 14 if(a[i - 1] > a[i]) 15 { 16 int t = a[i]; 17 int j = i; 18 while(j > l && a[j - 1] > t) 19 { 20 a[j] = a[j - 1]; 21 j--; 22 } 23 a[j] = t; 24 } 25 } 26 } 27 28 //尋找中位數的中位數 29 int FindMid(int a[], int l, int r) 30 { 31 if(l == r) return l; 32 int i = 0; 33 int n = 0; 34 for(i = l; i < r - 5; i += 5) 35 { 36 InsertSort(a, i, i + 4); 37 n = i - l; 38 //插入排序之後,a[i+2]就是a[i,...,i+5]的中位數 39 //把中位數都放到前面 40 swap(a[l + n / 5], a[i + 2]); 41 } 42 43 //處理剩餘元素 44 int num = r - i + 1; 45 if(num > 0) 46 { 47 InsertSort(a, i, i + num - 1); 48 n = i - l; 49 swap(a[l + n / 5], a[i + num / 2]); 50 } 51 n /= 5; 52 if(n == l) 53 return l; 54 55 //前n個數就是上述找出來的每一組的中位數 56 return FindMid(a, l, l + n); 57 } 58 59 //進行劃分過程,就是一趟快速排序的過程,返回劃分後的基準數的下標i 60 int Partition(int a[], int l, int r, int p) 61 { 62 swap(a[p], a[l]); 63 int i = l; 64 int j = r; 65 int pivot = a[l]; 66 while(i < j) 67 { 68 while(a[j] >= pivot && i < j) 69 j--; 70 while(a[i] <= pivot && i < j) 71 i++; 72 swap(a[j], a[i]); 73 } 74 swap(a[l], a[i]); 75 76 return i; 77 } 78 79 int Select(int a[], int l, int r, int k) 80 { 81 int p = FindMid(a, l, r); //尋找中位數的中位數 82 int i = Partition(a, l, r, p); //劃分之後的下標 83 84 int m = i - l + 1; 85 if(m == k) 86 return a[i]; 87 if(m > k) 88 return Select(a, l, i - 1, k); 89 90 return Select(a, i + 1, r, k - m); 91 } 92 93 int main() 94 { 95 int n, k; 96 scanf("%d", &n); 97 int *a = new int[n]; 98 for(int i = 0; i < n; i++) 99 scanf("%d", &a[i]); 100 scanf("%d", &k); 101 printf("%d", Select(a, 0, n - 1, k)); 102 103 delete[] a; 104 return 0; 105 }
複雜度分析
思考與引申
快速排序的 Partition 劃分思想可以用於計算某個位置的數值等問題,可以實現 O(n)複雜度的選擇問題,之所以這種選擇演算法具有線性時間,是因為沒有進行排序,並且每次都有選擇的只對左右其中的一邊進行遞迴處理,而排序需要進行比較,並且快速排序左右兩邊都需要進行遞迴處理,即使是在平均情況下,排序也需要 O(nlogn)的時間複雜度,而這個線性時間的選擇演算法沒有使用排序就解決了選擇問題。
優缺點
但缺點也很明顯,最主要的就是記憶體問題,在海量資料的情況下,很有可能沒辦法一次性將資料全部載入入記憶體,這個時候這個方法就無法完成使命了。此時可以利用堆來解決,維護一個大小為 K 的小頂堆,依次將資料放入堆中,當堆的大小滿了的時候,只需要將堆頂元素與下一個數比較:如果大於堆頂元素,則將當前的堆頂元素拋棄,並將該元素插入堆中。遍歷完全部資料,Top K 的元素也自然都在堆裡面了。但是使用堆解決這個問題,時間花費為 O(nlogn)。
參考
《演算法導論 (第3版)》 第9.3節