隨機演算法完全指南 — QuickSelect、Reservoir Sampling 與 Fisher-Yates 洗牌 | 資料結構與演算法

2026/07/19
隨機演算法完全指南 — QuickSelect、Reservoir Sampling 與 Fisher-Yates 洗牌 | 資料結構與演算法

隨機演算法(Randomized Algorithms) 是在執行過程中引入隨機決策的演算法,透過 「擲骰子」 來換取更簡單的實作與更好的平均效能。核心分為兩大類型:保證正確但時間隨機的 Las Vegas 演算法(如 Randomized QuickSort、Reservoir Sampling),以及固定時間但答案可能有誤的 Monte Carlo 演算法(如 Miller-Rabin 素數測試)。本文將帶你從理論基礎出發,完整掌握 QuickSelectFisher-Yates 洗牌Skip List 等經典技術,搭配 JavaScript/TypeScript 與 C++ 雙語言實作。

前言

想像你面對一副 52 張的撲克牌,需要將它們完全洗亂。你會怎麼做?直覺的做法可能是「每張牌隨機和另一張交換」——但這個看似合理的方法,其實無法產生真正均勻的隨機排列。正確的做法是 1938 年由 Ronald Fisher 和 Frank Yates 提出的 Fisher-Yates Shuffle,它用數學嚴格保證每種排列出現的機率完全相等。

這就是隨機演算法的魅力所在——看似簡單的「加入隨機性」背後,蘊含著精密的數學分析與機率保證。

隨機演算法在現代軟體工程中無處不在:

  • 排序與選擇:Randomized QuickSort 與 QuickSelect 避免對抗性最壞情況
  • 串流處理:Reservoir Sampling 從即時資料流中等機率抽樣
  • 資料結構:Skip List 用隨機層數替代複雜的平衡樹旋轉(Redis Sorted Set 的核心)
  • 密碼學與安全:Miller-Rabin 素數測試是 RSA 金鑰生成的基礎
  • 推薦系統:Epsilon-Greedy 策略在探索與利用之間取得平衡

學習本文後,你將能夠:

  • 區分 Las VegasMonte Carlo 兩大隨機演算法類型
  • 實作 Randomized QuickSortQuickSelect 避免最壞情況
  • 掌握 Reservoir Sampling 處理串流資料的等機率抽樣
  • 正確實作 Fisher-Yates Shuffle 並理解 Naive Shuffle 的錯誤
  • 建構完整的 Skip List 資料結構
  • 理解 Miller-Rabin 隨機化素數測試的原理

核心概念

Las Vegas vs Monte Carlo 演算法

隨機演算法依據「正確性」與「時間」的取捨,分為兩大類型:

隨機化演算法
├── Las Vegas(拉斯維加斯)
│   ├── 定義:答案保證正確,但執行時間是隨機變數
│   ├── 特性:正確,但偶爾很慢(期望時間有界)
│   ├── 應用:Randomized QuickSort、QuickSelect、Reservoir Sampling、Skip List
│   └── 類比:在拉斯維加斯的賭場賭「時間」——你一定會拿到正確答案,只是不確定要等多久
│
└── Monte Carlo(蒙地卡羅)
    ├── 定義:固定時間內完成,但答案有一定機率錯誤
    ├── 特性:快速,但可能給出錯誤答案
    ├── 應用:Miller-Rabin 素數測試、近似計算 π、Bloom Filter
    └── 類比:在蒙地卡羅的賭場賭「正確性」——快速拿到答案,但有機率猜錯
特性Las VegasMonte Carlo
正確性100% 正確有機率錯誤
時間隨機(期望有界)確定(固定上限)
核心取捨賭時間賭正確性
典型範例Randomized QuickSortMiller-Rabin
適用場景不容許任何錯誤可接受微小誤差

為什麼需要隨機化?

問題一:確定性演算法存在對抗性最壞情況
  QuickSort 固定選首元素為 Pivot → 攻擊者可構造已排序輸入使其退化到 O(n²)
  Randomized QuickSort → 隨機選 Pivot,任何輸入下期望 O(n log n)

問題二:某些問題需要處理未知大小的串流資料
  串流資料:無法儲存所有資料,Reservoir Sampling 以 O(k) 空間抽樣
  線上演算法:資料即時到達,Fisher-Yates 提供均勻隨機保證

問題三:平衡樹實作複雜,維護代價高
  Red-Black Tree:插入/刪除需要複雜旋轉邏輯,實作易出錯
  Skip List:用隨機層數替代確定性平衡,期望 O(log n),實作直觀

期望複雜度 vs 最壞情況複雜度

理解隨機演算法的關鍵在於區分 期望複雜度(Expected Complexity)最壞情況複雜度(Worst-case Complexity)

  • 期望複雜度:對所有可能的隨機選擇取平均。例如 Randomized QuickSort 的期望時間為 O(n log n),無論輸入是什麼。
  • 最壞情況複雜度:最不幸的隨機選擇下的時間。例如 Randomized QuickSort 最壞仍為 O(n^2),但發生的機率極低(約 1/n!)。
  • 高機率保證:許多隨機演算法可以證明「以至少 1 - 1/n^c 的機率在 O(f(n)) 內完成」,c 可以任意大。

這與確定性演算法的「平均情況分析」不同——後者假設輸入的分布,而隨機演算法的期望是對 演算法自身的隨機選擇 取平均,不依賴輸入分布。


JavaScript / TypeScript 實作

Randomized QuickSort

Randomized QuickSort 是 Las Vegas 演算法的代表:答案保證正確(排序結果一定對),但執行時間因隨機 pivot 選擇而有波動。

// ─── Randomized QuickSort — Las Vegas 演算法 ─────────────────

function randomizedQuickSort(arr: number[], lo: number = 0, hi: number = arr.length - 1): void {
  if (lo >= hi) return;

  // 隨機選 pivot,避免對抗性最壞情況
  const pivotIdx = lo + Math.floor(Math.random() * (hi - lo + 1));
  [arr[pivotIdx], arr[hi]] = [arr[hi], arr[pivotIdx]]; // 將 pivot 移到末尾

  // Lomuto Partition
  const pivot = arr[hi];
  let i = lo - 1;
  for (let j = lo; j < hi; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
  }
  [arr[i + 1], arr[hi]] = [arr[hi], arr[i + 1]];
  const p = i + 1;

  randomizedQuickSort(arr, lo, p - 1);
  randomizedQuickSort(arr, p + 1, hi);
}

// 測試
const data = [5, 3, 8, 1, 9, 2, 7, 4, 6];
randomizedQuickSort(data);
console.log(data); // 輸出:[1, 2, 3, 4, 5, 6, 7, 8, 9]

// 對抗性測試:已排序輸入不會退化
const sorted = Array.from({ length: 10000 }, (_, i) => i);
randomizedQuickSort(sorted); // 期望 O(n log n),不會退化到 O(n²)

QuickSelect——第 k 小元素

QuickSelect 基於相同的 partition 思想,但每次只遞迴一側,期望時間 O(n),比排序後取第 k 個元素更高效。

// ─── QuickSelect — 期望 O(n) 找第 k 小元素 ─────────────────

function quickSelect(arr: number[], k: number): number {
  // k 為 1-indexed(第 k 小)
  return _quickSelect(arr, 0, arr.length - 1, k - 1);
}

function _quickSelect(arr: number[], lo: number, hi: number, k: number): number {
  if (lo === hi) return arr[lo];

  // 隨機選 pivot
  const pivotIdx = lo + Math.floor(Math.random() * (hi - lo + 1));
  [arr[pivotIdx], arr[hi]] = [arr[hi], arr[pivotIdx]];

  // Lomuto Partition
  const pivot = arr[hi];
  let i = lo - 1;
  for (let j = lo; j < hi; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
  }
  [arr[i + 1], arr[hi]] = [arr[hi], arr[i + 1]];
  const p = i + 1;

  if (k === p) {
    return arr[p];
  } else if (k < p) {
    return _quickSelect(arr, lo, p - 1, k);   // 只遞迴左半
  } else {
    return _quickSelect(arr, p + 1, hi, k);   // 只遞迴右半
  }
}

// 測試
console.log(quickSelect([7, 2, 5, 1, 8, 3], 3)); // 輸出:3(第 3 小的元素)
console.log(quickSelect([7, 2, 5, 1, 8, 3], 1)); // 輸出:1(最小元素)
console.log(quickSelect([7, 2, 5, 1, 8, 3], 6)); // 輸出:8(最大元素)

QuickSelect vs Sort + Index

找第 k 小元素:
  排序 + 取索引:O(n log n)
  QuickSelect:  O(n) 期望

差異原因:
  排序需要遞迴兩側 → T(n) = 2T(n/2) + O(n) → O(n log n)
  QuickSelect 只遞迴一側 → T(n) = T(n/2) + O(n) → O(n)

Reservoir Sampling——蓄水池抽樣

Reservoir Sampling 解決一個經典問題:從大小未知的串流資料中,等機率地抽取 k 個樣本,且只使用 O(k) 空間。

// ─── Reservoir Sampling — 串流等機率抽樣 ─────────────────────

class ReservoirSampler<T> {
  private reservoir: T[];
  private count: number = 0;
  private k: number;

  constructor(k: number) {
    this.k = k;
    this.reservoir = [];
  }

  // 處理串流中的每個元素(O(1) per element)
  add(item: T): void {
    this.count++;

    if (this.reservoir.length < this.k) {
      // 前 k 個元素直接放入水庫
      this.reservoir.push(item);
    } else {
      // 以 k/count 機率替換水庫中的某個元素
      const j = Math.floor(Math.random() * this.count);
      if (j < this.k) {
        this.reservoir[j] = item;
      }
    }
  }

  getSample(): T[] {
    return [...this.reservoir];
  }
}

// 測試:從 1~1000 中隨機取 5 個
const sampler = new ReservoirSampler<number>(5);
for (let i = 1; i <= 1000; i++) {
  sampler.add(i);
}
console.log(sampler.getSample()); // 從 1~1000 中隨機取 5 個,每個被選機率 = 5/1000
Reservoir Sampling 圖解(k=3,串流 [1, 2, 3, 4, 5, 6, 7, 8]):

初始化:reservoir = [1, 2, 3]  (前 k 個直接放入)

處理第 4 個元素(i=4):
  隨機選 j = rand(0, 3),若 j < 3 → 替換 reservoir[j]
  假設 j=1 → reservoir = [1, 4, 3]

處理第 5 個元素(i=5):
  隨機選 j = rand(0, 4),若 j < 3 → 替換
  假設 j=4 → 不替換 → reservoir = [1, 4, 3]

...持續處理到第 8 個元素

數學保證:每個元素被選入最終樣本的機率 = k/n = 3/8

正確性證明(歸納法):
  第 n 個元素:以 k/n 機率選入 → 正確 ✓
  第 i 個元素(i ≤ n-1):
    被選入水庫的機率 × 不被後續元素替換的機率
    = k/i × (i/(i+1)) × ((i+1)/(i+2)) × ... × ((n-1)/n)
    = k/n ✓(望遠鏡式消去)

LeetCode 382 變體——Linked List Random Node(k=1 特例)

// ─── Reservoir Sampling k=1:鏈結串列隨機節點 ────────────────

class ListNode {
  val: number;
  next: ListNode | null = null;
  constructor(val: number) { this.val = val; }
}

class Solution382 {
  private head: ListNode | null;

  constructor(head: ListNode | null) {
    this.head = head;
  }

  getRandom(): number {
    let result = this.head!.val;
    let count = 1;
    let curr = this.head!.next;

    while (curr) {
      count++;
      // 以 1/count 機率用當前節點替換結果
      if (Math.floor(Math.random() * count) === 0) {
        result = curr.val;
      }
      curr = curr.next;
    }
    return result;
  }
}

// 驗證均勻性
const head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);

const sol = new Solution382(head);
const freq: Record<number, number> = { 1: 0, 2: 0, 3: 0 };
const trials = 30000;

for (let i = 0; i < trials; i++) {
  freq[sol.getRandom()]++;
}
Object.entries(freq).forEach(([val, cnt]) => {
  console.log(`節點${val}${(cnt / trials * 100).toFixed(1)}%(期望 33.3%)`);
});
// 輸出:每個節點約 33.3%(±1% 隨機誤差)

Fisher-Yates Shuffle——均勻洗牌

Fisher-Yates Shuffle 是唯一正確的 O(n) 原地洗牌演算法,能保證 n! 種排列各以 1/n! 的機率出現。

// ─── Fisher-Yates Shuffle — 正確的均勻洗牌 ──────────────────

function fisherYatesShuffle<T>(arr: T[]): T[] {
  const result = [...arr]; // 不修改原陣列
  const n = result.length;

  // 從後向前遍歷,每步從 [0, i] 中隨機選一個位置與 i 交換
  for (let i = n - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

// 測試
console.log(fisherYatesShuffle([1, 2, 3, 4, 5]));
// 輸出:[1, 2, 3, 4, 5] 的某個隨機排列
Fisher-Yates Shuffle 過程圖解:

陣列 [1, 2, 3, 4, 5],從後向前交換

步驟1(i=4):j = rand(0~4),假設 j=2
  [1, 2, 5, 4, 3]  ← 位置 4 與位置 2 交換
                ↑固定

步驟2(i=3):j = rand(0~3),假設 j=0
  [4, 2, 5, 1, 3]  ← 位置 3 與位置 0 交換
             ↑固定

步驟3(i=2):j = rand(0~2),假設 j=2(不動)
  [4, 2, 5, 1, 3]
          ↑固定

步驟4(i=1):j = rand(0~1),假設 j=0
  [2, 4, 5, 1, 3]
       ↑固定

最終結果:[2, 4, 5, 1, 3]

為何正確?
  執行路徑數 = 5 × 4 × 3 × 2 × 1 = 5! = 120
  每條路徑機率相等 → 120 種排列各以 1/120 出現 ✓

Naive Shuffle 為什麼錯誤?

// ─── 錯誤示範:Naive Shuffle(不均勻!)────────────────────

function naiveShuffle<T>(arr: T[]): T[] {
  const result = [...arr];
  const n = result.length;

  // 錯誤:每步都從 [0, n-1] 中隨機選
  for (let i = 0; i < n; i++) {
    const j = Math.floor(Math.random() * n); // 應該是 rand(0, i) 而非 rand(0, n-1)
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

// 驗證不均勻性
function compareShuffles(): void {
  const n = 3;
  const trials = 600000;
  const fisherCounts = new Map<string, number>();
  const naiveCounts = new Map<string, number>();

  for (let t = 0; t < trials; t++) {
    const arr1 = [1, 2, 3];
    const arr2 = [1, 2, 3];

    const s1 = fisherYatesShuffle(arr1).join(',');
    const s2 = naiveShuffle(arr2).join(',');

    fisherCounts.set(s1, (fisherCounts.get(s1) ?? 0) + 1);
    naiveCounts.set(s2, (naiveCounts.get(s2) ?? 0) + 1);
  }

  console.log('Fisher-Yates(期望每種 100000 次):');
  for (const [perm, cnt] of [...fisherCounts].sort()) {
    console.log(`  ${perm}: ${cnt}`);
  }

  console.log('Naive Shuffle(分布不均勻):');
  for (const [perm, cnt] of [...naiveCounts].sort()) {
    console.log(`  ${perm}: ${cnt}`);
  }
}
// Fisher-Yates:6 種排列各約 100000 次
// Naive Shuffle:某些排列出現次數明顯偏多或偏少
為什麼 Naive Shuffle 不均勻?

n = 3 時:
  Fisher-Yates 路徑數 = 3 × 2 × 1 = 6 → 對應 3! = 6 種排列 → 均勻 ✓
  Naive Shuffle 路徑數 = 3 × 3 × 3 = 27 → 27 / 6 = 4.5 → 無法整除 → 不均勻 ✗

路徑數 n^n 無法被 n! 整除,因此某些排列被映射到更多路徑,機率偏高。

LeetCode 384——Shuffle an Array

// ─── LeetCode 384:設計可重置洗牌的資料結構 ─────────────────

class ShuffleArray {
  private original: number[];
  private current: number[];

  constructor(nums: number[]) {
    this.original = [...nums];
    this.current = [...nums];
  }

  reset(): number[] {
    this.current = [...this.original];
    return this.current;
  }

  shuffle(): number[] {
    const n = this.current.length;
    for (let i = n - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this.current[i], this.current[j]] = [this.current[j], this.current[i]];
    }
    return this.current;
  }
}

// 測試
const sa = new ShuffleArray([1, 2, 3]);
console.log(sa.shuffle()); // 輸出:某個隨機排列
console.log(sa.reset());   // 輸出:[1, 2, 3]
console.log(sa.shuffle()); // 輸出:另一個隨機排列

Skip List——隨機化有序字典

Skip List(跳表) 是用隨機層數替代確定性平衡的有序資料結構,期望 O(log n) 搜索/插入/刪除,實作遠比 Red-Black Tree 簡單。Redis Sorted Set 和 LevelDB MemTable 都採用 Skip List。

// ─── Skip List — 隨機化有序字典 ─────────────────────────────

const MAX_LEVEL = 16;
const PROBABILITY = 0.5;

class SkipListNode {
  val: number;
  forward: (SkipListNode | null)[];

  constructor(val: number, level: number) {
    this.val = val;
    this.forward = new Array(level).fill(null);
  }
}

class SkipList {
  private head: SkipListNode;
  private level: number = 1;
  private size: number = 0;

  constructor() {
    this.head = new SkipListNode(-Infinity, MAX_LEVEL);
  }

  // 隨機生成層數(幾何分佈)
  private randomLevel(): number {
    let lvl = 1;
    while (Math.random() < PROBABILITY && lvl < MAX_LEVEL) lvl++;
    return lvl;
  }

  // 搜索:O(log n) 期望
  search(target: number): boolean {
    let curr = this.head;
    for (let i = this.level - 1; i >= 0; i--) {
      while (curr.forward[i] && curr.forward[i]!.val < target) {
        curr = curr.forward[i]!;
      }
    }
    curr = curr.forward[0]!;
    return curr !== null && curr.val === target;
  }

  // 插入:O(log n) 期望
  insert(val: number): void {
    const update: (SkipListNode | null)[] = new Array(MAX_LEVEL).fill(null);
    let curr = this.head;

    for (let i = this.level - 1; i >= 0; i--) {
      while (curr.forward[i] && curr.forward[i]!.val < val) {
        curr = curr.forward[i]!;
      }
      update[i] = curr;
    }

    const newLevel = this.randomLevel();
    if (newLevel > this.level) {
      for (let i = this.level; i < newLevel; i++) update[i] = this.head;
      this.level = newLevel;
    }

    const newNode = new SkipListNode(val, newLevel);
    for (let i = 0; i < newLevel; i++) {
      newNode.forward[i] = update[i]!.forward[i];
      update[i]!.forward[i] = newNode;
    }
    this.size++;
  }

  // 刪除:O(log n) 期望
  erase(val: number): boolean {
    const update: (SkipListNode | null)[] = new Array(MAX_LEVEL).fill(null);
    let curr = this.head;

    for (let i = this.level - 1; i >= 0; i--) {
      while (curr.forward[i] && curr.forward[i]!.val < val) {
        curr = curr.forward[i]!;
      }
      update[i] = curr;
    }

    const target = curr.forward[0];
    if (!target || target.val !== val) return false;

    for (let i = 0; i < this.level; i++) {
      if (update[i]!.forward[i] !== target) break;
      update[i]!.forward[i] = target.forward[i];
    }

    while (this.level > 1 && !this.head.forward[this.level - 1]) {
      this.level--;
    }
    this.size--;
    return true;
  }
}

// 測試
const sl = new SkipList();
[3, 6, 7, 9, 12, 17, 19, 21, 25].forEach(v => sl.insert(v));
console.log(sl.search(7));  // 輸出:true
console.log(sl.search(8));  // 輸出:false
sl.erase(7);
console.log(sl.search(7));  // 輸出:false
Skip List 結構圖解:

Level 3: HEAD ─────────────────────── 50 ──── nil
Level 2: HEAD ──────── 20 ─────────── 50 ──── nil
Level 1: HEAD ── 10 ── 20 ── 30 ────── 50 ──── nil
Level 0: HEAD ── 10 ── 20 ── 30 ── 40 ── 50 ── nil

搜索 35 的過程:
  Level3: HEAD → 50(太大,下降)
  Level2: HEAD → 20 → 50(太大,從 20 下降)
  Level1: 20 → 30 → 50(太大,從 30 下降)
  Level0: 30 → 40(35 < 40,不存在)

插入新節點時,隨機生成層數:
  每層以 p=0.5 機率繼續向上
  期望層數 = 1/(1-p) = 2 層
  最大層數 ≈ log₂(n)

Miller-Rabin 隨機化素數測試

Miller-Rabin 是 Monte Carlo 演算法的經典代表:在固定時間內完成,但有微小機率將合數誤判為素數。

// ─── Miller-Rabin 素數測試 — Monte Carlo 演算法 ──────────────

function millerRabin(n: number, k: number = 10): boolean {
  // 處理邊界情況
  if (n < 2) return false;
  if (n === 2 || n === 3) return true;
  if (n % 2 === 0) return false;

  // 將 n-1 寫成 2^r × d 的形式
  let r = 0;
  let d = n - 1;
  while (d % 2 === 0) {
    r++;
    d = Math.floor(d / 2);
  }

  // 進行 k 輪測試
  for (let i = 0; i < k; i++) {
    // 隨機選取 a ∈ [2, n-2]
    const a = 2 + Math.floor(Math.random() * (n - 3));

    let x = modPow(a, d, n); // a^d mod n

    if (x === 1 || x === n - 1) continue;

    let composite = true;
    for (let j = 0; j < r - 1; j++) {
      x = modPow(x, 2, n); // x = x² mod n
      if (x === n - 1) {
        composite = false;
        break;
      }
    }

    if (composite) return false; // 確定是合數
  }

  return true; // 可能是素數(誤判率 ≤ 4^-k)
}

// 快速冪取模
function modPow(base: number, exp: number, mod: number): number {
  let result = 1;
  base = base % mod;
  while (exp > 0) {
    if (exp % 2 === 1) {
      result = (result * base) % mod;
    }
    exp = Math.floor(exp / 2);
    base = (base * base) % mod;
  }
  return result;
}

// 測試
console.log(millerRabin(97));    // 輸出:true(97 是素數)
console.log(millerRabin(100));   // 輸出:false(100 不是素數)
console.log(millerRabin(7919));  // 輸出:true(7919 是素數)
console.log(millerRabin(561));   // 輸出:false(561 是 Carmichael 數)
// k=10 時,將合數誤判為素數的機率 ≤ 4^-10 ≈ 0.000001

C++ 對照實作

Randomized QuickSort 與 QuickSelect

#include <vector>
#include <random>
#include <algorithm>
#include <iostream>
using namespace std;

mt19937 rng(random_device{}());

// ─── Randomized QuickSort ───────────────────────────────────

int partition(vector<int>& arr, int lo, int hi) {
    // 隨機選 pivot
    int pivotIdx = lo + rng() % (hi - lo + 1);
    swap(arr[pivotIdx], arr[hi]);

    int pivot = arr[hi], i = lo - 1;
    for (int j = lo; j < hi; j++) {
        if (arr[j] <= pivot) swap(arr[++i], arr[j]);
    }
    swap(arr[i + 1], arr[hi]);
    return i + 1;
}

void randomizedQuickSort(vector<int>& arr, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(arr, lo, hi);
    randomizedQuickSort(arr, lo, p - 1);
    randomizedQuickSort(arr, p + 1, hi);
}

// ─── QuickSelect ────────────────────────────────────────────

int quickSelect(vector<int>& arr, int lo, int hi, int k) {
    if (lo == hi) return arr[lo];

    int p = partition(arr, lo, hi);
    if (k == p) return arr[p];
    else if (k < p) return quickSelect(arr, lo, p - 1, k);
    else return quickSelect(arr, p + 1, hi, k);
}

int main() {
    vector<int> data = {5, 3, 8, 1, 9, 2, 7, 4, 6};
    randomizedQuickSort(data, 0, data.size() - 1);
    for (int x : data) cout << x << " ";
    // 輸出:1 2 3 4 5 6 7 8 9
    cout << endl;

    vector<int> arr = {7, 2, 5, 1, 8, 3};
    cout << quickSelect(arr, 0, arr.size() - 1, 2) << endl;
    // 輸出:3(第 3 小,0-indexed 為 2)
    return 0;
}

Reservoir Sampling 與 Fisher-Yates Shuffle

#include <vector>
#include <random>
#include <iostream>
using namespace std;

// ─── Reservoir Sampling ─────────────────────────────────────

template<typename T>
class ReservoirSampler {
    vector<T> reservoir;
    int count = 0;
    int k;
    mt19937 rng{random_device{}()};

public:
    ReservoirSampler(int k) : k(k) {}

    void add(const T& item) {
        count++;
        if ((int)reservoir.size() < k) {
            reservoir.push_back(item);
        } else {
            uniform_int_distribution<int> dist(0, count - 1);
            int j = dist(rng);
            if (j < k) reservoir[j] = item;
        }
    }

    vector<T> getSample() const { return reservoir; }
};

// ─── Fisher-Yates Shuffle ───────────────────────────────────

void fisherYatesShuffle(vector<int>& arr) {
    int n = arr.size();
    mt19937 rng(random_device{}());

    for (int i = n - 1; i > 0; i--) {
        uniform_int_distribution<int> dist(0, i);
        int j = dist(rng);
        swap(arr[i], arr[j]);
    }
}

// C++ STL 提供的 std::shuffle 底層即 Fisher-Yates
void stdShuffle(vector<int>& arr) {
    mt19937 rng(random_device{}());
    shuffle(arr.begin(), arr.end(), rng);
}

int main() {
    // Reservoir Sampling 測試
    ReservoirSampler<int> sampler(3);
    for (int i = 1; i <= 1000; i++) sampler.add(i);
    auto sample = sampler.getSample();
    for (int x : sample) cout << x << " ";
    cout << endl;
    // 輸出:從 1~1000 中隨機取 3 個

    // Fisher-Yates Shuffle 測試
    vector<int> arr = {1, 2, 3, 4, 5};
    fisherYatesShuffle(arr);
    for (int x : arr) cout << x << " ";
    cout << endl;
    // 輸出:某個隨機排列
    return 0;
}

Skip List

#include <vector>
#include <random>
#include <climits>
#include <iostream>
using namespace std;

struct SkipNode {
    int val;
    vector<SkipNode*> forward;
    SkipNode(int v, int lvl) : val(v), forward(lvl, nullptr) {}
};

class SkipList {
    static const int MAX_LEVEL = 16;
    static constexpr double P = 0.5;

    SkipNode* head;
    int level = 1;
    mt19937 rng{random_device{}()};

    int randomLevel() {
        int lvl = 1;
        uniform_real_distribution<double> dist(0, 1);
        while (dist(rng) < P && lvl < MAX_LEVEL) lvl++;
        return lvl;
    }

public:
    SkipList() : head(new SkipNode(INT_MIN, MAX_LEVEL)) {}

    bool search(int target) {
        SkipNode* curr = head;
        for (int i = level - 1; i >= 0; i--)
            while (curr->forward[i] && curr->forward[i]->val < target)
                curr = curr->forward[i];
        curr = curr->forward[0];
        return curr && curr->val == target;
    }

    void insert(int val) {
        vector<SkipNode*> update(MAX_LEVEL, nullptr);
        SkipNode* curr = head;
        for (int i = level - 1; i >= 0; i--) {
            while (curr->forward[i] && curr->forward[i]->val < val)
                curr = curr->forward[i];
            update[i] = curr;
        }
        int newLvl = randomLevel();
        if (newLvl > level) {
            for (int i = level; i < newLvl; i++) update[i] = head;
            level = newLvl;
        }
        SkipNode* node = new SkipNode(val, newLvl);
        for (int i = 0; i < newLvl; i++) {
            node->forward[i] = update[i]->forward[i];
            update[i]->forward[i] = node;
        }
    }

    bool erase(int val) {
        vector<SkipNode*> update(MAX_LEVEL, nullptr);
        SkipNode* curr = head;
        for (int i = level - 1; i >= 0; i--) {
            while (curr->forward[i] && curr->forward[i]->val < val)
                curr = curr->forward[i];
            update[i] = curr;
        }
        SkipNode* target = curr->forward[0];
        if (!target || target->val != val) return false;
        for (int i = 0; i < level; i++) {
            if (update[i]->forward[i] != target) break;
            update[i]->forward[i] = target->forward[i];
        }
        while (level > 1 && !head->forward[level - 1]) level--;
        delete target;
        return true;
    }

    ~SkipList() {
        SkipNode* curr = head;
        while (curr) {
            SkipNode* next = curr->forward[0];
            delete curr;
            curr = next;
        }
    }
};

int main() {
    SkipList sl;
    for (int v : {3, 6, 7, 9, 12, 17, 19, 21, 25}) sl.insert(v);
    cout << boolalpha;
    cout << sl.search(7) << endl;   // 輸出:true
    cout << sl.search(8) << endl;   // 輸出:false
    sl.erase(7);
    cout << sl.search(7) << endl;   // 輸出:false
    return 0;
}

複雜度分析

演算法時間複雜度空間複雜度類型
Randomized QuickSortO(n log n) 期望O(log n) 期望Las Vegas
QuickSelectO(n) 期望O(1)Las Vegas
Reservoir SamplingO(n)O(k)Las Vegas
Fisher-Yates ShuffleO(n)O(1) 原地 / O(n) 副本Las Vegas
Skip List 搜索/插入/刪除O(log n) 期望O(n log n) 期望Las Vegas
Random Pick with WeightO(n) 建構, O(log n) 查詢O(n)確定性(前綴和)
Miller-Rabin 素數測試O(k log² n)O(1)Monte Carlo(誤判率 ≤ 4^-k)

關鍵觀察

  • QuickSelect 為何是 O(n)? 每次 partition 後只遞迴一側,遞迴式為 T(n) = T(n/2) + O(n),解為 O(n)。相比之下,QuickSort 遞迴兩側,T(n) = 2T(n/2) + O(n) = O(n log n)。
  • Skip List 為何是 O(log n)? 每層期望跳過一半的節點(因為每個節點以 p=0.5 機率晉升到上一層),搜索路徑長度期望為 O(log n)。
  • Miller-Rabin 的 4^-k:每輪測試有至多 1/4 的機率誤判,k 輪獨立測試後誤判率降至 (1/4)^k。k=20 時誤判率約 10^-12,實務上足夠可靠。

變體與延伸

1. Weighted Reservoir Sampling

當串流中每個元素有不同權重,需要按權重比例抽樣:

// ─── Weighted Reservoir Sampling(Efraimidis-Spirakis 算法)───

class WeightedReservoirSampler<T> {
  private reservoir: { item: T; key: number }[];
  private k: number;

  constructor(k: number) {
    this.k = k;
    this.reservoir = [];
  }

  add(item: T, weight: number): void {
    // 計算隨機鍵:key = random^(1/weight)
    const key = Math.pow(Math.random(), 1 / weight);

    if (this.reservoir.length < this.k) {
      this.reservoir.push({ item, key });
      if (this.reservoir.length === this.k) {
        // 建立最小堆(以 key 排序)
        this.reservoir.sort((a, b) => a.key - b.key);
      }
    } else if (key > this.reservoir[0].key) {
      // 替換最小 key 的元素
      this.reservoir[0] = { item, key };
      this.reservoir.sort((a, b) => a.key - b.key);
    }
  }

  getSample(): T[] {
    return this.reservoir.map(r => r.item);
  }
}

2. Concurrent Skip List

Skip List 的一大優勢是天然適合並發操作。與平衡樹不同,Skip List 的插入/刪除只修改局部指標,不需要全局旋轉:

並發 Skip List 的關鍵設計:
  1. 每個節點有獨立的鎖(Fine-grained Locking)
  2. 插入只鎖定待修改的前驅節點
  3. 搜索完全無鎖(Lock-free Read)
  4. Java ConcurrentSkipListMap 即為此設計

vs 平衡樹的並發困難:
  AVL/RBT 旋轉可能涉及祖父節點 → 需要鎖定更大範圍
  最壞情況下旋轉傳播到根節點 → 需全域鎖

3. Randomized Binary Search Tree (Treap)

Treap 結合了 BST 和 Heap,每個節點有隨機優先級,透過旋轉維護 Heap 性質:

// ─── Treap 基本概念 ─────────────────────────────────────────

// Treap = Tree + Heap
// 每個節點有兩個值:
//   - key:遵守 BST 性質(左小右大)
//   - priority:隨機值,遵守 Max-Heap 性質(父大於子)
// 兩個性質聯合保證樹的期望高度為 O(log n)

// 插入時:先按 BST 規則插入,再按 priority 旋轉上浮
// 刪除時:將節點旋轉到葉子再刪除

面試考點

常見面試問題

問題關鍵思路
如何從串流中等機率抽取一個元素?Reservoir Sampling(k=1),每個元素以 1/i 機率替換
Fisher-Yates 和 Naive Shuffle 的差別?路徑數 n! vs n^n,後者無法均勻映射到所有排列
QuickSort 如何避免最壞情況?隨機選 pivot,或使用 Median-of-3 策略
Skip List vs Red-Black Tree?Skip List 實作簡單、並發友好;RBT 最壞情況保證更強
如何在 O(n) 內找中位數?QuickSelect,期望 O(n)
Math.random() 是否適用於密碼學?不適用,需用 crypto.getRandomValues()

常見陷阱

陷阱說明解決方式
Math.random() 不是真隨機偽隨機數生成器(PRNG),有週期性密碼學場景改用 crypto.getRandomValues()
Naive Shuffle 不均勻rand(0, n-1) 產生 n^n 種路徑嚴格使用 Fisher-Yates(從後往前,rand(0, i)
Reservoir Sampling 下標混淆1-indexed 與 0-indexed 導致機率偏差統一使用 rand(0, count-1),等於 0 時替換
QuickSort 固定 pivot已排序輸入退化 O(n^2)隨機選 pivot 或用 IntroSort
Skip List 記憶體洩漏C++ 不刪除節點在析構函數中逐節點 delete
前綴和整數溢出權重之和超過 INT_MAX改用 long longBigInt

LeetCode 練習題

382. Linked List Random Node(Medium)

從鏈結串列中隨機回傳一個節點值,不允許事先知道長度。使用 Reservoir Sampling(k=1)。完整實作見上方。

384. Shuffle an Array(Medium)

設計支援 reset()shuffle() 的資料結構。使用 Fisher-Yates Shuffle。完整實作見上方。

398. Random Pick Index(Medium)

陣列中某值可能重複,隨機回傳該值的某個索引(等機率)。

// ─── LeetCode 398:Reservoir Sampling 變體 ──────────────────

class Solution398 {
  private nums: number[];

  constructor(nums: number[]) {
    this.nums = nums;
  }

  pick(target: number): number {
    let result = -1;
    let count = 0;
    for (let i = 0; i < this.nums.length; i++) {
      if (this.nums[i] === target) {
        count++;
        if (Math.floor(Math.random() * count) === 0) {
          result = i;
        }
      }
    }
    return result;
  }
}

// 時間:O(n) per pick,空間:O(1)
// 比預先建 HashMap 省空間,適用於記憶體受限場景

528. Random Pick with Weight(Medium)

根據權重陣列按比例隨機回傳索引。使用前綴和 + 二分搜索。

// ─── LeetCode 528:前綴和 + 二分搜索 ────────────────────────

class WeightedRandom {
  private prefixSum: number[];
  private total: number;

  constructor(w: number[]) {
    this.prefixSum = [];
    let sum = 0;
    for (const weight of w) {
      sum += weight;
      this.prefixSum.push(sum);
    }
    this.total = sum;
  }

  pickIndex(): number {
    const target = Math.floor(Math.random() * this.total) + 1;
    let lo = 0, hi = this.prefixSum.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.prefixSum[mid] < target) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }
}

// 直覺圖解:
// w = [1, 3, 2],total = 6,prefixSum = [1, 4, 6]
// 數軸:[1] [2,3,4] [5,6]
//        ↑    ↑       ↑
//      索引0  索引1    索引2
// 隨機選 1~6,二分找第一個 prefixSum[i] >= target

// 時間:建構 O(n),查詢 O(log n)
// 空間:O(n)

215. Kth Largest Element in an Array(Medium)

找陣列中第 k 大的元素。直接使用 QuickSelect。

// ─── LeetCode 215:QuickSelect 找第 k 大 ────────────────────

function findKthLargest(nums: number[], k: number): number {
  // 第 k 大 = 第 (n - k + 1) 小 → 0-indexed 為 n - k
  const target = nums.length - k;
  return _select(nums, 0, nums.length - 1, target);
}

function _select(arr: number[], lo: number, hi: number, k: number): number {
  if (lo === hi) return arr[lo];

  const pivotIdx = lo + Math.floor(Math.random() * (hi - lo + 1));
  [arr[pivotIdx], arr[hi]] = [arr[hi], arr[pivotIdx]];

  const pivot = arr[hi];
  let i = lo - 1;
  for (let j = lo; j < hi; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
  }
  [arr[i + 1], arr[hi]] = [arr[hi], arr[i + 1]];
  const p = i + 1;

  if (k === p) return arr[p];
  else if (k < p) return _select(arr, lo, p - 1, k);
  else return _select(arr, p + 1, hi, k);
}

// 測試
console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 輸出:5
console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 輸出:4
// 期望時間:O(n),空間:O(1)

總結

隨機演算法是演算法設計中一個獨特而強大的範式——透過引入「可控的隨機性」,我們能夠在許多場景中獲得比確定性演算法更簡單、更高效的解法。

本文涵蓋了隨機演算法的核心知識:

  1. 兩大類型:Las Vegas(賭時間,保證正確)與 Monte Carlo(賭正確性,保證時間)
  2. 排序與選擇:Randomized QuickSort 避免對抗性最壞情況,QuickSelect 以期望 O(n) 找第 k 小元素
  3. 串流抽樣:Reservoir Sampling 在 O(k) 空間內實現未知大小串流的等機率抽樣
  4. 均勻洗牌:Fisher-Yates Shuffle 是唯一正確的 O(n) 洗牌算法,Naive Shuffle 因路徑數不匹配而不均勻
  5. 隨機化資料結構:Skip List 用隨機層數替代平衡樹的複雜旋轉,被 Redis 和 LevelDB 採用
  6. 素數測試:Miller-Rabin 以極低的錯誤機率在多項式時間內判定素數

記住核心原則:隨機化不是猜測,而是有數學保證的策略。每個隨機演算法的背後都有嚴格的機率分析,確保其正確性或效能達到預期。

下一篇文章,我們將探討 近似與線上演算法(Approximation & Online Algorithms),學習如何在無法獲得最優解的情況下,找到「足夠好」的解答——這在真實世界的大規模系統中尤其重要。

上一篇:博弈論完全指南
BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。