DP — 實戰題解
範圍 — 不是模板錨定題的經典 DP 題(LC 343、91、62、279、276、1137),每一題都是一張 problem card,歸檔在它所歸約到的 dp.md 模板底下,再加上一份「其他常見 DP 題在哪裡有完整解」的索引;本身不放模板也不談理論。 另見:dp.md — 這些 card 歸約到的模板;knapsack.md — 完整的背包家族;stock_trading.md — 所有股票狀態機的變形;dp_advanced.md — 冷門技巧與深入探討;dp_pattern.md — 更精簡的「一個模式一節」模板索引。
LeetCode 題目清單
總覽
這是 dp.md 的題目集:模板在那邊,題目在這邊。每張 card 寫出這題在模板之外多加的那一件事(每一步多一個選項、一道合法性檢查、一個不同的狀態),並給一份標準解。各節依 dp.md 的模板順序排列;題目都已在自己 card 上、或在專門的表裡解完的模板,這裡就沒有那一節。
索引
| LC | 題目 | 歸約到 | 難度 |
|---|---|---|---|
| 343 | Integer Break | Template 1 一維線性 DP,試遍最後一刀 | Medium |
| 91 | Decode Ways | Template 1a 以長度為索引的表 | Medium |
| 62 | Unique Paths | Template 2 二維格子 DP,計數 | Medium |
| 279 | Perfect Squares | knapsack.md § Template 4 完全背包,最少個數 | Medium |
| 276 | Paint Fence | Template 5 狀態機,兩個狀態 | Medium |
| 1137 | N-th Tribonacci Number | Template 6 滾動變數 | Easy |
在別處有完整解
每一題都只在擁有它的那張 card 或那份表裡解一次。
| LC | 題目 | 所在 |
|---|---|---|
| 53 | Maximum Subarray | dp.md § Template 1(錨定題);Kadane 家族在 kadane_algorithm.md |
| 152 | Maximum Product Subarray | kadane_algorithm.md:同時追蹤目前的最大與最小乘積,因為負數會讓兩者對調 |
| 70 | Climbing Stairs | dp.md § Template 1a(錨定題) |
| 139 | Word Break | dp.md § Template 1b(錨定題) |
| 64 | Minimum Path Sum | dp.md § Template 2(錨定題) |
| 416 / 494 / 1049 | Partition Equal Subset Sum / Target Sum / Last Stone Weight II | knapsack.md — Template 1(錨定題)與它的 LC Examples |
| 322 / 518 / 377 | Coin Change / II / Combination Sum IV | knapsack.md — Template 4、2、3(錨定題) |
| 309 | Best Time to Buy and Sell Stock with Cooldown | dp.md § Template 5(錨定題) |
| 121 / 122 / 123 / 188 / 714 | 其他股票題 | stock_trading.md |
| 198 | House Robber | dp.md § Template 6(錨定題) |
這裡沒有完整解 — 一句話的歸約
| 題目 | LC | 歸約到 | 一句話的想法 |
|---|---|---|---|
| Trapping Rain Water | 42 | 前綴/後綴陣列 | water[i] = min(maxLeft[i], maxRight[i]) - h[i];雙指標可以省掉陣列 |
| Jump Game | 55 | 一維可達性 → 貪心 | 追蹤能到達的最遠索引 |
| Jump Game II | 45 | 一維 DP → 貪心分層 | dp[i] = min(dp[j] + 1);像 BFS 那樣分層可以做到 O(n) |
| Minimum Cost For Tickets | 983 | 以天為單位的 Template 1 | dp[d] = min(dp[d-1] + c1, dp[d-7] + c7, dp[d-30] + c30) |
| 01 Matrix | 542 | Template 2,掃兩趟 | 左上 → 右下,再反過來一趟 |
| Counting Bits | 338 | Template 1 | dp[i] = dp[i >> 1] + (i & 1) |
| Is Subsequence | 392 | LCS 的退化情況 | 雙指標 O(n);只有查詢很多次時才用 LCS 表 |
| Pascal’s Triangle / II | 118 / 119 | Template 6,一列滾動 | row[j] += row[j-1],倒著跑 |
一維線性 DP 範例
Integer Break — LC 343
關鍵 —
dp[i]是把i拆成至少兩段時的最大乘積。最後一刀j + (i - j)之後,剩下的 部分可以保持完整(j * (i - j)),也可以再拆(j * dp[i - j])。兩種都要:dp[2] = 1,但把2留著不拆值 2。
歸約到:dp.md § Template 1 — dp[i] 讀之前的每一格,每一刀有兩個選項。
# python
# LC 343 - Integer Break
# IDEA: dp[i] = best product of >= 2 parts summing to i; for each split, keep the rest whole or split it
# time = O(n^2), space = O(n)
def integerBreak(n):
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
for j in range(1, i): # the first part is j
keep_rest = j * (i - j) # stop: i - j stays one part
break_rest = j * dp[i - j] # split i - j further
dp[i] = max(dp[i], keep_rest, break_rest)
return dp[n]
// java
// LC 343 - Integer Break
// IDEA: dp[i] = best product of >= 2 parts summing to i; for each split, keep the rest whole or split it
// time = O(n^2), space = O(n)
public int integerBreak(int n) {
int[] dp = new int[n + 1];
dp[1] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j < i; j++) {
int keepRest = j * (i - j); // stop splitting: i - j stays whole
int breakRest = j * dp[i - j]; // split i - j further
dp[i] = Math.max(dp[i], Math.max(keepRest, breakRest));
}
}
return dp[n];
}
試跑 — n = 10:
i 1 2 3 4 5 6 7 8 9 10
dp[i] 1 1 2 4 6 9 12 18 27 36
陷阱
- 只用
j * dp[i - j]:剩下的 2 或 3 一定會被再拆,價值就掉了 ——n = 4得到 3 而不是 4,n = 6得到 6 而不是 9。 - O(n) 的貪心(盡量拆成 3,
3 + 1改成2 + 2)是追問,不是開場:它需要一個 DP 不需要的證明。
以長度為索引的表 範例
Decode Ways — LC 91
關鍵 — 有合法性檢查的 Climbing Stairs:
dp[i](前i個字元的解碼方式數)在最後一位是1–9時加上dp[i - 1],在最後兩位是10–26時加上dp[i - 2]。0只能當兩位數的第二位。
歸約到:dp.md § Template 1a — n + 1 的表,dp[0] = 1 代表空前綴。
# python
# LC 91 - Decode Ways
# IDEA: dp[i] = ways to decode s[:i]; one-digit step if s[i-1] != '0', two-digit step if 10..26
# time = O(n), space = O(n)
def numDecodings(s):
n = len(s)
dp = [0] * (n + 1)
dp[0] = 1 # the empty prefix decodes one way
for i in range(1, n + 1):
if s[i - 1] != "0": # s[i-1] alone is a letter
dp[i] += dp[i - 1]
if i >= 2 and 10 <= int(s[i - 2:i]) <= 26: # s[i-2:i] together is a letter
dp[i] += dp[i - 2]
return dp[n]
// java
// LC 91 - Decode Ways
// IDEA: dp[i] = ways to decode s[:i]; one-digit step if s[i-1] != '0', two-digit step if 10..26
// time = O(n), space = O(n)
public int numDecodings(String s) {
int n = s.length();
int[] dp = new int[n + 1];
dp[0] = 1; // the empty prefix: one way
for (int i = 1; i <= n; i++) {
if (s.charAt(i - 1) != '0') { // s[i-1] decoded on its own
dp[i] += dp[i - 1];
}
if (i >= 2) {
int twoDigits = Integer.parseInt(s.substring(i - 2, i));
if (twoDigits >= 10 && twoDigits <= 26) { // s[i-2..i-1] decoded as a pair
dp[i] += dp[i - 2];
}
}
}
return dp[n];
}
陷阱
"06"是0,不是1:06不是合法的兩位數代碼,所以檢查是10..26,不是<= 26。- 在
n + 1的表裡,s[i - 1]是目前的字元,不是前一個 —— 見 Template 1a 底下的s[i-1]陷阱。
類似 — LC 639 (有 * 萬用字元) · LC 70 · LC 1137
二維格子 DP 範例
Unique Paths — LC 62
關鍵 — 走進一格的路徑數,是走進上面那格的路徑數加上走進左邊那格的路徑數,因為最後一步 只有這兩種。第一列和第一行都是
1:到每一格只有一條直線路徑。
歸約到:dp.md § Template 2 — 把 min 換成 +,格子沒有成本。
# python
# LC 62 - Unique Paths
# IDEA: dp[r][c] = dp[r-1][c] + dp[r][c-1]; the first row and column are 1
# time = O(m * n), space = O(m * n)
def uniquePaths(m, n):
dp = [[1] * n for _ in range(m)] # row 0 and column 0 stay 1
for row in range(1, m):
for col in range(1, n):
dp[row][col] = dp[row - 1][col] + dp[row][col - 1]
return dp[m - 1][n - 1]
// java
// LC 62 - Unique Paths
// IDEA: dp[r][c] = dp[r-1][c] + dp[r][c-1]; the first row and column are 1
// time = O(m * n), space = O(m * n)
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int row = 0; row < m; row++) {
dp[row][0] = 1; // first column: only straight down
}
for (int col = 0; col < n; col++) {
dp[0][col] = 1; // first row: only straight right
}
for (int row = 1; row < m; row++) {
for (int col = 1; col < n; col++) {
dp[row][col] = dp[row - 1][col] + dp[row][col - 1];
}
}
return dp[m - 1][n - 1];
}
陷阱
- 每一列只需要上一列,所以只開一個長度
n的陣列、做dp[col] += dp[col - 1],就是 O(n) 空間的追問。 - 封閉解
C(m + n - 2, m - 1)也對;用整數運算算它,見 combinatorics_math_patterns.md。
類似 — LC 63 (有障礙物:被擋住的格子是 0) · LC 64 · LC 980 (每格剛好走一次:回溯,不是 DP)
背包範例
Perfect Squares — LC 279
關鍵 — 平方數
1, 4, 9, …就是無限供應的硬幣,n是金額:湊成n的最少平方數個數, 就是 Coin Change。dp[0] = 0;其他每格都從n開始(n個 1 永遠可行),所以沒有-1的情況。
歸約到:knapsack.md § Template 4 — 完全背包求最少個數,硬幣是所有 ≤ 金額的平方數。
# python
# LC 279 - Perfect Squares
# IDEA: unbounded knapsack, min count: squares are the coins, n is the amount
# time = O(n * sqrt(n)), space = O(n)
def numSquares(n):
dp = [0] + [n] * n # dp[a] = fewest squares summing to a; n ones is the worst
for amount in range(1, n + 1):
root = 1
while root * root <= amount: # every square that fits
dp[amount] = min(dp[amount], dp[amount - root * root] + 1)
root += 1
return dp[n]
// java
// LC 279 - Perfect Squares
// IDEA: unbounded knapsack, min count: squares are the coins, n is the amount
// time = O(n * sqrt(n)), space = O(n)
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, n); // n ones always works
dp[0] = 0;
for (int amount = 1; amount <= n; amount++) {
for (int root = 1; root * root <= amount; root++) {
dp[amount] = Math.min(dp[amount], dp[amount - root * root] + 1);
}
}
return dp[n];
}
陷阱
- 「先拿最大的平方數」的貪心會錯:
12 = 9 + 1 + 1 + 1(4 個),但4 + 4 + 4只要 3 個。 - 求最少個數時迴圈順序不重要;數方法數時就重要了 (knapsack.md § Loop Order)。
類似 — LC 322 · LC 518 · LC 377 — 對餘數做 BFS、一層一層走,也能解同一題。
狀態機 DP 範例
Paint Fence — LC 276
關鍵 — 最後一根柱子有兩個狀態:
same(跟前一根同色)與diff(不同色)。「不能三根連續同色」 代表same只能接在diff後面,所以same = diff_prev,diff = (same_prev + diff_prev) * (k - 1)。
歸約到:dp.md § Template 5 — 兩個只讀前一根柱子的狀態,滾動成兩個變數。
# python
# LC 276 - Paint Fence
# IDEA: same = ways the last post repeats its neighbour, diff = ways it does not; roll both forward
# time = O(n), space = O(1)
def numWays(n, k):
if n == 0:
return 0
if n == 1:
return k
same = k # posts 1-2 the same colour
diff = k * (k - 1) # posts 1-2 different
for _ in range(3, n + 1):
prev_same = same
same = diff # repeat a colour only right after a change
diff = (prev_same + diff) * (k - 1) # any of the other k - 1 colours
return same + diff
// java
// LC 276 - Paint Fence
// IDEA: same = ways the last post repeats its neighbour, diff = ways it does not; roll both forward
// time = O(n), space = O(1)
public int numWays(int n, int k) {
if (n == 0) {
return 0;
}
if (n == 1) {
return k;
}
int same = k; // posts 1-2 the same colour
int diff = k * (k - 1); // posts 1-2 different
for (int i = 3; i <= n; i++) {
int prevSame = same;
same = diff; // repeat a colour only after a change
diff = (prevSame + diff) * (k - 1); // any of the other k - 1 colours
}
return same + diff;
}
陷阱
- 用新的
same去更新diff:先把舊值存起來,就像每一個狀態機一樣。 k = 1、n ≥ 3是0;遞迴式自己會算到(diff一開始就是 0)。
類似 — LC 198 · LC 309 · LC 740 (對值做拿/不拿) · LC 790 (把鋪磚當成狀態)
滾動變數範例
N-th Tribonacci Number — LC 1137
關鍵 —
T(n)剛好讀最後三個值,所以三個變數就能取代整張表。由最舊的開始往前移 (a = b、b = c、c = next),否則某個值會在被讀到之前就被覆蓋。
歸約到:dp.md § Template 6 — 最後一階,k = 3 個滾動變數。
# python
# LC 1137 - N-th Tribonacci Number
# IDEA: T(n) = T(n-1) + T(n-2) + T(n-3); keep only the last three values
# time = O(n), space = O(1)
def tribonacci(n):
if n == 0:
return 0
if n <= 2:
return 1
a, b, c = 0, 1, 1 # T(i-3), T(i-2), T(i-1)
for _ in range(3, n + 1):
nxt = a + b + c
a = b # shift oldest first
b = c
c = nxt
return c
// java
// LC 1137 - N-th Tribonacci Number
// IDEA: T(n) = T(n-1) + T(n-2) + T(n-3); keep only the last three values
// time = O(n), space = O(1)
public int tribonacci(int n) {
if (n == 0) {
return 0;
}
if (n <= 2) {
return 1;
}
int a = 0, b = 1, c = 1; // T(i-3), T(i-2), T(i-1)
for (int i = 3; i <= n; i++) {
int next = a + b + c;
a = b; // shift oldest first
b = c;
c = next;
}
return c;
}
陷阱
n = 0, 1, 2在迴圈前就回傳;起始值0, 1, 1必須滿足它們開頭的那個遞迴式。- 滾動變數的更新順序是經典 bug —— dp_advanced.md 在滾動變數的深入探討裡逐步追蹤了它。
類似 — LC 70 · LC 509 · LC 746