演算法系列 · 第 20 關 · 遞迴與進階

動態規劃 DP

把算過的存起來,避免重複計算。大問題由小問題的答案疊出來。

動態規劃(DP)專治「一個大問題,會反覆用到相同小問題答案」的情況。核心就一句:把算過的存起來,絕不重算。它有兩塊前提:最佳子結構(大答案能由小答案組出來)和重疊子問題(小答案會被一再用到)。做法有兩派:由上而下(記憶化)用遞迴 + 快取;由下而上(填表)從最小的開始一格格填。

DP 的兩個前提

最佳子結構 —— 大問題的答案,可以由子問題的答案組合出來(例如 dp[i] 由 dp[i-1] 來)。

重疊子問題 —— 同樣的子問題會被重複用到很多次(這時存起來才划算)。

少了重疊就沒必要 DP —— 分治的子問題不重疊,存了也沒用。

關鍵是「狀態」和「轉移式」 —— 定義 dp[i] 代表什麼、以及它怎麼從更小的算出來。

兩種寫法:記憶化 vs 填表

由上而下・記憶化(top-down) —— 照遞迴的樣子寫,但把算過的存進 cache;遇到算過的直接拿。最貼近原本的遞迴思路。

由下而上・填表(bottom-up) —— 從最小的子問題開始,一格格把表填滿,最後一格就是答案。沒有遞迴開銷。

兩者等價 —— 通常時間複雜度一樣,填表常更省記憶體(可只留最近幾格)。

下面視覺化就是「填表」 —— 從 dp[0] 一路填到 dp[n]。

DP 四步驟(解題流程)

1. 定義狀態 —— dp[i](或 dp[i][j])代表什麼?

2. 找轉移式 —— dp[i] 怎麼從更小的狀態算出來?(最難、最關鍵)

3. 定 base case —— 最小的子問題答案是什麼?

4. 決定順序 —— 要先算好誰,才能算 dp[i]?通常由小到大填。

複雜度

O(n)一維 DP —— 爬樓梯、費式:填 n 格、每格看常數個前格。
O(n·m)二維 DP —— 背包、最長共同子序列、編輯距離:填 n×m 的表。
O(n)空間 —— 一維只要一排;二維常可壓成一排(滾動陣列)。
DP 最難的不是寫程式,是想出轉移式。一旦你能用「更小的答案」描述「現在的答案」,剩下就是填表。多看經典題,累積「狀態怎麼定」的直覺。

用生活比喻它

🧗

爬樓梯記步數

到第 n 階的走法 = 到 n-1 階 + 到 n-2 階的走法,從下面一階階疊上來。

🧾

記帳本(備忘錄)

算過的答案抄在本子上,下次要用直接翻,絕不重算。

🏗️

蓋樓

先蓋好低樓層,高樓層才有地基;由下往上一層層蓋,不能跳。

🎮 爬樓梯 DP 填表

爬 8 階,每次可爬 1 或 2 階,問有幾種走法?狀態 dp[i] = 到第 i 階的走法數,轉移式 dp[i] = dp[i-1] + dp[i-2](最後一步爬 1 階或 2 階)。紫色是正在填的格、琥珀是它相加的兩格、綠色是填好的。按「下一步」由下而上填。

10 個程式碼範例(Python)

從費式的三種寫法、爬樓梯,到零錢兌換、背包、LIS、LCS、編輯距離、Kadane、打家劫舍。

顯示範例:

常見陷阱

❌ 沒有重疊子問題還硬用 DP —— 子問題不重複(如純分治),存了也沒省到。

❌ 狀態定義不清 —— dp[i] 到底代表什麼講不清,轉移式就寫不對。DP 一切從「定義狀態」開始。

❌ base case / 邊界錯 —— dp[0]、第一行第一列常要特別設,錯了整張表全歪。

❌ 填表順序錯 —— 算 dp[i] 時它依賴的格子還沒算好,要先確定依賴關係。

🎯 換你了(先別急著查答案)

把答案打到對話裡,我幫你對。