考題:第 K 大與 Top-K
只要前 k 個,就不必把全部排好:整個排序、維持大小為 k 的堆積、Quickselect、桶排序,四種做法放在一起比。
解法
14
3
題目
回傳未排序陣列中第 k 大的元素。例如 [3, 2, 1, 5, 6, 4]、k = 2 → 5。同類題「出現次數最多的 k 個值」先用雜湊表數次數,再用同樣四種方法挑出次數最大的 k 個——桶子的索引就是出現次數。1/16
nums
- 210
- 231
- 62
- 193
- 24
- 185
- 226
- 147
- 288
- 79
- 2410
- 2211
- 2012
- 013
目前最大的 3 個,存在最小堆積(頂端在最前)
還在考慮基準值/堆積頂端答案
在這 14 個數裡找第 3 大。
| 解法 | 答案 | 比較或計數次數 | 額外記憶體 | Big O |
|---|---|---|---|---|
| 整個排序 | 23 | 40 | 14 格 | O(n log n) |
| 大小為 k 的堆積 | 23 | 21 | 3 格 | O(n log k) |
| Quickselect | 23 | 17 | 14 格 | O(n) avg |
| 計數桶 | 23 | 20 | 29 格 | O(n + R) |
亮起來的是這一步執行的程式碼
function kthSort(nums: number[], k: number): number { const sorted = [...nums].sort((a, b) => b - a); return sorted[k - 1];} // A min-heap of the k largest so far: its top is the k-th largest.function kthHeap(nums: number[], k: number): number { const h: number[] = []; const swap = (i: number, j: number) => { [h[i], h[j]] = [h[j], h[i]]; }; for (const x of nums) { if (h.length < k) { h.push(x); for (let i = h.length - 1; i > 0 && h[i] < h[(i - 1) >> 1]; i = (i - 1) >> 1) swap(i, (i - 1) >> 1); } else if (x > h[0]) { h[0] = x; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < h.length && h[l] < h[m]) m = l; if (r < h.length && h[r] < h[m]) m = r; if (m === i) break; swap(i, m); i = m; } } } return h[0];} function kthQuickselect(nums: number[], k: number): number { const a = [...nums]; const target = a.length - k; let seed = 42, lo = 0, hi = a.length - 1; while (lo < hi) { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; const p = lo + (seed % (hi - lo + 1)); [a[p], a[hi]] = [a[hi], a[p]]; let i = lo; for (let j = lo; j < hi; j++) { if (a[j] < a[hi]) { [a[i], a[j]] = [a[j], a[i]]; i++; } } [a[i], a[hi]] = [a[hi], a[i]]; if (i === target) return a[i]; if (target < i) hi = i - 1; else lo = i + 1; } return a[lo];} // Values in 0..max: count each one, then walk down from the largest.function kthBucket(nums: number[], k: number): number { const count = new Array(Math.max(...nums) + 1).fill(0); for (const x of nums) count[x]++; let seen = 0; for (let v = count.length - 1; v >= 0; v--) { seen += count[v]; if (seen >= k) return v; } throw new Error("k too large");}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 面試先講排序(簡單、O(n log n)),再提出只要前 k 個就不必全排:大小為 k 的堆積是最穩的答案,資料流也適用。
- 被追問「能不能 O(n)」時講 Quickselect:平均線性,但要說明隨機基準和最差 O(n²)。
- 值或次數的範圍很小時(例如 Top-K 高頻,次數不會超過 n),用桶子可以完全不比較。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 整個排序 | O(n log n) | O(n log n) |
| 大小為 k 的堆積 只留 k 個,空間 O(k),也適合資料流 | O(n log k) | O(n log k) |
| Quickselect 隨機基準讓最差情況幾乎不會發生 | O(n) | O(n²) |
| 計數桶 R 是值的範圍;只有範圍小時划算 | O(n + R) | O(n + R) |
空間:O(n) / O(k) / O(n) / O(R),依序是排序、堆積、Quickselect、計數桶
Big O 實測:n 變大時步數怎麼長
數的是:比較或計數次數(k = 10,值在 0–999)
| Big O | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 整個排序 | O(n log n) | 8,700 | 42,807 | 203,239 | ×23 (×22) |
| 大小為 k 的堆積(k 固定) | O(n) | 1,220 | 4,260 | 16,313 | ×13 (×16) |
| Quickselect | O(n) | 1,638 | 4,964 | 21,339 | ×13 (×16) |
| 計數桶 | O(n) | 1,010 | 4,004 | 16,001 | ×16 (×16) |
k 固定時,堆積的 log k 是常數,所以跟著 n 線性成長。
和其他做法比
| 隨機 10,000 個(0–999) | 已排好序的 2,000 個,找中位數 | |
|---|---|---|
| 整個排序 | 120,427 | 11,088 |
| 大小為 k 的堆積 | 10,299 | 19,954 |
| Quickselect(隨機基準) | 17,584 | 9,141 |
| Quickselect(固定取最後一個) | 21,122 | 1,499,500 |
| 計數桶 | 10,002 | 3,000 |
隨機輸入取 k = 10;已排序的輸入找中位數(k = 1,000)。數的是比較次數(計數桶是計數加掃描的步數)。固定拿最後一個當基準的 Quickselect 遇到已排好序的輸入,每一輪只排除一個數,比較次數變成 1,499,500,比整個排序還多;隨機基準只要 9,141。
真實世界裡的它
- LeetCode 215(第 K 大)、347(Top-K 高頻)、973(離原點最近的 K 個點)。
- 實務:排行榜、搜尋結果取前 k 筆、監控系統的「最慢的 10 個請求」。
取捨與陷阱
- 找第 k 大要用最小堆積:頂端是留下來的 k 個裡最小的那個,才是要被淘汰的。
- Quickselect 固定取第一個或最後一個當基準,遇到排好序的輸入、又要找中間附近的值時,就退化成 O(n²)(見下表)。
- 第 k 大是從 1 開始數:排好序之後取 index k − 1,或升序的 n − k。