動態規劃(DP)

動態規劃與遞迴優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 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
# 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
// 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 演算法。

試跑

text
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
# 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
// 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
# 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 看起來像是「往回退一個字元」,但它只是把某個長度換算成該長度最後一個字元的索引。

text
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

把兩列對齊看一次,困惑就消失了 — 它們本來就差一,因為長度那一列在最前面多了一個「空前綴」的位置:

text
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
# 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" 上的追蹤:

text
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
# 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 格
text
        j                 i
s:  [ ---- dp[j] ---- ][ s[j:i] ]
      already solved     one word?

只要有某一個 j 讓兩個問題都成立,dp[i] 就是 true —— 這也是為什麼內層迴圈一命中就可以 break。 把 i 讀成邊界,不是字元:它是「已經切好」和「還沒看」之間的分界線,所以可以合法地走到 n —— 最後一個字元的再下一格。這正是 BFS 寫法丟進佇列的那個邊界。

python
# 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
// 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
# 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
# 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

試跑

text
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
# 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
// 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)。

試跑

text
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
# 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
// 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。

我要蓋的是哪一種形狀?

text
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
# 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] 的答案。

text
            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

相依關係 — 一個「兩端往內縮」的轉移會讀到的三個格子:

text
   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
# 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
# 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)
text
   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,用上面的程式填出來:

text
   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")
它到底往哪邊走?— 反向走回表格

正向那一趟存下來的是數值。決策是事後在每個格子重問一次「這個值是哪個鄰居給的」還原出來的 — 不需要父指標,把轉移重算一次就好:

text
   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 行代表空前綴的後果。

text
                  ""     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

相依關係 — 三支箭頭,全都從上方/左方/左上方過來:

text
   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
# 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,用上面的程式填出來:

text
              ""    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
# 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
# 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
// 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 沒辦法滾動 — 切點會往回讀到每一個更短的長度。

試跑

text
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
# 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
// 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: 這件物品只能拿一次):

text
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
# 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
// 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)。

試跑

text
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
# 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
// 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 出錯的那個輸入:

text
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
# 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
# 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
# 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
// 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 陣列。

試跑

text
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
# 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
// 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):雜湊表裡每個不同的值最多一筆。

試跑

text
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
# 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
// 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
# 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
// 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))。

試跑

text
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
# 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
// 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),所以表格沒有要 重複利用時它是預設選擇。

試跑

text
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 — 加分項

python
# 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) 開始擴
python
# 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" 的圖解推演:

text
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 還是雙指標

text
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
# 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
// 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
# 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
// 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) 的遞迴深度。

試跑

text
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 嗎?

text
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) 已經是最省的了

狀態定義的原則

python
# 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"

解題步驟

  1. 判斷能不能用 DP:檢查有沒有重疊子問題
  2. 定義狀態:dp[i] 代表什麼?
  3. 找出遞迴式:狀態之間怎麼互相關聯?
  4. 確認初始條件:初值是什麼
  5. 決定走訪順序:由下而上該往哪個方向填
  6. 優化空間:能不能改用滾動陣列?

常見錯誤與提醒

🚫 常見錯誤:

  • 狀態定義錯誤
  • 漏掉初始條件
  • 走訪順序不對
  • 沒處理邊界情況
  • 資料量大時整數溢位

✅ 最佳實務:

  • 先寫遞迴解,再做優化
  • 畫小例子找出規律
  • 仔細檢查陣列邊界
  • 先確定正確性,再考慮空間優化
  • 狀態變數用有意義的名字

面試提點

  1. 從簡單開始:先寫遞迴解
  2. 找出子問題:畫遞迴樹
  3. 加上記憶化:轉成由上而下的 DP
  4. 考慮由下而上:通常效率更好
  5. 優化空間:用滾動陣列加分
  6. 拿例子測:用小輸入手動推一遍

相關主題

  • 貪婪:局部最佳能導出全域最佳時
  • 回溯:需要列出所有解時
  • 分治法:沒有重疊子問題時
  • 圖論演算法:圖上的 DP(最短路徑)
  • 二分搜尋:具單調性的最佳化問題