題目描述
給定一個整數數組a[0,...,n-1],求數組中第k小數
輸入描述
首先輸入數組長度n和k,其中1<=n<=5000, 1<=k<=n
然后輸出n個整形元素,每個數的范圍[1, 5000]
輸出描述
該數組中第k小數
樣例輸入
4 2 1 2 3 4
樣例輸出
2
其實可以用 堆 來做,保證根節點為最小值,然后逐步剔除。不過當然也可以直接排序。
權當熟悉一下STL:
1 #include <vector> 2 #include <algorithm> 3 #include <iostream> 4 using namespace std; 5 6 int main() 7 { 8 int n, k; 9 cin >> n >> k; 10 11 vector<int> a(n, 0); 12 for (int i = 0; i < n; i++) 13 { 14 cin >> a[i]; 15 } 16 sort(a.begin(), a.end()); 17 18 cout << a[k-1]; 19 20 return 0; 21 }
在和 Space_Double7 討論后(詳見討論區),於是有了想要比較 快排 和 堆排 對於這道題各自的效率的想法。
於是寫了一個程序來比較它們各自的運行時間,程序主要包括 隨機生成輸入數據、分別用快排和堆排求解並計時、比較 這幾個部分。
代碼如下:
1 #include <vector> 2 #include <algorithm> 3 #include <iostream> 4 #include <stdlib.h> 5 #include <time.h> 6 #include <windows.h> 7 8 using namespace std; 9 10 // for timing 11 clock_t start, stop; 12 double durationOfQsort, durationOfHeapsort; 13 14 vector<int> num; 15 int k, ans, n; 16 bool found; 17 18 // 快排: 選定軸點 19 int parti(int lo, int hi) 20 { 21 swap(num[lo], num[lo + rand() % (hi - lo + 1)]); 22 int pivot = num[lo]; 23 while (lo < hi) 24 { 25 while ((lo < hi) && (pivot <= num[hi])) hi--; 26 num[lo] = num[hi]; 27 while ((lo < hi) && (num[lo] <= pivot)) lo++; 28 num[hi] = num[lo]; 29 } 30 num[lo] = pivot; 31 if (lo == k) 32 { 33 found = true; // 表征已確定找到第 k 小數 34 ans = num[k]; 35 } 36 return lo; 37 } 38 39 // 快排主體 40 void quicksort(int lo, int hi) 41 { 42 if ((hi - lo < 2) || (found)) 43 { 44 if ((!found) && (lo == k)) 45 { 46 found = true; 47 ans = num[k]; 48 } 49 return; 50 } 51 int mi = parti(lo, hi - 1); 52 quicksort(lo, mi); 53 quicksort(mi + 1, hi); 54 } 55 56 #define InHeap(n, i) (( (-1) < (i) ) && ( (i) < (n) )) 57 #define Parent(i) ((i-1)>>1) 58 #define LastInternal(n) Parent(n-1) 59 #define LChild(i) (1+((i) << 1)) 60 #define RChild(i) ((1+(i)) << 1) 61 #define LChildValid(n, i) InHeap(n, LChild(i)) 62 #define RChildValid(n, i) InHeap(n, RChild(i)) 63 #define Bigger(PQ, i, j) ((PQ[i])<(PQ[j])? j : i) 64 #define ProperParent(PQ, n, i) \ 65 (RChildValid(n, i) ? Bigger(PQ, Bigger(PQ, i, LChild(i)), RChild(i)):\ 66 (LChildValid(n, i) ? Bigger(PQ, i, LChild(i)):i\ 67 )\ 68 ) 69 70 // 對向量前 n 個元素中的第 i 實施下濾操作 71 int percolateDown(int n, int i) 72 { 73 int j; 74 while (i != (j = ProperParent(num, n, i))) 75 { 76 swap(num[i], num[j]); 77 i = j; 78 } 79 return i; 80 } 81 82 // Floyd 建堆算法 83 void heapify() 84 { 85 for (int i = LastInternal(n); InHeap(n, i); i--) 86 percolateDown(n, i); 87 } 88 89 // 刪除堆中最大的元素 90 int delMax(int hi) 91 { 92 int maxElem = num[0]; 93 num[0] = num[hi]; 94 percolateDown(hi, 0); 95 return maxElem; 96 } 97 98 // 堆排主體 99 void heapsort() 100 { 101 heapify(); 102 int hi = n; 103 while (hi > 0) 104 { 105 --hi; 106 num[hi] = delMax(hi); 107 if (hi == k) 108 { 109 ans = num[k]; 110 return; 111 } 112 } 113 } 114 115 int main() 116 { 117 int scoreOfQsort = 0, scoreOfHeapsort = 0; 118 119 for (int iter = 0; iter < 30; iter++) 120 { 121 // 確定 n 的大致最大范圍,注意隨機 n 會有向右 MaxN 的偏差 122 const int MaxN = 3001; 123 124 // 產生一個 0..n-1 的隨機序列輸入數組,n 最大為3000 125 cout << "**********************第" << iter + 1 << "次************************" << endl; 126 //srand(unsigned(clock())); 127 n = rand() % MaxN + MaxN; 128 vector<int> a(n, 0); 129 for (int i = 0; i < n; i++) 130 a[i] = i; 131 random_shuffle(a.begin(), a.end()); 132 133 cout << "產生一個 0.." << n - 1 << " 的隨機序列輸入數組:" << endl; 134 /*for (int i = 0; i < n; i++) 135 cout << a[i] << " ";*/ 136 cout << endl; 137 138 // 隨機生成 k 139 //srand(unsigned(clock())); 140 k = rand() % n; 141 cout << "k = " << k << endl << endl; 142 143 // 第 k 小的數一定是 k,因為 random_shuffle,同時避免退化情形對快排一般性的影響 144 cout << "在該數組中第 " << k << " 小的數是: " << k << endl << endl << endl; 145 146 // qsort 147 cout << "快排:" << endl; 148 num = a; 149 start = clock(); 150 found = false; 151 quicksort(0, n); 152 stop = clock(); 153 durationOfQsort = ((double)(stop - start)*1000) / CLK_TCK; 154 cout << "Ok.. 我已經找到了第 " << k << " 小的數,它是: " << ans << endl; 155 cout << "快排用時: " << durationOfQsort << "ms" << endl; 156 if (ans != k) 157 { 158 cout << "注意!!!答案錯誤!!!" << endl; 159 system("pause"); 160 } 161 /*else 162 { 163 cout << "此時的序列情況是: "; 164 for (int i = 0; i < n; i++) 165 cout << num[i] << " "; 166 }*/ 167 cout << endl << endl; 168 169 // heapsort 170 cout << "堆排:" << endl; 171 num = a; 172 start = clock(); 173 heapsort(); 174 stop = clock(); 175 durationOfHeapsort = ((double)(stop - start) * 1000) / CLK_TCK; 176 cout << "Ok.. 我已經找到了第 " << k << " 小的數,它是: " << num[k] << endl; 177 cout << "堆排用時: " << durationOfHeapsort << "ms" << endl; 178 if (num[k] != k) 179 { 180 cout << "注意!!!答案錯誤!!!" << endl; 181 system("pause"); 182 } 183 /*else 184 { 185 cout << "此時的序列情況是: "; 186 for (int i = 0; i < n; i++) 187 cout << num[i] << " "; 188 }*/ 189 cout << endl << endl; 190 191 if (durationOfHeapsort > durationOfQsort) scoreOfQsort++; 192 else scoreOfHeapsort++; 193 } 194 cout << "*******************END***********************"; 195 cout << endl << endl << "總比分(快排:堆排): " << scoreOfQsort << " : " << scoreOfHeapsort; 196 cout << endl; 197 198 return 0; 199 }
運行了幾下,大致可以看出 快排:堆排 的效率比 趨近於 2:1(對於每個case的耗時少則計分,計分多的效率高),這是其中 3 次的結果:
感興趣的可以自己在本地運行一下~
然而我發現依然存在一個問題,我們主要想討論的是 對於 “一找到 第k小 的元素便立即退出”這件事 對於整個完全排序本身優化程度的大小。
橫向來看,僅僅比較 優化后的排序 耗時並不能確定 到底是快速排序 本身對於隨機數據而言比 堆排序 性能好(從上面看,似乎是顯然的),還是 “一找到 第k小 的元素便立即退出”這個優化在這里 幫了快速排序的大忙,使其在這里表現出了更好的性能。
縱向來看,我們想看看該優化對於完全排序而言,能優化到什么程度。
所以我決定比較兩種排序 優化退出的時間占完全排序的時間 的比,來看看這個優化對於完全排序的影響程度。
這是修改以后的代碼,算的是百分比,百分比越小,優化越明顯:
1 #include <vector> 2 #include <algorithm> 3 #include <iostream> 4 #include <stdlib.h> 5 #include <time.h> 6 #include <windows.h> 7 8 using namespace std; 9 10 // for timing 11 clock_t start, stop, stop1; 12 double durationOfQsort, durationOfHeapsort; 13 14 vector<int> num; 15 int k, ans, n; 16 bool found; 17 double time1, time2; 18 double average1 = 0, average2 = 0; 19 20 // 快排: 選定軸點 21 int parti(int lo, int hi) 22 { 23 swap(num[lo], num[lo + rand() % (hi - lo + 1)]); 24 int pivot = num[lo]; 25 while (lo < hi) 26 { 27 while ((lo < hi) && (pivot <= num[hi])) hi--; 28 num[lo] = num[hi]; 29 while ((lo < hi) && (num[lo] <= pivot)) lo++; 30 num[hi] = num[lo]; 31 } 32 num[lo] = pivot; 33 if ((!found)&&(lo == k)) 34 { 35 stop1 = clock(); 36 found = true; 37 } 38 return lo; 39 } 40 41 // 快排主體 42 void quicksort(int lo, int hi) 43 { 44 if (hi - lo < 2) 45 { 46 if ((!found) && (lo == k)) 47 { 48 stop1 = clock(); 49 found = true; 50 } 51 return; 52 } 53 int mi = parti(lo, hi - 1); 54 quicksort(lo, mi); 55 quicksort(mi + 1, hi); 56 } 57 58 #define InHeap(n, i) (( (-1) < (i) ) && ( (i) < (n) )) 59 #define Parent(i) ((i-1)>>1) 60 #define LastInternal(n) Parent(n-1) 61 #define LChild(i) (1+((i) << 1)) 62 #define RChild(i) ((1+(i)) << 1) 63 #define LChildValid(n, i) InHeap(n, LChild(i)) 64 #define RChildValid(n, i) InHeap(n, RChild(i)) 65 #define Bigger(PQ, i, j) ((PQ[i])<(PQ[j])? j : i) 66 #define ProperParent(PQ, n, i) \ 67 (RChildValid(n, i) ? Bigger(PQ, Bigger(PQ, i, LChild(i)), RChild(i)):\ 68 (LChildValid(n, i) ? Bigger(PQ, i, LChild(i)):i\ 69 )\ 70 ) 71 72 // 對向量前 n 個元素中的第 i 實施下濾操作 73 int percolateDown(int n, int i) 74 { 75 int j; 76 while (i != (j = ProperParent(num, n, i))) 77 { 78 swap(num[i], num[j]); 79 i = j; 80 } 81 return i; 82 } 83 84 // Floyd 建堆算法 85 void heapify() 86 { 87 for (int i = LastInternal(n); InHeap(n, i); i--) 88 percolateDown(n, i); 89 } 90 91 // 刪除堆中最大的元素 92 int delMax(int hi) 93 { 94 int maxElem = num[0]; 95 num[0] = num[hi]; 96 percolateDown(hi, 0); 97 return maxElem; 98 } 99 100 // 堆排主體 101 void heapsort() 102 { 103 heapify(); 104 int hi = n; 105 while (hi > 0) 106 { 107 --hi; 108 num[hi] = delMax(hi); 109 if (hi == k) stop1 = clock(); 110 } 111 } 112 113 int main() 114 { 115 // 確定 n 的大致最大范圍,注意隨機 n 會有向右 MaxN 的偏差 116 const int MaxN = 3001; 117 118 // 計算次數 119 const int times = 30; 120 121 int scoreOfQsort = 0, scoreOfHeapsort = 0; 122 123 for (int iter = 0; iter < times; iter++) 124 { 125 126 127 // 產生一個 0..n-1 的隨機序列輸入數組,n 最大為3000 128 cout << "**********************第" << iter + 1 << "次************************" << endl; 129 //srand(unsigned(clock())); 130 n = rand() % MaxN + MaxN; 131 vector<int> a(n, 0); 132 for (int i = 0; i < n; i++) 133 a[i] = i; 134 random_shuffle(a.begin(), a.end()); 135 136 cout << "產生一個 0.." << n - 1 << " 的隨機序列輸入數組:" << endl; 137 /*for (int i = 0; i < n; i++) 138 cout << a[i] << " ";*/ 139 cout << endl; 140 141 // 隨機生成 k 142 //srand(unsigned(clock())); 143 k = rand() % n; 144 cout << "k = " << k << endl << endl; 145 146 // 第 k 小的數一定是 k,因為 random_shuffle,同時避免退化情形對快排一般性的影響 147 cout << "在該數組中第 " << k << " 小的數是: " << k << endl << endl << endl; 148 149 // qsort 150 cout << "快排:" << endl; 151 num = a; 152 start = clock(); 153 found = false; 154 quicksort(0, n); 155 stop = clock(); 156 time1 = (double)(stop1 - start) * 1000 / CLK_TCK; 157 time2 = (double)(stop - start) * 1000 / CLK_TCK; 158 cout << "找到 k 的時間: " << time1 << " ms" << endl; 159 cout << "完全排序 的時間: " << time2 << " ms" << endl; 160 durationOfQsort = time1 / time2 * 100; 161 average1 += durationOfQsort; 162 /*cout << "Ok.. 我已經找到了第 " << k << " 小的數,它是: " << ans << endl;*/ 163 cout << "快排占比: " << durationOfQsort << " %" << endl; 164 /*if (ans != k) 165 { 166 cout << "注意!!!答案錯誤!!!" << endl; 167 system("pause"); 168 }*/ 169 /*else 170 { 171 cout << "此時的序列情況是: "; 172 for (int i = 0; i < n; i++) 173 cout << num[i] << " "; 174 }*/ 175 cout << endl << endl; 176 177 // heapsort 178 cout << "堆排:" << endl; 179 num = a; 180 start = clock(); 181 heapsort(); 182 stop = clock(); 183 time1 = (double)(stop1 - start) * 1000 / CLK_TCK; 184 time2 = (double)(stop - start) * 1000 / CLK_TCK; 185 cout << "找到 k 的時間: " << time1 << " ms" << endl; 186 cout << "完全排序 的時間: " << time2 << " ms" << endl; 187 durationOfHeapsort = time1 / time2 * 100; 188 average2 += durationOfHeapsort; 189 //cout << "Ok.. 我已經找到了第 " << k << " 小的數,它是: " << num[k] << endl; 190 cout << "堆排占比: " << durationOfHeapsort << " %" << endl; 191 /*if (num[k] != k) 192 { 193 cout << "注意!!!答案錯誤!!!" << endl; 194 system("pause"); 195 }*/ 196 /*else 197 { 198 cout << "此時的序列情況是: "; 199 for (int i = 0; i < n; i++) 200 cout << num[i] << " "; 201 }*/ 202 cout << endl << endl; 203 204 if (durationOfHeapsort > durationOfQsort) scoreOfQsort++; 205 else scoreOfHeapsort++; 206 } 207 cout << "*******************END***********************"; 208 cout << endl << endl << "總比分(快排:堆排): " << scoreOfQsort << " : " << scoreOfHeapsort; 209 cout << endl; 210 cout << endl << "快排平均占比: " << average1 / times << " %" << endl; 211 cout << endl << "堆排平均占比: " << average2 / times << " %" << endl; 212 213 return 0; 214 }
運行 3 次的結果:
可見,
1、這個優化對於兩種排序的影響程度是差不多的(百分比越小,優化越明顯);
2、對於完全排序而言,它大概相當於在前面加了一個 0.6 的系數,也就是 只 干了完全排序 0.6 倍的工作量,正如分析來看,依然是常系數級的優化。
由於數據是完全隨機的(並且沒有重復元素),快排也適應得很好,在實際用途中(對於近似隨機數據),它的效率是可觀的。
不!還沒完!
來來來,現在我們回到原問題本身,回到 查找 數組 第 k 小數 這樣經典而基礎的問題本身上來……
盡管 原問題 數據規模小,水水就能過,但是既然已經鼓搗過了,干脆鼓搗完。
所以我還是決定寫一個對於大規模數據具有普適意義的盡可能優化的算法來解決問題(優化到線性復雜度)。
再考慮這個問題,在寫了一遍快排之后,會發現這是一個與 快排中選取軸點 很類似的問題。
軸點是左右集合的分界點,左集合所有元素比軸點小,右集合所有元素比軸點大,你可以發現,找第 K 小數就是找在位置 K 上的軸點(也正如上述優化所想)!
然而我們依然要向上面所寫的程序一樣,找到 k 就退出嗎?
1、快速選取算法
考慮這樣的情形,
當前選取的軸點正好是 第k個,自然就退出;
當選取的軸點比 k 小時,我們實際上可以不用再對左集合排序了!因為我們只需要知道它們都比軸點要小,而且知道它們的個數,而此時軸點比 k 還要小,所以我們可以繼續只對 右集合 分治下去!
同樣的,當選取的軸點比 k 大時,我們實際上可以不用再對右集合排序了!因為我們只需要知道它們都比軸點要大,而且知道它們的個數,而此時軸點比 k 還要大,所以我們可以繼續只對 左集合 分治下去!
這樣,我們可以把每一次的向下兩次遞歸變成一次。
用 非遞歸 版本代碼大致表示如下:
1 void quickSelect() 2 { 3 for (int lo = 0, hi = n-1; lo < hi; ) 4 { 5 int i = lo, j = hi, pivot = num[lo]; 6 while (i < j) 7 { 8 while ( (i<j) && (pivot <= num[j]) ) j--; 9 num[i] = num[j]; 10 while ( (i<j) && (num[i] <= pivot) ) i++; 11 num[j] = num[i]; 12 } 13 num[i] = pivot; 14 if ( k<=i ) hi = i - 1; 15 if ( i<=k ) lo = i + 1; 16 } 17 } // 結束后 num[k] 即是解
對於一般情況(數據接近隨機),跟快排一樣,此算法效率很高,不過快排的缺點它也一樣具有。也就是當軸點把左右集合划分得極不均勻甚至某一個集合為空時,此時效率跟快排一樣退化到 O(n^2)。
接下來考慮, 堆排是不是也有優化空間呢?
2、堆選取算法
題目只需要我們選取 第 K 小數,跟快速選取一樣,無關目的的部分我們完全沒必要做無用功。
基於這樣的考慮,我們完全可以只維護一個規模為 K 的大根堆嘛!~
算法思考大致是:
首先將序列前 K 的元素用 Floyd 建堆(O(k)效率)維護成一個大根堆。
然后將剩下的元素依次插入堆,每插入一個,隨機刪除堆頂,使堆的規模保持在 k。
這樣當所有的元素插入完畢,那么堆的根就是問題的解。
一般情況下,它的效率是比完全排序效率要高的,不過當 k 接近於 n/2 的時候,它的復雜度又會退化到 O( nlogn )。
難道真的不能從實質上將這個問題優化到線性效率上來嗎?
3、k-選取算法
算法里面對於排序有一個喪病的思路:選定一個 k,當序列長度小於 k 時,sort 函數直接不作處理返回原序列。整個序列經過這樣一次 sort 之后當然不是有序的,此時對這個序列做一次插入排序(因為插入排序在處理 “幾乎” 有序的序列時,運行非常快)。根據算導的結論,這樣的復雜度是 O(nk + n log(n/k)) 的。(其實就是相當於做n/k次k長的插入)
這種思想在這里我們也可以借鑒,大致的算法思想如下:
0) 選定一個數Q,Q為一個不大的常數;
select(A, k):
1) 如果序列A規模不大於 Q 時直接蠻力算法; // 遞歸基
2) 將A均勻划分為 n/Q 個子序列,各含 Q 個元素;
3) 各子序列分別排序(可采用任何排序算法),計算中位數,並將所有中位數組成一個序列;
4) 遞歸調用select(),計算出中位數序列的中位數,記作M;
5) 根據元素相對於 M 的大小,將 A 中元素分為三個子集:L(小於),E(相等)和G(大於);
6) if ( |L| >= k ) return select(L, k);
else if ( |L| + |E| >= k ) return M;
else return select(G, k - |L| - |E|);
復雜度分析:(計最壞情況下運行時間為 T(n))
2): O(n);
3): 由於Q為常數,累計也為 O(n);
4): 遞歸調用,T(n/Q)
5): O(n);
6): 中位數序列的中位數一定是全局中位數 M,而 L 和 G 的規模一定不會超過 3n/4。
所以可得如下遞推關系:
T(n) = cn + T(n/Q) + T(3n/4),c 為常數
如果取 Q = 5, 則有:
T(n) = cn + T(n/5) + T(3n/4) = O(20cn) = O(n)
可見,復雜度是線性。雖如此,其常系數過大,且算法過程較復雜,在一般規模的應用中難以真正體現出效率的優勢。
Reference : 《數據結構習題解析》,鄧俊輝