近似與線上演算法完全指南 — 頂點覆蓋、集合覆蓋與秘書問題 | 資料結構與演算法

2026/07/20
近似與線上演算法完全指南 — 頂點覆蓋、集合覆蓋與秘書問題 | 資料結構與演算法

近似演算法(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,否則不存在多項式時間的精確解。

面對這些問題,我們有兩條路:

  1. 近似演算法:放棄追求最優解,轉而追求「有數學保證的足夠好的解」。例如 Vertex Cover 的 2-近似演算法保證找到的解最多是最優解的 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 nm=子集數
Metric TSP 2-近似O(n^2 log n)O(n)<= 2需三角不等式
Secretary ProblemO(n)O(1)1/e ≈ 0.368 成功率隨機輸入假設
LRU PagingO(1)/請求O(k)k-competitivek=快取大小
FIFO PagingO(1)/請求O(k)k-competitivek=快取大小
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

總結

近似與線上演算法是面對現實世界中「無法追求完美」場景的核心武器。本文涵蓋了以下關鍵知識:

  1. NP-hardness 與近似比:理解為何需要近似演算法,以及 α-近似的數學定義
  2. Vertex Cover 2-近似:基於 Maximal Matching 的經典技術,近似比證明簡潔優美
  3. Set Cover O(ln n)-近似:貪心策略的典範,每輪選覆蓋最多新元素的子集
  4. Metric TSP 2-近似:MST + DFS + Shortcut,三角不等式是關鍵前提
  5. Secretary Problem 1/e 策略:先探索後利用的最優停止規則
  6. Paging 競爭分析:LRU 與 FIFO 的 k-competitive 分析
  7. Load Balancing:List Scheduling 與 LPT 的近似比差異
  8. PTAS / FPTAS 分類:更精細的近似方案層級

記住核心原則:近似演算法不是放棄最優,而是在多項式時間內以數學保證換取實用性。在真實世界的大規模系統中——雲端資源管理、廣告競價、快取策略——這些技術每天都在運作。

下一篇文章,我們將探討 後綴結構(Suffix Structures),學習 Suffix Array、Suffix Tree 與 LCP Array 等強大的字串處理工具——它們是搜尋引擎與生物資訊學的基石。

上一篇:隨機演算法完全指南
BenZ Software Developer

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

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →