動態規劃(DP)
範圍 — DP 的主文件 — 狀態設計、模式目錄,以及每個必會 DP 家族各一份標準模板;實作解法庫、冷門技巧,以及五個最重的子題目都各自獨立成篇,從這裡連過去。 另見 — 從本檔拆出:dp_examples.md — 不是模板錨定題的經典題目的 problem card,加上每一題常見 DP 在哪裡有完整解的索引;dp_advanced.md — 賽局理論、樹上 DP、區間與字串的深入探討、機率 DP;knapsack.md — 0/1 vs 完全背包、子集和、組合 vs 排列(knapsack_01_zh.md — 0/1 背包的繁體中文詳解);dp_string.md — 雙序列格子家族;dp_bitmask.md — 狀態壓縮;dp_digit.md — 按位數計數;dp_monotonic_stack.md — 堆疊裡帶著 DP 值;dp_loop_order.md — 為什麼轉移式會決定迴圈的巢狀與方向,並把 LC 139 用五種寫法走一遍。 相鄰文件:dp_pattern.md — 精簡的模板索引,一個經典模式一節;recursion_to_dp.md — 一步步把能跑的遞迴改寫成 DP;kadane_algorithm.md — 最大子陣列家族的深入版;stock_trading.md — LC 121/122/188/309/714 的狀態機。
LeetCode 題目清單
總覽
動態規劃是一種演算法典範:把複雜問題拆成較簡單的子問題,並把子問題的答案存起來,避免重複計算。
關鍵性質
- 時間複雜度:因題而異,通常是 O(n²) 或 O(n³)
- 空間複雜度:記憶化表格通常是 O(n) 到 O(n²)
- 核心想法:把重疊的子問題記下來,用空間換時間
- 什麼時候用:具備最佳子結構與重疊子問題的題目
- 關鍵技巧:記憶化(由上而下)與表格化(由下而上)
核心特徵
-
最佳子結構:最佳解由子問題的最佳解組成
-
重疊子問題:同一個子問題被算很多次
-
記憶化:把結果存起來,避免重算
-
狀態轉移:定義狀態之間的關係
-
核心變數:
state、transition -
轉移需要知道的任何東西,都可以放進
state

步驟
第 1 步:定義 dp def
第 2 步:定義 dp eq
第 3 步:檢查邊界條件、題目要求、邊界情況
第 4 步:取出答案
參考資料
辨認
限制條件的線索來自 complexity_cheatsheet.md 裡的尺標。
| 題目敘述裡的訊號 | 限制條件線索 | 該用 | 不是這個,如果 |
|---|---|---|---|
| 「連續子陣列的最大和/最大乘積」、「不能挑相鄰的兩個」 | n ≤ 10⁵ | 模板 1 一維線性 DP | 前驅可以是任何更早的索引 → 模板 7 |
「走到第 n 階的方法數」、「前 i 個字元」、有一個代表什麼都沒有的 base case |
n ≤ 10⁴ | 模板 1a 以長度為索引的表格 | |
| 「把字串切/拆/斷成合法的片段」 | n ≤ 300 | 模板 1b 前綴切分 | 要列出每一種切法 → backtrack.md |
| 「最小路徑和/不同路徑數」,只能往右和往下走 | m, n ≤ 200 | 模板 2 格子 DP | 可以往四個方向走 → Dijkstra/BFS |
| 題目沒有直接給你的二維表格(回文子序列、兩個字串) | n ≤ 1000 | 模板 2a 形狀與填表順序 | |
| 「把一段範圍裡的東西全部戳破/合併/相乘」,成本取決於鄰居 | n ≤ 500 | 模板 3 區間 DP,最後的切點 | 只有兩端在動 → 模板 2a,O(n²) |
| 「每個元素最多用一次,湊出某個和/切成和相等的兩半」 | sum ≤ 2·10⁴ | 模板 4 0/1 背包 | 物品可以重複用 → knapsack.md 完全背包 |
| 「含冷卻期/手續費/最多 k 次交易的買賣股票」 | n ≤ 10⁵ | 模板 5 狀態機 | 無限次、無手續費 → 貪婪 |
| 「用 O(1) 空間打劫/爬樓梯/解碼……」 | n ≤ 10⁵ | 模板 6 滾動變數 | dp[i] 會讀到每一個更早的格子 → 保留整張表 |
| 「最長的遞增/整除/巢狀子序列」 | n ≤ 2500(n ≤ 10⁵ → tails) |
模板 7 LIS | 公差固定 → 模板 7a |
「相鄰元素相差給定 difference 的最長子序列」 |
n ≤ 10⁵ | 模板 7a 以值為鍵的 DP | 公差沒給 → (index, diff) 雜湊表 |
| 「把一個字串變成另一個的最少插入/刪除/取代次數」 | m, n ≤ 500 | 模板 8 編輯距離 | 只能刪除 → 模板 9 |
| 「最長公共子序列」、「讓兩個字串相等的最少刪除次數」 | m, n ≤ 1000 | 模板 9 LCS | 要連續 → 不相等時歸零(LC 718) |
| 「最長的/有幾個回文子字串」 | n ≤ 1000 | 模板 10 回文表 | 子序列 → 模板 2a 裡的 LC 516 |
| 「把每個物品各指派/配對/走訪一次」 | n ≤ 20 | 模板 11 位元遮罩 DP | n ≥ 40 → 貪婪、網路流或折半搜尋 |
| 「≤ n 的數字裡有幾個具有某種位數性質 P」 | n ≤ 10¹⁸ | 模板 12 數位 DP | n ≤ 10⁶ → 直接跑迴圈 |
| 「列出所有組合/切法」 | 輸出是指數級 | 不是 DP → backtrack.md |
題型分類
分類 1:線性 DP
- 說明:單一序列、相依關係呈線性的題目
- 範例:LC 70(Climbing Stairs)、LC 198(House Robber)、LC 300(LIS)
- 模式:dp[i] 依賴 dp[i-1]、dp[i-2] 等等
- 子型態 — 前綴切分:dp[i] 改成掃過每一個切點 j < i,並檢查整段 s[j:i] — LC 139(Word Break)、LC 132、LC 279。見 Template 1b。
- 子型態 — 以值為鍵(DP + 雜湊表):x 唯一合法的前一個元素是某個已知的值 (x - d),所以 dp 存在以「值」為鍵的雜湊表裡,內層迴圈就消失了 — LC 1218。見 Template 7a。
分類 2:格子/二維 DP
- 說明:在二維格子或矩陣上的題目
- 範例:LC 62(Unique Paths)、LC 64(Minimum Path Sum)、LC 221(Maximal Square)
- 模式:dp[i][j] 依賴鄰居
分類 3:區間 DP
- 說明:在區間或子陣列上的題目
- 範例:LC 312(Burst Balloons)、LC 1000(Minimum Cost to Merge Stones)
- 模式:dp[i][j] 表示區間 [i, j]
分類 3-2:賽局理論/Minimax DP → dp_advanced.md
- 說明:兩名玩家在陣列上做最佳決策;每人只能從頭或尾取
- 範例:LC 486(Predict the Winner)、LC 877(Stone Game)、LC 1140(Stone Game II)
- 模式:
dp[i][j]= 在nums[i..j]上的最大相對分差(當前玩家減對手) - 遞迴式:
dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])— 推導與模板見 dp_advanced.md
分類 4:樹上 DP
-
說明:在樹狀結構上做 DP
-
範例:LC 337(House Robber III)、LC 968(Binary Tree Cameras)
-
模式:每個節點的狀態取決於它的子節點
-
📚 實作方式:樹上 DP 靠 DFS 走訪來實作。DFS 的走訪模式見 dfs.md 模板 6(由下而上的 DFS)
-
子模式:由下而上(後序)的樹上 DP,以及換根 DP(兩趟 DFS)— 都在 dp_advanced.md
分類 5:狀態機 DP
-
說明:具有多個狀態與轉移的題目
-
範例:LC 714(Stock with Fee)、LC 309(Stock with Cooldown)、LC 122(Stock II)
-
模式:不同狀態各用一個 DP 陣列
-
關鍵特徵:狀態轉移取決於前一個狀態 + 動作限制
-
子模式:2 狀態(LC 122)、含冷卻期的 3 狀態(LC 309)、k 次交易的 2k 狀態(LC 123 / 188)— 全都在 stock_trading.md
分類 6:背包 DP
- 說明:帶限制的選取問題
- 範例:LC 416(Partition Equal Subset)、LC 494(Target Sum)
- 模式:dp[i][j],i 是物品、j 是容量/目標
分類 7:字串 DP
- 說明:字串比對、轉換與子序列問題
- 範例:LC 72(Edit Distance)、LC 1143(LCS)、LC 5(Longest Palindromic Substring)
- 模式:dp[i][j] 表示兩個字串各自的位置
分類 8:狀態壓縮 DP
- 說明:用位元遮罩表示狀態,壓低空間複雜度
- 範例:LC 691(Stickers to Spell Word)、LC 847(Shortest Path Visiting All Nodes)
- 模式:dp[mask],mask 表示已走訪/已選取的項目
分類 9:單調堆疊 + DP → dp_monotonic_stack.md
訊號:暴力解是一輪一輪把元素移除(每個元素在有更大的元素追上它時被刪掉), 或是直方圖上的面積/矩形問題。這兩種用模擬都是 O(n^2),改用帶 DP 值的單調堆疊就是 O(n)。
| 題目 | LC | 堆疊裡帶的東西 |
|---|---|---|
| Steps to Make Array Non-decreasing | 2289 | dp[i] = 元素 i 能撐幾輪;被彈出的一整串貢獻 max(...) |
| Largest Rectangle in Histogram | 84 | 前一個比它矮的柱子索引 → 以此結尾的矩形寬度 |
| Maximal Rectangle | 85 | 把 LC 84 逐列套用在累積的直方圖上 |
| Maximal Square / Count Square Submatrices | 221 / 1277 | dp[i][j] = 1 + min(up, left, up-left)(格子 DP,不是堆疊) |
| Flip String to Monotone Increasing | 926 | 單趟計數 DP |
dp_monotonic_stack.md 完整涵蓋:存活輪數的轉移式與實際推演、 LC 2289 的 Java 與 Python 模板、直方圖面積 DP、最大正方形的遞迴式,以及 LC 926 的單趟 DP。
模板與演算法
模板比較表
| 模板類型 | 適用情境 | 狀態定義 | 什麼時候用 |
|---|---|---|---|
| 一維線性 | 單一序列 | dp[i] = 位置 i 的狀態 | 費氏數列類題目 |
| 二維格子 | 矩陣路徑 | dp[i][j] = 座標 (i,j) 的狀態 | 路徑計數、求最小/最大 |
| 區間 | 子陣列/子字串 | dp[i][j] = 區間 [i,j] | 回文、分割 |
| 背包 | 帶容量上限的選取 | dp[i][w] = 物品與重量 | 0/1、完全背包 |
| 狀態機 | 多個狀態 | dp[i][state] = 在 i 且處於 state | 買賣股票 |
| 以值為鍵(雜湊表) | 固定步長的鏈 | dp[value] = 以 value 結尾的最佳鏈長 | 前一個元素是唯一已知的值 |
模板 1:一維線性 DP — LC 53 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 一個陣列,而位置 i 的答案只取決於固定幾個更早的位置:「連續子陣列的最大和」、
「走到第 n 階的方法數」、「不能拿相鄰的兩個」。n ≤ 10⁵ → 預期是一趟 O(n)。
關鍵 — dp[i] 講的是恰好結束在 i 的子陣列,所以它只有兩個選擇:接上結束在 i-1
的最佳那一段,或是從 i 重新開始。答案可以結束在任何地方,所以是 max(dp),不是 dp[n-1]。
- 狀態:
dp[i]= 恰好結束在索引i的子陣列的最大和 - 遞迴式:
dp[i] = max(nums[i], dp[i-1] + nums[i]) - 基底:
dp[0] = nums[0] - 順序:
i遞增 —dp[i]只讀dp[i-1] - 答案:
max(dp)
# python
# IDEA: dp[i] = best sum of a subarray that ENDS at i: extend the previous run or restart at i
# time = O(n), space = O(n)
# LC 53 - Maximum Subarray
def maxSubArray(nums):
n = len(nums)
if n == 0:
return 0
# State: dp[i] = max sum of a subarray ending exactly at index i
dp = [0] * n
dp[0] = nums[0]
for i in range(1, n):
# Transition: extend the run ending at i-1, or start a new run at i
dp[i] = max(nums[i], dp[i - 1] + nums[i])
# The answer can end anywhere, so it is the best dp[i], not dp[n-1]
return max(dp)
// java
// LC 53 - Maximum Subarray
// IDEA: dp[i] = best sum of a subarray that ENDS at i: extend the previous run or restart at i
// time = O(n), space = O(n)
public int maxSubArray(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int best = dp[0];
for (int i = 1; i < n; i++) {
// extend the run ending at i-1, or start a new run at i
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
best = Math.max(best, dp[i]); // the answer can end at any index
}
return best;
}
複雜度
- 時間 O(n):唯一的
for i in range(1, n)迴圈對每個索引做 O(1) 的事。 - 空間 O(n):
dp陣列。dp[i]只讀dp[i-1],所以一個滾動變數就能變成 O(1) — 那就是 Kadane 演算法。
試跑
nums = [-2, 1, -3, 4, -1, 2]
i=0 dp=-2 best=-2
i=1 max(1, -2+1=-1) -> 1 best=1
i=2 max(-3, 1-3=-2) -> -2 best=1
i=3 max(4, -2+4=2) -> 4 restart at 4
i=4 max(-1, 4-1=3) -> 3
i=5 max(2, 3+2=5) -> 5 best=5 ([4, -1, 2])
陷阱
dp[i] = max(dp[i-1], nums[i])漏掉了+ nums[i],回傳的是最大的元素而不是最大的和:[1, 2]會給 2 而不是 3。- 回傳
dp[n-1]等於假設最佳的一段結束在最後一個索引:[5, -9]會回傳 -4 而不是 5。 - 把
best初始化成 0(或dp全填 0),在每個值都是負數時就錯了:[-3, -1]必須回傳 -1。
不適用時 — 前驅可以是任何更早的索引,而不只是 i-1 → LIS
(模板 7)。如果是乘積而不是和,就要同時帶著
結束在 i 的最大值與最小值 → kadane_algorithm.md 裡的 LC 152。
練習 — LC 53 ⭐ · LC 746 (取 min 而不是 max,往回兩步) · LC 152 ⭐ (同時帶著 max 與 min) · LC 918 (環狀:總和 − 最小子陣列) · LC 1191 (重複的陣列)
遞迴式目錄(一維)
| 題型 | 遞迴式 | 範例 | 時間 | 空間 |
|---|---|---|---|---|
| Fibonacci | dp[i] = dp[i-1] + dp[i-2] | LC 70 Climbing Stairs | O(n) | O(1) |
| House Robber(打家劫舍) | dp[i] = max(dp[i-1], dp[i-2] + nums[i]) | LC 198 House Robber | O(n) | O(1) |
| Decode Ways | dp[i] = dp[i-1] + dp[i-2](合法時) | LC 91 Decode Ways | O(n) | O(1) |
| Word Break → Template 1b | dp[i] = OR(dp[j] AND s[j:i] in dict) | LC 139 Word Break | O(n²) 個切點,算進切片是 O(n³) | O(n) |
最大子陣列(Kadane)→ kadane_algorithm.md
dp[i] = max(nums[i], dp[i-1] + nums[i]) 就是同一個一維形狀,只是多了「重開還是延續」的抉擇,
所以根本不需要表格。kadane_algorithm.md 完整涵蓋這個家族 —
LC 53、LC 152(最大乘積)、LC 918(環狀)、LC 1191(重複陣列)— 包含索引追蹤與分治法版本。
模板 1a:以長度為索引的表格(n + 1 格)— LC 70
辨識 — 答案講的是前 i 個項目或大小為 i 的目標,而且大小 0 本身就有一個有意義的答案:
「走到第 n 階的方法數」、「前 i 個字元能不能……」、「湊出金額 a 的最少硬幣數」。
訊號是有一個代表什麼都沒有的 base case。
關鍵 — dp[i] 講的是一個長度(或金額),不是索引 i 上的那個元素。第 0 格放的是空前綴,
所以表格有 n + 1 格,第 i 步決定的元素是 nums[i-1],而答案是 dp[n]。
- 狀態:
dp[i]= 站上第i階的不同方法數 - 遞迴式:
dp[i] = dp[i-1] + dp[i-2]— 最後一步走了一階或兩階 - 基底:
dp[0] = 1(原地不動算一種),dp[1] = 1 - 順序:
i遞增,2 → n - 答案:
dp[n]
# python
# LC 70 - Climbing Stairs
# IDEA: dp[i] = ways to reach step i; i is a COUNT of steps, so the table has n + 1 slots
# time = O(n), space = O(n)
def climbStairs(n):
dp = [0] * (n + 1) # dp[0] .. dp[n]: step n is the goal, so it needs a slot
dp[0] = 1 # one way to be at the bottom: take no step
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2] # last move was 1 step or 2 steps
return dp[n]
// java
// LC 70 - Climbing Stairs
// IDEA: dp[i] = ways to reach step i; i is a COUNT of steps, so the table has n + 1 slots
// time = O(n), space = O(n)
public int climbStairs(int n) {
int[] dp = new int[n + 1];
dp[0] = 1; // one way to be at the bottom: take no step
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
複雜度
- 時間 O(n):
for i in range(2, n + 1)跑一趟,每一階 O(1)。 - 空間 O(n):
n + 1格的表。兩個滾動變數可以讓它變成 O(1) (模板 6)。
陷阱
- 三個訊號必須一致 —
[0] * (n + 1)、range(1, n + 1)、return dp[n]。長度觀點和索引觀點混用, 不是讀到超出字串尾端的s[n],就是漏掉最後一個元素。 - 在以長度為索引的迴圈裡,
s[i-1]是目前這個字元,不是前一個(見下)。 - 沒有防護就初始化
dp[2]:n = 1時表只有 2 格,dp[2] = …會丟出IndexError。只初始化dp[0]與dp[1]就只需要n ≥ 1。
不適用時 — dp[i] 講的是位在索引 i 的元素(「結束在 i 的子陣列」)→ 大小是 n,
用 模板 1。每一步都要檢查一整段 s[j:i] →
模板 1b。
練習 — LC 70 ⭐ · LC 746 (dp[n] 是「頂端」,在最後一階的再下一格) · LC 198 ⭐ (下面的 n + 1 寫法) ·
LC 91 ⭐ (dp[0] = 1,讀 s[i-1] 與 s[i-2:i]) · LC 926 (s[i-1] 陷阱)
為什麼會有兩種大小 — 差別在於你的 DP 陣列裡,單一個索引代表什麼。兩個理由就涵蓋幾乎所有題目; 第三個理由 — 實體「階梯」vs「目標」,LC 746 — 在 dp_advanced.md。
1.「索引」vs「數量」(偏移量)
這是最常見的原因。
-
跑到
n(陣列大小 =n):你把索引當成輸入陣列裡的某個具體元素dp[i]意思是「用到第 i 個元素時的最佳結果」- 例:
dp[3]代表「在元素索引 3 的結果」
-
跑到
n+1(陣列大小 =n+1):你把索引當成數量或長度dp[i]意思是「用前 i 個元素的最佳結果」- 例:
dp[3]代表「用前 3 個元素的結果」
範例:LC 198(House Robber)
# python
# LC 198 - House Robber, sized both ways
# IDEA: index view (dp[i] = best using house i) vs length view (dp[i] = best over the first i houses)
# time = O(n), space = O(n)
# Approach 1: Array size = n (index-based)
def rob_v1(nums):
n = len(nums)
if n == 0: return 0
if n == 1: return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n): # Loop to n
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[n-1] # Answer at last index
# Approach 2: Array size = n+1 (count-based)
def rob_v2(nums):
n = len(nums)
dp = [0] * (n + 1) # Extra space for "0 houses"
dp[0] = 0 # Robbing 0 houses = 0
dp[1] = nums[0] if n > 0 else 0
for i in range(2, n + 1): # Loop to n+1
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]) # Note: nums[i-1]
return dp[n] # Answer at position n
s[i-1] 陷阱 — 它是目前的字元,不是前一個
一旦表格長度是 n+1,i 就不再是位置,而是長度。正是這個改變,讓 s[i-1] 乍看之下讀起來不對:
-1 看起來像是「往回退一個字元」,但它只是把某個長度換算成該長度最後一個字元的索引。
dp[i] = a claim about the FIRST i CHARACTERS -> i is a LENGTH (0 .. n)
s[j] = the character sitting AT position j -> j is an INDEX (0 .. n-1)
a prefix of length i ends at index i-1
-> s[i-1] is the newest character inside dp[i] -> the CURRENT one
把兩列對齊看一次,困惑就消失了 — 它們本來就差一,因為長度那一列在最前面多了一個「空前綴」的位置:
s = 0 1 0 (n = 3)
index = 0 1 2 <- s is indexed 0..n-1
^ ^ ^
dp slot = 0 1 2 3 <- dp has n+1 slots
| | | |
"" "0" "01" "010" <- the prefix dp[i] talks about
| 迴圈步驟 | i(長度) |
前綴 s[:i] |
它的最後一個字元 | val = s[i-1] |
|---|---|---|---|---|
| 第 1 步 | 1 | "0" |
'0' |
s[0] = '0' ✅ 目前 |
| 第 2 步 | 2 | "01" |
'1' |
s[1] = '1' ✅ 目前 |
| 第 3 步 | 3 | "010" |
'0' |
s[2] = '0' ✅ 目前 |
s[i-1] 永遠不是前一個字元 — 前一個字元是 s[i-2],而你幾乎從來不需要它,因為關於它已經決定好的一切
都已經折進 dp[i-1] 裡了。
用這個觀點寫 LC 926(Flip String to Monotone Increasing) — dp[i] = 讓前 i 個字元變成單調遞增
所需的最少翻轉次數:
# python
# LC 926 - Flip String to Monotone Increasing
# IDEA: dp[i] = min flips over the PREFIX OF LENGTH i, so the char decided at step i is s[i-1]
# time = O(n), space = O(n)
def minFlipsMonoIncr(s):
n = len(s)
dp = [0] * (n + 1) # n+1 slots: dp[0] = empty prefix = 0 flips
one_so_far = 0 # '1's seen so far in s[:i-1]
for i in range(1, n + 1): # i = prefix LENGTH, not a position
val = s[i - 1] # the character being decided right now
if val == '0':
# flip every earlier '1' -> one_so_far
# flip this '0' to '1' -> 1 more than dp[i-1]
dp[i] = min(one_so_far, dp[i - 1] + 1)
else:
dp[i] = dp[i - 1] # a '1' at the end is always safe, costs nothing
one_so_far += 1
return dp[n] # dp[n], NOT dp[n-1]
在 s = "010" 上的追蹤:
i=1 val=s[0]='0' dp[1]=min(one_so_far=0, dp[0]+1=1) = 0
i=2 val=s[1]='1' dp[2]=dp[1]=0 one_so_far -> 1
i=3 val=s[2]='0' dp[3]=min(one_so_far=1, dp[2]+1=1) = 1
answer = dp[3] = 1 ("010" -> "011" or "000")
有三個跡象代表你在用長度觀點,而且三者必須一致 — dp = [0] * (n + 1)、
for i in range(1, n + 1)、return dp[n]。只要其中一個混用了索引觀點,你不是讀到超出字串尾端的
s[n],就是無聲地漏掉它的最後一個字元。
2. 處理「空」的初始條件
很多 DP 題需要一個代表「什麼都沒有」的初始條件(目標和 = 0、空字串等等)。
範例:
- 背包/Coin Change:需要
dp[target + 1],因為dp[0]代表總和 = 0 - 最長公共子序列:用
(n+1) x (m+1)的矩陣,第一列/第一行代表空字串 ——但用實際位置當索引的 2-D 表(LC 647 的dp[i][j]=s[i..j])是n × n;見 表格要開多大
# python
# LC 322 - Coin Change
# IDEA: dp[a] = fewest coins for amount a; dp[0] = 0 is the "empty" amount, so amount + 1 slots
# time = O(amount * len(coins)), space = O(amount)
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1) # Need amount+1
dp[0] = 0 # Base case: 0 coins needed for amount 0
for i in range(1, amount + 1): # Loop to amount+1
for coin in coins:
if i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
比較總結
| 特徵 | 迴圈 0 到 n |
迴圈 0 到 n+1 |
|---|---|---|
| 陣列大小 | new int[n] |
new int[n + 1] |
dp[i] 的意義 |
在元素 i 的結果 |
考慮前 i 個項目的結果 |
| 典型初始條件 | dp[0] 和 dp[1] |
dp[0] 是「空」狀態 |
| 存取方式 | dp[i] ↔ nums[i] |
dp[i] ↔ nums[i-1] |
| 最終答案 | dp[n - 1] |
dp[n] |
| 適用情境 | 直接對應到元素 | 計數/數量類題目、超出陣列的「目標」 |
模板 1b:前綴切分 DP — LC 139 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
模板 1a 的 n+1 尺寸就是為了這個形狀而存在的。dp[i] 講的是一段前綴長度的性質,
而不是「索引 i 上那個字元」——而且這是一維家族裡唯一一個轉移要檢查一整段 s[j:i]、
而不是像 dp[i-1]、dp[i-2] 那樣固定位移的模式。這家族其他題目只換掉一件事:套在那一段上的判斷。
辨識 — 「字串能不能被切/拆/斷成每段都滿足……的片段」、「最少切幾刀」、「最少幾段」。
n ≤ 300(LC 139)→ O(n²) 的切點迴圈沒問題;字典都是短字(L ≤ 20)時,內層迴圈走 L 步就能停。
關鍵 — 猜最後一個單字。 如果 s[:i] 切得開,最後一定以某個單字 s[j:i] 結尾,而它前面的部分
就是 dp[j] 早已回答過的更小子問題 s[:j]。所以 dp[i] 是所有切點 j 上
dp[j] AND s[j:i] in words 的 OR。
| 面向 | 內容 |
|---|---|
| 模式 | 前綴切分 — 外層迴圈跑前綴長度,內層迴圈跑切點 |
| 狀態 | dp[i] = 前 i 個字元 s[:i] 能不能被切成字典裡的單字? |
| base case | dp[0] = True — 空前綴天然成立 |
| 轉移 | dp[i] = OR over j < i of (dp[j] AND s[j:i] in dict) |
| 答案 | dp[n],不是 dp[n-1] — 表格有 n+1 格 |
j i
s: [ ---- dp[j] ---- ][ s[j:i] ]
already solved one word?
只要有某一個 j 讓兩個問題都成立,dp[i] 就是 true —— 這也是為什麼內層迴圈一命中就可以 break。
把 i 讀成邊界,不是字元:它是「已經切好」和「還沒看」之間的分界線,所以可以合法地走到 n ——
最後一個字元的再下一格。這正是 BFS 寫法丟進佇列的那個邊界。
# python
# IDEA: PREFIX PARTITION DP — dp[i] = can s[:i] be cut into dictionary words
# LC 139 - Word Break
# time = O(n^2) cuts * O(L) slice+hash, space = O(n + dictionary)
class Solution(object):
def wordBreak(self, s, wordDict):
n = len(s)
words = set(wordDict) # set, not list — see the trap below
dp = [False] * (n + 1) # n+1 slots, indexed by prefix LENGTH
dp[0] = True # the empty prefix is always segmentable
for i in range(1, n + 1): # i = end boundary of the prefix
for j in range(i): # j = start of the candidate last word
if dp[j] and s[j:i] in words:
dp[i] = True
break # one valid cut is enough — it is an OR
return dp[n]
// java
// IDEA: PREFIX PARTITION DP — dp[i] = can s[:i] be cut into dictionary words
// LC 139 - Word Break
// time = O(n^2) cuts * O(L) substring+hash, space = O(n + dictionary)
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
Set<String> words = new HashSet<>(wordDict);
boolean[] dp = new boolean[n + 1]; // n+1 slots, indexed by prefix LENGTH
dp[0] = true; // the empty prefix is always segmentable
for (int i = 1; i <= n; i++) { // i = end boundary of the prefix
for (int j = 0; j < i; j++) { // j = start of the candidate last word
if (dp[j] && words.contains(s.substring(j, i))) {
dp[i] = true;
break; // one valid cut is enough — it is an OR
}
}
}
return dp[n];
}
複雜度 — 老實講。雙層的 for i/for j 迴圈是 O(n²) 次迭代,但每一次都要產生並雜湊一段子字串,
長度最多到 n:
| 計費方式 | 上界 | 什麼時候該講這個 |
|---|---|---|
| 把切片+雜湊當成 O(1) | O(n²) | 面試時的慣用簡稱 |
| 老實算切片的成本 | O(n³) | Python s[j:i]/Java substring — 嚴格上界 |
內層迴圈只回看最長單字 L |
O(n·L²) | 下面那個優化;LC 139 保證 L ≤ 20 |
空間是 dp 的 O(n) 再加上單字集合。比字典裡最長的單字還長的「最後一個單字」永遠不可能命中,
所以內層迴圈只需要往回看 L 個字元:
# python
# IDEA: same DP, inner loop capped by the longest word — O(n * L^2), effectively linear in n
# time = O(n * L^2), space = O(n)
L = max(map(len, words))
for i in range(1, n + 1):
for j in range(max(0, i - L), i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
第二種解法,留下來是因為它是不同的演算法:把每個邊界 i 當成圖上的節點、每個字典單字 s[i:j] 當成一條邊
i → j,再問 n 走不走得到。上界相同,但它只展開真的走得到的邊界;面試官把題目講成路徑搜尋時,就用這個寫法。
# python
# IDEA: BFS over boundaries — from boundary i, every dictionary word s[i:j] reaches boundary j
# time = O(n * L) cuts * O(L) slice+hash = O(n * L^2), L = longest word; space = O(n)
from collections import deque
def wordBreak(s, wordDict):
words = set(wordDict)
longest = max(len(w) for w in words)
n = len(s)
seen = [False] * (n + 1) # boundary already queued
seen[0] = True
queue = deque([0])
while queue:
i = queue.popleft()
if i == n: # the whole string is cut into words
return True
for j in range(i + 1, min(n, i + longest) + 1): # no word is longer: never slice past it
if not seen[j] and s[i:j] in words:
seen[j] = True
queue.append(j)
return False
試跑
s = "leetcode", dict = {"leet", "code"}
i=0 dp[0] = True (base: empty prefix)
i=1..3 no j works dp[1..3] = False
i=4 j=0: dp[0] AND "leet" in dict -> dp[4] = True
i=5..7 no j works dp[5..7] = False
i=8 j=4: dp[4] AND "code" in dict -> dp[8] = True
^ answer = dp[n] = dp[8]
陷阱
- 直接對原本的 list 做
in wordDict。s[j:i] in wordDict如果wordDict是 list, 那是一次線性掃描、每一步還要做字串比較 —— 單次查詢 O(k·L),整體退化成 O(n²·k·L) 而 TLE。set(wordDict)讓它變成一次雜湊。這是「看起來正確的 Word Break」超時最常見的原因。 - 把字典迴圈放到外層。 內層迴圈是
range(i)—— 跑位置 —— 而不是for w in wordDict。 以單字驅動的內層迴圈是可以的(它讀到的仍然都是已經算完的格子);但以單字驅動的外層 迴圈不行,而且它會安靜地在題目自己的 Example 2 上出錯 ——"applepenapple"在"pen"的前後 都要用到"apple",而一輪迴圈只能把一個單字放一次。原因與五種寫法的對照: dp_loop_order.md。 - 尺寸開錯之後回傳
dp[-1]。 只開n格的話,dp[0] = True這個 base case 根本沒地方放, 於是每個答案都塌成False。
不適用時 — 題目要的是每一種切法,而不是有沒有切法: LC 131 Palindrome Partitioning 是回溯,因為 DP 能把問題壓成是非題或最佳值, 但沒辦法把指數多的輸出列舉得更快。LC 140 則是混血:先用 DP 證明切得開,再用回溯把它們列出來。
練習 — LC 139 ⭐ · LC 91 ⭐ (段落最多 2 位數,合併 = 加總) · LC 279 (段落 = 完全平方數, 合併 = min + 1) · LC 132 (回文判斷,最少切幾刀) · LC 140 (收集句子) · LC 472 (字典就是輸入本身)
類似 LeetCode 題目
下面每一題都是同一個 dp[i] = f(dp[j], segment(j, i)) 骨架。只有段落判斷和合併運算子在變:
| 題目 | 對 s[j:i] 的段落判斷 |
合併 | 備註 |
|---|---|---|---|
| LC 139 Word Break | 是不是字典裡的單字? | OR | 上面的模板 |
| LC 140 Word Break II | 是不是字典裡的單字? | 收集 | 記憶化的是句子清單而不是布林值;從同一張表回溯 |
| LC 472 Concatenated Words | 是不是其他單字之一? | OR | 先依長度排序,字典裡就只會有嚴格更短的字 |
| LC 132 Palindrome Partitioning II | 是不是回文? | min(+1) |
先預處理回文表,然後跑一模一樣的切點迴圈 |
| LC 91 Decode Ways | 是不是合法的 1~2 位數編碼? | 加總 | 段落長度上限是 2,所以內層迴圈退化成 dp[i-1] + dp[i-2] |
| LC 279 Perfect Squares | i - j 是不是完全平方數? |
min(+1) |
同樣的形狀,只是跑在整數而不是字元上 |
| LC 1043 Partition Array for Max Sum | 這一段長度是不是 ≤ k? |
max |
要跟著 j 一起帶著這段的執行中最大值 |
| LC 322 Coin Change | i - j 是不是某個硬幣面額? |
min(+1) |
同一條遞迴式的完全背包寫法 |
模板 2:二維格子 DP — LC 64 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 題目直接給你一個格子,而且只能往右/往下走(或任何單向移動):「從左上到右下的 最小路徑和」、「不同路徑的數量」、「全是 1 的最大正方形」。m, n ≤ 200 → 預期是 O(m·n)。
關鍵 — 單向移動讓格子變成一個 DAG:走到 (i, j) 的最佳路徑一定來自上面或左邊,而依列優先
的順序會在 (i, j) 之前把兩者都填好。所以每個格子只算一次,也不需要 visited 集合。
| 面向 | 內容 |
|---|---|
| 狀態 | dp[i][j] = 從 (0, 0) 走到格子 (i, j) 的最小成本 |
| 轉移 | dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) |
| 初始條件 | 第一列:由左往右做前綴和;第一行:由上往下做前綴和 |
| 順序 | 列優先,先 i 後 j,兩者都遞增 |
| 答案 | dp[m-1][n-1] |
# python
# LC 64 - Minimum Path Sum
# IDEA: a cell is reached from above or from the left, so take the cheaper and add the cell
# time = O(m * n), space = O(m * n)
def minPathSum(grid):
m, n = len(grid), len(grid[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = grid[0][0]
for i in range(1, m): # first column: only from above
dp[i][0] = dp[i - 1][0] + grid[i][0]
for j in range(1, n): # first row: only from the left
dp[0][j] = dp[0][j - 1] + grid[0][j]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])
return dp[m - 1][n - 1]
// java
// LC 64 - Minimum Path Sum
// IDEA: a cell is reached from above or from the left, so take the cheaper and add the cell
// time = O(m * n), space = O(m * n)
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0]; // base: starting cell
for (int i = 1; i < m; i++) { // first column — only from above
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int j = 1; j < n; j++) { // first row — only from the left
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < m; i++) { // the rest: min of above vs left
for (int 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];
}
複雜度
- 時間 O(m·n):巢狀的
for i/for j每個格子走一次,每格 O(1)。 - 空間 O(m·n):
dp表。第i列只讀第i-1列,所以一列長度n的陣列就夠了 — O(n)。
試跑
grid dp
1 3 1 1 4 5
1 5 1 -> 2 7 6
4 2 1 6 8 7 answer dp[2][2] = 7 (1 → 3 → 1 → 1 → 1)
陷阱
- 用一般公式去填第一列/第一行會讀到
dp[-1][j],在 Python 裡那是最後一列而不是錯誤:答案會 悄悄出錯。請把它們分開初始化。 grid = [[5]](1 × 1):兩個初始化迴圈都是空的,答案就是grid[0][0];從dp[1][1]開始寫的解法會 索引越界。- LC 63(有障礙物):第一列上的障礙物會擋住它右邊的每一格,所以第一列的初值不是到處都是
1。
不適用時 — 可以往四個方向移動就會出現環,「已經填好」不再成立 → Dijkstra(LC 1631, 見下表)。是兩個字串而不是一個格子 → 表格是你自己造的、不是題目給的 → 模板 2a。
練習 — LC 64 · LC 62 ⭐ (計數而不是取最小) · LC 63 (初始列上的障礙物) ·
LC 120 (三角形,由下而上) · LC 221 (三個鄰居的 1 + min) · LC 174 (從右下角往回填)
DP 還是 Dijkstra — 單向移動 vs 四向移動
| LC 64(Min Path Sum,最小路徑和) | LC 1631(Min Effort Path,最小體力消耗路徑) | |
|---|---|---|
| 移動方向 | 只能往右 + 往下 | 四個方向都可以 |
| 成本 | 累加總和 | 各步高低差的最大值 |
| 會重走格子嗎? | 不會(單向) | 會(可能有更好的路徑) |
| 演算法 | 二維 DP | Dijkstra + min-heap |
需要 visited 嗎? |
不需要 | 需要 |
| DP 為什麼行得通 | 沒有環,是 DAG 結構 | DP 會失效:可以重走 |
規則:移動被限制成單向(往右/往下)→ 用二維 DP。四個方向都能走 → 用 Dijkstra(或帶優先佇列的 BFS)。
遞迴式目錄(二維)
| 題型 | 遞迴式 | 範例 | 時間 | 空間 |
|---|---|---|---|---|
| Unique Paths | dp[i][j] = dp[i-1][j] + dp[i][j-1] | LC 62 Unique Paths | O(m×n) | O(n) |
| Min Path Sum(最小路徑和) | dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j] | LC 64 Min Path Sum | O(m×n) | O(n) |
| LCS | 相符則 dp[i][j] = dp[i-1][j-1] + 1,否則 max(…) | LC 1143 LCS | O(m×n) | O(n) |
| Edit Distance | dp[i][j] = min(插入, 刪除, 取代) | LC 72 Edit Distance | O(m×n) | O(n) |
Python 版寫法、原地變形、O(m) 滾動列寫法、由上而下的記憶化版本、四種做法的比較表、 類似題對照,以及
[[1,3,1],[1,5,1],[4,2,1]]的推演,都在 dp_advanced.md; 計數版的雙胞胎 LC 62(Unique Paths)在 dp_examples.md 有完整實作。
模板 2a:二維表格的形狀與填表順序 — LC 516 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
模板 2 是最輕鬆的二維情況:表格本身就是輸入的格子,dp[i][j] 早就被題目放好了。另外兩個二維
家族 — 區間 DP(模板 3、模板 10)與雙序列 DP(模板 8、模板 9)— 憑空生出一張題目沒有給你的表格,
而在那裡真正卡住人的很少是遞推式。是表格本身:它是什麼形狀、遞推式會讀到哪些格子,因此哪一種
迴圈順序才合法。 這一節是它們共用的地圖,底下的模板都預設你看過。
辨識 — 一張題目沒有給你的二維表格:「最長回文子序列」、「讓字串變成回文的最少插入次數」、 「兩個字串之間的編輯距離/LCS」、「把一段範圍裡的東西全部戳破/合併」。n ≤ 1000 → O(n²) 個格子; n ≤ 500 且有切點 → O(n³)。
關鍵 — 轉移式右邊的每一個 dp[...] 都是一支指向 dp[i][j] 的箭頭;一種迴圈順序合法,
若且唯若每一支箭頭都落在已經填好的格子上。LC 516 會讀第 i+1 列與第 j-1 行,所以 i 遞減、j 遞增。
- 狀態:
dp[i][j]=s[i..j]裡最長的回文子序列(形狀 A,n × n) - 遞迴式:
s[i] == s[j]時dp[i+1][j-1] + 2,否則max(dp[i+1][j], dp[i][j-1]) - 基底:對角線,
dp[i][i] = 1 - 順序:
i從n-1往下到0,j從i+1往上到n-1 - 答案:右上角
dp[0][n-1]
# python
# LC 516 - Longest Palindromic Subsequence
# IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
# time = O(n^2), space = O(n^2)
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n - 1, -1, -1): # bottom row first
dp[i][i] = 1 # base: one character
for j in range(i + 1, n): # left to right, above the diagonal
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
// java
// LC 516 - Longest Palindromic Subsequence
// IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
// time = O(n^2), space = O(n^2)
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
for (int i = n - 1; i >= 0; i--) { // bottom row first
dp[i][i] = 1; // base: one character
for (int j = i + 1; j < n; j++) { // left to right, above the diagonal
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
複雜度
- 時間 O(n²):
for i/for j這一對把上三角的每個格子走一次,每格 O(1)。裡面再套一個切點k(模板 3)就變成 O(n³)。 - 空間 O(n²):
n × n的表。形狀 B 的表可以壓成一列;形狀 A 不行(見下面的空間壓縮)。
試跑 — LC 516 在 s = "bbbab" 上的逐格推演在下面的
形狀 A(推演表 — LC 516);答案是右上角的 4。
陷阱
- 填錯方向。 形狀 A 的
i一定要遞減;寫成for i in range(n)會在dp[i+1][...]還是0的時候就去讀它,答案於是悄悄偏小 — 不會爆、不會有索引錯誤。方向請從下面那張箭頭表推出來,不要 用背的。 - 踩進死掉的三角形。 形狀 A 裡當
j == i + 1時,dp[i+1][j-1]就是dp[i+1][i],在對角線 下方。它天生是0/False— 這對 LC 516 剛好正確(0 + 2 = 2,兩個字元的回文),對 LC 5 卻是錯的,那正是後者需要明確寫出j - i <= 2這個護欄的原因。 - 把別題的基底列抄過來。
0、i、1對不同的形狀 B 題目 — LCS、編輯距離、LC 115 — 各自都是 正確的基底列,見形狀 B 底下那段提醒。 - 還沒確定只要數字就先壓空間。 滾動列只有在答案是一個值的時候才是免費的;追問一變成「那是哪些 操作?」,你就得把整張表要回來。
不適用時 — 格子是題目給的,i、j 是座標 →
模板 2。轉移式需要範圍內部的切點 k → 只有依長度的順序
(順序 B)才安全 → 模板 3。
練習 — LC 516 · LC 647 ⭐ (n × n,數有幾個 True) · LC 5 ⭐ (j - i <= 2 護欄) ·
LC 1143 ⭐ (形狀 B) · LC 72 ⭐ (形狀 B,三種移動) · LC 1312 (最少插入次數)
決定迴圈順序的那條規則
先把轉移式寫出來。右手邊每一個
dp[...]都是一支指進dp[i][j]的箭頭。一種迴圈順序 合法,當且僅當每支箭頭都落在已經填好的格子上。就只有這樣而已。
| 轉移式讀到 | 是哪個鄰居 | 迴圈就必須 |
|---|---|---|
dp[i-1][j] |
上一列 | i 遞增(1 → m) |
dp[i+1][j] |
下一列 | i 遞減(n-1 → 0) |
dp[i][j-1] |
左一行 | j 遞增(1 → n) |
dp[i][j+1] |
右一行 | j 遞減 |
dp[i+1][j-1] |
內縮後的區間(兩端各往內一格) | i 遞減且 j 遞增 |
dp[i][k]、dp[k][j](i < k < j) |
更短的區間 | 依區間長度遞增 |
這張表就是完整的決策流程,也就是為什麼 LC 516 的 i 要倒著跑、LC 72 的 i 要正著跑 — 不是風格,
不是喜好,只是每個遞推式讀到哪些鄰居而已。
這張表的一維版本 —— 外加迴圈順序的另一個軸,也就是 item 迴圈到底可不可以放在狀態迴圈 外面 —— 在 dp_loop_order.md。
我要蓋的是哪一種形狀?
What do i and j MEAN in dp[i][j]?
├── ONE sequence; i and j are the two ENDS of a sub-range s[i..j]
│ → Shape A — n × n, only the upper triangle is real,
│ base on the diagonal, answer at the TOP-RIGHT dp[0][n-1]
│ LC 516, 5, 647, 1312, 312, 877
│
├── TWO sequences; i and j are PREFIX LENGTHS (first i of s1, first j of s2)
│ → Shape B — (m+1) × (n+1), row 0 / column 0 are the empty prefixes,
│ answer at the BOTTOM-RIGHT dp[m][n]
│ LC 72, 1143, 115, 97, 10
│
└── a real grid handed to you; i and j are COORDINATES
→ Template 2 above — m × n, answer at dp[m-1][n-1]
LC 62, 64, 221
表格要開多大 —— n × n 還是 (n+1) × (n+1)?
以 LC 647(Palindromic Substrings)為例。這是 Template 1a 的規則套用到兩個軸上。 大小取決於索引代表什麼,而不是表格有幾個維度:
dp[i][j] 用的是… |
i 是… |
大小 | 初始條件放在 | 讀字元的寫法 |
|---|---|---|---|---|
字串的實際 index —— 子字串 s[i..j] |
位置 | n × n |
對角線 dp[i][i] |
s[i]、s[j] |
前綴長度 —— 前 i 個/前 j 個字元 |
數量 | (m+1) × (n+1) |
第 0 列/第 0 行 = 空前綴 | s1[i-1]、s2[j-1] |
面試記憶技巧:用實際 index → n × n;用「前 i 個字元」→ n + 1。多出來的那一格只是為了放
空前綴。子字串 s[i..j](i <= j)永遠不是空的,所以 Shape A 根本沒有東西可以放在那一格。
# python
# LC 647 - Palindromic Substrings
# IDEA: dp[i][j] = s[i..j] is a palindrome. i and j are REAL indexes, so n x n, not n+1.
# time = O(n^2), space = O(n^2)
def countSubstrings(s):
n = len(s)
dp = [[False] * n for _ in range(n)] # n slots: every index is a character of s
count = 0
for length in range(1, n + 1): # short ranges first: dp[i+1][j-1] is shorter
for i in range(n - length + 1):
j = i + length - 1 # j is a position too, so it stays <= n-1
if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]):
dp[i][j] = True
count += 1
return count
對照 LC 1143(Shape B):dp[i][j] 是「s1 的前 i 個、s2 的前 j 個」,所以 i 會跑到 m
—— 比最後一個 index 多一 —— 表格需要 m + 1 列才放得下。
判斷方式是前後一致,跟 1-D 一樣。 Index 觀點:[[...] * n for _ in range(n)]、迴圈不超過
n-1、比較 s[i]、回傳 dp[0][n-1]。長度觀點:(m+1) × (n+1)、迴圈跑 1 → m、比較
s1[i-1]、回傳 dp[m][n]。把 LC 647 開成 n + 1 卻仍比較 s[i],程式可能還是會過 —— 但第 n
列是多餘的,下一次把迴圈改成跑到 n 的修改就會讀到超出字串尾端的 s[n]。
形狀 A — 單一序列,i/j 是區間的兩端
狀態:dp[i][j] = 子區間 s[i..j] 的答案。
j = 0 1 2 ... n-1
i = 0 [ base ] [ ? ] [ ? ] ... [ 🎯 dp[0][n-1] ] ← the answer
1 [ dead ] [ base ] [ ? ] ... [ ? ]
2 [ dead ] [ dead ] [ base ] ... [ ? ]
:
n-1 [ dead ] [ dead ] [ dead ] ... [ base ]
diagonal i == j : the length-1 base case (dp[i][i] = 1, or True)
below it i > j : an empty range — never written, never read
corner (0, n-1): the whole sequence — this is what you return
相依關係 — 一個「兩端往內縮」的轉移會讀到的三個格子:
the 2 x 2 window, drawn the same way up as the table above:
j-1 j
┌──────────────┬──────────────┐
row i │ dp[i][j-1] │ dp[i][j] │ ← 🎯 being filled
├──────────────┼──────────────┤
row i+1 │ dp[i+1][j-1] │ dp[i+1][j] │
└──────────────┴──────────────┘
dp[i][j] ⟵ dp[i][j-1] → from the LEFT — drop the right end s[j]
dp[i][j] ⟵ dp[i+1][j] ↑ from BELOW — drop the left end s[i]
dp[i][j] ⟵ dp[i+1][j-1] ↗ from BELOW-LEFT — drop BOTH ends
Every arrow travels UP and to the RIGHT. Nothing arrives from above or from
the right, so the table fills from the diagonal OUTWARD, toward the
top-right corner.
順序 A — i 倒著走、j 正著走(就寫這個)
# python
# LC 516 - Longest Palindromic Subsequence
# IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
# time = O(n^2), space = O(n^2)
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n): # base: one character is a palindrome of length 1
dp[i][i] = 1
for i in range(n - 1, -1, -1): # ↑ bottom row first
for j in range(i + 1, n): # → left to right, staying above the diagonal
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
順序 B — 依區間長度(斜線掃描)
# python
# IDEA: same table, one diagonal at a time. The only order that works when the transition
# needs a split point k (dp[i][k] and dp[k][j]) instead of the two ends.
# time = O(n^2) for a two-end transition, O(n^3) once a k-loop is nested inside
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1 # base: the length-1 diagonal
for length in range(2, n + 1): # sub-range length: short → long
for i in range(n - length + 1): # left end
j = i + length - 1 # right end is derived, never looped
if s[i] == s[j]: # same transition as Order A (LC 516)
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
兩種都合法 — 以下是格子實際被填的順序(n = 5)
Order A (i: n-1→0, j: i+1→n-1) Order B (length: 2→n, i: 0→n-length)
j0 j1 j2 j3 j4 j0 j1 j2 j3 j4
i0 · 7 8 9 10 i0 · 1 5 8 10
i1 · 4 5 6 i1 · 2 6 9
i2 · 2 3 i2 · 3 7
i3 · 1 i3 · 4
i4 · i4 ·
row by row, bottom → top diagonal by diagonal, ↗
Check the corner cell dp[0][4] — step 10 in both orders.
It reads dp[1][3], dp[1][4], dp[0][3]:
Order A → steps 5, 6, 9 ✅ all filled before step 10
Order B → steps 6, 9, 8 ✅ all filled before step 10
怎麼選:轉移移動的是兩端(dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1])就用順序 A — 回文、
拿字串跟自己的反轉取 LCS。轉移需要區間內部的切點 k(dp[i][k] + dp[k][j])就用順序 B —
戳氣球、合併石頭、矩陣連乘 — 因為在那裡「已經填好」指的是嚴格更短,而只有依長度驅動的迴圈保證
得了這件事。
實際填好的表 — 最長回文子序列,s = "bbbab"
LC 516,用上面的程式填出來:
s = b b b a b
j0 j1 j2 j3 j4
b i0 [ 1 ][ 2 ][ 3 ][ 3 ][ 4 ] ← 🎯 answer = 4
b i1 · [ 1 ][ 2 ][ 2 ][ 3 ]
b i2 · · [ 1 ][ 1 ][ 3 ]
a i3 · · · [ 1 ][ 1 ]
b i4 · · · · [ 1 ]
dp[2][3] = 1 : 'b' != 'a' → max(dp[3][3]=1, dp[2][2]=1) = 1
dp[0][2] = 3 : s[0]=='b'==s[2] → dp[1][1] + 2 = 3 ("bbb")
dp[1][3] = 2 : 'b' != 'a' → max(dp[2][3]=1, dp[1][2]=2) = 2
dp[0][4] = 4 : s[0]=='b'==s[4] → dp[1][3] + 2 = 4 ("bbbb")
它到底往哪邊走?— 反向走回表格
正向那一趟存下來的是數值。決策是事後在每個格子重問一次「這個值是哪個鄰居給的」還原出來的 — 不需要父指標,把轉移重算一次就好:
at (0,4) s[0]=='b'==s[4] → matched pair: take both, jump ↘ to (1,3)
at (1,3) 'b' != 'a', and
dp[1][2]=2 > dp[2][3]=1 → the max came from the LEFT: drop s[3], move to (1,2)
at (1,2) s[1]=='b'==s[2] → matched pair: take both, jump ↘ to (2,1)
at (2,1) i > j → empty range: stop
pairs collected, outer → inner: (s0, s4) then (s1, s2)
answer string: b [ b b ] b = "bbbb" ✓ length 4 = dp[0][4]
形狀 A 的題目
| 題目 | s[i] == s[j] 時 |
否則 |
|---|---|---|
| LC 516 Longest Palindromic Subsequence | dp[i+1][j-1] + 2 |
max(dp[i+1][j], dp[i][j-1]) |
| LC 5 Longest Palindromic Substring | j - i <= 2 或 dp[i+1][j-1] 時為 True |
False |
| LC 647 Palindromic Substrings | 判斷同 LC 5,數有幾個 True |
False |
| LC 1312 Min Insertions to Make Palindrome | dp[i+1][j-1] |
1 + min(dp[i+1][j], dp[i][j-1]) |
有兩題完全沒有字元比對,而它們正好就是替你決定填表順序的那種:
- LC 312(Burst Balloons)— 有切點,所以只有順序 B 能用:
dp[i][j] = max over k in (i, j) of dp[i][k] + dp[k][j] + b[i]*b[k]*b[j]。模板 3 有完整解法。 - LC 877 / LC 486(Stone Game / Predict the Winner)— 同一個三角形,但
dp[i][j]裝的是 分數差:max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])。動的是兩端,所以用順序 A。 minimax 的推理在 dp_advanced.md。
形狀 B — 雙序列,i/j 是前綴長度
狀態:dp[i][j] = s1 的前 i 個字元與 s2 的前 j 個字元的答案。所以這個格子在決定的
字元是 s1[i-1] 與 s2[j-1] — 大家都會踩到的那個差一,只是第 0 列/第 0 行代表空前綴的後果。
"" s2[0] s2[1] ... s2[n-1]
j = 0 1 2 ... n
"" i = 0 [base] [base] [base] ... [ base ] ← s1 is empty
s1[0] 1 [base] [ ? ][ ? ] ... [ ? ]
s1[1] 2 [base] [ ? ][ ? ] ... [ ? ]
:
s1[m-1] m [base] [ ? ][ ? ] ... [ 🎯 dp[m][n] ] ← the answer
row 0 / column 0 : one string is empty, so the answer is FORCED, not computed
every other cell : three neighbours, one decision
相依關係 — 三支箭頭,全都從上方/左方/左上方過來:
the same 2 x 2 window, one row higher:
j-1 j
┌──────────────┬──────────────┐
row i-1 │ dp[i-1][j-1] │ dp[i-1][j] │
├──────────────┼──────────────┤
row i │ dp[i][j-1] │ dp[i][j] │ ← 🎯 being filled
└──────────────┴──────────────┘
dp[i][j] ⟵ dp[i-1][j-1] ↘ from ABOVE-LEFT — consume from BOTH strings
dp[i][j] ⟵ dp[i-1][j] ↓ from ABOVE — consume from s1 only
dp[i][j] ⟵ dp[i][j-1] → from the LEFT — consume from s2 only
Nothing arrives from below or from the right, so plain row-major
(i: 1 → m, inner j: 1 → n) is legal — and it is the only order anyone writes.
每一步各代表什麼 — 遞推式會隨題目改變,幾何永遠不變:
| 走法 | 讀到的格子 | 意義 | LC 72(編輯距離) | LC 1143(LCS) |
|---|---|---|---|---|
| ↘ 對角 | dp[i-1][j-1] |
兩邊各吃掉一個字元 | 相同免費,否則取代(+1) |
相同就 +1 |
| ↓ 垂直 | dp[i-1][j] |
只吃 s1 一個字元 |
刪除 s1[i-1](+1) |
跳過 s1[i-1] |
| → 水平 | dp[i][j-1] |
只吃 s2 一個字元 |
插入 s2[j-1](+1) |
跳過 s2[j-1] |
# python
# LC 72 - Edit Distance
# IDEA: Shape B — base row/col are the forced answers, then row-major over three neighbours
# time = O(m * n), space = O(m * n) → O(min(m, n)) once rolled
def minDistance(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # s2 empty → delete every character of s1
for j in range(n + 1):
dp[0][j] = j # s1 empty → insert every character of s2
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # ↘ free
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # ↓ delete
dp[i][j - 1], # → insert
dp[i - 1][j - 1], # ↘ replace
)
return dp[m][n]
基底那一列才是 bug 的住處。 編輯距離是
dp[i][0] = i,LCS 是0,LC 115 是1— 空字串是 任何字串的子序列,而且恰好一次。要用推的,不要用抄的:把i = 0代進你自己的狀態句子,再用白話 把答案唸出來。
實際填好的表 — 編輯距離,"horse" → "ros",含它選的那條路
LC 72,用上面的程式填出來:
"" r o s
"" [ 0*] 1 2 3
h [ 1 ] [ 1*] 2 3
o [ 2 ] 2 [ 1*] 2
r [ 3 ] 2 [ 2*] 2
s [ 4 ] 3 3 [ 2*]
e [ 5 ] 4 4 [ 3*] ← 🎯 dp[5][3] = 3
* = the cells on the chosen path, recovered by walking backwards from dp[5][3]:
(5,3) 'e' vs 's' ✗ 1 + min(↓ dp[4][3]=2, → dp[5][2]=4, ↘ dp[4][2]=3)
the min came from ↓ ⇒ DELETE 'e'
(4,3) 's' == 's' ✓ free ↘ ⇒ KEEP 's'
(3,2) 'r' vs 'o' ✗ 1 + min(↓ dp[2][2]=1, → dp[3][1]=2, ↘ dp[2][1]=2)
the min came from ↓ ⇒ DELETE 'r'
(2,2) 'o' == 'o' ✓ free ↘ ⇒ KEEP 'o'
(1,1) 'h' vs 'r' ✗ 1 + min(↓ dp[0][1]=1, → dp[1][0]=1, ↘ dp[0][0]=0)
the min came from ↘ ⇒ REPLACE 'h' → 'r'
(0,0) both prefixes empty ⇒ stop
replayed forwards: replace h→r "horse" → "rorse"
keep o
delete r "rorse" → "rose"
keep s
delete e "rose" → "ros" ✓ 3 ops = dp[5][3]
用幾何的方式讀這條路:↘ 代表某個字元活了下來,↓ 代表花掉 s1 的一個字元,→ 代表花掉 s2 的
一個字元。 同樣這一趟反向走法套在 LC 1143 的表上,拼出來的就不是操作清單,而是真正的最長公共
子序列 — 同一張表、同一趟走法,只是沿路記下來的東西不同。
空間壓縮 — 以及什麼時候沒得壓
dp[i][j] 只會讀到第 i 列與第 i-1 列,所以形狀 B 可以塌成兩列,再塌成一列加一個純量
(用來接住那個即將被覆蓋掉的對角值):
# python
# IDEA: rolling row — prev is row i-1, cur is row i. s2 is the inner axis, so pass the
# SHORTER string as s2.
# time = O(m * n), space = O(min(m, n))
def minDistance_rolled(s1, s2):
m, n = len(s1), len(s2)
prev = list(range(n + 1))
for i in range(1, m + 1):
cur = [i] + [0] * n
for j in range(1, n + 1):
cur[j] = prev[j - 1] if s1[i - 1] == s2[j - 1] else 1 + min(prev[j], cur[j - 1], prev[j - 1])
prev = cur
return prev[n]
形狀 A 壓不了。 dp[i][j] 讀的是第 i+1 列的兩個不同行(j-1 和 j),而斜線掃描又會一直
往回摸到更早的每一條斜線 — 根本沒有「最近兩列」這個可以留著的視窗。區間 DP 就編列 O(n²),然後往
下走。
還有,只要你需要反向回溯就完全不要壓:滾動列丟掉的,剛好就是反向那一趟要讀的格子。只要答案是那條 路徑而不是那個數字,就把整張表留著(或把每個格子選了哪一步記下來)。
形狀 B 的題目
| 題目 | s1[i-1] == s2[j-1] 時 |
否則 |
|---|---|---|
| LC 72 Edit Distance | dp[i-1][j-1] |
1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) |
| LC 1143 Longest Common Subsequence | dp[i-1][j-1] + 1 |
max(dp[i-1][j], dp[i][j-1]) |
| LC 115 Distinct Subsequences | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
還有兩題住在同一張網格上,卻沒有 s1 跟 s2 互比字元這件事:
- LC 97(Interleaving String)— 布林值,而且是拿去跟第三個字串比:
dp[i][j] = (dp[i-1][j] and s1[i-1] == s3[i+j-1]) or (dp[i][j-1] and s2[j-1] == s3[i+j-1])。 完全沒有對角線 — 只有 ↓ 和 →,這正是「每個字元恰好來自兩個來源之一」的形狀。 - LC 10(Regular Expression Matching)—
j軸是樣式。一般字元或.就是普通的 ↘;'*'會 裂成「零份」dp[i][j-2]與「再多一份」dp[i-1][j],兩者讀的仍然只有左邊和上面,所以照列掃還是 成立。
二維 DP 速查表
| 形狀 A — 單一序列 | 形狀 B — 雙序列 | 模板 2 — 真的格子 | |
|---|---|---|---|
dp[i][j] 的意思 |
區間 s[i..j] |
s1 前 i 個 vs s2 前 j 個 |
座標 (i, j) 這一格 |
| 表格大小 | n × n,上三角 |
(m+1) × (n+1) |
m × n |
| 基底情況 | 對角線 dp[i][i] |
第 0 列與第 0 行 | 第一列與第一行 |
| 答案在哪 | 右上角 dp[0][n-1] |
右下角 dp[m][n] |
右下角 dp[m-1][n-1] |
| 讀到 | dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1] |
dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1] |
dp[i-1][j]、dp[i][j-1] |
| 迴圈順序 | i: n-1 → 0、j: i+1 → n-1(或依長度) |
i: 1 → m、j: 1 → n |
i: 0 → m-1、j: 0 → n-1 |
| 時間/空間 | O(n²) / O(n²) — 有切點時 O(n³) | O(m·n) / 滾動後 O(min(m,n)) | O(m·n) / 滾動後 O(n) |
| 代表題 | LC 516、5、647、1312、312 | LC 72、1143、115、97 | LC 62、64、221 |
模板 3:區間 DP — LC 312
這張表是形狀 A —
n × n、上三角、答案在右上角。它的骨架、兩種合法的填表順序,以及為什麼有 切點就只能用依長度驅動的那一種,都在 模板 2a。
辨識 — 一次操作的成本取決於會隨過程改變的鄰居:「戳破氣球,硬幣 = 左 × 自己 × 右」、 「合併相鄰的石堆,成本 = 兩堆之和」、「把一串矩陣相乘」。n ≤ 300–500 → 這個上限就是為 O(n³) 切點迴圈設的。
關鍵 — 想的是哪個元素最後處理,不是最先處理。當 k 是開區間 (i, j) 裡最後被戳破的氣球時,
(i, k) 和 (k, j) 裡的一切都已經消失,所以兩邊是彼此獨立的子問題,而 k 的鄰居恰好就是 i 和 j。
- 狀態:
dp[i][j]= 戳破i和j之間(不含兩端)所有氣球的最大硬幣數 - 遞迴式:
dp[i][j] = max over i < k < j of dp[i][k] + dp[k][j] + b[i]·b[k]·b[j] - 基底:
dp[i][i+1] = 0— 裡面什麼都沒有的開區間 - 順序:區間長度遞增(切點讀的是嚴格更短的範圍)
- 答案:先補成
b = [1] + nums + [1],答案是dp[0][n+1]
# python
# LC 312 - Burst Balloons
# IDEA: interval DP — pick k as the LAST balloon burst in (left, right); its neighbours are then fixed
# time = O(n^3), space = O(n^2)
def maxCoins(nums):
n = len(nums)
balloons = [1] + nums + [1] # boundary balloons with value 1
dp = [[0] * (n + 2) for _ in range(n + 2)] # dp[i][j]: open interval (i, j)
for length in range(2, n + 2): # short intervals first
for left in range(n + 2 - length):
right = left + length
for k in range(left + 1, right): # k = the LAST balloon burst in (left, right)
coins = balloons[left] * balloons[k] * balloons[right]
total = coins + dp[left][k] + dp[k][right]
dp[left][right] = max(dp[left][right], total)
return dp[0][n + 1]
// java
// LC 312 - Burst Balloons
// IDEA: interval DP — pick k as the LAST balloon burst in (i, j); its neighbours are then fixed
// time = O(n^3), space = O(n^2)
public int maxCoins(int[] nums) {
int n = nums.length;
int[] balloons = new int[n + 2]; // boundaries: [1, ...nums..., 1]
balloons[0] = 1;
balloons[n + 1] = 1;
for (int i = 0; i < n; i++) {
balloons[i + 1] = nums[i];
}
int[][] dp = new int[n + 2][n + 2]; // dp[i][j]: open interval (i, j)
for (int len = 2; len <= n + 1; len++) { // short intervals first
for (int i = 0; i <= n + 1 - len; i++) {
int j = i + len;
for (int k = i + 1; k < j; k++) { // k = the LAST balloon burst in (i, j)
int total = balloons[i] * balloons[k] * balloons[j] + dp[i][k] + dp[k][j];
dp[i][j] = Math.max(dp[i][j], total);
}
}
}
return dp[0][n + 1];
}
複雜度
- 時間 O(n³):
length×left共 O(n²) 個區間,每個區間裡的for k切點迴圈是 O(n)。 - 空間 O(n²):
(n+2) × (n+2)的表。區間 DP 沒辦法滾動 — 切點會往回讀到每一個更短的長度。
試跑
nums = [3, 1, 5] -> balloons = [1, 3, 1, 5, 1] (indexes 0..4)
length 2: every (i, i+2) has one balloon inside
dp[0][2] = 1*3*1 = 3 dp[1][3] = 3*1*5 = 15 dp[2][4] = 1*5*1 = 5
length 3:
dp[0][3] = max(k=1: 1*3*5 + 0 + dp[1][3]=15 -> 30, k=2: 1*1*5 + dp[0][2]=3 + 0 -> 8) = 30
dp[1][4] = max(k=2: 3*1*1 + 0 + dp[2][4]=5 -> 8, k=3: 3*5*1 + dp[1][3]=15 + 0 -> 30) = 30
length 4:
dp[0][4] = max(k=1: 3 + 0 + 30 = 33, k=2: 1 + 3 + 5 = 9, k=3: 5 + 30 + 0 = 35) = 35
陷阱
- 選最先戳的氣球,而不是最後一顆。 先戳
k會讓(i, k)和(k, j)變成彼此的鄰居,子問題 就不再獨立,也推不出遞迴式。 - 先跑
left再跑length。for left … for right …會讀到某個k > left的dp[k][right], 而它還沒被填;只有最外層跑長度才能保證「嚴格更短的都已經做完」。 - 忘了補邊界。 沒有
[1] + nums + [1],第一顆和最後一顆氣球就沒有鄰居可以相乘:nums = [5]必須回傳 5。
不適用時 — 轉移只移動兩端(dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1])而沒有切點 →
O(n²) 的形狀 A、順序 A(模板 2a)。
兩個玩家從兩端拿 → dp_advanced.md 裡的極小化極大 DP。
練習 — LC 312 · LC 1039 (多邊形三角剖分,同一個切點迴圈) · LC 1547 (切棍子:先把切點排序、 補上兩端) · LC 664 (奇怪的印表機) · LC 1000 (合併 k 堆石頭) · LC 546 (移除盒子,第三個狀態)
閉區間邊界的變形、由上而下的記憶化版本、
nums = [3,1,5,8]的完整推演、 辨認清單、常見錯誤、O(n³)/O(n²) 的推導、抽象的分割點/矩陣連乘骨架, 以及「i 倒著跑、j 正著跑」的迴圈順序規則,都在 dp_advanced.md。
模板 4:0/1 背包 — LC 416 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
背包家族 — LC 416 本身、子集和的轉換、完全/多重/分組背包,以及迴圈順序的規則 — 都在 knapsack.md 有完整解;0/1 的繁體中文詳解是 knapsack_01_zh.md。 下面是值得背下來的形狀。
辨識 — 每個元素最多用一次,而你要對著一個預算或目標挑一個子集:「切成和相等的兩個子集」、 「給每個數加上 + 或 −,湊出目標」、「讓兩組的差最小」、「在容量內挑物品」。目標值很小 (sum ≤ 10⁴–2·10⁴)→ 預期是一張 O(n · target) 的表。
關鍵 — 只用一個一維陣列時,容量要倒著跑:用某件物品更新 dp[w] 的時候,dp[w - weight]
在這一輪還沒被動過,所以它的意思仍然是「沒用這件物品」,每件物品就最多被算一次。
- 狀態:
dp[w]= 到目前為止看過的物品裡,總重 ≤w的子集能拿到的最大價值 - 遞迴式:
dp[w] = max(dp[w], dp[w - weight] + value)— 不拿,或拿 - 基底:
dp[*] = 0— 空子集 - 順序:外層跑物品;內層跑容量,從
capacity倒著跑到weight - 答案:
dp[capacity]
# python
# IDEA: 0/1 knapsack, max value — dp[w] = best value within capacity w; the backward loop uses each item once
# time = O(n * W), space = O(W)
def knapsack_01(weights, values, capacity):
dp = [0] * (capacity + 1) # dp[w] = best value with total weight <= w
for i in range(len(weights)):
for w in range(capacity, weights[i] - 1, -1): # backward: dp[w - weight] is still "without i"
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
// java
// IDEA: 0/1 knapsack, max value — dp[w] = best value within capacity w; the backward loop uses each item once
// time = O(n * W), space = O(W)
public int knapsack01(int[] weights, int[] values, int capacity) {
int[] dp = new int[capacity + 1]; // dp[w] = best value with total weight <= w
for (int i = 0; i < weights.length; i++) {
for (int w = capacity; w >= weights[i]; w--) { // backward: dp[w - weight] is still "without i"
dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[capacity];
}
同一個迴圈能回答三種問題,只換轉移式 — 「能不能湊到 w」用 or(LC 416),「有幾個子集」用 +=(LC 494),
最大價值用 max(…, + value)。
複雜度
- 時間 O(n · W):外層跑 n 次,裡面倒著跑的迴圈最多
W次。 - 空間 O(W):一列。二維寫法
dp[i][w]是 O(n · W);正是倒著跑讓第i列可以原地覆蓋第i-1列。
試跑 — 為什麼內層迴圈一定要倒著跑:只有一件重 3、價值 5 的物品,capacity = 6(答案是 5:
這件物品只能拿一次):
dp = [0, 0, 0, 0, 0, 0, 0]
0 1 2 3 4 5 6
Forward (WRONG): w=3: dp[3] = dp[0] + 5 = 5
w=6: dp[6] = dp[3] + 5 = 10 <- dp[3] already holds this item: taken twice ❌
Backward (RIGHT): w=6: dp[6] = dp[3] + 5 = 5 <- dp[3] is still 0, "without the item"
w=3: dp[3] = dp[0] + 5 = 5
result dp[6] = 5 ✓
陷阱
- 內層迴圈正著跑。 這會把 0/1 背包變成完全背包:上面那件物品被拿了兩次。
- 內層迴圈跑到重量以下。
range(capacity, -1, -1)會在負索引讀dp[w - weight],在 Python 裡那是 陣列的尾端,而不是錯誤。 - 轉移式跟問題對不上。 在計數表上用
or會弄丟方法數。在布林表上max等於or,但or的意圖比較清楚。
不適用時 — 物品可以重複使用(硬幣、無限供應)→ 完全背包,內層迴圈正著跑,而迴圈的 巢狀順序取決於順序重不重要 → knapsack.md。數值很大但 n ≤ 20 → 改成列舉子集/ 折半搜尋,而不是開一張和的表。
練習 — LC 416 ⭐ · LC 494 (數子集個數:(total + target) / 2) · LC 1049 (≤ total / 2 的
最大可達和) · LC 474 (兩種容量,都倒著跑) · LC 879 (計數,二維容量) — 都在 knapsack.md。
模板 5:狀態機 DP — LC 309
整個 LC 121/122/123/188/309/714 家族 — 每個變形、每種狀態數 — 都在 stock_trading.md 有完整實作。下面只留值得背的那個形狀。
辨識 — 每一步你都處在少數幾個有名字的情境之一,而規則規定從每個情境能做哪些動作: 「賣出後隔天不能買」、「最多 k 次交易」、「每次賣出付手續費」。n ≤ 5000–10⁵ → O(n · 狀態數)。
關鍵 — 每個狀態一個變數,存的是今天以該狀態收尾時的最佳獲利。每個轉移都只讀昨天的值, 所以覆寫之前要先存下來。
- 狀態:
hold(持有一股)、sold(今天剛賣,所以明天是冷卻期)、rest(沒持股、可以買) - 遞迴式:
hold = max(hold, rest - p)、sold = hold + p、rest = max(rest, sold)— 全部都用昨天的值 - 基底:第 0 天 —
hold = -prices[0]、sold = 0、rest = 0 - 順序:天數遞增
- 答案:
max(sold, rest)— 絕不以持股收尾
# python
# LC 309 - Best Time to Buy and Sell Stock with Cooldown
# IDEA: three states per day; each reads yesterday's values, so snapshot before updating
# time = O(n), space = O(1)
def maxProfit(prices):
if not prices:
return 0
hold = -prices[0] # bought on day 0
sold = 0 # cannot have sold on day 0; 0 is the neutral value
rest = 0 # no share, not in cooldown
for i in range(1, len(prices)):
prev_hold, prev_sold, prev_rest = hold, sold, rest
hold = max(prev_hold, prev_rest - prices[i]) # keep holding, or buy (only from rest)
sold = prev_hold + prices[i] # sell what you held
rest = max(prev_rest, prev_sold) # stay idle, or finish the cooldown
return max(sold, rest)
// java
// LC 309 - Best Time to Buy and Sell Stock with Cooldown
// IDEA: three states per day; each reads yesterday's values, so snapshot before updating
// time = O(n), space = O(1)
public int maxProfit(int[] prices) {
if (prices.length == 0) {
return 0;
}
int hold = -prices[0];
int sold = 0;
int rest = 0;
for (int i = 1; i < prices.length; i++) {
int prevHold = hold, prevSold = sold, prevRest = rest;
hold = Math.max(prevHold, prevRest - prices[i]); // keep holding, or buy (only from rest)
sold = prevHold + prices[i]; // sell what you held
rest = Math.max(prevRest, prevSold); // stay idle, or finish the cooldown
}
return Math.max(sold, rest);
}
複雜度
- 時間 O(n):
prices走一趟;每天三次 O(1) 的更新。 - 空間 O(1):三個狀態變數(加上它們的快照)。若是 k 次交易就是 O(k)。
試跑
prices = [1, 2, 3, 0, 2]
day 0 p=1 hold=-1 sold=0 rest=0
day 1 p=2 hold=-1 sold=1 rest=0
day 2 p=3 hold=-1 sold=2 rest=1
day 3 p=0 hold= 1 sold=-1 rest=2 buy at 0, from rest (the day-1 sale has cooled down)
day 4 p=2 hold= 1 sold=3 rest=2 answer max(3, 2) = 3
陷阱
- 沒存快照就原地更新。 先
hold = max(hold, rest - p)再sold = hold + p,等於把今天才買的 那一股賣掉:在[1, 2, 3, 0, 2]上答案就錯了。 - 從
sold買進。hold = max(hold, sold - p)會跳過冷卻日;只能從rest買。 - LC 121(只能交易一次) 不是把這段程式的
rest設成 0 就好:那裡每次買進都必須從獲利 0 開始,hold = max(hold, -p),否則會偷偷多出第二次交易。
不適用時 — 沒有冷卻期、交易次數不限、沒有手續費(LC 122)→ 貪婪:把每一天和前一天之間的
正差全部加起來。「最多 k 次交易」→ 2k 個狀態,見 stock_trading.md。
練習 — LC 309 ⭐ · LC 121 ⭐ (兩個狀態,從 0 買進) · LC 122 (兩個狀態) · LC 714 (賣出時收 手續費) · LC 123 (四個狀態) · LC 188 (2k 個狀態)
模板 6:記憶化 → 表格化 → 滾動變數 — LC 198 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
空間優化的階梯:先寫遞迴,加上快取,翻成表格,最後把表格塌縮成
k個變數。 recursion_to_dp.md 詳細走過前兩階。
辨識 — 一條會讀固定視窗內較早答案(dp[i-1]、dp[i-2]、……)的一維遞迴式:「打劫房屋,
不能相鄰」、「一次爬 1 或 2 階」、「解碼一串數字」。追問「能不能用 O(1) 空間?」就是走到最後一階的訊號。
關鍵 — 如果 dp[i] 只讀最後 k 格,那活著的就只有 k 個變數。讓 p1 = dp[i-2]、p2 = dp[i-1]
這個不變量在每一輪開頭都成立,答案就是最新的那個滾動變數 — 即使迴圈一次都沒跑,它也是合法的。
- 狀態:
dp[i]= 從房子0..i能搶到的最多錢 - 遞迴式:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])— 跳過房子i,或搶它 - 基底:
dp[0] = nums[0]、dp[1] = max(nums[0], nums[1]) - 順序:
i遞增;兩個變數取代整張表 - 答案:
p2(=dp[n-1]),絕不是迴圈裡的暫存變數
# python
# LC 198 - House Robber
# IDEA: dp[i] = max(dp[i-1], dp[i-2] + nums[i]) -> only 2 vars needed
# time = O(n), space = O(1)
def rob(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0] # p2's seed reads nums[1], so n == 1 must leave early
# invariant at the top of iteration i: p1 = dp[i-2], p2 = dp[i-1]
p1 = nums[0] # dp[0]
p2 = max(nums[0], nums[1]) # dp[1]
for i in range(2, n):
cur = max(p1 + nums[i], p2) # dp[i]
p1 = p2 # oldest -> newest
p2 = cur
return p2 # NOT `cur`
// java
// LC 198 - House Robber
// IDEA: same 2-variable rolling window; return the rolling var, not the temp
// time = O(n), space = O(1)
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
if (n == 1) return nums[0];
int p1 = nums[0]; // dp[0]
int p2 = Math.max(nums[0], nums[1]); // dp[1]
for (int i = 2; i < n; i++) {
int cur = Math.max(p1 + nums[i], p2);
p1 = p2;
p2 = cur;
}
return p2; // hoisting `cur` out of the loop just to return it would need `int cur = 0;`
// -> returns 0 for n == 2 instead of the real answer
}
複雜度
- 時間 O(n):
for i in range(2, n)跑一趟,每棟房子 O(1)。階梯的每一階都是 O(n);記憶化遞迴 每個i只走一次。 - 空間 O(1):
p1、p2、cur。表格那一階是 O(n),記憶化那一階在 O(n) 的快取之外還多了 O(n) 的遞迴堆疊。
試跑 — 讓 return cur 出錯的那個輸入:
n = 2, nums = [2, 7]
p1 = 2 <- dp[0]
p2 = max(2, 7) = 7 <- dp[1]
for i in range(2, 2): <- zero iterations, `cur` never assigned
return p2 -> 7 ✅
return cur -> UnboundLocalError ❌
陷阱
return cur。n == 2時迴圈體一次都沒跑,所以cur沒被綁定(Python 裡是UnboundLocalError;在 Java 裡把int cur = 0拉到迴圈外,會默默回傳 0)。p2在迴圈之前就初始化了, 而且兩者都存在時p2就等於cur,所以它在嚴格更多的情況下是對的。- 由新到舊更新。 先
p2 = cur再p1 = p2,兩個都會變成cur:[2, 1, 1, 2]會回傳錯的總和。 要由舊到新更新,或用一次 tuple 賦值。 - 初值滿足題目敘述,卻不滿足遞迴式。 把
max(nums[0], nums[1])寫成p2 = nums[1],會讓[2, 1, 1, 2](答案 4)出錯。 - 記憶化那一階遞迴太深。 Python 預設的上限是 1000 層,所以帶快取的遞迴在 10⁴ 個元素的輸入上會丟出
RecursionError,而表格那一階處理得了。
不適用時 — dp[i] 會讀每一個更早的格子(for j < i)→ 沒有東西可以滾動,保留整張表
(模板 7、模板 1b)。二維表格只有在第 i 列只讀第 i-1 列時才能滾成一列
(模板 2a 裡的形狀 B)。
練習 — LC 198 ⭐ · LC 70 ⭐ · LC 746 · LC 213 ⭐ (環狀:跑兩次,各跳過一端) · LC 1137 (三個變數) · LC 740 (化簡成以值為單位的 House Robber)
第 1 步 — 由上而下的記憶化
# python
# LC 198 - House Robber, top-down
# IDEA: dp(i) = best over houses 0..i; cache each i so the two-way recursion is linear
# time = O(n), space = O(n)
def top_down_dp(nums):
"""Top-down DP with memoization"""
memo = {}
def dp(i):
# Base case
if i < 0:
return 0
if i == 0:
return nums[0]
# Check memo
if i in memo:
return memo[i]
# Recurrence relation
result = max(dp(i-1), dp(i-2) + nums[i])
memo[i] = result
return result
return dp(len(nums) - 1)
第 2 步 — 把表格塌縮成 k 個滾動變數
# python
# IDEA: a recurrence that reads two cells back needs two variables, prev2 and prev1
# time = O(n), space = O(1)
def fibonacci_variants():
"""Common Fibonacci-like patterns"""
# 1. Classic Fibonacci
def fibonacci(n):
if n <= 1:
return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev1
# 2. Climbing Stairs
def climbStairs(n):
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev1
滾動變數檢查清單
| 步驟 | 要問自己的問題 |
|---|---|
| 1. 深度 | 遞迴式往回讀幾步?那就是幾個變數。 |
| 2. 不變量 | 在迴圈上方寫註解:p1 = dp[i-k] … pk = dp[i-1]。 |
| 3. 初值 | 這些初值滿足的是遞迴式,還只是題目敘述? |
| 4. 起始索引 | 從第一個「整個視窗都已初始化」的 i 開始跑(LC 198 是 i = 2,k 格的視窗是 i = k)。 |
| 5. 更新順序 | 最舊 → 最新,或用一次 tuple 賦值。絕對不要最新 → 最舊。 |
| 6. 回傳 | 回傳最新的滾動變數(p3 / p2),絕不回傳迴圈體的暫存變數 — 迴圈跑 0 次時它是未綁定的。並確認 n < start 的情況已經提早回傳。 |
糖果棒/Tribonacci 的完整走法、視窗的圖解推演、更新順序的 bug、初值設定規則、 其他具有相同更新形狀的題目列表,以及往回
k步(雙端佇列/環形緩衝區)的通用化, 都在 dp_advanced.md。
模板 7:最長遞增子序列 — LC 300 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
dp[i]= 以索引i結尾的 LIS 長度,答案 =max(dp)。O(n log n) 的進階解改成維護一個tails陣列,用二分搜尋找插入位置。
辨識 — 「最長的子序列(不必連續),每個元素都比前一個大/能整除前一個/能套進前一個」。
n ≤ 2500 → O(n²) 的表;n ≤ 10⁵ → O(n log n) 的 tails 進階解。
關鍵 — dp[i] 是結尾在 nums[i] 的最長鏈,所以任何更早且 nums[j] < nums[i] 的 j
都可以再接長一格。跟模板 1 不同,前驅可以是任何更早的索引,那就是內層的 for j < i 迴圈。
- 狀態:
dp[i]= 恰好結尾在i的最長遞增子序列長度 - 遞迴式:對每個
j < i且nums[j] < nums[i],dp[i] = max(dp[i], dp[j] + 1) - 基底:
dp[i] = 1— 每個元素自己就是一個子序列 - 順序:
i遞增,j跑0..i-1 - 答案:
max(dp)— 鏈可以結束在任何地方
# python
# LC 300 - Longest Increasing Subsequence
# IDEA: dp[i] = LIS ending at i; extend every earlier j whose value is strictly smaller
# time = O(n^2), space = O(n)
def lengthOfLIS(nums):
if not nums:
return 0
n = len(nums)
dp = [1] * n # each element alone has length 1
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]: # strictly smaller: only then can nums[i] extend it
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
// java
// LC 300 - Longest Increasing Subsequence
// IDEA: dp[i] = LIS ending at i; extend every earlier j whose value is strictly smaller
// time = O(n^2), space = O(n)
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // each element alone has length 1
int best = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) { // strictly smaller: only then can nums[i] extend it
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
best = Math.max(best, dp[i]); // the chain can end at any index
}
return best;
}
複雜度
- 時間 O(n²):內層
for j in range(i)對每個i跑i次,大約 n²/2 次比較。 - 空間 O(n):
dp陣列。
試跑
nums = [10, 9, 2, 5, 3, 7, 101, 18]
dp = [1, 1, 1, 2, 2, 3, 4, 4]
i=3: 2 < 5 -> dp[2]+1 = 2
i=5: 2, 5, 3 < 7 -> max(dp[2], dp[3], dp[4]) + 1 = 3
i=6, i=7: extend dp[5] -> 4
answer = max(dp) = 4 ([2, 3, 7, 101] or [2, 5, 7, 18])
陷阱
- 回傳
dp[n-1]。 LIS 不一定結束在最後一個元素:[3, 4, 1]的dp[n-1] = 1,但答案是 2。 - 用
<=而不是<。 LC 300 要的是嚴格遞增:[7, 7, 7]必須回傳 1。 - 只在
if裡更新全域最大值。n == 1(或嚴格遞減的輸入)時if從不觸發;best要從 1 開始, 或在最後取max(dp)。
不適用時 — 前驅是一個已知的值(x - d)而不是「任何更小的」→ 內層迴圈變成一次雜湊查詢
(模板 7a)。n = 10⁵ → 耐心排序/tails + 二分搜尋,
O(n log n),見 patience_sorting.md。
練習 — LC 300 ⭐ · LC 673 (長度旁邊再帶一個計數) · LC 368 (整除鏈:先排序,再把路徑重建出來) · LC 1048 (字串鏈,依長度排序) · LC 354 (依寬度排序,在高度上做 LIS)
模板 7a:以值為鍵的 DP(DP + 雜湊表)— LC 1218 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
模式:形狀跟 LIS 一樣的「以這裡結尾的最長鏈」DP,差別在於能排在
x前面的元素不是 「任何比它小的」— 而是恰好一個值,x - d。所以 模板 7 裡的內層for j < i迴圈縮成一次雜湊表查詢,DP 改以值而非索引為鍵:O(n^2)→O(n)。
辨識 — 「相鄰元素相差固定 difference 的最長子序列」、「每一步都是前一個加/減某個已知東西
的鏈」。值域 -10⁴..10⁴(或更大)且 n ≤ 10⁵ → O(n²) 的 LIS 迴圈太慢,以值為索引的陣列又稀疏或
需要負索引。
關鍵 — x 的前驅完全確定(x - difference),而由左往右掃代表 dp 只存著更早索引的值,
所以一次查詢就能回答「我能接上的鏈有多長?」,結果也仍然是子序列。
- 狀態:
dp[x]= 到目前為止、以值x結尾的最長合法子序列長度 - 遞迴式:
dp[x] = dp.get(x - difference, 0) + 1 - 基底:空的雜湊表 — 沒有前驅的值自己開一條長度 1 的鏈
- 順序:由左往右掃
arr;重複出現的值覆寫它的紀錄 - 答案:寫過的
dp[x]裡最大的那個
# python
# LC 1218 - Longest Arithmetic Subsequence of Given Difference
# IDEA: dp keyed by value — the only legal predecessor of x is x - difference
# time = O(n), space = O(n)
def longestSubsequence(arr, difference):
dp = {} # value -> longest chain ending with that value
max_len = 0
for x in arr:
prev = x - difference
# read the predecessor BEFORE writing dp[x] (matters when difference == 0)
dp[x] = dp.get(prev, 0) + 1
max_len = max(max_len, dp[x])
return max_len
// java
// LC 1218 - Longest Arithmetic Subsequence of Given Difference
// IDEA: dp keyed by value — the only legal predecessor of x is x - difference
// time = O(n), space = O(n)
public int longestSubsequence(int[] arr, int difference) {
Map<Integer, Integer> dp = new HashMap<>(); // value -> longest chain ending with it
int maxLen = 0;
for (int x : arr) {
int len = dp.getOrDefault(x - difference, 0) + 1;
dp.put(x, len);
maxLen = Math.max(maxLen, len);
}
return maxLen;
}
複雜度
- 時間 O(n):
for x in arr跑一趟,每次一個平均 O(1) 的雜湊查詢與寫入。 - 空間 O(n):雜湊表裡每個不同的值最多一筆。
試跑
arr = [1,5,7,8,5,3,4,2,1], difference = -2 (predecessor of x is x + 2)
x=1 dp[3]? no -> dp[1]=1
x=5 dp[7]? no -> dp[5]=1
x=7 dp[9]? no -> dp[7]=1
x=8 dp[10]? no -> dp[8]=1
x=5 dp[7]=1 -> dp[5]=2 (overwrites the first 5's entry)
x=3 dp[5]=2 -> dp[3]=3
x=4 dp[6]? no -> dp[4]=1
x=2 dp[4]=1 -> dp[2]=2
x=1 dp[3]=3 -> dp[1]=4 7 -> 5 -> 3 -> 1
answer = 4
陷阱
- 伸手就寫
O(n^2)的 LIS 迴圈。 它是對的,但在n = 10^5時會超時;固定的公差正是拿掉內層迴圈的關鍵。 - 用值當陣列索引。
arr[i]和difference都可能是負數;位移過的陣列只有在連x - difference的範圍也算進去時才行得通。雜湊表沒有這種邊界問題。 - 先寫
dp[x]再讀dp[x - difference]。 當difference = 0時兩個鍵是同一個;要先讀, 否則同一個值的每次重複都會被多算一次。
不適用時 — 公差沒給、本身就是答案的一部分 → 鍵變成 (index, difference),成本變成 O(n²)
(LC 1027、LC 446;見下表)。
練習 — LC 1218 · LC 1048 (鍵 = 單字,前驅 = 少一個字母的單字) · LC 1027 (每個索引一張雜湊表) · LC 873 (類費氏:鍵 = 最後兩個值) · LC 446 (計數而不是長度)
以值為鍵的題型家族
| LC | 題目 | 鍵 | 成本 |
|---|---|---|---|
| 1218 | Longest Arithmetic Subsequence of Given Difference | 值本身;d 固定 |
一個雜湊表,O(n) |
| 1027 | Longest Arithmetic Subsequence | (index, difference) — d 不固定,所以每個索引一個雜湊表 |
O(n^2) |
| 446 | Arithmetic Slices II - Subsequence | (index, difference),存的是個數 |
O(n^2) |
| 1048 | Longest String Chain | 單字;前一個是少一個字母的單字(先依長度排序) | O(n · L^2) |
LC 1027 和 LC 446 — 公差是狀態的一部分而不是題目給定的 — 完整解法在 dp_advanced.md。
模板 8:編輯距離 — LC 72 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
模式:把 word1 變成 word2 所需的最少插入/刪除/取代次數(Levenshtein 距離)。
辨認清單、三種操作的直覺、由上而下與空間優化的變形,以及圖解表格,都在
dp_advanced.md;整個雙序列家族在 dp_string.md。
這張表是形狀 B — 它的形狀、為什麼照列掃是唯一合法的順序,以及把操作清單還原出來的反向走法,
都在 模板 2a。
辨識 — 兩個字串,問的是用插入/刪除/取代把一個變成另一個的最低成本,或「讓兩者相等的最少刪除 次數」。m, n ≤ 500 → 一張 O(m·n) 的格子。
關鍵 — dp[i][j] 回答的是 word1 的前 i 個字元與 word2 的前 j 個字元,所以一切由
最後的字元決定:相等 → 免費走對角線;不相等 → 一次操作,再加上它通往的三個鄰居裡最便宜的那個。
- 狀態:
dp[i][j]= 把word1[:i]變成word2[:j]的最少操作次數 - 遞迴式:
word1[i-1] == word2[j-1]時dp[i-1][j-1],否則1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])(刪除、插入、取代) - 基底:
dp[i][0] = i(全部刪掉)、dp[0][j] = j(全部插入) - 順序:列優先,
i: 1 → m、j: 1 → n - 答案:
dp[m][n]
# python
# LC 72 - Edit Distance
# IDEA: Shape B grid over prefix lengths; equal last chars are free, else 1 + min of three moves
# time = O(m * n), space = O(m * n)
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # word2 empty: delete all i characters
for j in range(n + 1):
dp[0][j] = j # word1 empty: insert all j characters
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # no operation
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # delete word1[i-1]
dp[i][j - 1], # insert word2[j-1]
dp[i - 1][j - 1], # replace
)
return dp[m][n]
# Same recurrence, rolling row: row i reads only row i-1 (prev) and the cell to its left
# time = O(m * n), space = O(n)
def minDistance_rolling(word1, word2):
m, n = len(word1), len(word2)
dp = list(range(n + 1)) # row 0: j inserts
for i in range(1, m + 1):
prev = dp[:] # row i-1
dp[0] = i # column 0: i deletes
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[j] = prev[j - 1]
else:
dp[j] = 1 + min(prev[j], dp[j - 1], prev[j - 1])
return dp[n]
// java
// LC 72 - Edit Distance
// IDEA: Shape B grid over prefix lengths; equal last chars are free, else 1 + min of three moves
// time = O(m * n), space = O(m * n)
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
dp[i][0] = i; // delete all characters
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j; // insert all characters
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
int delete = dp[i - 1][j];
int insert = dp[i][j - 1];
int replace = dp[i - 1][j - 1];
dp[i][j] = 1 + Math.min(delete, Math.min(insert, replace));
}
}
}
return dp[m][n];
}
複雜度
- 時間 O(m·n):巢狀的
for i/for j把(m+1)(n+1)個格子各填一次,每格 O(1)。 - 空間 O(m·n):整張表;第
i列只讀第i-1列,所以滾動一列就能變成 O(min(m, n)) (見模板 2a 的空間壓縮)。
試跑 — "horse" → "ros" 的逐格推演,連同它選的路徑,在
模板 2a 的形狀 B(推演表 — LC 72);答案是 dp[5][3] = 3。
陷阱
- 基底列全是 0。 把 LCS 的
dp[0][j] = 0抄過來,"" → "abc"的成本就變成 0;編輯距離的基底列是j。 - 用
word1[i]而不是word1[i-1]。 這張表以長度為索引,所以第i列決定的字元是word1[i-1];word1[i]在最後一列會讀超出尾端。 - 相等時也收費。 字元相等時寫成
1 + dp[i-1][j-1],會讓"a" → "a"的成本變成 1。
不適用時 — 只允許刪除(LC 583),或目標是最長的共同部分 → LCS
(模板 9);只能刪除時 edit = m + n − 2·LCS。
一個序列和它的兩端 → 形狀 A。
練習 — LC 72 ⭐ · LC 583 (只能刪除) · LC 712 (刪除成本是 ASCII 值) · LC 161 (只差一次編輯, 不用表也能 O(n)) · LC 115 (計數,而不是成本)
比較:Edit Distance vs LCS
LC 72 與 LC 1143 並排對照:
| 面向 | LC 72 (Edit Distance) | LC 1143 (LCS) |
|---|---|---|
| 目標 | 最小化所需操作數 | 最大化相符的字元數 |
| 操作 | 插入、刪除、取代 | 只有相符或跳過 |
| 相符時 | 不用付出代價(不需操作) | 長度 +1 |
| 不符時 | 1 + min(三個選項) | max(跳過左邊, 跳過右邊) |
| DP 轉移 | dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) |
dp[i][j] = dp[i-1][j-1] + 1 或 max(dp[i-1][j], dp[i][j-1]) |
模板 9:最長公共子序列 — LC 1143
辨識 — 兩個序列,問「它們依序共有、但不必連續的最長部分」:「最長公共子序列」、「讓兩個字串 相等的最少刪除次數」、「最多的不相交線」。m, n ≤ 1000 → 一張 O(m·n) 的格子。
關鍵 — 比較兩個前綴的最後一個字元:相等 → 兩者可以一起當公共子序列的結尾,所以取對角線再加一; 不相等 → 至少有一個不在裡面,所以丟掉其中一邊、取較好的那個。
- 狀態:
dp[i][j]=text1[:i]與text2[:j]的 LCS 長度 - 遞迴式:
text1[i-1] == text2[j-1]時dp[i-1][j-1] + 1,否則max(dp[i-1][j], dp[i][j-1]) - 基底:第 0 列與第 0 行都是
0— 空前綴沒有任何共同的部分 - 順序:列優先,
i: 1 → m、j: 1 → n - 答案:
dp[m][n]
# python
# LC 1143 - Longest Common Subsequence
# IDEA: Shape B grid; matching last chars extend the diagonal, otherwise drop one side
# time = O(m * n), space = O(m * n)
def longestCommonSubsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)] # row 0 / column 0 = empty prefix = 0
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
# Same recurrence, rolling row: keep only the previous row
# time = O(m * n), space = O(n)
def longestCommonSubsequence_rolling(text1, text2):
n = len(text2)
dp = [0] * (n + 1)
for c in text1:
prev, dp = dp, [0] * (n + 1) # prev = row i-1, dp = row i
for j in range(n):
if c == text2[j]:
dp[j + 1] = prev[j] + 1
else:
dp[j + 1] = max(prev[j + 1], dp[j])
return dp[n]
// java
// LC 1143 - Longest Common Subsequence
// IDEA: Shape B grid; matching last chars extend the diagonal, otherwise drop one side
// time = O(m * n), space = O(m * n)
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1]; // row 0 / column 0 = empty prefix = 0
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 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];
}
複雜度
- 時間 O(m·n):巢狀的
for i/for j每個格子走一次。 - 空間 O(m·n):整張表;滾動一列(再額外存下對角線)就能變成 O(min(m, n))。
試跑
text1 = "abcde", text2 = "ace"
"" a c e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3 answer dp[5][3] = 3 ("ace")
陷阱
- 不相等時走對角線。
max(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])是對的但多餘;不相等時寫dp[i-1][j-1] + 1則是錯的("ab"、"ba"會回傳 2)。 - 連續 vs 不連續。 「最長公共子字串」(LC 718)在不相等時歸零,並在所有格子裡取最大值;
LCS 則是把
max一路帶下去。 - 滾動一列卻沒存對角線。 列優先的迴圈走到
j時,dp[i-1][j-1]已經被覆寫了;要用一個prev_diag變數把它存起來。
不適用時 — 操作有成本而且可以取代 → 編輯距離 (模板 8)。兩個序列是同一個字串與它的反轉 → LC 516 用形狀 A 寫比較乾淨(模板 2a)。
練習 — LC 1143 ⭐ · LC 583 (m + n − 2·LCS) · LC 1035 (不相交線 = LCS) · LC 718 (子字串:不相等時歸零) · LC 1092 (最短公共超序列:沿著表格往回走)
雙序列格子 → dp_string.md
幾乎所有雙字串題目都是同一張格子:dp[i][j] = s1 的前 i 個字元與 s2 的前 j 個字元的答案。
只有轉移式不一樣。
| 移動方向 | 意義 |
|---|---|
對角線 dp[i-1][j-1] |
兩個字串各消掉一個字元(它們相符) |
垂直 dp[i-1][j] |
跳過/刪掉 s1 的一個字元 |
水平 dp[i][j-1] |
跳過/插入 s2 的一個字元 |
| 題目 | LC | 相符時 | 不符時 |
|---|---|---|---|
| Longest Common Subsequence | 1143 | 1 + dp[i-1][j-1] |
max(dp[i-1][j], dp[i][j-1]) |
| Edit Distance | 72 | dp[i-1][j-1] |
1 + min(取代, 刪除, 插入) |
| Distinct Subsequences | 115 | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
| Interleaving String | 97 | dp[i-1][j] && s1[i-1]==s3[i+j-1](或換成 s2 那一側) |
False |
LC 72 和 LC 1143 的模板留在上面(模板 8 — 編輯距離、模板 9 — LCS)。
模板 10:回文子字串 DP — LC 5 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
題目原型:LC 647(Count Palindromic Substrings)、LC 5(Longest Palindromic Substring)
這張表是形狀 A — 下面那個
i倒著走、j正著走的順序就是 模板 2a 裡的順序 A;而j - i <= 2這個護欄之所以存在,是因為dp[i+1][j-1]會掉進死掉的三角形裡。
辨識 — 「最長的/有幾個回文子字串」(連續的),或後面的某個 DP 需要以 O(1) 回答「s[i..j]
是不是回文?」(LC 131、LC 132)。n ≤ 1000 → O(n²)。
關鍵 — s[i..j] 是回文,若且唯若兩端相等而且內部 s[i+1..j-1] 也是回文;長度 ≤ 3 的範圍
沒有值得檢查的內部。所以表格由短範圍往外填,i 遞減。
- 狀態:
dp[i][j] = True表示s[i..j]是回文 - 遞迴式:
dp[i][j] = s[i] == s[j] and (j - i <= 2 or dp[i+1][j-1]) - 基底:由
j - i <= 2這個護欄涵蓋(長度 1、2、3) - 順序:
i從n-1往下到0,j從i往上到n-1 - 答案:LC 5 —
j - i最大的True格子;LC 647 —True格子的數量
# python
# LC 5 - Longest Palindromic Substring
# IDEA: dp[i][j] = s[i..j] is a palindrome; i descends so dp[i+1][j-1] is already filled
# time = O(n^2), space = O(n^2)
def longestPalindrome(s):
n = len(s)
dp = [[False] * n for _ in range(n)]
best_start, best_len = 0, 1 if n else 0
for i in range(n - 1, -1, -1): # i backward: row i+1 is ready
for j in range(i, n): # j forward: column j-1 is ready
if s[i] == s[j] and (j - i <= 2 or dp[i + 1][j - 1]):
dp[i][j] = True
if j - i + 1 > best_len:
best_start, best_len = i, j - i + 1
return s[best_start:best_start + best_len]
// java
// LC 5 - Longest Palindromic Substring
// IDEA: dp[i][j] = s[i..j] is a palindrome; i descends so dp[i+1][j-1] is already filled
// time = O(n^2), space = O(n^2)
public String longestPalindrome(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
int bestStart = 0, bestLen = n == 0 ? 0 : 1;
for (int i = n - 1; i >= 0; i--) { // i backward: row i+1 is ready
for (int j = i; j < n; j++) { // j forward: column j-1 is ready
if (s.charAt(i) == s.charAt(j) && (j - i <= 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
if (j - i + 1 > bestLen) {
bestStart = i;
bestLen = j - i + 1;
}
}
}
}
return s.substring(bestStart, bestStart + bestLen);
}
複雜度
- 時間 O(n²):
for i/for j這一對把約 n²/2 個範圍各走一次,每個 O(1)。 - 空間 O(n²):布林表。中心擴展(下面的做法 2)時間同樣是 O(n²),但空間是 O(1),所以表格沒有要 重複利用時它是預設選擇。
試跑
s = "cbbd" (T = s[i..j] is a palindrome)
j=0 j=1 j=2 j=3
i=3 T "d"
i=2 T F "b", "bd"
i=1 T T F "b", "bb" (s[1] == s[2], j - i = 1 <= 2), "bbd"
i=0 T F F F "c", "cb", "cbb", "cbbd"
longest True range = dp[1][2] -> "bb"
陷阱
- 檢查
dp[i+1][j-1]卻沒有j - i <= 2護欄。 當j - i == 1時,dp[i+1][j-1]是dp[i+1][i],在對角線下方、仍然是False:"bb"會找不到。 i遞增。for i in range(n)會在第i+1列填好之前就去讀它;"abba"會回傳"bb",因為長度 4 的檢查讀到還沒填的內層"bb"("aba"只是靠j - i <= 2的保護才沒出錯)。- 中心擴展只用奇數中心。 一定要同時呼叫
expand(i, i)和expand(i, i + 1);"abba"沒有奇數中心。
不適用時 — 回文可以跳過字元(子序列)→ LC 516,用 max 而不是布林值 (模板 2a)。只需要答案、從不需要表格 → 中心擴展,O(1) 空間。
練習 — LC 5 ⭐ · LC 647 ⭐ (數有幾個 True) · LC 131 ⭐ (把表當成回溯的 O(1) 閘門) ·
LC 132 (表格 + 前綴切分的最少切割) · LC 1745 (切成三段回文)
依長度填表的順序、Manacher 的 O(n) 演算法,以及類似題對照,都在 dp_advanced.md;palindrome.md 收錄的是非 DP 的回文工具箱。
做法比較
| 做法 | 時間 | 空間 | 什麼時候用 |
|---|---|---|---|
| 暴力 | O(n³) | O(1) | 面試絕對不要 |
| 二維 DP(依長度)— dp_advanced.md | O(n²) | O(n²) | 需要完整的 dp 表來回答其他查詢 |
| 二維 DP(i 倒著跑) | O(n²) | O(n²) | 同上,寫起來稍微乾淨一點 |
| 雙指標(中心擴展) ⭐ | O(n²) | O(1) | 預設選擇 — 更單純、空間最省 |
| Manacher 演算法 — dp_advanced.md | O(n) | O(n) | 競賽程式/最佳解 |
做法 1:二維 DP — i 倒著跑 + j 正著跑 優先度 1/5 — 加分項
# IDEA: loop i backward so dp[i+1][...] is always ready when computing dp[i][...]
def countSubstrings_dp_backward(s):
# time = O(n^2), space = O(n^2)
n = len(s)
dp = [[False] * n for _ in range(n)]
count = 0
for i in range(n - 1, -1, -1): # i backward: ensures dp[i+1][j-1] is computed
for j in range(i, n): # j forward: ensures dp[i][j-1] is computed
if s[i] == s[j] and (j - i <= 2 or dp[i+1][j-1]):
dp[i][j] = True
count += 1
return count
為什麼 i 要倒著跑?
dp[i][j] 依賴 dp[i+1][j-1](內層子字串)。填第 i 列時要讀到第 i+1 列,所以 i 要從 n-1 往 0 跑。
做法 2:雙指標 — 從中心往外擴 (推薦)優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問
關鍵洞見:每個回文都有一個中心。從每個可能的中心往外擴,一邊擴一邊數。
- 奇數長度回文:中心是一個字元 — 從
(i, i)開始擴 - 偶數長度回文:中心在兩個字元之間 — 從
(i, i+1)開始擴
# IDEA: expand around center — O(1) space, no dp table needed
def countSubstrings_two_pointers(s):
# time = O(n^2), space = O(1)
count = 0
n = len(s)
def expand(l, r):
cnt = 0
while l >= 0 and r < n and s[l] == s[r]:
cnt += 1
l -= 1
r += 1
return cnt
for i in range(n):
count += expand(i, i) # odd-length (center at i)
count += expand(i, i + 1) # even-length (center between i and i+1)
return count
s = "aaa" 的圖解推演:
Center i=0:
odd (0,0): "a" → count +1
even (0,1): "aa" → count +1
Center i=1:
odd (1,1): "a"→(0,2)"aaa" → count +2 (expands to full string)
even (1,2): "aa" → count +1
Center i=2:
odd (2,2): "a" → count +1
even (2,3): out of bounds → count +0
Total = 6 ✓
關鍵抉擇:DP 還是雙指標
Need the full dp[i][j] table? (e.g., for partitioning / further DP)
YES → Use 2D DP (Approach 1)
NO → Use Two Pointers (Approach 2) — simpler + O(1) space
模板 11:子集上的位元遮罩 DP — LC 1879
辨識 — 狀態是「我已經用掉哪個子集的物品」,而 n 很小:「把 n 個工人各指派到 n 份工作之一」、
「每個節點走訪一次」、「把元素兩兩配對」。n ≤ 14–20 → 預算就是 2^n 個遮罩(2²⁰ ≈ 10⁶)。
關鍵 — 光看遮罩就知道進度到哪:物品照固定順序指派時,popcount(mask) 就是下一個要放的索引,
所以 dp[mask] 不需要第二個維度,而且每個遮罩都只會從更小的遮罩走過來。
- 狀態:
dp[mask]= 把nums1[0..k-1]與mask裡的那些nums2元素配對之後的最小 XOR 和, 其中k = popcount(mask) - 遞迴式:對每個不在
mask裡的j,dp[mask | 1<<j] = min(…, dp[mask] + (nums1[k] ^ nums2[j])) - 基底:
dp[0] = 0 - 順序:
mask遞增 — 加一個位元一定會讓數字變大 - 答案:
dp[(1 << n) - 1]
# python
# LC 1879 - Minimum XOR Sum of Two Arrays
# IDEA: dp[mask] = best cost having paired nums1[:popcount(mask)] with the nums2 items in mask
# time = O(2^n * n), space = O(2^n)
def minimumXORSum(nums1, nums2):
n = len(nums1)
full = (1 << n) - 1
INF = float('inf')
dp = [INF] * (1 << n)
dp[0] = 0
for mask in range(1 << n):
if dp[mask] == INF:
continue
k = bin(mask).count('1') # next nums1 index to pair
if k == n:
continue
for j in range(n):
if not mask & (1 << j): # nums2[j] still free
nxt = mask | (1 << j)
dp[nxt] = min(dp[nxt], dp[mask] + (nums1[k] ^ nums2[j]))
return dp[full]
// java
// LC 1879 - Minimum XOR Sum of Two Arrays
// IDEA: dp[mask] = best cost having paired nums1[:popcount(mask)] with the nums2 items in mask
// time = O(2^n * n), space = O(2^n)
public int minimumXORSum(int[] nums1, int[] nums2) {
int n = nums1.length;
int[] dp = new int[1 << n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int mask = 0; mask < (1 << n); mask++) {
if (dp[mask] == Integer.MAX_VALUE) {
continue;
}
int k = Integer.bitCount(mask); // next nums1 index to pair
if (k == n) {
continue;
}
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) == 0) { // nums2[j] still free
int nxt = mask | (1 << j);
dp[nxt] = Math.min(dp[nxt], dp[mask] + (nums1[k] ^ nums2[j]));
}
}
}
return dp[(1 << n) - 1];
}
複雜度
- 時間 O(2ⁿ · n):外層
for mask跑2ⁿ次,內層for j跑 n 次。 - 空間 O(2ⁿ):每個遮罩一格。
| 位元運算 | 意義 |
|---|---|
mask & (1 << i) |
物品 i 在集合裡嗎? |
mask | (1 << i) |
加入物品 i |
mask ^ (1 << i) |
移除物品 i(只有在它存在時才對) |
sub = (sub - 1) & mask |
枚舉 mask 的子遮罩 |
bin(mask).count('1') |
popcount = 用掉幾件物品 |
陷阱
- 多開一個用不到的索引維度。 當
i = popcount(mask)時,dp[i][mask]就是同一張表重複 n 次; 記憶體變成 n 倍,在 n = 20 時可能超過限制。 - Java 的
1 << n溢位。 超過 31 個物品的遮罩要用long和1L << n;n ≤ 20 時int沒問題, 但這個習慣很重要。 - 忘了檢查到不到得了。 從仍然是
INF的遮罩去鬆弛,在 Python 裡是加到無限大(無害),在 Java 裡 卻是加到Integer.MAX_VALUE,溢位成負數,然後贏下每一次min。
不適用時 — n 到 40 以上 → 2^n 不可行;改找貪婪、匹配/網路流,或折半搜尋。物品彼此
可以互換、只有數量重要 → 遮罩縮成一個整數(一般的背包)。
練習 — LC 1879 · LC 526 (計數而不是取最小) · LC 1986 (工作時段:帶著已用掉的時間) · LC 847 (在 (node, mask) 上做 BFS) · LC 1125 (遮罩是目標:已涵蓋的技能)
dp_bitmask.md 完整涵蓋:位元運算完整參考、TSP 與指派問題的模板、
子遮罩枚舉 DP、複雜度估算,以及經典陷阱(n >= 31 要用 1L << n、子遮罩迴圈寫錯、
把 popcount 當索引用)。
模板 12:數位 DP — LC 233
辨識 — 「[L, R](或 ≤ n)之間有幾個數字滿足某種位數層級的性質」:數某個數字出現幾次、
各位數嚴格遞增、沒有重複數字、數位和能被 k 整除。n ≤ 10⁹ 或 10¹⁸ → 範圍大到根本不可能逐一走訪,
但一個數字只有 10–19 位。
關鍵 — 從左邊開始一位一位地組出數字。只要前綴還等於 n 的前綴(tight),下一位就被 n 的那一位
封頂;一旦比它小,後面每一位都是自由的,所以所有 (pos, count) 相同的非 tight 狀態答案都一樣,
可以快取。
| 狀態 | 意義 | 為什麼需要它 |
|---|---|---|
pos |
正在填第幾位 | 遞迴的索引 |
tight |
前綴是否仍與上界的前綴相同? | 是的話這一位的上限是 bound[pos];不是的話 0..9 隨便填 |
started |
是否已經放過非零數字? | 用來區分真正的首位數字和補位的零 |
LC 233 數的是數字 1,而前導零永遠不會貢獻 1,所以它只需要 pos、tight 和目前的計數 —
started 是給補位的零會破壞的性質用的(「沒有重複數字」)。
# python
# LC 233 - Number of Digit One
# IDEA: digit DP over (pos, ones so far, tight); only tight states depend on n's digits
# time = O(D^2 * 10), space = O(D^2) D = number of digits in n
from functools import lru_cache
def countDigitOne(n):
digits = list(map(int, str(n)))
D = len(digits)
@lru_cache(maxsize=None)
def count(pos, ones, tight):
if pos == D:
return ones # one finished number: its count of 1s
limit = digits[pos] if tight else 9
total = 0
for d in range(limit + 1):
total += count(pos + 1, ones + (d == 1), tight and d == limit)
return total
return count(0, 0, True)
// java
// LC 233 - Number of Digit One
// IDEA: digit DP over (pos, ones so far, tight); only non-tight states are cached
// time = O(D^2 * 10), space = O(D^2) D = number of digits in n
private int[] digits;
private Integer[][] memo; // memo[pos][ones], non-tight states only
public int countDigitOne(int n) {
String s = Integer.toString(n);
digits = new int[s.length()];
for (int i = 0; i < s.length(); i++) {
digits[i] = s.charAt(i) - '0';
}
memo = new Integer[digits.length][digits.length + 1];
return count(0, 0, true);
}
private int count(int pos, int ones, boolean tight) {
if (pos == digits.length) {
return ones; // one finished number: its count of 1s
}
if (!tight && memo[pos][ones] != null) {
return memo[pos][ones];
}
int limit = tight ? digits[pos] : 9;
int total = 0;
for (int d = 0; d <= limit; d++) {
total += count(pos + 1, ones + (d == 1 ? 1 : 0), tight && d == limit);
}
if (!tight) {
memo[pos][ones] = total;
}
return total;
}
複雜度
- 時間 O(D² · 10):
pos×ones共 O(D²) 個被快取的狀態,每個狀態裡的for d迴圈最多試 10 個數字。 tight 的那條路只是一條 D 次呼叫的鏈。 - 空間 O(D²):快取,再加上 O(D) 的遞迴深度。
試跑
n = 13 -> digits [1, 3]
pos 0, tight: d = 0 (free) or d = 1 (stays tight)
d=0: numbers 00..09 -> pos 1 free, ones=0 -> d=1 adds one -> 1
d=1: numbers 10..13 -> pos 1 tight (limit 3), ones=1 -> 4 numbers carry 1 each,
and d=1 at pos 1 (the 11) adds one more -> 5
answer = 1 + 5 = 6 (1, 10, 11, 11, 12, 13)
陷阱
- 把 tight 狀態和自由狀態一起快取。
count(pos, ones, True)和count(pos, ones, False)不同; 只用(pos, ones)當鍵的快取會在 tight 的那條路上回傳自由狀態的答案,算多了。 - 是
tight and d == limit,不是d == digits[pos]。 前綴一旦比n小就會一直比較小;在自由的路上 重新跟digits[pos]比,會錯誤地把後面的位數封頂。 - 區間查詢。
answer(L, R) = count(R) - count(L - 1);用count(L)會把L本身漏掉。
不適用時 — 性質取決於數字的值而不是它的各位數(質數、完全平方數)→ 不是數位 DP; 改用列舉或篩法。n ≤ 10⁶ → 直接跑迴圈。
練習 — LC 233 · LC 357 (沒有重複數字:需要 started 和一個已用遮罩) · LC 902 (數字來自一個集合) ·
LC 1012 (357 的補集) · LC 600 (二進位裡沒有連續的 1)
dp_digit.md 完整涵蓋:附註解的通用模板、LC 233(數 1 的個數)、
LC 902(用指定數字集組出的數)、數位和與不可有連續數字的變形、剪枝,以及記憶化鍵值的陷阱。
關鍵字:DP, dynamic programming, memoization, tabulation, optimal substructure, overlapping subproblems, state transition, knapsack, LCS, LIS, interval DP, tree DP, state machine, bitmask, monotonic stack, mono stack, stack DP
練習路徑
| 階段 | 題目(由易到難) | 它加了什麼 |
|---|---|---|
| 核心 ⭐ | LC 70 ok · LC 198 ok · LC 213 ok · LC 53 ok · LC 152 ok · LC 91 again · LC 139 again · LC 62 ok · LC 300 again · LC 5 again · LC 647 again · LC 1143 ok · LC 72 ok · LC 416 again · LC 322 again · LC 309 again | 每個核心模板各一次:一維、以長度為索引、前綴切分、格子、LIS、回文表、雙序列格子、背包、狀態機 |
| 練習 | LC 746 again · LC 64 ok · LC 516 again · LC 1218 again · LC 494 ok · LC 518 ok · LC 714 未評定 · LC 279 again | 每個模板最常被拿來考的那個變形 |
| 延伸 | LC 312 again · LC 1879 未評定 · LC 233 未評定 · LC 115 未評定 · LC 10 again · LC 1000 未評定 | 有切點的區間 DP、位元遮罩、數位 DP、較難的雙序列格子 |
總結與快速查表
這題是 DP 嗎?
DP Problem Identification Flowchart:
1. Can the problem be broken into subproblems?
├── NO → Not a DP problem
└── YES → Continue to 2
2. Do subproblems overlap?
├── NO → Use Divide & Conquer
└── YES → Continue to 3
3. Does it have optimal substructure?
├── NO → Not a DP problem
└── YES → Use DP, continue to 4
4. What type of DP pattern?
├── Single sequence → Linear DP (Template 1)
├── 2D grid/matrix → Grid DP (Template 2)
├── Interval/substring → Interval DP (Template 3)
├── Selection with limit → Knapsack (Template 4)
└── Multiple states → State Machine (Template 5)
5. Implementation approach?
├── Recursive structure clear → Top-down memoization
└── Iterative structure clear → Bottom-up tabulation
DP vs 其他做法
| 題型 | 用 DP | 有其他選擇嗎 | 替代做法 |
|---|---|---|---|
| 最佳化(求最小/最大) | ✅ | 有時候 | 若貪婪也最佳就用貪婪 |
| 數方法數/路徑數 | ✅ | - | - |
| 判定(是/否) | ✅ | 有時候 | 貪婪/DFS |
| 需要列出所有解 | ❌ | ✅ | 回溯 |
| 沒有重疊子問題 | ❌ | ✅ | 分治法 |
| 具備貪婪選擇性質 | ❌ | ✅ | 貪婪 |
該用哪個 DP 模式?
模式快速選擇表
| 題型 | 辨認關鍵字 | DP 分類 | 範例題目 |
|---|---|---|---|
| 費氏數列類 | “nth number”、“climbing stairs”、“decode ways” | 線性 DP | LC 70, 91, 746 |
| 打家劫舍 | “non-adjacent”、“cannot pick consecutive” | 線性 DP | LC 198, 213, 337 |
| 前綴切分 | “break/segment a string”、“cut into valid pieces” | 前綴切分 DP | LC 139, 140, 132, 279 |
| 最長遞增 | “longest increasing”、“LIS”、“envelope” | 線性 DP | LC 300, 354, 673 |
| 固定步長的鏈 | “subsequence with given difference”、“each step is prev ± d” | 以值為鍵的 DP | LC 1218, 1048 |
| 路徑計數 | “unique paths”、“number of ways to reach” | 格子 DP | LC 62, 63, 980 |
| 路徑和(最小/最大) | “minimum path sum”、“maximum sum” | 格子 DP | LC 64, 120, 174 |
| 正方形/矩形 | “maximal square”、“largest rectangle” | 格子 DP | LC 221, 85 |
| 區間問題 | “burst balloons”、“merge stones”、“palindrome partition” | 區間 DP | LC 312, 1000, 516 |
| 樹上問題 | “house robber on tree”、“tree cameras” | 樹上 DP | LC 337, 968 |
| 樹的換根 | “for every node compute”、“sum of distances” | 換根 DP | LC 834, 2581 |
| 股票交易 | “buy and sell stock”、“transaction”、“cooldown” | 狀態機 | LC 122, 309, 714 |
| 背包(0/1) | “subset sum”、“partition”、“target sum” | 背包 DP | LC 416, 494 |
| 背包(完全) | “coin change”、“unlimited supply” | 背包 DP | LC 322, 518 |
| 編輯距離 | “edit distance”、“minimum operations” | 字串 DP | LC 72, 583, 712 |
| LCS/LPS | “longest common subsequence”、“palindrome” | 字串 DP | LC 1143, 516, 647 |
| Bitmask/子集 | “visit all nodes”、“assign tasks”、“TSP” | 狀態壓縮 | LC 847, 1723, 691 |
訊號 → 模式
| 訊號 | 模式 |
|---|---|
| 「帶滑動限制的最大/最小子陣列」 | 單調佇列 DP |
| 「以每個節點為根各求一次答案」 | 換根(2 趟 DFS) |
| 「最大矩形/最大正方形」 | 堆疊 DP,或在前綴高度上做 DP |
| 「賽局:兩人都最佳決策」 | Minimax DP:dp[i][j] = 分差 |
| 「數出符合位數限制的數字」 | 數位 DP:(pos, tight, accumulator) |
| 「把字串切成合法單字」 | 前綴切分 DP:dp[i] 跑前綴長度 + 單字集合 |
| 「給定公差的最長子序列」 | 以值為鍵的 DP:在雜湊表裡做 dp[x] = dp[x - d] + 1 |
| 「買賣股票的各種變形」 | 狀態機(持有/賣出/休息) |
| 「編輯距離、LCS、交錯字串」 | 二維 DP → 一維空間優化 |
複雜度速查
| 模式 | 時間複雜度 | 空間複雜度 | 空間優化 |
|---|---|---|---|
| 一維線性 | O(n) | O(n) | 用變數可壓到 O(1) |
| 二維格子 | O(m×n) | O(m×n) | 用滾動陣列可壓到 O(n) |
| 區間 | 通常 O(n³) | O(n²) | 通常壓不下去 |
| 0/1 背包 | O(n×W) | O(n×W) | 用一維陣列可壓到 O(W) |
| 狀態機 | O(n×k) | O(k) | 已經是最省的了 |
狀態定義的原則
# 1D: Position/index based
dp[i] = "optimal value considering first i elements"
# 2D: Two dimensions
dp[i][j] = "optimal value for subproblem (i, j)"
# Interval: Range based
dp[i][j] = "optimal value for interval [i, j]"
# Boolean: Decision problems
dp[i] = "whether target i is achievable"
解題步驟
- 判斷能不能用 DP:檢查有沒有重疊子問題
- 定義狀態:dp[i] 代表什麼?
- 找出遞迴式:狀態之間怎麼互相關聯?
- 確認初始條件:初值是什麼
- 決定走訪順序:由下而上該往哪個方向填
- 優化空間:能不能改用滾動陣列?
常見錯誤與提醒
🚫 常見錯誤:
- 狀態定義錯誤
- 漏掉初始條件
- 走訪順序不對
- 沒處理邊界情況
- 資料量大時整數溢位
✅ 最佳實務:
- 先寫遞迴解,再做優化
- 畫小例子找出規律
- 仔細檢查陣列邊界
- 先確定正確性,再考慮空間優化
- 狀態變數用有意義的名字
面試提點
- 從簡單開始:先寫遞迴解
- 找出子問題:畫遞迴樹
- 加上記憶化:轉成由上而下的 DP
- 考慮由下而上:通常效率更好
- 優化空間:用滾動陣列加分
- 拿例子測:用小輸入手動推一遍
相關主題
- 貪婪:局部最佳能導出全域最佳時
- 回溯:需要列出所有解時
- 分治法:沒有重疊子問題時
- 圖論演算法:圖上的 DP(最短路徑)
- 二分搜尋:具單調性的最佳化問題