近似與線上演算法完全指南 — 頂點覆蓋、集合覆蓋與秘書問題 | 資料結構與演算法
近似演算法(Approximation Algorithms) 與 線上演算法(Online Algorithms) 是面對現實世界中 NP-hard 問題與即時決策場景的兩大核心武器。近似演算法在多項式時間內找到「足夠好」的解,並以 近似比(Approximation Ratio) 量化品質保證;線上演算法則在完全不知未來輸入的情況下即時決策,以 競爭比(Competitive Ratio) 衡量與最優離線解的差距。本文將帶你從理論基礎出發,完整掌握 Vertex Cover 2-近似、Set Cover 貪心法、TSP 2-近似、秘書問題 1/e 策略、Paging 與 負載均衡 等經典技術,搭配 JavaScript/TypeScript 與 C++ 雙語言實作。
前言
在前幾篇文章中,我們已經學習了圖論演算法、動態規劃、貪心策略等強大工具。然而,現實世界中存在大量的 NP-hard 問題——例如旅行推銷員問題(TSP)、頂點覆蓋(Vertex Cover)、集合覆蓋(Set Cover)——除非 P = NP,否則不存在多項式時間的精確解。
面對這些問題,我們有兩條路:
- 近似演算法:放棄追求最優解,轉而追求「有數學保證的足夠好的解」。例如 Vertex Cover 的 2-近似演算法保證找到的解最多是最優解的 2 倍大。
- 線上演算法:面對更嚴苛的挑戰——輸入逐步到達,必須在不知未來資訊的情況下即時決策。例如作業系統的頁面置換(Paging)、廣告競價系統、雲端資源管理。
學習本文後,你將能夠:
- 理解 NP-hardness 與近似比的數學定義
- 實作 Vertex Cover 2-近似、Set Cover 貪心法、Metric TSP 2-近似
- 掌握 秘書問題 的 1/e 最優停止策略
- 分析 LRU / FIFO 頁面置換的競爭比
- 理解 PTAS / FPTAS 等進階近似方案類別
- 應用 串流演算法 在海量資料場景中的近似處理
核心概念
NP-hardness 與近似的必要性
NP-hard 問題是計算複雜度理論中「至少和 NP 中最難問題一樣難」的問題類別。除非 P = NP(目前學界普遍認為不成立),否則 NP-hard 問題不存在多項式時間的精確演算法。
NP-Hard 問題的應對策略:
1. 精確解(指數時間)
→ 暴力搜索 O(2^n),只適用於小規模輸入(n ≤ 20)
2. 近似演算法(多項式時間 + 品質保證)
→ 本文重點!在 O(n^k) 時間內保證近似比 α
3. 啟發式演算法(多項式時間,無保證)
→ 如基因演算法、模擬退火,實務常用但無理論保證
4. 參數化演算法(FPT)
→ 時間 O(f(k) × n^c),當參數 k 很小時可行
近似比(Approximation Ratio)
設最優解值為 OPT,近似演算法給出的解值為 ALG:
- 最小化問題(如 Vertex Cover、TSP):
α = ALG / OPT >= 1,保證ALG <= α × OPT - 最大化問題(如 MAX-SAT):
α = OPT / ALG >= 1,保證ALG >= OPT / α
α 越接近 1,解的品質越好。α = 1 表示精確解。
近似比速查表:
近似比 α | 含義 | 典型問題
─────────────|───────────────────────────|─────────────────────────
1 | 精確解 | P 類問題
1 + ε | PTAS(多項式時間近似方案) | Knapsack、Euclidean TSP
2 | 2 倍以內 | Vertex Cover、Metric TSP
O(log n) | 對數近似 | Set Cover
無常數界 | 無常數近似保證 | General TSP
競爭比(Competitive Ratio)——線上演算法的品質度量
線上演算法面對的挑戰是:輸入逐步到來,必須即時決策且不可反悔。其品質以 競爭比 衡量:
離線(Offline)vs 線上(Online):
離線:已知全部輸入,可做全局最優決策
例:知道未來 7 天股價,決定最佳買賣時機
線上:輸入逐步到來,必須即時決策,不可反悔
例:今天是否買股票,不知道明天的價格
競爭比 c 定義(最小化問題):
c = max over all inputs I: ALG(I) / OPT(I)
解讀:
c = 1 → 線上解與最優離線解一樣好
c = k → 線上解最差是最優解的 k 倍
c = ∞ → 無競爭比保證
Online vs Offline 決策模型
理解線上與離線的差異是本文的關鍵。以下是幾個直覺的例子:
| 場景 | 離線決策(知道未來) | 線上決策(不知未來) |
|---|---|---|
| 租房 | 知道所有房源,選最好的 | 看一間決定一間,不可回頭 |
| 快取管理 | 知道未來所有頁面請求 | 頁面逐個到來,即時決定置換 |
| 雲端採購 | 知道未來用量,決定買 Reserved 或 On-Demand | 不確定工作負載持續多久 |
JavaScript / TypeScript 實作
Vertex Cover 2-近似演算法
問題:給定無向圖 G=(V,E),找最小頂點集合 C,使每條邊至少有一個端點在 C 中。Vertex Cover 是 NP-Hard 問題。
策略:基於最大匹配(Maximal Matching)。每次挑選一條尚未被覆蓋的邊,將兩個端點都加入覆蓋集合。
// ─── Vertex Cover 2-近似演算法(Matching-based)──────────────
function vertexCover2Approx(
n: number,
edges: [number, number][]
): number[] {
const adj: Map<number, Set<number>> = new Map();
for (let i = 0; i < n; i++) adj.set(i, new Set());
for (const [u, v] of edges) {
adj.get(u)!.add(v);
adj.get(v)!.add(u);
}
const cover = new Set<number>();
const matched = new Set<string>();
for (const [u, v] of edges) {
const key = `${Math.min(u, v)},${Math.max(u, v)}`;
// 若這條邊的兩端都尚未在 cover 中
if (!cover.has(u) && !cover.has(v)) {
cover.add(u);
cover.add(v);
matched.add(key);
}
}
return [...cover];
}
// 驗證:每條邊至少有一端在 cover 中
function validateCover(
edges: [number, number][],
cover: number[]
): boolean {
const coverSet = new Set(cover);
return edges.every(([u, v]) => coverSet.has(u) || coverSet.has(v));
}
// 測試
const edges: [number, number][] = [
[0, 1], [0, 2], [1, 3], [2, 3], [3, 4]
];
const cover = vertexCover2Approx(5, edges);
console.log('Vertex Cover:', cover);
console.log('合法覆蓋?', validateCover(edges, cover));
console.log('大小:', cover.length);
// 輸出:大小最多是最優解的 2 倍
為何保證 2-近似? 演算法選取的邊形成一個匹配 M(互不相鄰的邊集合)。輸出 |C| = 2|M|,而任何合法 Vertex Cover 必須覆蓋 M 的每條邊,因此 |OPT| >= |M|,得到 |C| = 2|M| <= 2|OPT|。
Set Cover 貪心近似
問題:給定全集 U(|U|=n)和 m 個子集,找最少子集數量覆蓋全集。Set Cover 是 NP-Hard 問題,貪心演算法可達 O(ln n)-近似。
// ─── Set Cover 貪心近似——O(ln n) 近似比 ──────────────────────
function greedySetCover(
universe: Set<number>,
subsets: Map<string, Set<number>>
): string[] {
const uncovered = new Set(universe);
const selected: string[] = [];
while (uncovered.size > 0) {
// 選擇覆蓋最多未覆蓋元素的子集
let bestKey = '';
let bestCount = 0;
for (const [key, subset] of subsets) {
let count = 0;
for (const elem of subset) {
if (uncovered.has(elem)) count++;
}
if (count > bestCount) {
bestCount = count;
bestKey = key;
}
}
if (bestCount === 0) break; // 無法覆蓋(子集不足)
selected.push(bestKey);
const chosen = subsets.get(bestKey)!;
for (const elem of chosen) {
uncovered.delete(elem);
}
}
return selected;
}
// 測試
const universe = new Set([1, 2, 3, 4, 5, 6, 7]);
const subsets = new Map<string, Set<number>>([
['S1', new Set([1, 2, 3, 4, 5])],
['S2', new Set([1, 2, 6])],
['S3', new Set([3, 4, 7])],
['S4', new Set([5, 6, 7])],
]);
const result = greedySetCover(universe, subsets);
console.log('選中子集:', result); // 輸出:['S1', 'S4']
console.log('數量:', result.length); // 輸出:2
近似比直覺:設最優解用 k 個子集。第 1 輪貪心至少覆蓋 n/k 個元素,剩餘 <= n(1-1/k)。經過 t 輪後剩餘 <= n(1-1/k)^t < n × e^(-t/k)。當 t = k ln n 時剩餘 < 1,因此近似比 <= ln n。
Metric TSP 2-近似
問題:在滿足三角不等式的距離圖上找最短哈密頓回路。一般 TSP 連近似都是 NP-Hard,但在度量空間下有 2-近似。
// ─── Metric TSP 2-近似(MST-based)─────────────────────────
// Prim's MST 建構
function primMST(dist: number[][]): [number, number][] {
const n = dist.length;
const inMST = new Array(n).fill(false);
const key = new Array(n).fill(Infinity);
const parent = new Array(n).fill(-1);
key[0] = 0;
for (let count = 0; count < n; count++) {
// 找最小 key 的非 MST 頂點
let u = -1;
for (let v = 0; v < n; v++) {
if (!inMST[v] && (u === -1 || key[v] < key[u])) u = v;
}
inMST[u] = true;
for (let v = 0; v < n; v++) {
if (!inMST[v] && dist[u][v] < key[v]) {
key[v] = dist[u][v];
parent[v] = u;
}
}
}
const edges: [number, number][] = [];
for (let i = 1; i < n; i++) {
edges.push([parent[i], i]);
}
return edges;
}
// DFS 前序遍歷 MST,跳過已訪問節點(Shortcut)
function tspApprox(dist: number[][]): {
tour: number[];
cost: number;
} {
const n = dist.length;
const mstEdges = primMST(dist);
// 建鄰接表
const adj: Map<number, number[]> = new Map();
for (let i = 0; i < n; i++) adj.set(i, []);
for (const [u, v] of mstEdges) {
adj.get(u)!.push(v);
adj.get(v)!.push(u);
}
// DFS 前序遍歷
const visited = new Set<number>();
const tour: number[] = [];
function dfs(node: number): void {
visited.add(node);
tour.push(node);
for (const neighbor of adj.get(node)!) {
if (!visited.has(neighbor)) {
dfs(neighbor);
}
}
}
dfs(0);
tour.push(0); // 回到起點
// 計算總成本
let cost = 0;
for (let i = 0; i < tour.length - 1; i++) {
cost += dist[tour[i]][tour[i + 1]];
}
return { tour, cost };
}
// 測試:4 個城市,距離滿足三角不等式
const dist = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0],
];
const { tour, cost } = tspApprox(dist);
console.log('路線:', tour); // 輸出:[0, 1, 3, 2, 0](或其他合法路線)
console.log('成本:', cost); // 近似成本 <= 2 × OPT
正確性證明:MST 成本 <= OPT(最優 TSP 去掉一條邊即為生成樹),DFS 遍歷走每條邊兩次,成本 = 2 × MST <= 2 × OPT。三角不等式保證快捷路徑(Shortcut)不會更長。
Secretary Problem——1/e 最優停止策略
場景:面試 n 個候選人(順序隨機),每次面試後必須立即決定錄取或放棄,不可反悔。目標是最大化錄取到最優候選人的機率。
// ─── Secretary Problem:1/e 最優停止策略 ─────────────────────
function secretaryAlgorithm(candidates: number[]): {
selected: number;
index: number;
} {
const n = candidates.length;
const r = Math.max(1, Math.floor(n / Math.E)); // 觀察期 = n/e
// 觀察期:記錄前 r 名中的最高分
let bestInObservation = -Infinity;
for (let i = 0; i < r; i++) {
bestInObservation = Math.max(bestInObservation, candidates[i]);
}
// 選擇期:錄取第一個超過觀察期最高分的候選人
for (let i = r; i < n; i++) {
if (candidates[i] > bestInObservation) {
return { selected: candidates[i], index: i };
}
}
// 若無人超過,錄取最後一人
return { selected: candidates[n - 1], index: n - 1 };
}
// Fisher-Yates Shuffle
function shuffle(arr: number[]): void {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// 蒙地卡羅模擬驗證成功率
function simulateSecretary(
n: number,
trials: number
): number {
let successes = 0;
for (let t = 0; t < trials; t++) {
// 生成 1~n 的隨機排列
const candidates = Array.from({ length: n }, (_, i) => i + 1);
shuffle(candidates);
const best = n; // 最優候選人分數 = n
const { selected } = secretaryAlgorithm(candidates);
if (selected === best) successes++;
}
return successes / trials;
}
// 測試
const rate = simulateSecretary(100, 50000);
console.log(`成功率:${(rate * 100).toFixed(1)}%`);
// 輸出:成功率 ≈ 36.8%(理論值 = 1/e ≈ 0.368)
直覺理解:先用 37% 的候選人「探索」市場水準,建立基準線。之後一旦遇到超越基準的人就立刻錄取。這個策略在 n 趨向無窮時,成功機率趨近 1/e ≈ 36.8%。
Online Paging——LRU vs FIFO
場景:快取容量 k 個頁面,存取序列逐步到來。快取未命中時需從主記憶體讀入,若已滿需置換一頁。
// ─── Online Paging 模擬——LRU vs FIFO ────────────────────────
class LRUCache {
private cache: Map<number, number>; // key → 最後使用時間
private capacity: number;
private time: number = 0;
misses: number = 0;
constructor(k: number) {
this.capacity = k;
this.cache = new Map();
}
access(page: number): boolean {
this.time++;
if (this.cache.has(page)) {
this.cache.set(page, this.time); // 更新使用時間
return true; // hit
}
this.misses++;
if (this.cache.size >= this.capacity) {
// 置換最久未使用的頁面
let lruPage = -1;
let lruTime = Infinity;
for (const [p, t] of this.cache) {
if (t < lruTime) {
lruTime = t;
lruPage = p;
}
}
this.cache.delete(lruPage);
}
this.cache.set(page, this.time);
return false; // miss
}
}
class FIFOCache {
private queue: number[] = [];
private inCache: Set<number> = new Set();
private capacity: number;
misses: number = 0;
constructor(k: number) {
this.capacity = k;
}
access(page: number): boolean {
if (this.inCache.has(page)) return true; // hit
this.misses++;
if (this.queue.length >= this.capacity) {
const evicted = this.queue.shift()!;
this.inCache.delete(evicted);
}
this.queue.push(page);
this.inCache.add(page);
return false; // miss
}
}
// Belady 最優離線演算法(知道未來全部請求)
function beladyOPT(
requests: number[],
k: number
): number {
const cache = new Set<number>();
let misses = 0;
for (let i = 0; i < requests.length; i++) {
if (cache.has(requests[i])) continue;
misses++;
if (cache.size >= k) {
// 置換未來最遠才用到的頁面
let farthest = -1;
let evictPage = -1;
for (const page of cache) {
let nextUse = Infinity;
for (let j = i + 1; j < requests.length; j++) {
if (requests[j] === page) { nextUse = j; break; }
}
if (nextUse > farthest) {
farthest = nextUse;
evictPage = page;
}
}
cache.delete(evictPage);
}
cache.add(requests[i]);
}
return misses;
}
// 測試比較
const requests = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5];
const k = 3;
const lru = new LRUCache(k);
const fifo = new FIFOCache(k);
for (const r of requests) { lru.access(r); fifo.access(r); }
const optMisses = beladyOPT(requests, k);
console.log(`LRU misses: ${lru.misses}`); // 線上演算法
console.log(`FIFO misses: ${fifo.misses}`); // 線上演算法
console.log(`OPT misses: ${optMisses}`); // 離線最優
console.log(`LRU 競爭比: ${(lru.misses / optMisses).toFixed(2)}`);
// 理論競爭比上界 = k = 3
競爭比結論:LRU 和 FIFO 等確定性線上演算法的競爭比為 k(快取大小)。對手可以構造長度 k+1 的循環序列,使確定性演算法每次都 miss,而 OPT 只 miss 一次。
Load Balancing——貪心負載均衡
問題:n 個工作要分配到 m 台機器,每個工作有處理時間 p[i],目標是最小化最大機器負載(Makespan)。
// ─── Greedy Load Balancing(List Scheduling)────────────────
function greedyLoadBalance(
jobs: number[],
m: number
): { assignment: number[][]; makespan: number } {
const machines: number[][] = Array.from({ length: m }, () => []);
const loads = new Array(m).fill(0);
for (const job of jobs) {
// 找負載最小的機器
let minIdx = 0;
for (let i = 1; i < m; i++) {
if (loads[i] < loads[minIdx]) minIdx = i;
}
machines[minIdx].push(job);
loads[minIdx] += job;
}
const makespan = Math.max(...loads);
return { assignment: machines, makespan };
}
// LPT(Longest Processing Time First)——改進版
function lptLoadBalance(
jobs: number[],
m: number
): { assignment: number[][]; makespan: number } {
// 先將工作按時間降序排列
const sorted = [...jobs].sort((a, b) => b - a);
return greedyLoadBalance(sorted, m);
}
// 測試
const jobs = [6, 3, 8, 2, 7, 4, 5, 1];
const m = 3;
const greedy = greedyLoadBalance(jobs, m);
const lpt = lptLoadBalance(jobs, m);
console.log('Greedy makespan:', greedy.makespan);
console.log('LPT makespan:', lpt.makespan);
// LPT 的近似比 = 4/3 - 1/(3m),比純貪心的 2 - 1/m 更好
近似比分析:
- List Scheduling(純貪心):近似比
<= 2 - 1/m - LPT(先排序再貪心):近似比
<= 4/3 - 1/(3m),品質更好但需 O(n log n) 排序
C++ 對照實作
Vertex Cover 與 Set Cover
#include <bits/stdc++.h>
using namespace std;
// ─── Vertex Cover 2-近似 ────────────────────────────────────
vector<int> vertexCover2Approx(int n, vector<pair<int,int>>& edges) {
set<int> cover;
for (auto& [u, v] : edges) {
// 若兩端都不在 cover 中,將兩端都加入
if (cover.find(u) == cover.end() &&
cover.find(v) == cover.end()) {
cover.insert(u);
cover.insert(v);
}
}
return vector<int>(cover.begin(), cover.end());
}
// ─── Set Cover 貪心近似 ─────────────────────────────────────
vector<int> greedySetCover(
int n,
vector<vector<int>>& subsets
) {
set<int> uncovered;
for (int i = 0; i < n; i++) uncovered.insert(i);
vector<int> selected;
vector<bool> used(subsets.size(), false);
while (!uncovered.empty()) {
int bestIdx = -1, bestCount = 0;
for (int i = 0; i < (int)subsets.size(); i++) {
if (used[i]) continue;
int count = 0;
for (int elem : subsets[i]) {
if (uncovered.count(elem)) count++;
}
if (count > bestCount) {
bestCount = count;
bestIdx = i;
}
}
if (bestIdx == -1) break;
selected.push_back(bestIdx);
used[bestIdx] = true;
for (int elem : subsets[bestIdx]) {
uncovered.erase(elem);
}
}
return selected;
}
int main() {
// Vertex Cover 測試
vector<pair<int,int>> edges = {{0,1},{0,2},{1,3},{2,3},{3,4}};
auto cover = vertexCover2Approx(5, edges);
cout << "Vertex Cover: ";
for (int v : cover) cout << v << " ";
cout << "\n大小: " << cover.size() << "\n";
// Set Cover 測試
// 全集 = {0,1,2,3,4,5,6}
vector<vector<int>> subsets = {
{0, 1, 2, 3, 4}, // S0
{0, 1, 5}, // S1
{2, 3, 6}, // S2
{4, 5, 6}, // S3
};
auto selected = greedySetCover(7, subsets);
cout << "Set Cover 選中: ";
for (int idx : selected) cout << "S" << idx << " ";
cout << "\n數量: " << selected.size() << "\n";
return 0;
}
Secretary Problem 與 LRU Paging
#include <bits/stdc++.h>
using namespace std;
// ─── Secretary Problem 模擬 ─────────────────────────────────
int secretaryAlgorithm(vector<int>& candidates) {
int n = candidates.size();
int r = max(1, (int)floor(n / M_E)); // 觀察期 = n/e
int bestInObs = *max_element(
candidates.begin(), candidates.begin() + r
);
for (int i = r; i < n; i++) {
if (candidates[i] > bestInObs) {
return candidates[i];
}
}
return candidates[n - 1];
}
double simulateSecretary(int n, int trials) {
mt19937 rng(random_device{}());
int successes = 0;
for (int t = 0; t < trials; t++) {
vector<int> candidates(n);
iota(candidates.begin(), candidates.end(), 1); // 1~n
shuffle(candidates.begin(), candidates.end(), rng);
if (secretaryAlgorithm(candidates) == n) {
successes++;
}
}
return (double)successes / trials;
}
// ─── LRU Cache(使用 list + unordered_map)─────────────────
class LRUCache {
int capacity;
list<int> order; // 前端 = 最近使用
unordered_map<int, list<int>::iterator> cache;
public:
int misses = 0;
LRUCache(int k) : capacity(k) {}
bool access(int page) {
auto it = cache.find(page);
if (it != cache.end()) {
order.erase(it->second);
order.push_front(page);
cache[page] = order.begin();
return true; // hit
}
misses++;
if ((int)cache.size() >= capacity) {
int evict = order.back();
order.pop_back();
cache.erase(evict);
}
order.push_front(page);
cache[page] = order.begin();
return false; // miss
}
};
int main() {
// Secretary Problem
double rate = simulateSecretary(100, 50000);
printf("成功率: %.1f%% (理論值 ≈ 36.8%%)\n", rate * 100);
// LRU Paging
vector<int> requests = {1,2,3,4,1,2,5,1,2,3,4,5};
LRUCache lru(3);
for (int r : requests) lru.access(r);
cout << "LRU misses: " << lru.misses << "\n";
return 0;
}
複雜度分析
| 演算法 | 時間複雜度 | 空間複雜度 | 近似比 / 競爭比 | 備註 |
|---|---|---|---|---|
| Vertex Cover 2-近似 | O(V+E) | O(V) | <= 2 | 基於最大匹配 |
| Set Cover 貪心 | O(m × n) | O(n) | <= ln n | m=子集數 |
| Metric TSP 2-近似 | O(n^2 log n) | O(n) | <= 2 | 需三角不等式 |
| Secretary Problem | O(n) | O(1) | 1/e ≈ 0.368 成功率 | 隨機輸入假設 |
| LRU Paging | O(1)/請求 | O(k) | k-competitive | k=快取大小 |
| FIFO Paging | O(1)/請求 | O(k) | k-competitive | k=快取大小 |
| Load Balancing (List) | O(n × m) | O(m) | <= 2 - 1/m | 線上版 |
| Load Balancing (LPT) | O(n log n) | O(m) | <= 4/3 - 1/(3m) | 離線版 |
關鍵觀察:
- Vertex Cover 為何 2-近似很難突破? 在 Unique Games Conjecture 成立的前提下,Vertex Cover 不存在 (2-ε)-近似演算法。2-近似是一個「tight」的結果。
- Set Cover 的 ln n 近似比是最優的:除非 P = NP,Set Cover 不存在 (1-ε)ln n-近似。
- LRU 雖然競爭比 = k,但實務效果極佳:因為真實工作負載具有局部性(Locality),遠比最壞情況好。
變體與延伸
PTAS 與 FPTAS——更精細的近似方案
PTAS(Polynomial-Time Approximation Scheme)是對任意 ε > 0,都能在多項式時間內達到 (1+ε)-近似的演算法,但時間可能隨 ε 指數成長(如 O(n^(1/ε)))。
FPTAS(Fully PTAS)更強:時間對 n 和 1/ε 都是多項式的(如 O(n^2/ε))。
近似方案層級(由強到弱):
FPTAS ⊂ PTAS ⊂ APX ⊂ NPO
───────────────────────────────────────────
層級 | 定義 | 典型問題
───────────────────────────────────────────
FPTAS | O(poly(n, 1/ε)) | 0/1 Knapsack
PTAS | O(n^f(1/ε)) | Euclidean TSP
APX | 存在常數近似比 | Vertex Cover (2-近似)
NPO | NP 最佳化問題 | General TSP (無常數近似)
───────────────────────────────────────────
0/1 Knapsack FPTAS 關鍵思路:將物品價值除以一個縮放因子 K,四捨五入後用 DP 求解。K 越大精度越差但速度越快,K = ε × max_value / n 可達到 (1-ε)-近似,時間 O(n^3/ε)。
串流演算法(Streaming Algorithms)
串流演算法是線上演算法的重要延伸,專為處理無法全部載入記憶體的海量資料流設計,通常只能單次遍歷(One Pass),使用 O(polylog n) 記憶體。
串流演算法的資源限制與解法:
資料流大小 N = 10 億筆
一筆資料 4 bytes
全部載入 4 GB RAM ← 不可行
常見串流問題與解法:
1. 頻率估計 → Count-Min Sketch(永不低估,ε 誤差)
2. 基數估計 → HyperLogLog(12KB 追蹤十億量級 UV)
3. 重元素(Heavy Hitters)→ Misra-Gries / Space-Saving
4. 分位數估計 → Greenwald-Khanna / t-digest
真實應用:
- Redis PFCOUNT → HyperLogLog 估算獨立訪客數
- Cloudflare DDoS 偵測 → Count-Min Sketch 追蹤 IP 流量
- Apache Flink → 即時串流分析引擎
面試考點
常見面試問題
| 問題 | 關鍵思路 |
|---|---|
| Vertex Cover 如何 2-近似? | 基於 Maximal Matching,每條匹配邊兩端都加入 |
| Set Cover 貪心的近似比? | O(ln n),每輪選覆蓋最多新元素的子集 |
| 何謂 PTAS 和 FPTAS? | PTAS 對 1/ε 可指數,FPTAS 對兩者都多項式 |
| Secretary Problem 最佳策略? | 觀察前 n/e 人,之後選第一個超越觀察期最佳者 |
| LRU 的競爭比是多少? | k-competitive,k 為快取大小 |
| 近似演算法 vs 啟發式? | 近似有數學保證(近似比),啟發式無保證 |
常見陷阱
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 混淆近似比方向 | 最小化問題 α >= 1,最大化問題也 α >= 1 | 統一定義:α = max(ALG/OPT, OPT/ALG) |
| 認為 TSP 都能近似 | 一般 TSP 無常數近似(除非 P=NP) | 僅 Metric TSP(三角不等式)有 2-近似 |
| Secretary Problem 邊界 | r=0 或 r=n 時策略退化 | 確保 r >= 1,使用 Math.max(1, floor(n/e)) |
| 線上演算法比離線差很多 | 最壞情況確實差 k 倍 | 實務中利用 Locality 效果遠比理論好 |
| Paging 下標混淆 | 置換策略中忘記更新時間戳 | LRU 用 Map + 有序結構精確維護 |
LeetCode 練習題
1199. Minimum Time to Build Blocks(Hard)
n 個 block 需要建造,每個有建造時間 blocks[i],可以花 split 時間將一個工人分成兩個。最小化完成所有 block 的時間。
這是一個 近似 / 貪心 問題,本質上是霍夫曼編碼(Huffman Coding)的變體。
// ─── LeetCode 1199:Huffman-style 貪心 ──────────────────────
function minBuildTime(blocks: number[], split: number): number {
// 最小堆
const heap = new MinHeap();
for (const b of blocks) heap.push(b);
while (heap.size() > 1) {
heap.pop(); // 較小值(被合併掉)
const second = heap.pop()!;
heap.push(second + split); // 合併後加上 split 時間
}
return heap.pop()!;
}
// 簡易最小堆實作
class MinHeap {
private data: number[] = [];
size(): number { return this.data.length; }
push(val: number): void {
this.data.push(val);
this.bubbleUp(this.data.length - 1);
}
pop(): number | undefined {
if (this.data.length === 0) return undefined;
const top = this.data[0];
const last = this.data.pop()!;
if (this.data.length > 0) {
this.data[0] = last;
this.sinkDown(0);
}
return top;
}
private bubbleUp(i: number): void {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.data[parent] <= this.data[i]) break;
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
i = parent;
}
}
private sinkDown(i: number): void {
const n = this.data.length;
while (true) {
let smallest = i;
const l = 2 * i + 1, r = 2 * i + 2;
if (l < n && this.data[l] < this.data[smallest]) smallest = l;
if (r < n && this.data[r] < this.data[smallest]) smallest = r;
if (smallest === i) break;
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
i = smallest;
}
}
}
// 測試
console.log(minBuildTime([1, 2], 5)); // 輸出:7
console.log(minBuildTime([1, 2, 3], 1)); // 輸出:4
1515. Best Position for a Service Centre(Hard)
找到到所有客戶距離總和最小的服務中心位置。這是 幾何中位數(Geometric Median) 問題,NP-Hard 的精確解,使用 Weiszfeld 迭代近似。
// ─── LeetCode 1515:Weiszfeld 迭代近似 ──────────────────────
function getMinDistSum(positions: number[][]): number {
let cx = 0, cy = 0;
for (const [x, y] of positions) { cx += x; cy += y; }
cx /= positions.length;
cy /= positions.length;
for (let iter = 0; iter < 1000; iter++) {
let nx = 0, ny = 0, wSum = 0;
for (const [x, y] of positions) {
const d = Math.sqrt((cx - x) ** 2 + (cy - y) ** 2);
if (d < 1e-10) continue;
const w = 1 / d;
nx += w * x;
ny += w * y;
wSum += w;
}
if (wSum === 0) break;
cx = nx / wSum;
cy = ny / wSum;
}
let total = 0;
for (const [x, y] of positions) {
total += Math.sqrt((cx - x) ** 2 + (cy - y) ** 2);
}
return total;
}
// 測試
console.log(getMinDistSum([[0,1],[1,0],[1,2],[2,1]]).toFixed(4));
// 輸出:4.0000
1235. Maximum Profit in Job Scheduling(Hard)
帶有權重的區間排程問題。使用 DP + 二分搜索,本質上是加權的 Job Scheduling 近似思維。
// ─── LeetCode 1235:DP + 二分搜索 ───────────────────────────
function jobScheduling(
startTime: number[],
endTime: number[],
profit: number[]
): number {
const n = startTime.length;
const jobs = Array.from({ length: n }, (_, i) => ({
start: startTime[i],
end: endTime[i],
profit: profit[i],
}));
// 依結束時間排序
jobs.sort((a, b) => a.end - b.end);
// dp[i] = 考慮前 i 個工作的最大利潤
const dp = new Array(n + 1).fill(0);
for (let i = 1; i <= n; i++) {
// 不選第 i 個工作
dp[i] = dp[i - 1];
// 選第 i 個工作:二分找最後一個結束時間 <= start 的工作
const job = jobs[i - 1];
let lo = 0, hi = i - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (jobs[mid - 1]?.end <= job.start) lo = mid;
else hi = mid - 1;
}
dp[i] = Math.max(dp[i], dp[lo] + job.profit);
}
return dp[n];
}
// 測試
console.log(jobScheduling(
[1, 2, 3, 3], [3, 4, 5, 6], [50, 10, 40, 70]
)); // 輸出:120
總結
近似與線上演算法是面對現實世界中「無法追求完美」場景的核心武器。本文涵蓋了以下關鍵知識:
- NP-hardness 與近似比:理解為何需要近似演算法,以及 α-近似的數學定義
- Vertex Cover 2-近似:基於 Maximal Matching 的經典技術,近似比證明簡潔優美
- Set Cover O(ln n)-近似:貪心策略的典範,每輪選覆蓋最多新元素的子集
- Metric TSP 2-近似:MST + DFS + Shortcut,三角不等式是關鍵前提
- Secretary Problem 1/e 策略:先探索後利用的最優停止規則
- Paging 競爭分析:LRU 與 FIFO 的 k-competitive 分析
- Load Balancing:List Scheduling 與 LPT 的近似比差異
- PTAS / FPTAS 分類:更精細的近似方案層級
記住核心原則:近似演算法不是放棄最優,而是在多項式時間內以數學保證換取實用性。在真實世界的大規模系統中——雲端資源管理、廣告競價、快取策略——這些技術每天都在運作。
下一篇文章,我們將探討 後綴結構(Suffix Structures),學習 Suffix Array、Suffix Tree 與 LCP Array 等強大的字串處理工具——它們是搜尋引擎與生物資訊學的基石。
上一篇:隨機演算法完全指南