跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 考題:第 K 大與 Top-K

考題:第 K 大與 Top-K

只要前 k 個,就不必把全部排好:整個排序、維持大小為 k 的堆積、Quickselect、桶排序,四種做法放在一起比。

解法
14
3
題目
回傳未排序陣列中第 k 大的元素。例如 [3, 2, 1, 5, 6, 4]、k = 2 → 5。同類題「出現次數最多的 k 個值」先用雜湊表數次數,再用同樣四種方法挑出次數最大的 k 個——桶子的索引就是出現次數。
1/16
nums
  1. 21
    0
  2. 23
    1
  3. 6
    2
  4. 19
    3
  5. 2
    4
  6. 18
    5
  7. 22
    6
  8. 14
    7
  9. 28
    8
  10. 7
    9
  11. 24
    10
  12. 22
    11
  13. 20
    12
  14. 0
    13
目前最大的 3 個,存在最小堆積(頂端在最前)
    還在考慮基準值/堆積頂端答案

    在這 14 個數裡找第 3 大。

    同一份輸入,所有解法
    解法答案比較或計數次數額外記憶體Big O
    整個排序234014 格O(n log n)
    大小為 k 的堆積23213 格O(n log k)
    Quickselect231714 格O(n) avg
    計數桶232029 格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 On = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
    整個排序O(n log n)8,70042,807203,239×23 (×22)
    大小為 k 的堆積(k 固定)O(n)1,2204,26016,313×13 (×16)
    QuickselectO(n)1,6384,96421,339×13 (×16)
    計數桶O(n)1,0104,00416,001×16 (×16)

    k 固定時,堆積的 log k 是常數,所以跟著 n 線性成長。

    和其他做法比

    隨機 10,000 個(0–999)已排好序的 2,000 個,找中位數
    整個排序120,42711,088
    大小為 k 的堆積10,29919,954
    Quickselect(隨機基準)17,5849,141
    Quickselect(固定取最後一個)21,1221,499,500
    計數桶10,0023,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。

    LeetCode 練習