貪心演算法 — Greedy 策略、正確性證明與經典問題 | 資料結構與演算法

2026/07/05
貪心演算法 — Greedy 策略、正確性證明與經典問題 | 資料結構與演算法

貪心演算法(Greedy Algorithm) 是一種每個決策步驟都選取當前最優選項的策略——不回溯、不猶豫,直接拿走「眼前最好的」。它的魅力在於極度簡潔與高效;它的挑戰在於,你必須嚴格證明「局部最優」真的能累積成「全域最優」,否則貪心只是貪心,不是正確。

前言

想像超市收銀台找零:你要找 41 元,收銀員不會去思考所有組合,而是直接抓一枚 25 分、一枚 10 分、一枚 5 分、一枚 1 分——每次都選當前能用的最大面額,最終以最少枚數湊齊零錢。這就是貪心策略的日常縮影。

然而,貪心並非萬能。換一套面額(例如 [1, 3, 4])找 6 元,貪心會選 4+1+1 共 3 枚,但最優解是 3+3 共 2 枚。貪心對嗎?取決於問題結構。

讀完本文,你將能夠:

  • 理解貪心選擇性質(Greedy Choice Property)與最優子結構(Optimal Substructure)
  • 掌握交換論證法(Exchange Argument)驗證貪心正確性
  • 實作活動選擇、Huffman 編碼、跳躍遊戲、加油站等經典題型
  • 判斷面試題應用貪心還是動態規劃

核心概念

貪心選擇性質(Greedy Choice Property)

貪心演算法成立的第一個充分條件:全域最優解可以透過一系列局部最優選擇達成。換言之,在第一步做出貪心選擇後,剩餘子問題仍然能導向全域最優,而不需要回頭修改已做的選擇。

這個性質並非所有問題都擁有。動態規劃(Dynamic Programming,DP)也要求最優子結構,但 DP 同時考慮所有子問題的可能組合;貪心則更激進——它假設只需要解決「做完當前選擇後」的那一個子問題。

最優子結構(Optimal Substructure)

貪心演算法成立的第二個條件:問題的最優解包含其子問題的最優解。這與 DP 共享,但貪心只需要解一個子問題(做完當前貪心選擇後的子問題),而 DP 需要解所有子問題。

交換論證法(Exchange Argument)

這是證明貪心正確性最常用的工具,步驟如下:

  1. 假設存在一個最優解 OPT,它在某個步驟沒有採用貪心選擇
  2. 執行交換:將 OPT 中那個「非貪心的選擇」替換為「貪心選擇」
  3. 驗證不損失最優性:證明替換後的方案成本不差於 OPT

若能完成步驟 3,則貪心策略可以逐步將任意最優解「轉化」為使用貪心選擇的方案,從而證明貪心解也是最優解。

活動選擇的交換論證示範:

給定活動集合,若最優解的第一個選擇不是「結束時間最早的活動 A1」,設它選了 A2(結束時間較晚)。由於 A1 結束時間 ≤ A2 結束時間,用 A1 替換 A2 後,後續可用時間只會等於或多於原方案,因此替換後的選法不差於 OPT。交換論證成立,選「最早結束」是正確的貪心策略。

活動選擇問題時間軸

活動選擇問題(Activity Selection Problem):
給定 n 個活動,每個活動有開始時間 s[i] 和結束時間 f[i],
選取最多不重疊活動。

各活動時間軸(按結束時間排序):

時間:  0   1   2   3   4   5   6   7   8   9   10  11
       |---|---|---|---|---|---|---|---|---|---|---|---|

A1:    [====]                          f=2  ✓ 選(最早結束)
A2:        [========]                  f=4
A3:    [============]                  f=5
A4:            [========]              f=6
A5:                [====]              f=7  ✓ 選(s≥2,且最早結束)
A6:        [================]          f=9
A7:                    [========]      f=11 ✓ 選(s≥7,且最早結束)

貪心策略:
  Step 1:選 A1(結束時間最早,f=2),下次開始時間 ≥ 2
  Step 2:略過 A2(s=1 < 2,重疊),略過 A3(s=0 < 2,重疊)
          選 A5(s=5 ≥ 2,f=7),下次開始時間 ≥ 7
  Step 3:略過 A6(s=1 < 7,重疊)
          選 A7(s=7 ≥ 7,f=11)

結果:選取 {A1, A5, A7},共 3 個活動(最優解)

JS/TS 實作

1. 活動選擇(Interval Scheduling)

按結束時間升序排序後,貪心選擇每個不重疊的最早結束活動。

function activitySelection(intervals: [number, number][]): [number, number][] {
  // 按結束時間升序排列(貪心關鍵:必須按結束時間,非開始時間)
  intervals.sort((a, b) => a[1] - b[1]);

  const result: [number, number][] = [];
  let lastEnd = -Infinity;

  for (const [start, end] of intervals) {
    // 只有當前活動不與上一個選中的活動重疊時才選取
    if (start >= lastEnd) {
      result.push([start, end]);
      lastEnd = end;
    }
  }
  return result;
}

// 測試
const activities: [number, number][] = [[0,2],[1,4],[0,5],[5,7],[1,9],[7,11]];
console.log(activitySelection(activities));
// 輸出:[[0,2],[5,7],[7,11]]

2. 分發餅乾(Assign Cookies,LC 455)

每個孩子有一個「滿足度門檻」g[i],每塊餅乾有大小 s[j],一塊餅乾只能給一個孩子,求最多能滿足幾個孩子。

貪心策略:排序後,用最小能滿足當前孩子的餅乾配對,留下較大的餅乾給更難滿足的孩子。

function findContentChildren(g: number[], s: number[]): number {
  // 孩子的胃口和餅乾大小都升序排序
  g.sort((a, b) => a - b);
  s.sort((a, b) => a - b);

  let child = 0;   // 當前嘗試滿足的孩子指標
  let cookie = 0;  // 當前嘗試分配的餅乾指標

  while (child < g.length && cookie < s.length) {
    // 若當前餅乾能滿足當前孩子,配對成功,移到下一個孩子
    if (s[cookie] >= g[child]) {
      child++;
    }
    // 不管配對是否成功,餅乾都消耗掉(或移向下一塊)
    cookie++;
  }

  return child;  // 已滿足的孩子數量
}

// 測試
console.log(findContentChildren([1, 2, 3], [1, 1]));    // 輸出:1
console.log(findContentChildren([1, 2], [1, 2, 3]));    // 輸出:2

3. 跳躍遊戲(Jump Game,LC 55 & 45)

LC 55 — 判斷能否到達終點:

維護當前能到達的最遠位置 maxReach,若任何時刻 i > maxReach 則無法繼續。

function canJump(nums: number[]): boolean {
  let maxReach = 0;  // 從起點出發,目前能到達的最遠索引

  for (let i = 0; i < nums.length; i++) {
    // 若當前位置已超過最遠可達位置,表示無法到達此處
    if (i > maxReach) return false;
    // 更新最遠可達位置
    maxReach = Math.max(maxReach, i + nums[i]);
  }

  return true;
}

// 測試
console.log(canJump([2, 3, 1, 1, 4]));  // 輸出:true
console.log(canJump([3, 2, 1, 0, 4]));  // 輸出:false

LC 45 — 最少跳幾次才能到達終點:

貪心直覺:將每一跳視為 BFS 的一層(Layer),每層掃描能到達的最遠位置作為下一層邊界,層數即為跳躍次數。

function jump(nums: number[]): number {
  // 貪心直覺:BFS 層級遍歷
  // 當前層(currentEnd 以內)能到達的最遠位置,就是下一層的邊界
  //
  // 例:[2, 3, 1, 1, 4]
  // Layer 0(index 0):nums[0]=2,最遠到 index 2
  // Layer 1(index 1-2):nums[1]=3 → index 4;nums[2]=1 → index 3
  //   整層最遠:index 4(終點),第 2 跳到達
  // 總跳數:2

  const n = nums.length;
  let jumps = 0;
  let currentEnd = 0;   // 當前跳躍層的最遠邊界
  let farthest = 0;     // 下一跳所能到達的最遠位置

  for (let i = 0; i < n - 1; i++) {
    farthest = Math.max(farthest, i + nums[i]);
    // 到達當前層的邊界,必須再跳一次
    if (i === currentEnd) {
      jumps++;
      currentEnd = farthest;
      if (currentEnd >= n - 1) break;  // 已能到達終點,提早結束
    }
  }

  return jumps;
  // 時間:O(n),空間:O(1)
}

// 測試
console.log(jump([2, 3, 1, 1, 4]));  // 輸出:2
console.log(jump([2, 3, 0, 1, 4]));  // 輸出:2

4. 加油站(Gas Station,LC 134)

沿著環形公路行駛,每站有油量 gas[i],到下一站需消耗 cost[i],找出能完成環繞的起始站(保證答案唯一)。

function canCompleteCircuit(gas: number[], cost: number[]): number {
  // 貪心洞察:
  // 1. 若 sum(gas) >= sum(cost),則一定存在解
  // 2. 若從 startStation 出發,到達某站 i 時油量為負,
  //    則 [startStation, i] 中任何站作為起點都不可行
  //    → 直接跳到 i+1 作為新候選起點
  //
  // 為什麼中間的站不行?
  // 設 startStation 到 j(中間某站)油量 T ≥ 0
  // 從 j 出發到 i 的累計油量 = (startStation 到 i 的累計) - T < 0 - T < 0
  // 因此從 j 出發也會在 i 處油量為負,不可行。

  const n = gas.length;
  let totalGas = 0;    // 全程總淨油量(若 < 0 則無解)
  let currentGas = 0;  // 從候選起點出發的累計油量
  let startStation = 0;

  for (let i = 0; i < n; i++) {
    const net = gas[i] - cost[i];  // 本站淨油量
    totalGas += net;
    currentGas += net;

    if (currentGas < 0) {
      startStation = i + 1;  // 候選起點移至 i+1
      currentGas = 0;
    }
  }

  return totalGas >= 0 ? startStation : -1;
  // 時間:O(n),空間:O(1)
}

// 測試
console.log(canCompleteCircuit([1, 2, 3, 4, 5], [3, 4, 5, 1, 2]));  // 輸出:3
console.log(canCompleteCircuit([2, 3, 4], [3, 4, 3]));               // 輸出:-1

5. Huffman 編碼(Huffman Encoding)

Huffman 編碼是貪心在資訊壓縮領域的經典應用:高頻字符獲得短編碼,低頻字符獲得長編碼,總編碼長度最短。

貪心策略:每次取頻率最小的兩個節點合併,使用最小堆(Min-Heap)實現。

class MinHeap<T> {
  private heap: T[] = [];
  constructor(private compare: (a: T, b: T) => number) {}

  push(val: T): void {
    this.heap.push(val);
    this.siftUp(this.heap.length - 1);
  }

  pop(): T {
    const top = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this.siftDown(0);
    }
    return top;
  }

  get size(): number { return this.heap.length; }

  private siftUp(i: number): void {
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.compare(this.heap[i], this.heap[parent]) < 0) {
        [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]];
        i = parent;
      } else break;
    }
  }

  private siftDown(i: number): void {
    const n = this.heap.length;
    while (true) {
      let smallest = i;
      const l = 2 * i + 1, r = 2 * i + 2;
      if (l < n && this.compare(this.heap[l], this.heap[smallest]) < 0) smallest = l;
      if (r < n && this.compare(this.heap[r], this.heap[smallest]) < 0) smallest = r;
      if (smallest === i) break;
      [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]];
      i = smallest;
    }
  }
}

interface HuffmanNode {
  char: string | null;
  freq: number;
  left: HuffmanNode | null;
  right: HuffmanNode | null;
}

function buildHuffmanTree(freqMap: Map<string, number>): HuffmanNode {
  const pq = new MinHeap<HuffmanNode>((a, b) => a.freq - b.freq);

  // 每個字元建立葉節點加入最小堆
  for (const [char, freq] of freqMap) {
    pq.push({ char, freq, left: null, right: null });
  }

  // 持續取兩個最小頻率節點合併,直到只剩根節點
  while (pq.size > 1) {
    const left = pq.pop();
    const right = pq.pop();
    pq.push({
      char: null,
      freq: left.freq + right.freq,
      left,
      right,
    });
  }

  return pq.pop();
}

function generateCodes(root: HuffmanNode): Map<string, string> {
  const codes = new Map<string, string>();

  function dfs(node: HuffmanNode | null, code: string): void {
    if (!node) return;
    if (node.char !== null) {
      // 單字元時給 '0' 以確保有效編碼
      codes.set(node.char, code || '0');
      return;
    }
    dfs(node.left, code + '0');
    dfs(node.right, code + '1');
  }

  dfs(root, '');
  return codes;
}

function huffmanEncoding(text: string): { encoded: string; codes: Map<string, string> } {
  // 統計字元頻率
  const freqMap = new Map<string, number>();
  for (const ch of text) freqMap.set(ch, (freqMap.get(ch) ?? 0) + 1);

  const root = buildHuffmanTree(freqMap);
  const codes = generateCodes(root);
  const encoded = text.split('').map(ch => codes.get(ch)!).join('');
  return { encoded, codes };
}

// 測試
const { encoded, codes } = huffmanEncoding('abracadabra');
console.log('Codes:', Object.fromEntries(codes));
// 輸出:{ a: '0', b: '10', r: '110', c: '1110', d: '1111' }(依頻率而定)
console.log('Encoded length:', encoded.length);
// 輸出:比固定 3-bit 編碼(33 bits)更短

6. 分割字串(Partition Labels,LC 763)

將字串盡可能分割成多個片段,使每個字母只出現在一個片段中,回傳每個片段的長度。

function partitionLabels(s: string): number[] {
  // 記錄每個字母最後出現的位置(決定片段邊界的關鍵)
  const lastIndex = new Map<string, number>();
  for (let i = 0; i < s.length; i++) {
    lastIndex.set(s[i], i);
  }

  const result: number[] = [];
  let start = 0;   // 當前片段的起始位置
  let end = 0;     // 當前片段的最遠延伸位置

  for (let i = 0; i < s.length; i++) {
    // 若當前字元的最後出現位置超過 end,延伸當前片段
    end = Math.max(end, lastIndex.get(s[i])!);

    // 到達當前片段末尾,切割並記錄長度
    if (i === end) {
      result.push(end - start + 1);
      start = i + 1;
    }
  }

  return result;
  // 時間:O(n),空間:O(1)(字母表大小固定 26)
}

// 測試
console.log(partitionLabels('ababcbacadefegdehijhklij'));
// 輸出:[9, 7, 8]
console.log(partitionLabels('eccbbbbdec'));
// 輸出:[10]

C++ 對照

C++ 版本使用 STL 的 sortpriority_queue 與結構化綁定(C++17),展示與 TypeScript 版本的對應關係。

活動選擇

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

vector<pair<int,int>> activitySelection(vector<pair<int,int>>& intervals) {
    // 按結束時間升序排列
    sort(intervals.begin(), intervals.end(),
         [](const pair<int,int>& a, const pair<int,int>& b) {
             return a.second < b.second;
         });

    vector<pair<int,int>> result;
    int lastEnd = INT_MIN;

    for (auto& [start, end] : intervals) {
        if (start >= lastEnd) {
            result.push_back({start, end});
            lastEnd = end;
        }
    }
    return result;
}

Huffman 編碼

#include <queue>
#include <unordered_map>
#include <string>
#include <memory>
#include <functional>
using namespace std;

struct HuffNode {
    char ch;
    int freq;
    shared_ptr<HuffNode> left, right;
    HuffNode(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};

struct Compare {
    bool operator()(const shared_ptr<HuffNode>& a, const shared_ptr<HuffNode>& b) {
        return a->freq > b->freq;  // Min-Heap:頻率小的優先
    }
};

unordered_map<char, string> huffmanCodes(const string& text) {
    unordered_map<char, int> freq;
    for (char c : text) freq[c]++;

    priority_queue<shared_ptr<HuffNode>,
                   vector<shared_ptr<HuffNode>>,
                   Compare> pq;

    for (auto& [ch, f] : freq)
        pq.push(make_shared<HuffNode>(ch, f));

    while (pq.size() > 1) {
        auto left = pq.top(); pq.pop();
        auto right = pq.top(); pq.pop();
        auto merged = make_shared<HuffNode>('\0', left->freq + right->freq);
        merged->left = left;
        merged->right = right;
        pq.push(merged);
    }

    // 生成編碼(DFS 遍歷 Huffman 樹)
    unordered_map<char, string> codes;
    function<void(shared_ptr<HuffNode>, string)> dfs = [&](
        shared_ptr<HuffNode> node, string code) {
        if (!node) return;
        if (node->ch != '\0') { codes[node->ch] = code; return; }
        dfs(node->left, code + "0");
        dfs(node->right, code + "1");
    };
    dfs(pq.top(), "");
    return codes;
}

跳躍遊戲 II

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

int jump(vector<int>& nums) {
    int n = nums.size();
    int jumps = 0, currentEnd = 0, farthest = 0;

    for (int i = 0; i < n - 1; i++) {
        farthest = max(farthest, i + nums[i]);
        if (i == currentEnd) {
            jumps++;
            currentEnd = farthest;
            if (currentEnd >= n - 1) break;
        }
    }
    return jumps;
}

加油站

#include <vector>
using namespace std;

int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
    int totalGas = 0, currentGas = 0, startStation = 0;
    int n = gas.size();

    for (int i = 0; i < n; i++) {
        int net = gas[i] - cost[i];
        totalGas += net;
        currentGas += net;

        if (currentGas < 0) {
            startStation = i + 1;
            currentGas = 0;
        }
    }
    return totalGas >= 0 ? startStation : -1;
}

分數背包(Fractional Knapsack)

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

struct Item {
    double weight, value;
    double ratio() const { return value / weight; }
};

double fractionalKnapsack(vector<Item>& items, double capacity) {
    // 按單位價值(value/weight)降序排列
    sort(items.begin(), items.end(),
         [](const Item& a, const Item& b) {
             return a.ratio() > b.ratio();
         });

    double totalValue = 0.0;

    for (auto& item : items) {
        if (capacity <= 0) break;
        if (item.weight <= capacity) {
            // 整個物品都能放入
            totalValue += item.value;
            capacity -= item.weight;
        } else {
            // 只能放一部分(分數背包的關鍵操作)
            totalValue += item.ratio() * capacity;
            capacity = 0;
        }
    }
    return totalValue;
}

複雜度分析表

演算法時間複雜度空間複雜度貪心策略
活動選擇O(n log n)O(1)按結束時間排序,選最早結束的不重疊活動
Huffman 編碼O(n log n)O(n)Min-Heap 取兩最小頻率合併
分發餅乾(LC 455)O(n log n)O(1)最小能滿足配最小孩子
跳躍遊戲(LC 55)O(n)O(1)維護最遠可達位置
跳躍遊戲 II(LC 45)O(n)O(1)區間 BFS,每層覆蓋最遠
加油站(LC 134)O(n)O(1)候選起點跳過不可行區段
分割字串(LC 763)O(n)O(1)字母最後出現位置決定邊界
分數背包O(n log n)O(1)按單位價值排序,優先取高值密度物品

變體與延伸

分數背包(Fractional Knapsack)

與 0/1 背包(需 DP)不同,分數背包允許取物品的一部分(如液體、穀物)。貪心策略:按**單位重量的價值(value/weight)**降序排列,依序取物,容量不足時取一部分。

為什麼分數背包可以貪心,0/1 背包不行?

分數背包的貪心選擇性質成立:取單位價值最高的物品不會後悔,因為任何剩餘空間都能以最高可得單位價值填滿,不存在「留著容量給後來物品」更好的情況。0/1 背包中取了某物品後容量固定減少,可能導致更高價值的組合無法達成——需要窮舉所有子集(DP)。

function fractionalKnapsack(
  items: { weight: number; value: number }[],
  capacity: number
): number {
  // 按單位價值降序排列
  items.sort((a, b) => b.value / b.weight - a.value / a.weight);

  let totalValue = 0;

  for (const item of items) {
    if (capacity <= 0) break;

    if (item.weight <= capacity) {
      // 整個物品放入
      totalValue += item.value;
      capacity -= item.weight;
    } else {
      // 只取部分(分數背包的關鍵:允許切割)
      totalValue += (item.value / item.weight) * capacity;
      capacity = 0;
    }
  }

  return totalValue;
}

// 測試:items=[{w:10,v:60},{w:20,v:100},{w:30,v:120}],capacity=50
const items = [
  { weight: 10, value: 60 },   // 單位價值 6.0
  { weight: 20, value: 100 },  // 單位價值 5.0
  { weight: 30, value: 120 },  // 單位價值 4.0
];
console.log(fractionalKnapsack(items, 50));
// 輸出:240(取全部 w=10, w=20 和一半 w=30 的物品)

最小生成樹:Kruskal 與 Prim 簡介

最小生成樹(Minimum Spanning Tree,MST)問題也是貪心的經典應用領域:

  • Kruskal 演算法:按邊的權重升序排序,貪心選取不形成環的最小邊,利用 Union-Find 偵測環。時間複雜度 O(E log E),適合稀疏圖。

  • Prim 演算法:從任意節點出發,每步貪心選取連接「已選集合」與「未選集合」之間的最小權重邊。使用 Min-Heap 實現時間複雜度 O((V+E) log V),適合稠密圖。

兩者均依賴割定理(Cut Property):對圖的任意割(Cut),跨越割的最小權重邊一定屬於某個 MST。這就是這兩個演算法正確性的理論基礎,也是貪心在圖論中最漂亮的應用之一。


面試考點:貪心 vs DP 判斷框架

許多面試官會刻意設計「看起來像貪心,其實需要 DP」或反過來的題目。以下是判斷框架:

使用貪心的信號

  1. 問題有明確的排序維度:「按結束時間」、「按大小」、「按單位價值」等
  2. 局部選擇不影響後續可選集合的「品質」:選完當前最優,剩下的問題結構不變
  3. 交換論證能成立:用貪心選擇替換最優解中的非貪心選擇,結果不變差

使用 DP 的信號

  1. 選擇之間有「後效性」:當前選擇影響後續哪些選擇可用
  2. 局部最優不等於全域最優:能找到一個反例
  3. 問題問的是「組合計數」或「恰好等於某值的方案數」

具體判斷流程:

問題 → 能否找到貪心準則?
       ↓ 是
嘗試交換論證:替換貪心選擇後,答案不差?
       ↓ 是          ↓ 否
    用貪心         用 DP(重疊子問題+最優子結構)

貪心 vs DP 對比表:

維度貪心動態規劃
決策方式每步選當前最優,不回頭考慮所有子問題的最優組合
子問題數量只解一個(當前貪心後的子問題)解所有子問題
時間效率通常 O(n log n) 或 O(n)通常 O(n²) 或 O(n·k)
正確性保證需要嚴格證明(交換論證)狀態轉移方程正確則保證最優
典型題型區間排程、Huffman、跳躍遊戲背包問題、最長公共子序列、矩陣鏈乘法

LeetCode 練習

題號題目難度貪心策略
455Assign CookiesEasy排序後雙指標,最小餅乾配最小孩子
55Jump GameMedium維護最遠可達位置,判斷是否能到終點
134Gas StationMedium全局總油量判斷有無解,局部負值跳過起點
435Non-overlapping IntervalsMedium按結束時間排序,貪心移除最少區間
763Partition LabelsMedium字母最後出現位置決定片段邊界

LC 435 — 無重疊區間(Non-overlapping Intervals):

本題與活動選擇互為鏡像:求最少移除幾個區間使其餘區間不重疊,等同於求最多保留幾個不重疊區間,再用總數相減。

function eraseOverlapIntervals(intervals: number[][]): number {
  if (intervals.length === 0) return 0;

  // 按結束時間升序排列(活動選擇的核心排序方式)
  intervals.sort((a, b) => a[1] - b[1]);

  let count = 1;     // 最多保留的不重疊區間數
  let lastEnd = intervals[0][1];

  for (let i = 1; i < intervals.length; i++) {
    if (intervals[i][0] >= lastEnd) {
      // 不重疊,選取此區間
      count++;
      lastEnd = intervals[i][1];
    }
    // 重疊,跳過(即「移除」此區間)
  }

  return intervals.length - count;  // 需要移除的數量
}

// 測試
console.log(eraseOverlapIntervals([[1,2],[2,3],[3,4],[1,3]]));  // 輸出:1
console.log(eraseOverlapIntervals([[1,2],[1,2],[1,2]]));        // 輸出:2

常見陷阱

學習貪心演算法時,有幾個高頻錯誤值得特別注意:

  1. 忘記排序:絕大多數貪心問題需要先對輸入排序(按結束時間、按大小、按比率等),未排序直接貪心幾乎必錯。活動選擇、分發餅乾、分數背包都依賴排序作為前提。

  2. 貪心選擇依據錯誤:活動選擇問題中,按「開始時間最早」或「持續時間最短」貪心均是錯的,只有「結束時間最早」才能保證全域最優。選擇貪心標準時務必做交換論證驗證。

  3. 對 0/1 問題套用分數背包貪心:分數背包貪心正確,但 0/1 背包(物品不可分割)使用同樣的貪心策略會得到錯誤答案。判斷依據:物品能否分割?

  4. 加油站問題忽略無解情況:程式在 totalGas < 0 時需返回 -1 而非任意值。遺漏此檢查在無解情況下會回傳錯誤的起點索引。

  5. Huffman 單字元輸入未處理:若輸入只有一種字元(如 "aaaa"),優先佇列中只剩一個節點無法合併,需特判並給予編碼 '0'


總結

貪心演算法的核心魅力在於它的「無畏」——每步都拿當前最好的,從不回頭。這種簡單的策略在滿足貪心選擇性質最優子結構的問題上,能以 O(n log n) 甚至 O(n) 的極高效率取得最優解。

本文涵蓋的核心要點:

  • 兩個充分條件:貪心選擇性質 + 最優子結構,缺一不可
  • 交換論證法:證明貪心正確性的標準工具,假設替換再驗證不損失最優性
  • 六個經典實作:活動選擇、分發餅乾、跳躍遊戲(I/II)、加油站、Huffman 編碼、分割字串
  • 貪心 vs DP 判斷框架:能交換論證 → 貪心;有後效性或需組合計數 → DP

下一篇將探討分治法(Divide and Conquer)——另一個將大問題切分為小問題的核心策略,與貪心在「子問題處理方式」上形成鮮明對比。歡迎持續關注!

如有任何問題或疑惑,歡迎透過 聯絡頁面 與我交流。

BenZ Software Developer

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