動態規劃基礎 — 五步驟解題法與經典 DP 問題詳解 | 資料結構與演算法

2026/07/03
動態規劃基礎 — 五步驟解題法與經典 DP 問題詳解 | 資料結構與演算法

動態規劃(Dynamic Programming,DP) 是演算法學習中最重要也最難掌握的技術之一。它透過將問題分解為重疊子問題,用表格記錄每個子問題的答案避免重複計算,從而高效解決最優化與計數問題。本文從爬樓梯的直覺出發,系統介紹 DP 的五步驟解題框架,並以 TypeScriptC++ 完整實作背包、LCS、LIS 等面試必考題型。

前言

想像你站在一棟大樓門口,要爬到第 10 層。每次可以走 1 步或 2 步。你想知道:總共有幾種走法?

你可能直覺地這樣想:到第 10 層,前一步要麼從第 9 層走 1 步上來,要麼從第 8 層走 2 步上來。所以第 10 層的走法數 = 第 9 層的走法數 + 第 8 層的走法數。

這就是動態規劃的核心——把大問題拆成小問題,用小問題的答案推導出大問題的答案

讀完這篇文章,你將能夠:

  • 理解動態規劃的兩個必要條件:重疊子問題與最優子結構
  • 掌握 DP 五步驟解題法,系統化應對任何 DP 問題
  • 區分 Top-Down(記憶化搜尋)與 Bottom-Up(迭代)的使用時機
  • 用 TypeScript 解出爬樓梯、0/1 背包、LCS、LIS、零錢兌換五類核心題型
  • 理解滾動陣列等空間優化技巧,以及面試中的 DP 分類框架

核心概念

兩個必要條件

一個問題能用動態規劃解決,必須同時具備以下兩個特徵:

重疊子問題(Overlapping Subproblems)

同一個子問題在求解過程中被多次計算。以 Fibonacci 數列為例,計算 fib(5) 需要 fib(4)fib(3),而 fib(4) 又需要 fib(3)fib(2)——fib(3) 被重複計算。DP 透過記憶化(Memoization)或表格(Tabulation)將每個子問題只算一次。

fib(5) 的遞迴樹(暴力遞迴,指數時間):

fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2) ← 重複!
│   │   └── fib(1)
│   └── fib(2) ← 重複!
└── fib(3) ← 重複!
    ├── fib(2) ← 重複!
    └── fib(1)

fib(2) 共計算 3 次,fib(3) 共計算 2 次
時間複雜度:O(2^n)

最優子結構(Optimal Substructure)

問題的最優解包含子問題的最優解。例如「從 A 到 C 的最短路徑」必然包含「從 A 到中間點 B 的最短路徑」。如果繞過最短的 A→B 段反而能讓 A→C 更短,那就不存在最優子結構。

DP 五步驟解題法

任何 DP 問題都可以套用以下五步驟框架:

Step 1:定義狀態(State Definition)
  dp[i] 代表「以第 i 個元素結尾 / 前 i 個元素」的最優解
  → 狀態定義是 DP 最關鍵的一步,定義錯了整個推導都會出錯

Step 2:轉移方程(Transition Equation)
  dp[i] = f(dp[i-1], dp[i-2], ..., dp[j])
  → 描述大問題如何由小問題推導而來

Step 3:初始條件(Base Cases)
  dp[0] = ?, dp[1] = ?
  → 最小子問題的直接答案,避免遞迴或迭代無法啟動

Step 4:遍歷順序(Traversal Order)
  Bottom-Up:確保計算 dp[i] 時,所有依賴的 dp[j] 已計算完畢
  → 通常從小到大,有時從大到小(如矩陣鏈乘)

Step 5:空間優化(Space Optimization)
  當 dp[i] 只依賴固定幾個前驅狀態時,可用滾動變數降低空間複雜度
  → 返回值不一定是 dp[n],需回頭審視狀態定義

Top-Down vs Bottom-Up

DP 有兩種實作方式,各有優缺點:

特性Top-Down(記憶化搜尋)Bottom-Up(迭代填表)
實作方式遞迴 + memo 陣列/Map迭代 + dp 陣列
思考方式自然,接近遞迴定義需明確遍歷順序
空間O(n) + 遞迴棧O(n),可優化至 O(1)
只計算必要子問題是(按需計算)否(計算所有子問題)
Stack Overflow 風險存在(深遞迴)
適合情境狀態空間稀疏、依賴關係複雜狀態空間密集、效能敏感

實務建議:面試中兩種寫法都應掌握。Top-Down 更容易從遞迴轉化,Bottom-Up 效能更穩定且便於空間優化。


TypeScript 實作

爬樓梯(LeetCode 70)

爬樓梯是最入門的線性 DP,完整展示五步驟:

  • 狀態dp[i] = 爬到第 i 階的方法數
  • 轉移dp[i] = dp[i-1] + dp[i-2](從第 i-1 階走 1 步,或從第 i-2 階走 2 步)
  • 初始值dp[1] = 1dp[2] = 2
  • 遍歷:從 3 到 n
  • 返回dp[n]

以下先展示三種實作,從暴力遞迴到最終的空間優化版:

// 方式一:暴力遞迴(O(2^n),僅示意,n > 40 就超時)
function climbStairsNaive(n: number): number {
  if (n <= 2) return n;
  return climbStairsNaive(n - 1) + climbStairsNaive(n - 2);
}

// 方式二:Top-Down 記憶化(O(n) 時間,O(n) 空間)
function climbStairsMemo(
  n: number,
  memo: Map<number, number> = new Map()
): number {
  if (n <= 2) return n;
  if (memo.has(n)) return memo.get(n)!;
  const result = climbStairsMemo(n - 1, memo) + climbStairsMemo(n - 2, memo);
  memo.set(n, result);
  return result;
}

// 方式三:Bottom-Up + 空間優化(O(n) 時間,O(1) 空間)
function climbStairs(n: number): number {
  if (n <= 2) return n;
  let dp1 = 1, dp2 = 2; // dp[1]=1, dp[2]=2
  for (let i = 3; i <= n; i++) {
    const curr = dp1 + dp2;
    dp1 = dp2;
    dp2 = curr;
  }
  return dp2;
}

console.log(climbStairs(5));  // 輸出:8
console.log(climbStairs(10)); // 輸出:89

Bottom-Up 填表的過程可以視覺化如下:

n=5 的 dp 陣列填充過程:

index:  1    2    3    4    5
dp:    [1]  [2]  [?]  [?]  [?]   ← 初始條件

index:  1    2    3    4    5
dp:    [1]  [2]  [3]  [?]  [?]   ← dp[3] = dp[2]+dp[1] = 3

index:  1    2    3    4    5
dp:    [1]  [2]  [3]  [5]  [?]   ← dp[4] = dp[3]+dp[2] = 5

index:  1    2    3    4    5
dp:    [1]  [2]  [3]  [5]  [8]   ← dp[5] = dp[4]+dp[3] = 8

答案:dp[5] = 8

0/1 背包問題

背包問題是所有背包 DP 的基礎。每個物品只能選一次(0/1),在容量限制內求最大價值。

  • 狀態dp[j] = 容量為 j 時的最大價值(一維滾動陣列版)
  • 轉移dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
  • 初始值dp[0] = 0,其餘為 0
  • 遍歷:外層枚舉物品,內層從右往左遍歷容量(關鍵!)
/**
 * 0/1 背包問題(一維滾動陣列,空間優化版)
 * Time: O(n × capacity), Space: O(capacity)
 */
function knapsack01(
  weights: number[],
  values: number[],
  capacity: number
): number {
  const n = weights.length;
  // dp[j] = 容量為 j 時能獲得的最大價值
  const dp = new Array<number>(capacity + 1).fill(0);

  for (let i = 0; i < n; i++) {
    // 關鍵:從右往左遍歷,確保每個物品只被選一次
    // 若從左往右,dp[j - weights[i]] 可能已被當輪更新過(相當於選了多次)
    for (let j = capacity; j >= weights[i]; j--) {
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
    }
  }

  return dp[capacity];
}

// 測試:3 個物品,重量 [2,3,4],價值 [3,4,5],背包容量 5
const weights = [2, 3, 4];
const values  = [3, 4, 5];
console.log(knapsack01(weights, values, 5)); // 輸出:7(選物品 0 + 物品 1)

二維 DP 表填充過程(便於理解轉移邏輯):

items: [(w=2,v=3), (w=3,v=4), (w=4,v=5)]

       cap: 0   1   2   3   4   5
item 0:    [0] [0] [3] [3] [3] [3]   ← 只考慮 item0(重量 2,價值 3)
item 1:    [0] [0] [3] [4] [4] [7]   ← 考慮 item0+item1(cap=5:選 item0+item1=3+4=7)
item 2:    [0] [0] [3] [4] [5] [7]   ← 考慮三個物品(cap=4:item2 價值 5 > 之前的 4)

填充規則:dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
                        不選 item i    選 item i(若 j >= w[i])
答案:dp[2][5] = 7

最長共同子序列 LCS(LeetCode 1143)

LCS 是二維 DP 的代表題型,狀態定義在二維空間上。

  • 狀態dp[i][j] = text1 前 i 個字元與 text2 前 j 個字元的 LCS 長度
  • 轉移:若 text1[i-1] === text2[j-1],則 dp[i][j] = dp[i-1][j-1] + 1;否則 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  • 初始值:第 0 行、第 0 列全為 0(空字串與任何字串的 LCS 為 0)
/**
 * 最長共同子序列(Longest Common Subsequence)
 * Time: O(m × n), Space: O(m × n)
 */
function longestCommonSubsequence(text1: string, text2: string): number {
  const m = text1.length, n = text2.length;
  // dp[i][j]:text1 前 i 字元和 text2 前 j 字元的 LCS 長度
  const dp: number[][] = Array.from({ length: m + 1 }, () =>
    new Array<number>(n + 1).fill(0)
  );

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        // 字元匹配:繼承左上角 + 1
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        // 字元不匹配:取上方或左方的較大值
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }

  return dp[m][n];
}

console.log(longestCommonSubsequence("abcde", "ace")); // 輸出:3("ace")
console.log(longestCommonSubsequence("abc", "abc"));   // 輸出:3
console.log(longestCommonSubsequence("abc", "def"));   // 輸出:0

最長遞增子序列 LIS(LeetCode 300)

LIS 提供了兩種截然不同的 DP 解法,面試中高頻考察 O(n log n) 的優化思路。

/**
 * 最長遞增子序列(Longest Increasing Subsequence)
 *
 * 方法一:O(n²) 標準 DP
 * 狀態:dp[i] = 以 nums[i] 結尾的最長遞增子序列長度
 * 轉移:dp[i] = max(dp[j] + 1),對所有 j < i 且 nums[j] < nums[i]
 * 注意:答案是 max(dp),不是 dp[n-1]!
 */
function lisN2(nums: number[]): number {
  const n = nums.length;
  if (n === 0) return 0;

  const dp = new Array<number>(n).fill(1); // 每個元素自身是長度 1 的 LIS
  let maxLen = 1;

  for (let i = 1; i < n; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
    maxLen = Math.max(maxLen, dp[i]);
  }

  return maxLen;
}

/**
 * 方法二:O(n log n) — 耐心排序(Patience Sorting)+ 二分搜尋
 * tails[i] = 長度為 i+1 的遞增子序列的最小末尾值
 * tails 始終有序,可用二分搜尋找插入位置
 *
 * 注意:tails 陣列本身不是實際的 LIS,但其長度等於 LIS 長度
 */
function lisNLogN(nums: number[]): number {
  const tails: number[] = [];

  for (const num of nums) {
    // 二分搜尋:找 tails 中第一個 >= num 的位置
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (tails[mid] < num) lo = mid + 1;
      else hi = mid;
    }
    // 若 lo === tails.length,代表 num 大於所有元素,擴展 tails
    // 否則替換 tails[lo](保持最小末尾值,為後續更長子序列留空間)
    tails[lo] = num;
  }

  return tails.length;
}

// 測試:[10, 9, 2, 5, 3, 7, 101, 18],LIS 為 [2, 3, 7, 18] 或 [2, 5, 7, 101]
console.log(lisN2([10, 9, 2, 5, 3, 7, 101, 18]));    // 輸出:4
console.log(lisNLogN([10, 9, 2, 5, 3, 7, 101, 18])); // 輸出:4

最小路徑和(LeetCode 64)

二維網格 DP,展示如何在二維狀態空間上套用五步驟:

  • 狀態dp[i][j] = 從 (0,0) 到 (i,j) 的最小路徑和
  • 轉移dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])
  • 初始值:第 0 行與第 0 列各自累加(只能一路向右或向下)
/**
 * 最小路徑和(Minimum Path Sum)
 * 只能向右或向下移動,求左上角到右下角的最小路徑和
 * Time: O(m × n), Space: O(m × n),可原地修改 grid 降至 O(1)
 */
function minPathSum(grid: number[][]): number {
  const m = grid.length, n = grid[0].length;
  const dp: number[][] = Array.from({ length: m }, () =>
    new Array<number>(n).fill(0)
  );

  // 初始條件:第 0 行(只能從左走)
  dp[0][0] = grid[0][0];
  for (let j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
  // 初始條件:第 0 列(只能從上走)
  for (let i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];

  // 填充其餘格子
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
    }
  }

  return dp[m - 1][n - 1];
}

console.log(minPathSum([[1,3,1],[1,5,1],[4,2,1]])); // 輸出:7(1→3→1→1→1)

零錢兌換(LeetCode 322)

零錢兌換是完全背包的代表題,與 0/1 背包的關鍵區別在於遍歷方向。

  • 狀態dp[i] = 組成金額 i 所需的最少硬幣數
  • 轉移dp[i] = min(dp[i - coin] + 1),對所有 coini >= coin
  • 初始值dp[0] = 0,其餘初始化為 Infinity(代表不可達)
  • 遍歷:外層金額從 1 到 amount,內層枚舉每種硬幣(從左往右,允許同一硬幣用多次)
/**
 * 零錢兌換(Coin Change)— 完全背包變體
 * 每種硬幣可無限次使用,求組成 amount 的最少硬幣數
 * Time: O(amount × coins.length), Space: O(amount)
 */
function coinChange(coins: number[], amount: number): number {
  // 初始化為 amount+1(安全的「不可達」哨兵值,避免 Infinity + 1 溢出)
  const dp = new Array<number>(amount + 1).fill(amount + 1);
  dp[0] = 0;

  for (let i = 1; i <= amount; i++) {
    for (const coin of coins) {
      if (i >= coin) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }

  // 若 dp[amount] 仍為哨兵值,代表無法湊出
  return dp[amount] > amount ? -1 : dp[amount];
}

console.log(coinChange([1, 5, 11], 15)); // 輸出:3(5+5+5)
console.log(coinChange([1, 2, 5], 11));  // 輸出:3(5+5+1)
console.log(coinChange([2], 3));         // 輸出:-1(無法湊出)

完全背包 vs 0/1 背包的遍歷方向差異:

0/1 背包(每個物品只能選一次)→ 內層從右往左
完全背包(每個物品可選多次)  → 內層從左往右

原因:從左往右時,dp[i - coin] 已在本輪被更新過,
     代表「同一硬幣」已被選過,再選一次是允許的。

C++ 對照

0/1 背包(含二維與一維版本)

#include <vector>
#include <algorithm>

/**
 * 0/1 背包 — 二維版本(清晰展示轉移邏輯)
 * Time: O(n*W), Space: O(n*W)
 */
int knapsack2D(const std::vector<int>& weights,
               const std::vector<int>& values,
               int capacity) {
    int n = static_cast<int>(weights.size());
    // dp[i][j]:考慮前 i 個物品、容量為 j 時的最大價值
    std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0));

    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= capacity; j++) {
            dp[i][j] = dp[i - 1][j]; // 不選第 i 個物品
            if (j >= weights[i - 1]) {
                dp[i][j] = std::max(dp[i][j],
                    dp[i - 1][j - weights[i - 1]] + values[i - 1]);
            }
        }
    }
    return dp[n][capacity];
}

/**
 * 0/1 背包 — 一維滾動陣列(空間優化)
 * Time: O(n*W), Space: O(W)
 * 關鍵:內層必須從右往左,確保每個物品只選一次
 */
int knapsack1D(const std::vector<int>& weights,
               const std::vector<int>& values,
               int capacity) {
    std::vector<int> dp(capacity + 1, 0);

    for (int i = 0; i < static_cast<int>(weights.size()); i++) {
        for (int j = capacity; j >= weights[i]; j--) {
            dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}

LIS — O(n log n) 版本

#include <vector>
#include <algorithm>

/**
 * LIS — O(n log n),使用 STL lower_bound 二分搜尋
 * lower_bound 找第一個 >= num 的位置
 */
int lisOptimal(const std::vector<int>& nums) {
    std::vector<int> tails;

    for (int num : nums) {
        auto it = std::lower_bound(tails.begin(), tails.end(), num);
        if (it == tails.end()) {
            tails.push_back(num); // num 最大,擴展 tails
        } else {
            *it = num; // 替換,維持最小末尾值
        }
    }
    return static_cast<int>(tails.size());
}

零錢兌換(C++)

#include <vector>
#include <algorithm>

/**
 * Coin Change — 完全背包
 * 初始化為 amount+1 而非 INT_MAX,避免 INT_MAX + 1 整數溢出
 * Time: O(amount × coins.size()), Space: O(amount)
 */
int coinChange(const std::vector<int>& coins, int amount) {
    std::vector<int> dp(amount + 1, amount + 1);
    dp[0] = 0;

    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (i >= coin) {
                dp[i] = std::min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}

複雜度分析

問題類型時間複雜度空間複雜度(原始)空間複雜度(優化後)
Fibonacci / 爬樓梯O(n)O(n)O(1)(滾動變數)
0/1 背包O(n × W)O(n × W)O(W)(滾動陣列)
LCS(最長共同子序列)O(m × n)O(m × n)O(min(m, n))
LIS(最長遞增子序列)O(n²) / O(n log n)O(n)O(n)
編輯距離O(m × n)O(m × n)O(min(m, n))
最小路徑和 / 唯一路徑O(m × n)O(m × n)O(n)(單列滾動)
區間 DP(矩陣鏈乘等)O(n³)O(n²)O(n²)(難以優化)

變體與延伸

完全背包(Unbounded Knapsack)

每種物品可無限次選取。與 0/1 背包的唯一程式碼差異:內層從左往右遍歷

/**
 * 完全背包(零錢兌換是其最大化版本,此為最大化價值版)
 * 內層從左往右:允許同一物品重複選取
 */
function unboundedKnapsack(
  weights: number[],
  values: number[],
  capacity: number
): number {
  const dp = new Array<number>(capacity + 1).fill(0);

  for (let i = 0; i < weights.length; i++) {
    // 從左往右!與 0/1 背包的關鍵區別
    for (let j = weights[i]; j <= capacity; j++) {
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
    }
  }

  return dp[capacity];
}

空間優化:滾動陣列(Rolling Array)

dp[i] 只依賴 dp[i-1](甚至 dp[i-2])時,可把二維 DP 壓縮成一維,大幅節省空間。

/**
 * LCS 空間優化版:O(m*n) → O(min(m, n))
 * 每次只保留「前一行」的 dp 值
 */
function lcSpaceOptimized(text1: string, text2: string): number {
  // 確保 text2 是較短的字串(省空間)
  if (text1.length < text2.length) [text1, text2] = [text2, text1];
  const m = text1.length, n = text2.length;

  let prev = new Array<number>(n + 1).fill(0);
  let curr = new Array<number>(n + 1).fill(0);

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        curr[j] = prev[j - 1] + 1;
      } else {
        curr[j] = Math.max(prev[j], curr[j - 1]);
      }
    }
    [prev, curr] = [curr, prev]; // 滾動交換,避免額外記憶體分配
    curr.fill(0);
  }

  return prev[n];
}

console.log(lcSpaceOptimized("abcde", "ace")); // 輸出:3

區間 DP(Interval DP)簡介

區間 DP 是進階 DP 類型,狀態定義在一個區間 [i, j] 上:

狀態:dp[i][j] = 區間 [i, j] 的最優解
轉移:dp[i][j] = min/max over k in [i, j-1] of (dp[i][k] + dp[k+1][j] + cost)
遍歷:先枚舉區間長度 len(從 2 到 n),再枚舉起點 i
典型題型:矩陣鏈乘、戳氣球(LeetCode 312)、奇怪的印表機(LeetCode 664)
Time: O(n³), Space: O(n²)

面試考點:DP 分類框架

面試中 DP 問題大致分為四大類,每類有其標誌性狀態定義方式:

類型狀態特徵代表題目時間複雜度
線性 DPdp[i] 只依賴前幾個狀態爬樓梯、打家劫舍、LISO(n) 或 O(n²)
二維 DPdp[i][j] 在矩陣或兩序列上LCS、編輯距離、最小路徑和O(m × n)
背包 DP物品選取組合,容量限制0/1 背包、完全背包、分割等和子集O(n × W)
區間 DPdp[i][j] 表示區間 [i,j] 的解矩陣鏈乘、戳氣球O(n³)

辨認題目類型的快速方法:

  1. 問「最多/最少/幾種」→ 很可能是 DP
  2. 有「不能回頭」的限制(只能向右走、不能重選)→ 通常是 DP 而非貪心
  3. 子問題之間有重疊 + 有最優子結構 → 確認用 DP
  4. 若問題可以「排序後用貪心」解決 → 考慮是否需要 DP(例如 LIS 的 O(n log n) 解法融合了貪心)

LeetCode 練習題

題號題目難度DP 類型關鍵狀態定義
70Climbing StairsEasy線性 DPdp[i] = 到達第 i 階的方法數
322Coin ChangeMedium完全背包dp[i] = 組成金額 i 的最少硬幣數
1143Longest Common SubsequenceMedium二維 DPdp[i][j] = text1 前 i 與 text2 前 j 的 LCS
300Longest Increasing SubsequenceMedium線性 DPdp[i] = 以 nums[i] 結尾的 LIS 長度
64Minimum Path SumMedium二維 DPdp[i][j] = 到達 (i,j) 的最小路徑和

刷題建議順序:70 → 64 → 1143 → 300 → 322。從一維線性到二維矩陣,再到序列比對,最後是背包變體,每題都有承接關係。


常見陷阱

陷阱一:狀態定義不清晰

「dp[i] 代表前 i 個元素」與「dp[i] 代表以第 i 個元素結尾」是完全不同的定義。以 LIS 為例,後者定義使轉移方程清晰(dp[i] = max(dp[j]+1)),但最終答案需取 max(dp);前者定義則最終答案可能就是 dp[n]。定義前先想清楚返回值。

陷阱二:0/1 背包滾動陣列方向錯誤

一維滾動時必須從右往左遍歷容量,確保每個物品只選一次。完全背包則從左往右。混淆方向是最常見的 Bug,面試時務必口頭說明遍歷方向的原因。

陷阱三:初始條件遺漏

編輯距離的 dp[i][0] = i(刪除 i 個字元)和 dp[0][j] = j(插入 j 個字元)若設錯,整個 DP 表都會出錯。建議在寫轉移方程前,先把所有邊界情況列出來驗證。

陷阱四:返回值不是 dp[n]

LIS 的答案是 max(dp);打家劫舍的答案是 dp[n-1];零錢兌換若 dp[amount] > amount 需返回 -1。習慣性地返回 dp[n] 是常見錯誤,務必回頭核對狀態定義。

陷阱五:哨兵值溢出

零錢兌換初始化用 amount + 1 而非 Infinity,因為 Infinity + 1 = Infinity(JavaScript)在 C++ 中 INT_MAX + 1 會整數溢出。用 amount + 1 作為安全哨兵值是更穩健的做法。


總結

動態規劃的學習曲線陡峭,但掌握了五步驟框架之後,大部分 DP 問題都有跡可循:

  1. 定義狀態——這是最重要的一步,決定一切
  2. 找轉移方程——大問題如何由小問題推導
  3. 確定初始值——讓 DP 有起點可以啟動
  4. 決定遍歷順序——確保計算 dp[i] 時依賴的值已就緒
  5. 空間優化——滾動陣列、滾動變數

從爬樓梯(線性 DP)到 LCS(二維 DP),再到 0/1 背包(背包 DP),每一類都有固定的解題模式。建議從 LeetCode 70 出發,按本文推薦順序逐題練習,親手寫出每個 dp 陣列的填充過程,直到能夠不看模板也能推導出轉移方程為止。

希望這篇文章能幫助你建立清晰的 DP 解題思路。如有任何問題或想法,歡迎透過 聯絡頁面 與我交流!下一篇將深入探討動態規劃進階——區間 DP、樹形 DP 與狀態壓縮 DP 等進階模式,敬請期待。

BenZ Software Developer

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