DP 迴圈順序與相依性
範圍 — 為什麼自底向上 DP 的迴圈巢狀與方向是由它自己的轉移式所決定的、錯誤順序會失敗的四種方式,以及用五種順序實作 LC 139 Word Break——包括那個看起來正確、能通過
"leetcode"、卻悄悄在題目自己的 Example 2 上失敗的寫法。 另見:dp.md — Template 1b 就是 Word Break 的模板本身,Template 2a 是同一條規則套用在 2-D 表格上;knapsack.md — 四種背包迴圈順序並列比較;dp_advanced.md — LC 518 與 LC 377 的計數追蹤;knapsack_01_zh.md — 0/1 背包倒序的中文詳解;recursion_to_dp.md — 自頂向下的記憶化,這種寫法根本沒有迴圈順序可以寫錯。
- 核心概念:自底向上的 DP 就是對它自己的相依圖做一次拓撲掃描。把轉移式寫下來,合法的迴圈順序恰好就是那些在每條箭頭的頭之前先完成其尾的順序。
- 何時使用:每當你把遞迴式轉成表格、必須決定哪個迴圈包在哪個外面時——或者當表格化的解回傳錯誤答案,而同一個遞迴式的記憶化版本卻是對的時候。
- 關鍵 LeetCode 題目:LC 139、LC 279、LC 322、LC 518、LC 377、LC 416、LC 516
- 寫錯時的典型症狀:不會當掉。只是在某一個輸入上給出一個看似合理、略微偏小的答案。
LeetCode 題目清單
0) 概念
0-1) 一句話講完的規則 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
計算
dp[X]時,它的轉移式所讀取的每一個狀態都必須已經是最終值。
在動態規劃(DP)中,迴圈的巢狀順序取決於一個核心原則: 在計算當前狀態
dp[i]時,它所依賴的更小狀態必須已經被計算完畢。
這一頁沒有別的內容了。下面每一張表、每一段追蹤、每一個反例,都只是把這句話再套用到一個題目上。
0-2) 表格是一個 DAG——迴圈順序就是一種拓撲排序
轉移式就是一組箭頭:dp[i] ← dp[i - len(word)] 的意思是*「格子 i - len(word) 必須在讀取格子 i 之前完成。」*把所有箭頭收集起來,就得到一張定義在格子上的有向無環圖。自底向上的巢狀迴圈只不過是這個 DAG 的一個寫死的拓撲順序。
LC 139, s = "leetcode", dict = {"leet", "code"}
dp[0] dp[1] ... dp[4] ............ dp[8]
│ ▲ ▲
└──── "leet" ────┘ │
└────── "code" ─────┘
every arrow points RIGHT (i - len(word) < i, because len(word) >= 1)
→ any order that fills dp[] left to right is legal
→ an order that finishes "all of word w, then all of word w'" is NOT a
left-to-right order at all — it is an order over WORDS, and the arrows
do not care about words
最後一行的那個區分就是 §1-4 與 §2 的全部內容,也正是 LC 139 真正會咬人的地方。
0-3) 錯誤順序會失敗的四種方式 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
| # | 迴圈做了什麼 | 破壞了什麼 | 症狀 | 典型案例 |
|---|---|---|---|---|
| 1 | 讀了一個還沒寫入的格子 | 相依規則 | 答案偏小 / False / inf——從不當掉 |
LC 139 Order C |
| 2 | 讀了一個這一輪已經被改寫的格子 | 規則中「最終值」那一半 | item 被悄悄重複使用 | LC 416 正序內層迴圈 |
| 3 | 只讀已完成的格子,但以不同方式分組列舉 | 什麼都沒壞——兩者都是合法的 DP | 回答了另一個問題 | LC 518 vs LC 377 |
| 4 | 要求一個根本不存在的順序 | DAG 裡有環 | 你根本寫不出這個迴圈 | 可四向移動的格子圖 → Dijkstra,不是 DP(dp.md) |
失敗 1 才是危險的那個:它不會當掉、不會 TLE,在題目敘述的範例上也不會錯。它只是在某個你沒試過的輸入上回傳一個錯誤的 False。
0-4) 為什麼自頂向下的記憶化永遠不會有這個 bug 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
記憶化遞迴會按需計算每一個相依項:呼叫堆疊在執行期免費地找出一個合法的拓撲順序。自底向上則要求你手動預先算好這個順序並把它寫死在巢狀迴圈裡——這正是這一頁需要存在的唯一理由。
診斷法:如果表格化的 DP 算錯,而同一個遞迴式的記憶化版本是對的,那你遇到的是迴圈順序 bug,不是遞迴式 bug。別再重讀轉移式了。
想刻意在兩者之間轉換,請見 recursion_to_dp.md。
1) 一般形式
1-1) 決策流程 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
Step 1. Write the transition. Underline every dp[...] on the RIGHT-hand side.
Step 2. For each one, name the index it reads relative to the one being written.
-> this fixes the DIRECTION of the state loop (§1-2)
Step 3. Does the transition also range over a set of ITEMS
(coins, words, squares, intermediate vertices)?
no -> you are done, one loop
yes -> may that item loop sit OUTSIDE the state loop? (§1-4)
第 2 步,針對 1-D 表格——它是 Template 2a 2-D 表格的姊妹版:
| 轉移式讀取 | 範例 | 所以狀態迴圈要 |
|---|---|---|
dp[i-1]、dp[i-2]——往回固定偏移 |
LC 70、LC 198 | i 正序 |
dp[i - len(word)]、dp[i - coin]——往回可變偏移 |
LC 139、LC 322、LC 279 | i 正序 |
所有 j < i 的 dp[j]——所有較短的前綴 |
LC 139 Order B、LC 132 | i 正序,j 可以是 [0, i) 中任意位置 |
dp[i+1]、dp[i+2]——後綴狀態 |
後綴/「從這裡開始」的 DP | i 倒序 |
dp[w - weight],而這一輪不可以看到自己的寫入 |
LC 416(0/1) | w 倒序 |
dp[w - coin],而這一輪必須看到自己的寫入 |
LC 322、LC 518(完全背包) | w 正序 |
最後兩列是同一個表達式、意圖卻相反——這就是為什麼區分 0/1 背包與完全背包的是方向,而不是遞迴式。
1-2) 軸 1——方向:在重複使用的陣列裡,「已是最終值」代表什麼
一旦 2-D 表格被壓成一列,「已經計算過」就分裂成兩種意思——在前一輪計算過與在這一輪計算過。方向就是你在兩者之間做選擇的方式:
希望 dp[w - x] 代表 |
方向 | 效果 | 家族 |
|---|---|---|---|
| 加入這個 item 之前的值 | w 倒序 |
每個 item 最多用一次 | 0/1 背包——LC 416、494 |
| 包含這個 item 的值 | w 正序 |
每個 item 可無限次重複使用 | 完全背包——LC 322、518、279 |
展示正序迴圈會算出 3 + 3 = 6 的 nums = [3], target = 6 逐步追蹤,放在 dp.md 的 Why Must the Inner Loop Go Backward? 一節;中文詳解在 knapsack_01_zh.md。這裡不再重複。
1-3) 軸 2——巢狀:狀態在外 vs item 在外 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
每一種「前綴 + 一份 item 選單」的 DP 都能寫成兩種形式,而它們不能互換:
# python
# IDEA: the two nestings of one transition -- dp[i] <- dp[i - size(item)]
# (A) STATE-outer: "to reach state i, which item was placed LAST?"
for i in range(1, n + 1): # state
for item in items: # item
if i >= size(item):
dp[i] = combine(dp[i], dp[i - size(item)])
# (B) ITEM-outer: "introduce item 1 everywhere, then item 2 everywhere, ..."
for item in items: # item
for i in range(size(item), n + 1): # state
dp[i] = combine(dp[i], dp[i - size(item)])
(A) 永遠合法,只要箭頭嚴格指向後方,因為走到 i 時,每一個更小的索引都已經完成——對每一個 item 都是,而不只是其中一部分。
**(B) 是一個主張。**在 item t 這一輪讀取 dp[j] 的當下,那個格子只知道 item 1..t。所以形式 (B) 計算的是:
使用 items 依照 item 迴圈造訪它們的順序所能到達的狀態——一個解可以在 item 1 之後用 item 3,但永遠不能在 item 3 之後用 item 1。
這個限制是否無害,就是下一節的內容。
1-4) 交換律檢驗——item 在外何時合法 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
item 在外合法,若且唯若任何解都能被重新排列成 item 迴圈的順序,而且仍然是一個解。
每次都用兩個問題就能回答:
Q1. How do the chosen items compose into the answer?
Q2. Is that composition free to be re-ordered?
coins / squares: they are ADDED 3 + 1 + 3 == 1 + 3 + 3 ✅ commutes
words: they are CONCATENATED "apple"+"pen" != "pen"+"apple" ❌ does not
| 題目 | items 的組合方式 | 可重排? | item 在外? |
|---|---|---|---|
| LC 322 Coin Change(最小值) | + 加總 |
✅ | 合法——兩種寫法答案相同 |
| LC 279 Perfect Squares(最小值) | + 加總 |
✅ | 合法——兩種寫法答案相同 |
| LC 518 Coin Change II(計數) | + 加總 |
✅ | 合法,而且是必須的——正是它讓每個多重集合只被算一次 |
| LC 377 Combination Sum IV(計數) | +,但不同順序算不同答案 |
✅ 但答案會把它們分開計數 | 必須狀態在外,否則會漏掉排列 |
| LC 416 Partition Equal Subset Sum | +,每個 item 用一次 |
✅ | 合法,搭配倒序內層迴圈(§1-2) |
| LC 139 Word Break | 在 s 固定好的位置上串接 |
❌ | 不合法——§2-4 |
| Floyd–Warshall | 一條路徑的中繼點集合 | ✅(依索引排序) | 合法,而且 k 迴圈必須在最外層——§3-4 |
Word Break 之所以在每一份 DP 題型整理裡都是異類,正是這個原因:s 已經決定了單字出現的順序,所以 item 迴圈沒有自由再決定一次。
1-5) 洗牌檢驗——10 秒鐘的檢查 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
把 item 清單打亂再跑一次。**正確的自底向上 DP 對此是不變的。**如果答案變了,就是有個 item 迴圈放在了一個本該包住它的狀態迴圈外面。
s = "codeleet", dict order ["code", "leet"] -> item-outer returns True ✅ (correct)
s = "codeleet", dict order ["leet", "code"] -> item-outer returns False ❌ (same input!)
一個答案取決於字典剛好以什麼順序列出的演算法,不能算是演算法。(Python 讓這點很容易被忽略:迭代 set(wordDict) 會把你所依賴的順序藏起來——見 python_gotchas.md。)
這個檢驗是單向的:通過它不代表正確。§2-5 的 "applepenapple" 在每一種字典順序下都是錯的。
2) LC 139 Word Break——一個遞迴式,五種順序 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
2-1) 遞迴式與它的箭頭
| 面向 | 細節 |
|---|---|
| 狀態 | dp[i] = s[:i]——前 i 個字元——能否被切成字典中的單字? |
| 初始值 | dp[0] = True(空前綴) |
| 轉移 | dp[i] = OR over words w of ( dp[i - len(w)] AND s[i-len(w):i] == w ) |
| 答案 | dp[n] |
| 箭頭 | dp[i] ← dp[i - len(w)],且 len(w) >= 1,所以每條箭頭都嚴格指向後方 |
因為每條箭頭都指向後方,由左到右填 dp 是合法的。下面所有出錯的地方,都是因為沒有由左到右填。
模板、n+1 的大小以及 set(wordDict) 陷阱都在 dp.md Template 1b;這一節只談巢狀迴圈。原始碼:word-break.py。
2-2) Order A——索引在外,字典在內 ✅
# python
# LC 139 - Word Break
# IDEA: state-outer -- "to end a segmentation at i, which dictionary word was placed LAST?"
# time = O(n * k * L), space = O(n) n = len(s), k = len(wordDict), L = max word length
class Solution(object):
def wordBreak(self, s, wordDict):
n = len(s)
d_set = set(wordDict)
dp = [False] * (n + 1)
dp[0] = True
# NOTE !!! index OUTSIDE, dictionary INSIDE
# -> dp[i - w] is a strictly smaller index, so it is already final
for i in range(1, n + 1):
for word in d_set:
w = len(word)
if i >= w and dp[i - w] and s[i - w:i] == word:
dp[i] = True
break # it is an OR -- one witness is enough
return dp[n]
為什麼正確:當迴圈本體讀取 dp[i - w] 時,外層迴圈已經對每一個單字完成了所有小於 i 的索引。沒有任何單字的貢獻會遲到。
s = "leetcode", dict = {"leet", "code"}
i=1..3 no word ends here dp[1..3] = False
i=4 "leet": dp[0]=True and s[0:4]=="leet" -> dp[4] = True
i=5..7 "leet"/"code" do not match ending here dp[5..7] = False
i=8 "code": dp[4]=True and s[4:8]=="code" -> dp[8] = True ← answer
這裡內層迴圈迭代一個 set 是沒問題的,而這是值得注意的性質:單字是從一個已是最終值的列中讀取的,所以它們的順序無關緊要。§2-4 則是順序有關係的那種寫法。
2-3) Order B——索引在外,切點在內 ✅
# python
# LC 139 - Word Break
# IDEA: state-outer, driven by CUT POINTS instead of by words -- "where did the last word start?"
# time = O(n^2) cuts * O(L) slice+hash, space = O(n)
for i in range(1, n + 1):
for j in range(i): # j = start of the candidate last word
if dp[j] and s[j:i] in d_set:
dp[i] = True
break
相同的箭頭、相同的方向,只是用不同方式列舉同一個最後一段:Order A 問*「哪個單字?」,Order B 問「哪個切點?」*。兩者都只讀已完成的格子。完整追蹤在 dp.md Template 1b。
把內層迴圈限制在最長單字的長度內——for j in range(max(0, i - L), i)——正是讓它在 LC 139 的限制條件下成為兩者中最快的原因(§2-9)。
2-4) Order C——字典在外,索引在內 ❌
這就是看起來像完全背包的那一個,而它是錯的:
# python
# LC 139 - Word Break -- ❌ WRONG, do not write this
# IDEA: item-outer, copied from the coin-change shape. One pass per word.
for word in wordDict: # ❌ dictionary OUTSIDE
w = len(word)
for i in range(w, n + 1):
if dp[i - w] and s[i - w:i] == word:
dp[i] = True
它甚至能通過 Example 1("leetcode"),所以才能在快速測試中存活下來。
它實際算的是什麼:dp[i] 為真,若且唯若 s[:i] 能切成字典索引非遞減的單字。等到單字 2 這一輪產生單字 1 所需要的格子時,單字 1 那一輪早就結束了——相依關係 dp[i] ← dp[i - len(w)] 就索引而言是滿足的,但 dp[i - len(w)] 裡的值還不是最終值:後面的輪次還會改變它。
這就是 §0-3 的失敗模式 1,也是本 repo 的解法要這樣安排巢狀迴圈的全部理由。
2-5) 它撐不過的案例——"applepenapple"
LC 139 自己的 Example 2。答案是 True(apple | pen | apple):
s = "applepenapple" (n = 13), dict = ["apple", "pen"]
pass 1 — word = "apple" (len 5)
i=5 dp[0]=True, s[0:5] == "apple" -> dp[5] = True
i=10 dp[5]=True, s[5:10] == "penap" ✗
i=13 dp[8]=False ✗ ← dp[8] does not exist YET
state: dp true at {0, 5}
pass 2 — word = "pen" (len 3)
i=8 dp[5]=True, s[5:8] == "pen" -> dp[8] = True ← too late for pass 1
i=13 dp[10]=False ✗
state: dp true at {0, 5, 8}
dp[13] = False ❌ (correct answer: True)
"apple"在"pen"的前後都需要,而對字典的單次掃描只能在一個時間點使用一個單字。
而且任何重排都救不了它——["pen", "apple"] 一樣失敗——這是 §1-4 的檢驗直接不通過,而不是 §1-5 的洗牌檢驗運氣不好:
| 輸入 | Order A / B | Order C,字典 [apple, pen] |
Order C,字典 [pen, apple] |
|---|---|---|---|
"leetcode", [leet, code] |
True ✅ | True ✅ | True ✅ |
"codeleet", [leet, code] |
True ✅ | False ❌ | True ✅ |
"applepenapple", [apple, pen] |
True ✅ | False ❌ | False ❌ |
"catsandog", [cats, dog, sand, and, cat] |
False ✅ | False ✅ | False ✅ |
2-6) 挽救 Order C——迭代到不動點
Order C 其實是可達性閉包的一輪鬆弛。重複執行直到不再變化,它就會變成正確的——這是 Bellman–Ford 的技巧:
# python
# LC 139 - Word Break -- item-outer made correct by iterating to a fixpoint
# IDEA: each round can only add True cells; stop when a round adds none
# time = O(rounds * n * k * L), rounds <= n / min_word_len + 1, space = O(n)
changed = True
while changed:
changed = False
for word in wordDict:
w = len(word)
for i in range(w, n + 1):
if not dp[i] and dp[i - w] and s[i - w:i] == word:
dp[i] = True
changed = True
"applepenapple" 需要 3 輪(兩輪有進展、一輪證明已經完成)——而 Order A 只要一次掃描。正確、但嚴格更慢,值得知道的唯一理由是它點出了 Order C 缺少的東西:一次掃描不是閉包。
2-7) Order D——在邊界上做 BFS:讓佇列決定順序
# python
# LC 139 - Word Break
# IDEA: same DAG, but the topological order is discovered at run time instead of hard-coded
# time = O(n^2) (or O(n * k * L) scanning words), space = O(n)
from collections import deque
q = deque([0])
visited = {0}
while q:
idx = q.popleft()
if idx == n:
return True
for word in wordDict:
end = idx + len(word)
if end <= n and s[idx:end] == word and end not in visited:
visited.add(end)
q.append(end)
return False
idx 就是 dp[i] 所索引的那個邊界——「已切分」與「尚未檢視」之間的分界線,所以它能合法地到達 n。BFS 不可能有迴圈順序 bug,因為它從不讀取格子:它只會從一個已證明可達的邊界往前推進。這裡的 visited 不是最佳化,而是防止佇列以指數方式重複展開同一個邊界的關鍵。
2-8) Order E——自頂向下的記憶化:完全沒有迴圈順序
# python
# LC 139 - Word Break
# IDEA: recursion discovers the dependency order itself; memo makes it O(n^2)
# time = O(n^2 * L), space = O(n) memo + O(n) stack
from functools import lru_cache
@lru_cache(None)
def can(start):
if start == n:
return True
return any(s.startswith(word, start) and can(start + len(word)) for word in wordDict)
return can(0)
上面沒有任何東西能被排錯順序——這正是 §0-4 的診斷法:當表格化的寫法與預期不符、而你想知道兩者到底誰錯時,就該拿出這個版本。
2-9) 該寫哪一個,以及各自的代價
在 LC 139 的限制條件下——n ≤ 300、k ≤ 1000、L ≤ 20:
| 順序 | 巢狀 | 正確? | 複雜度 | 此處最壞情況 | 何時寫它 |
|---|---|---|---|---|---|
| A | 索引 → 單字 | ✅ | O(n·k·L) | 約 6 × 10⁶ 次字元比較 | 字典相對於 s 很小 |
| B | 索引 → 切點 | ✅ | O(n²) 個切點 × O(L) 雜湊 | 約 1.8 × 10⁶ | 預設答案 |
| B-capped | 索引 → 切點,j ≥ i - L |
✅ | O(n·L²) | 約 1.2 × 10⁵ | 字典很大;此處最快 |
| C | 單字 → 索引 | ❌ | — | — | 永遠不要 |
| C-fixpoint | 單字 → 索引,重複執行 | ✅ | O(rounds·n·k·L) | rounds ≤ n / min_word_len + 1 = 301 | 對這題永遠不要 |
| D | BFS | ✅ | O(n²) | 約 1.8 × 10⁶ | 你覺得用可達性思考比較容易 |
| E | 記憶化 | ✅ | O(n²·L) | — | 你想驗證遞迴式,或自頂向下比較自然 |
面試回答:寫 B,提到
L上限作為最佳化,並且大聲說出*「字典迴圈必須放在內層,因為單字被固定在s的位置上——這不是 coin change。」*這句話才是訊號;程式碼不是。
3) 同一個問題放到另外四道題上
3-1) LC 279 Perfect Squares——item 在外確實合法的形狀 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
骨架與 LC 139 完全相同:前綴狀態、一份 item 選單、dp[i] ← dp[i - size]。只是現在 items 是相加的,所以 §1-4 的檢驗通過,兩種巢狀都正確:
# python
# LC 279 - Perfect Squares
# IDEA: unbounded knapsack (min). Squares are summed, so they re-order freely -> both nestings work
# time = O(n * sqrt(n)), space = O(n)
sq = [k * k for k in range(1, int(n ** 0.5) + 1)]
dp = [0] + [float("inf")] * n
for val in sq: # item-outer -- legal here, illegal in LC 139
for j in range(val, n + 1):
dp[j] = min(dp[j], dp[j - val] + 1)
return dp[n]
交換兩個迴圈,每個答案都不變(已對 n = 1..399 驗證)。一行說明理由:
LC 279 12 = 4 + 4 + 4 -- the multiset can be walked in ANY order, so a single pass per square suffices
LC 139 "applepenapple" -- the words must appear in the order s says, so a single pass per word does not
§3-2 的最小值 vs 計數之分在這裡同樣適用:LC 279 是求最小值,所以連計數上的微妙之處都無關緊要。
3-2) LC 518 vs LC 377——順序改變的是問題,不是正確性 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
兩者都是合法的 DP。它們回答不同的問題,而巢狀迴圈是唯一的差別:
# python
# LC 518 - Coin Change II -- COMBINATIONS: coins outer
for coin in coins:
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
# python
# LC 377 - Combination Sum IV -- PERMUTATIONS: amount outer
for i in range(1, target + 1):
for num in nums:
if i >= num:
dp[i] += dp[i - num]
coins = [1, 2], amount = 3
coin-outer -> 2 : {1,1,1}, {1,2} each multiset counted ONCE
amount-outer -> 3 : {1,1,1}, {1,2}, {2,1} every ORDERING counted
item 在外的限制——「items 照迴圈的順序」——在這裡不是 bug,而是特性:正是它把 {1,2} 與 {2,1} 合併成一個。LC 322(求最小值)對兩者都無所謂。完整追蹤與決策樹:knapsack.md 與 dp_advanced.md。
不要把這個教訓帶到 LC 139。這裡 item 在外是刻意少算;在那裡則是意外答錯。
3-3) LC 416——問題出在方向,而不是巢狀 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
# python
# LC 416 - Partition Equal Subset Sum
# IDEA: 0/1 knapsack -- each number once, so the capacity loop must NOT see its own writes
# time = O(n * target), space = O(target)
for num in nums:
for s_ in range(target, num - 1, -1): # ❗ descending
dp[s_] = dp[s_] or dp[s_ - num]
這裡 item 在外沒問題(數字是加總的)——陷阱在內層的方向。若用正序,這一輪會讀到剛寫入的格子而重複使用 num,把 0/1 背包變成完全背包。這是失敗模式 2,也是 LC 139 的鏡像:相同的巢狀,不同的軸。
3-4) Floyd–Warshall——必須在最外層的 item 在外 優先度 3/5 — 值得會 — 多半是必備模式的變形
# python
# IDEA: k = "may paths route through vertex k?" -- an ITEM loop, and it belongs outside
# time = O(V^3), space = O(V^2)
for k in range(V): # ❗ intermediate vertex -- OUTERMOST
for i in range(V):
for j in range(V):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
3-D 遞迴式是 dp[k][i][j] = min(dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j]);把 k 這個維度壓掉,只有在 k 是外層掃描時才成立。把 k 放到最內層,程式碼照樣能跑、照樣會結束,但算出來的不是全點對最短路徑。
它之所以合法,是因為 §1-4 的理由:一條路徑上的中繼點集合總是能依索引重新排列。細節見 Floyd-Warshall.md。
3-5) 並列比較
| 題目 | Items | 組合方式 | 合法的巢狀 | 內層方向 |
|---|---|---|---|---|
| LC 139 Word Break | 單字 | 串接,位置固定 | 只能狀態在外 | 正序 |
| LC 279 Perfect Squares | 平方數 | 加總(最小值) | 皆可 | 正序 |
| LC 322 Coin Change | 硬幣 | 加總(最小值) | 皆可 | 正序 |
| LC 518 Coin Change II | 硬幣 | 加總(計數多重集合) | item 在外——它定義了答案 | 正序 |
| LC 377 Combination Sum IV | 數字 | 加總(計數序列) | 狀態在外——它定義了答案 | 正序 |
| LC 416 Subset Sum | 數字 | 加總,每個一次 | 皆可 | 倒序 |
| Floyd–Warshall | 頂點 | 路徑串接,可重排 | item 在外,且在最外層 | 皆可 |
4) 除錯疑似的迴圈順序 bug
1. Shuffle the item list, re-run. answer moved -> item loop is wrongly outside (§1-5)
2. Write the memoised version (§0-4). they disagree -> the LOOP is wrong, not the recurrence
3. Print dp[] after every outer iteration. a cell flips True LATE -> something read it too early
4. Re-run the item-outer loop to a fixpoint. answer changes -> one pass was not a closure (§2-6)
5. Ask §1-4 out loud: "can a solution's items be re-ordered into my loop's order?"
第 1 步與第 4 步各只要十行,完全不需要推理就能下定論——在再次盯著轉移式之前,值得先跑一跑。
5) 常見錯誤
- ❌ **把 coin change 的巢狀搬到 Word Break。**這是這個 bug 最常見的形式,也是 §2 存在的理由。硬幣是相加的;單字是在
s已經固定的位置上串接的。 - ❌ 只測 Example 1。
"leetcode"在錯誤的巢狀下也能通過。"applepenapple"——同一份題目敘述裡的下一個範例——就不行。 - ❌ **讓
set的迭代順序影響答案。**如果洗牌會改變結果,就是巢狀錯了;set只是把你依賴的順序藏了起來。 - ❌ **因為有人對 LC 322 這麼說過,就假設「兩種順序都行」。**這對 LC 322 成立,因為它是對加總求最小值。對 LC 518/377 不成立(答案不同),對 LC 139 也不成立(其中一個是錯的)。
- ❌ 用翻轉內層方向來修巢狀 bug(或反過來)。巢狀與方向是互相獨立的兩個軸——§1-3 與 §1-2——而錯誤的診斷通常只是把一個正確答案換成另一個錯誤答案。
- ❌ **在相依圖有環時還想找迴圈順序。**拓撲順序根本不存在;那種題目要的是 BFS/Dijkstra/Bellman–Ford。
6) 中文速記
核心原則:計算
dp[i]時,它所依賴的更小狀態必須已經被計算完畢。
| 要問的問題 | 決定什麼 |
|---|---|
轉移式右邊讀了哪些 dp[...]? |
迴圈方向(正序 / 倒序) |
| 同一個 item 可不可以在這一輪被重複使用? | 內層方向:不可以 → 倒序(0/1);可以 → 正序(完全背包) |
| 解答中的 items 可不可以任意重排? | item 迴圈可不可以放外層 |
- 可以重排(硬幣、平方數:它們是「相加」)→ item 放外層合法。LC 322 / 279 兩種寫法答案相同;
LC 518 更是必須把 coin 放外層,才會把
{1,2}和{2,1}算成同一種。 - 不可以重排(LC 139 的單字:它們是「接起來」,而且位置早就被
s決定了)→ item 只能放內層。 把 word 放外層會得到「單字必須照字典順序出現」的錯誤答案:"applepenapple"會回傳False, 因為"apple"在"pen"的前後都要用到,但一輪只能用一次。
一句話:
for i: for word:正確,for word: for i:錯誤 —— 不是風格問題,是s已經把單字的 先後順序決定了,item 迴圈沒有權利再決定一次。
7) 總結
- 自底向上的 DP 就是一個你手寫的拓撲排序。每一條迴圈順序規則都只是同一個限制換了一頂帽子。
- 方向回答的是*「這一輪可以看到自己的寫入嗎?」*——0/1 用倒序,完全背包用正序。
- 巢狀回答的是*「item 迴圈可以決定 items 被使用的順序嗎?」*——當 items 可交換(加總)時可以;當輸入已經固定了順序(字串)時不行。
- LC 139 是這個家族中唯一的硬性「不行」,值得把它記成一句話而不是一種形狀:硬幣是相加的,單字是串接的。
- 有疑慮時,就用記憶化。自頂向下不可能有這個 bug,而與它的結果不一致,一次執行就能定位 bug。