動態規劃基礎 — 五步驟解題法與經典 DP 問題詳解 | 資料結構與演算法
動態規劃(Dynamic Programming,DP) 是演算法學習中最重要也最難掌握的技術之一。它透過將問題分解為重疊子問題,用表格記錄每個子問題的答案避免重複計算,從而高效解決最優化與計數問題。本文從爬樓梯的直覺出發,系統介紹 DP 的五步驟解題框架,並以 TypeScript 與 C++ 完整實作背包、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] = 1,dp[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),對所有coin且i >= 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 問題大致分為四大類,每類有其標誌性狀態定義方式:
| 類型 | 狀態特徵 | 代表題目 | 時間複雜度 |
|---|---|---|---|
| 線性 DP | dp[i] 只依賴前幾個狀態 | 爬樓梯、打家劫舍、LIS | O(n) 或 O(n²) |
| 二維 DP | dp[i][j] 在矩陣或兩序列上 | LCS、編輯距離、最小路徑和 | O(m × n) |
| 背包 DP | 物品選取組合,容量限制 | 0/1 背包、完全背包、分割等和子集 | O(n × W) |
| 區間 DP | dp[i][j] 表示區間 [i,j] 的解 | 矩陣鏈乘、戳氣球 | O(n³) |
辨認題目類型的快速方法:
- 問「最多/最少/幾種」→ 很可能是 DP
- 有「不能回頭」的限制(只能向右走、不能重選)→ 通常是 DP 而非貪心
- 子問題之間有重疊 + 有最優子結構 → 確認用 DP
- 若問題可以「排序後用貪心」解決 → 考慮是否需要 DP(例如 LIS 的 O(n log n) 解法融合了貪心)
LeetCode 練習題
| 題號 | 題目 | 難度 | DP 類型 | 關鍵狀態定義 |
|---|---|---|---|---|
| 70 | Climbing Stairs | Easy | 線性 DP | dp[i] = 到達第 i 階的方法數 |
| 322 | Coin Change | Medium | 完全背包 | dp[i] = 組成金額 i 的最少硬幣數 |
| 1143 | Longest Common Subsequence | Medium | 二維 DP | dp[i][j] = text1 前 i 與 text2 前 j 的 LCS |
| 300 | Longest Increasing Subsequence | Medium | 線性 DP | dp[i] = 以 nums[i] 結尾的 LIS 長度 |
| 64 | Minimum Path Sum | Medium | 二維 DP | dp[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 問題都有跡可循:
- 定義狀態——這是最重要的一步,決定一切
- 找轉移方程——大問題如何由小問題推導
- 確定初始值——讓 DP 有起點可以啟動
- 決定遍歷順序——確保計算
dp[i]時依賴的值已就緒 - 空間優化——滾動陣列、滾動變數
從爬樓梯(線性 DP)到 LCS(二維 DP),再到 0/1 背包(背包 DP),每一類都有固定的解題模式。建議從 LeetCode 70 出發,按本文推薦順序逐題練習,親手寫出每個 dp 陣列的填充過程,直到能夠不看模板也能推導出轉移方程為止。
希望這篇文章能幫助你建立清晰的 DP 解題思路。如有任何問題或想法,歡迎透過 聯絡頁面 與我交流!下一篇將深入探討動態規劃進階——區間 DP、樹形 DP 與狀態壓縮 DP 等進階模式,敬請期待。