隨機演算法完全指南 — QuickSelect、Reservoir Sampling 與 Fisher-Yates 洗牌 | 資料結構與演算法
隨機演算法(Randomized Algorithms) 是在執行過程中引入隨機決策的演算法,透過 「擲骰子」 來換取更簡單的實作與更好的平均效能。核心分為兩大類型:保證正確但時間隨機的 Las Vegas 演算法(如 Randomized QuickSort、Reservoir Sampling),以及固定時間但答案可能有誤的 Monte Carlo 演算法(如 Miller-Rabin 素數測試)。本文將帶你從理論基礎出發,完整掌握 QuickSelect、Fisher-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 Vegas 與 Monte Carlo 兩大隨機演算法類型
- 實作 Randomized QuickSort 與 QuickSelect 避免最壞情況
- 掌握 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 Vegas | Monte Carlo |
|---|---|---|
| 正確性 | 100% 正確 | 有機率錯誤 |
| 時間 | 隨機(期望有界) | 確定(固定上限) |
| 核心取捨 | 賭時間 | 賭正確性 |
| 典型範例 | Randomized QuickSort | Miller-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 QuickSort | O(n log n) 期望 | O(log n) 期望 | Las Vegas |
| QuickSelect | O(n) 期望 | O(1) | Las Vegas |
| Reservoir Sampling | O(n) | O(k) | Las Vegas |
| Fisher-Yates Shuffle | O(n) | O(1) 原地 / O(n) 副本 | Las Vegas |
| Skip List 搜索/插入/刪除 | O(log n) 期望 | O(n log n) 期望 | Las Vegas |
| Random Pick with Weight | O(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 long 或 BigInt |
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)
總結
隨機演算法是演算法設計中一個獨特而強大的範式——透過引入「可控的隨機性」,我們能夠在許多場景中獲得比確定性演算法更簡單、更高效的解法。
本文涵蓋了隨機演算法的核心知識:
- 兩大類型:Las Vegas(賭時間,保證正確)與 Monte Carlo(賭正確性,保證時間)
- 排序與選擇:Randomized QuickSort 避免對抗性最壞情況,QuickSelect 以期望 O(n) 找第 k 小元素
- 串流抽樣:Reservoir Sampling 在 O(k) 空間內實現未知大小串流的等機率抽樣
- 均勻洗牌:Fisher-Yates Shuffle 是唯一正確的 O(n) 洗牌算法,Naive Shuffle 因路徑數不匹配而不均勻
- 隨機化資料結構:Skip List 用隨機層數替代平衡樹的複雜旋轉,被 Redis 和 LevelDB 採用
- 素數測試:Miller-Rabin 以極低的錯誤機率在多項式時間內判定素數
記住核心原則:隨機化不是猜測,而是有數學保證的策略。每個隨機演算法的背後都有嚴格的機率分析,確保其正確性或效能達到預期。
下一篇文章,我們將探討 近似與線上演算法(Approximation & Online Algorithms),學習如何在無法獲得最優解的情況下,找到「足夠好」的解答——這在真實世界的大規模系統中尤其重要。
上一篇:博弈論完全指南