既有教材把動態規劃嵌在個別問題裡:0/1 背包、子集和、編輯距離、漢彌爾頓路徑的狀態壓縮。本篇把「怎麼設計一題 DP」抽成可遷移的方法:狀態、轉移、順序、還原方案。讀完再回去看那些專題,會知道它們只是同一套骨架的不同填法。

相關教材:02_Knapsack_Teaching_Materials.tex、03_SubsetSum_Partition_Teaching_Materials.tex、08_HamiltonianPath_Teaching_Materials.tex、Fuzzy_Search_Complete_Teaching_Materials.md(編輯距離與 LCS)、Recursion_Teaching_Materials.md(記憶化)。


目錄

  1. 它和分治差在哪
  2. 兩個必要條件
  3. 四步設計法
  4. 自頂向下與自底向上
  5. 範例:硬幣找零
  6. 範例:最長遞增子序列
  7. 範例:矩陣鏈相乘
  8. 狀態設計目錄
  9. 空間滾動
  10. 還原一個最佳方案
  11. 什麼時候不該用 DP
  12. 常見陷阱
  13. 練習題

1. 它和分治差在哪

分治把問題切成互不重疊的子問題,各自解決再合併。合併排序的左右兩半沒有共用的子陣列。

動態規劃的子問題會重複出現。朴素遞迴的費氏數列:

[ F(5) = F(4)+F(3),\quad F(4)=F(3)+F(2) ]

F(3) 被算了兩次,更小的又被算更多次,總時間是指數。把每個 F(k) 的答案存起來,每個子問題只算一次,時間變成線性。

所以 DP 不是「用表格的分治」,而是「子問題重疊,所以用表格(或備忘錄)把指數遞迴壓成多項式」。


2. 兩個必要條件

  1. 最佳子結構:原問題的最佳解,由子問題的最佳解組合而成。若組合時必須回頭修改子問題的選擇,DP 的轉移式就不成立。
  2. 重疊子問題:遞迴展開後,同一組參數會被問到很多次。若子問題都不重複,把答案存起來沒有好處,那是分治或單純遞迴。

缺第一個,表格填的是錯的。缺第二個,表格只是把遞迴寫成迴圈,複雜度不會更好。


3. 四步設計法

拿到一題,依這個順序寫,不要先寫程式。

  1. 狀態。一句話:dp[i] 或 dp[i][j] 代表什麼。參數要夠描述「剩下的問題」,又不要多到狀態數爆炸。
  2. 轉移。dp[...] 由哪些更小的狀態、用什麼運算(min / max / 加總)得到。寫出為什麼每個候選都合法。
  3. 基底。最小的狀態是什麼,答案是多少。很多 bug 出在這裡,而不是轉移。
  4. 計算順序。自底向上時,用到的狀態必須已經算完。拓撲順序錯了,讀到的是初值。

複雜度通常是

[ O(\text{狀態數} \times \text{每個狀態的轉移代價}) ]


4. 自頂向下與自底向上

自頂向下(備忘錄):照遞迴寫,第一次算到就存進表,之後直接回傳。優點是只算「真的會走到」的狀態。缺點是遞迴深度可能爆堆疊,常數也較大。

自底向上:迴圈依順序填表。優點是空間與順序可控,方便滾動陣列。缺點是會填到用不到的狀態。

兩者答案相同。教學時先寫遞迴確認轉移,再改成迴圈。


5. 範例:硬幣找零

面額 coins = {1, 3, 4},湊出金額 6,求最少硬幣數。每種面額無限個。

狀態:dp[x] = 湊出金額 x 的最少硬幣數。

轉移:

[ dp[x] = \min_{c \le x} \bigl(dp[x-c] + 1\bigr) ]

基底:dp[0] = 0。其餘先設成一個哨兵(比任何合理答案大,例如 x+1)。

手算:

x 0 1 2 3 4 5 6
dp 0 1 2 1 1 2 2

dp[6]:1+dp[5]=3,1+dp[3]=2,1+dp[2]=3,最小值 2。一組方案是 3+3 或 4+1+1,枚數都是 2。

若沒有面額 1,而且湊不出來,dp[x] 會停在哨兵。回傳前要檢查,不要把哨兵當答案。

時間 (O(n \cdot |coins|)),空間 (O(n))。


6. 範例:最長遞增子序列

序列 a = [3, 1, 4, 1, 5, 9, 2, 6],求最長嚴格遞增子序列的長度(不必連續)。

狀態:dp[i] = 以 a[i] 結尾的最長遞增子序列長度。

轉移:

[ dp[i] = 1 + \max{ dp[j] : j < i,\ a[j] < a[i] } ]

沒有這樣的 j 時,長度就是 1。

手算(只列會更新的決策):

i a[i] dp[i] 接到誰
0 3 1 —
1 1 1 —
2 4 2 3 或 1
3 1 1 —
4 5 3 4
5 9 4 5
6 2 2 1
7 6 4 5

答案是 max dp[i] = 4,例如 1,4,5,9 或 1,4,5,6。

這個寫法 (O(n^2))。若只需要長度,維護「長度為 k 的遞增子序列的最小結尾」,對這個結尾陣列做二分,可到 (O(n\log n))。那個結尾陣列不是子序列本身,還原方案要另外記前驅。二分搜尋的寫法見 Binary_Search_Teaching_Materials.md。


7. 範例:矩陣鏈相乘

矩陣鏈 (A_1 A_2 A_3),維度依序是 (2\times 3)、(3\times 4)、(4\times 2)。乘法結合律成立,但標量乘法次數不同。

  • ((A_1 A_2)A_3):(A_1 A_2) 花 (2\cdot 3\cdot 4 = 24),結果 (2\times 4) 再乘 (A_3) 花 (2\cdot 4\cdot 2 = 16),合計 40。
  • (A_1(A_2 A_3)):(A_2 A_3) 花 (3\cdot 4\cdot 2 = 24),結果 (3\times 2) 再乘 (A_1) 花 (2\cdot 3\cdot 2 = 12),合計 36。

最佳是 36。

狀態:dp[i][j] = 只乘第 i 到第 j 個矩陣(1-based)的最少標量乘法次數。令維度陣列 d = [2, 3, 4, 2],第 k 個矩陣是 d[k-1] × d[k]。

轉移:在 i..j 之間選切開點 k

[ dp[i][j] = \min_{i \le k < j} \bigl(dp[i][k] + dp[k+1][j] + d[i-1]\cdot d[k]\cdot d[j]\bigr) ]

基底:dp[i][i] = 0(單一矩陣不用乘)。

順序:區間長度由短到長。長度 2 算完才能算長度 3。這就是區間 DP。

本例 dp[1][3] = 36,切開點在第 1 個矩陣之後。

時間 (O(n^3)),空間 (O(n^2))。


8. 狀態設計目錄

看到題目外形,先猜狀態長什麼樣子,再驗證最佳子結構。

外形 常見狀態 本站已有的例子
一條序列,決定每個位置 dp[i]:只看前 i 個 硬幣、爬階、最大子段和(分析教材的 Kadane 是它的貪心特例)
序列 + 每個元素拿或不拿,容量有限 dp[i][w] 02_Knapsack_Teaching_Materials.tex
布林可達 dp[x] = 能否湊出 x 03_SubsetSum_Partition_Teaching_Materials.tex
兩條序列對齊 dp[i][j] 編輯距離、LCS(模糊搜尋教材)
區間合併 dp[i][j],依長度填 矩陣鏈、戳破氣球一類
樹上,子樹獨立 dp[u][選/不選] 需要先有樹的 DFS
集合很小,(n \le 20) dp[S][v],S 是位元遮罩 08_HamiltonianPath_Teaching_Materials.tex

位元 DP 的狀態數是 (O(2^n \cdot n)),轉移若再掃一次集合外的點,就是 (O(2^n \cdot n^2))。(n) 到 22 左右就該停,這不是實作不優,是狀態數本身。


9. 空間滾動

若 dp[i] 只依賴 dp[i-1],兩個陣列輪流寫即可,空間從 (O(nW)) 降到 (O(W))。0/1 背包必須由右往左更新那一列,否則同一個物品會被用兩次。完全背包(物品無限)才由左往右。這個方向在背包教材的「一維空間優化」有對應程式,方向搞反是最常見的錯誤。

區間 DP 不能滾動掉整張表,因為長區間同時依賴很多短區間。


10. 還原一個最佳方案

表格通常只存「最佳值」。若要印出選了哪些硬幣、切在哪:

  • 多存 prev[x]:湊出 x 時最後選的硬幣。
  • 或從 dp[n] 往回,看哪個轉移真的取到最小值。

往回時要用和填表同一個轉移式。填表用 min、還原時改用另一個條件,會得到對不上的方案。


11. 什麼時候不該用 DP

  • 子問題不重疊:合併排序、快速排序,用分治。
  • 沒有最佳子結構:0/1 背包若改成「物品之間有任意排斥、不能寫成容量裡的局部決策」,轉移就斷了。此時可能是圖搜尋、回溯,或根本是 NP-hard 而且結構不夠乾淨。
  • 只是要一個可接受的解、不要求最佳:見貪心教材與 NP 教材的近似演算法。貪心能證明時,不要先寫 (O(n^2)) 的 DP。
  • 狀態裡藏了整個排列:那是 (n!),不是 DP。

12. 常見陷阱

  • 狀態定義含糊。 「dp[i] 是前 i 個的答案」和「以 i 結尾的答案」差一個迴圈。LIS 若寫成前者,轉移會不對。
  • 哨兵溢出。 INF + 1 若 INF 已是 INT_MAX,會溢位變成負數,min 會選到它。用 int64,或哨兵設成一個不會再加的值。
  • 0/1 與完全背包的迴圈方向。 見第 9 節。
  • 區間 DP 依下標而不是依長度迴圈。 dp[i][j] 可能讀到還沒算的 dp[i][k]。
  • 把「能湊出」和「最少個數」寫在同一格卻沒初始化。 布林 DP 的 false 和整數 DP 的 0 意義不同。
  • 負面額或負權。 上面的硬幣轉移假設面額為正。負數會讓子問題不「變小」,順序不存在,甚至有無限迴圈。

13. 練習題

  1. 面額 {2, 5},金額 3。dp 陣列應該長什麼樣?函式該回傳什麼?
  2. 寫出「爬樓梯,每次 1 或 2 階,到第 n 階的方法數」的狀態、轉移、基底。它和費氏數列是什麼關係?
  3. 0/1 背包容量 6,物品 (w,v) = (2,3), (3,4), (4,5)。最大價值是多少?若一維陣列由左往右更新,重量 2 的物品為什麼可能被算成價值 9?
  4. 矩陣維度 d = [2, 3, 4, 2] 已在第 7 節算過。把 dp[1][2]、dp[2][3]、dp[1][3] 都寫出來。
  5. 為什麼「印出 LIS 本體」不能只靠 (O(n\log n)) 那個最小結尾陣列?

參考答案

  1. dp[0]=0,dp[1] 與 dp[3] 維持哨兵(凑不出),dp[2]=1。金額 3 應回傳「不可能」,而不是哨兵數字。
  2. dp[n] = dp[n-1] + dp[n-2],dp[0]=1(停在地面一種方式)或依定義改成 dp[1]=1, dp[2]=2。方法數是費氏數列,只是編號平移。
  3. 0/1 最佳是 8:第一件與第三件(重量 6、價值 8)。由左往右更新時,重量 2 的物品可以在同一輪被重複使用三次,湊出重量 6、價值 9,這對 0/1 是非法的。正確的一維更新必須由右往左。
  4. dp[1][2] = 2*3*4 = 24,dp[2][3] = 3*4*2 = 24,dp[1][3] = min(24+0+2*4*2,\ 0+24+2*3*2) = min(40, 36) = 36。
  5. 最小結尾陣列的第 k 格是「長度 k 的某個子序列的結尾」,這些結尾不一定來自同一條子序列,也不能代表當時的前一個元素。要還原必須在 (O(n^2)) 表上記前驅,或在二分版本額外存前驅索引。

小結

DP 是把有重疊、有最佳子結構的遞迴,改成每個狀態只算一次。設計順序永遠是:狀態一句話、轉移、基底、填表順序。複雜度是狀態數乘上轉移代價。背包、編輯距離、區間合併、位元遮罩是同一套骨架;差別只在狀態的參數。沒有最佳子結構就不要填表。