字串 DP(雙序列網格模式)
範圍 — 在一或兩個字串上做的 DP:
dp[m+1][n+1]的雙序列網格、以前綴為基礎(1-indexed)的表格設計,以及 LCS、編輯距離、交錯字串、萬用字元/括號比對的完整範例。 另見:dp.md — 編輯距離與 LCS 的模板本身;palindrome.md — 回文專屬的 DP;string_matching_kmp_rolling_hash.md — 非 DP 的子字串搜尋。
LeetCode 題目清單
概觀
關鍵性質
- 複雜度:完整表格是
O(m * n)時間與空間;壓成兩列之後空間降到O(min(m, n))。 - 核心想法:
dp[i][j]= 前綴s1[:i]與前綴s2[:j]的答案。這一族的每一題都是同一張 網格 — 變的只有「配到/沒配到」時的轉移。 - 什麼時候用:兩個字串(或一個字串加一個 pattern)逐位比較,問的是最長/最小成本/方法數/可不可行。
題型分類
| 分類 | 問的問題 | LC |
|---|---|---|
| 最長共同 | 兩者共用了多少? | 1143, 583, 712 |
| 轉換成本 | 從 s1 改成 s2 最便宜的編輯序列? |
72, 161 |
| 路徑計數 | s2 在 s1 裡出現幾種方式? |
115 |
| 可行性 | 這兩個能不能交錯/能不能配上這個 pattern? | 97, 10, 44 |
| 單字串萬用字元 | 帶 * 的括號字串是否合法? |
678 |
參考資料
- dp.md — 編輯距離(LC 72)與 LCS(LC 1143)的模板
- dp.md — 模板 2a — 表格 本身:轉移會讀到哪些格子、為什麼這裡照列掃是唯一合法的填表順序,以及把填好的表變成編輯腳本的 反向走法
- palindrome.md — 單字串的回文 DP 家族
模板與演算法
雙字串網格 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
「雙字串/雙序列網格」模式 🧩
這是字串問題裡最重要的 DP 模式之一。一旦你認出這個模式,一整類問題都會突然變好解。
核心結構:
- 開一個 2D 陣列
dp[m+1][n+1],其中:- 列(i):代表字串 A 的前綴(前 i 個字元)
- 行(j):代表字串 B 的前綴(前 j 個字元)
- 格子
dp[i][j]:存這兩個特定前綴的答案
在網格上移動(怎麼選擇走法):
把這張網格想成一個從 (0,0) 走到 (m,n) 的遊戲:
- 對角走(
dp[i-1][j-1]):你同時「用掉」或「配對」兩個字串各一個字元 - 往下走(
dp[i-1][j]):你「跳過」或「刪掉」字串 A 的一個字元 - 往右走(
dp[i][j-1]):你「跳過」或「插入」字串 B 的一個字元
模式對照表:
| 題目 | 目標 | 配到時(s1[i-1] == s2[j-1]) |
沒配到時 | 關鍵洞見 |
|---|---|---|---|---|
| LC 1143: LCS | 最長共同長度 | 1 + dp[i-1][j-1](對角 + 1) |
max(dp[i-1][j], dp[i][j-1]) |
配到就走對角,否則取「跳過任一字串」的較大值 |
| LC 97: Interleaving String | s3 能否由 s1+s2 交錯而成? | dp[i-1][j] || dp[i][j-1] |
false |
檢查能不能從任一字串取一個字元湊出來 |
| LC 115: Distinct Subsequences | 數出現次數 | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
可以用這次配對,也可以跳過 s 的字元 |
| LC 72: Edit Distance | 變成一樣的最少編輯次數 | dp[i-1][j-1](不花成本) |
1 + min(top, left, diagonal) |
配到就不用動,否則三種操作都試一遍 |
| LC 583: Delete Operation | 變成一樣的最少刪除次數 | dp[i-1][j-1] |
1 + min(dp[i-1][j], dp[i][j-1]) |
從任一字串刪 |
| LC 712: Min ASCII Delete Sum | 變成一樣的最小 ASCII 總和 | dp[i-1][j-1] |
min(dp[i-1][j] + s1[i], dp[i][j-1] + s2[j]) |
追蹤 ASCII 成本 |
「空字串」的基底情況模式 💡
這是雙字串 DP 裡最重要的一個模式:
dp[0][0]:兩個字串都空的狀態(通常是0或true)- 第一列
dp[0][j]:字串 A 是空的,只有字串 B 有字元 - 第一行
dp[i][0]:字串 B 是空的,只有字串 A 有字元
為什麼是 m+1 和 n+1?
- 那個
+1是留給「空字串」這個基底情況的位置 - 沒有它的話,處理第一個字元時
dp[i-1][j]這種轉移會直接爆掉 dp[i][j]代表用了字串 1 的前i個字元、字串 2 的前j個字元
通用模板:
// java
// IDEA: the shared skeleton of every two-sequence grid DP
// time = O(m * n), space = O(m * n)
public int stringDP(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// Step 1: Initialize base cases (empty string states)
dp[0][0] = 0; // Both strings empty
// Initialize first row (s1 is empty)
for (int j = 1; j <= n; j++) {
dp[0][j] = initValueForEmptyS1(j);
}
// Initialize first column (s2 is empty)
for (int i = 1; i <= m; i++) {
dp[i][0] = initValueForEmptyS2(i);
}
// Step 2: Fill the DP table
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// NOTE: Use i-1 and j-1 to access string characters
if (s1.charAt(i-1) == s2.charAt(j-1)) {
// Characters match
dp[i][j] = transitionOnMatch(dp, i, j);
} else {
// Characters don't match
dp[i][j] = transitionOnMismatch(dp, i, j);
}
}
}
return dp[m][n];
}
空間最佳化的秘密 ⚡
在每一個「雙字串」問題裡,你真正會看到的只有:
- 目前這一列(
dp[i][j]) - 上面那一列(
dp[i-1][j]) - 對角那格(
dp[i-1][j-1])
也就是說,你永遠可以把空間從 O(m×n) 降到 O(n),做法是:
- 用一個一維陣列存上一列
- 用一個變數存對角的值
- 一列一列處理時滾動更新
空間最佳化版 LCS 範例:
// java
// LC 1143 - Longest Common Subsequence
// IDEA: diagonal + 1 on a match, else the better of dropping one character
// time = O(m * n), space = O(m * n)
public int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[] prev = new int[n + 1];
for (int i = 1; i <= m; i++) {
int[] curr = new int[n + 1];
for (int j = 1; j <= n; j++) {
if (s1.charAt(i-1) == s2.charAt(j-1)) {
curr[j] = prev[j-1] + 1; // Diagonal
} else {
curr[j] = Math.max(prev[j], curr[j-1]); // Top or left
}
}
prev = curr; // Roll forward
}
return prev[n];
}
深入:以前綴為基礎的索引模式(LCS 與其變形) 🔍 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
這一小節專門講清楚 1-indexed DP 表格這個概念 — 它是字串 DP 寫對的關鍵。
為什麼要用 1-indexed 的 DP 表格? 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
在字串問題裡建 2D DP 表時,我們用 dp[m+1][n+1] 而不是 dp[m][n]。這看起來只是多背一個差一錯誤,其實它很優雅:
關鍵洞見:dp[i][j] 代表的是 string1 長度為 i 的前綴、string2 長度為 j 的前綴之間的答案。
✅ CORRECT: 1-indexed approach
dp[i][j] = answer for string1.substring(0, i) and string2.substring(0, j)
- Row index i ∈ [0, m] where m = string1.length()
- Col index j ∈ [0, n] where n = string2.length()
- Character comparison: string1.charAt(i-1) vs string2.charAt(j-1)
❌ WRONG: 0-indexed approach (will cause boundary issues)
- No room for "empty string" base case
- First iteration accesses negative indices
前綴的概念:為什麼取字元要用 dp[i-1] 和 dp[j-1]
Example: string1 = "abcde", string2 = "ace"
When i=3, j=2 (processing prefixes "abc" and "ac"):
- DP state represents: LCS("abc", "ac")
- We compare: string1.charAt(3-1) = 'c' with string2.charAt(2-1) = 'c'
- The characters at POSITION (i-1) and (j-1) are what define the LAST character of each prefix
Index Mapping:
i=0: prefix length 0 (empty string)
i=1: prefix length 1 (first 1 char) → access string1[0]
i=2: prefix length 2 (first 2 chars) → access string1[1]
i=3: prefix length 3 (first 3 chars) → access string1[2]
...
Therefore: when at dp[i][j], compare string1[i-1] with string2[j-1]
三向轉移的邏輯(用 LCS 當例子) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
// java
// IDEA: the match / mismatch branch, isolated
// Pattern: Two cases only
if (string1.charAt(i - 1) == string2.charAt(j - 1)) {
// CASE 1: Characters match → extend previous best result
// The matching characters contribute +1 to the LCS length
dp[i][j] = 1 + dp[i - 1][j - 1]; // Diagonal: both strings move forward
} else {
// CASE 2: Characters don't match → take best of skipping either string
// We have two choices:
// Option A: Skip current char from string1 → dp[i-1][j]
// Option B: Skip current char from string2 → dp[i][j-1]
// Take whichever gives the better result
dp[i][j] = Math.max(dp[i - 1][j], // Skip from string1
dp[i][j - 1]); // Skip from string2
}
為什麼這樣行得通:
- 對角(dp[i-1][j-1]):字元配到時,我們把兩個字元都「用掉」去組答案。取較短前綴的最佳結果,再加 1。
- 往下(dp[i-1][j]):字元沒配到時,跳過 string1 目前的字元,看看還能不能跟 string2 找到好的 LCS。
- 往右(dp[i][j-1]):或者反過來跳過 string2 的字元,看能不能跟 string1 找到好的 LCS。
完整的 LCS 網格範例
string1 = "abcde"
string2 = "ace"
DP Grid (showing values):
"" 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
How to read:
dp[4][2] = 2 means LCS("abcd", "ac") has length 2
dp[5][3] = 3 means LCS("abcde", "ace") has length 3 ✓
Key moments:
- dp[1][1]: Compare 'a' with 'a' → match! → dp[0][0] + 1 = 1
- dp[2][1]: Compare 'b' with 'a' → no match → max(dp[1][1], dp[2][0]) = 1
- dp[3][2]: Compare 'c' with 'c' → match! → dp[2][1] + 1 = 2
- dp[5][3]: Compare 'e' with 'e' → match! → dp[4][2] + 1 = 3
Java 實作寫法
// java
// LC 1143 - Longest Common Subsequence
// IDEA: same recurrence, written as the canonical 1-indexed table
// time = O(m * n), space = O(m * n)
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
// Create (m+1) × (n+1) table to handle "empty string" base case
int[][] dp = new int[m + 1][n + 1];
// dp[0][j] and dp[i][0] are already 0 (empty string has LCS of 0)
// Loop uses 1-based indices to represent prefix lengths
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// i represents prefix length in text1
// j represents prefix length in text2
// Compare character at position i-1 and j-1 (0-indexed)
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
// Characters match: extend the best previous result
dp[i][j] = 1 + dp[i - 1][j - 1];
} else {
// Characters don't match: choose best by skipping either string
dp[i][j] = Math.max(dp[i - 1][j], // Skip from text1
dp[i][j - 1]); // Skip from text2
}
}
}
return dp[m][n]; // Answer for full strings
}
什麼時候用這個模式 📋
出現以下情況時,就用 1-indexed、以前綴為基礎的 2D DP:
| 條件 | 例題 |
|---|---|
| 輸入是兩個字串/序列 | LC 1143 (LCS), LC 72 (Edit Distance) |
| 答案取決於逐字元比較前綴 | LC 583 (Delete Ops), LC 712 (Min ASCII Delete) |
| 三向轉移(配到/跳過 1/跳過 2)或雙向轉移 | LC 1143, 97, 115 |
| 需要把「空字串」當成基底情況處理 | 所有雙字串 DP 問題 |
用到這個模式的類似 LeetCode 題目
| 題目 | 目標 | 配到時 | 沒配到時 | 複雜度 |
|---|---|---|---|---|
| LC 1143: LCS | 最長共同子序列的長度 | 1 + dp[i-1][j-1] |
max(dp[i-1][j], dp[i][j-1]) |
O(m×n) |
| LC 72: Edit Distance | 轉換所需的最少操作數 | dp[i-1][j-1](不花成本) |
1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) |
O(m×n) |
| LC 583: Delete Operation | 變成一樣的最少刪除次數 | dp[i-1][j-1] |
1 + min(dp[i-1][j], dp[i][j-1]) |
O(m×n) |
| LC 97: Interleaving String | s3 能否由 s1+s2 交錯而成? | dp[i-1][j] || dp[i][j-1] |
false |
O(m×n) |
| LC 115: Distinct Subsequences | 數 s2 作為 s1 子序列的出現次數 |
dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
O(m×n) |
| LC 712: Min ASCII Delete Sum | 讓兩字串相等的最小成本 | dp[i-1][j-1] |
min(dp[i-1][j] + cost1, dp[i][j-1] + cost2) |
O(m×n) |
常見陷阱 ⚠️
- 直接用 0-indexed 的 DP → 會出現負索引存取,也沒有空字串的位置
- 比較
string[i]而不是string[i-1]→ 字元比較上的差一錯誤 - 忘了初始化第一列/第一行 → 有些題目需要特別的初始化
- 沒配到時的轉移寫錯 → 一定要對應你這題自己的邏輯
快速辨識檢查表 ✅
看到下面這些訊號,就用「雙字串網格」模式:
- [ ] 輸入是兩個字串/序列
- [ ] 需要比較兩個字串的字元
- [ ] 答案取決於前綴(s1 的前 i 個字元、s2 的前 j 個字元)
- [ ] 關鍵字:「共同」、「比對」、「轉換」、「交錯」、「子序列」
常見題目:
- LC 1143 (LCS) - 找最長共同子序列
- LC 72 (Edit Distance) - 轉換所需的最少編輯次數
- LC 97 (Interleaving String) - s3 能不能由交錯組出來?
- LC 115 (Distinct Subsequences) - 數出現次數
- LC 583 (Delete Operation) - 變成一樣的最少刪除次數
- LC 712 (Min ASCII Delete Sum) - 變成一樣的最小 ASCII 成本
- LC 10 (Regular Expression Matching) - 帶 * 和 . 的 pattern 比對
- LC 44 (Wildcard Matching) - 帶 * 和 ? 的 pattern 比對
經典字串 DP 模式(詳解) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
| 題型 | 模式 | 複雜度 | 備註 |
|---|---|---|---|
| Edit Distance | dp[i][j] = 把 s1[:i] 變成 s2[:j] 需要的操作數 | O(m×n) | 插入/刪除/取代 |
| LCS | dp[i][j] = s1[:i] 與 s2[:j] 的最長共同子序列長度 | O(m×n) | 兩個序列;相等時走對角線 |
| LIS | dp[i] = 以 i 結尾的最長遞增子序列 | O(n²) | 單一序列 —— 不是這個格子;用 patience sorting 可到 O(n log n) |
| Palindrome | dp[i][j] = s[i:j+1] 是不是回文 | O(n²) | 從中心往外擴 |
| Word Break | dp[i] = s[:i] 能不能切開 | O(n²) 個切點,算進切片是 O(n³) | 單一字串、一維 —— 見 Template 1b |
LC 範例
交錯字串模式(LC 97) 🧩
模式:雙字串網格 DP(布林)
核心想法:給三個字串 s1、s2、s3,判斷 s3 是不是由 s1 和 s2 在保持各自相對順序的前提下交錯而成。可以把它想成在 2D 網格裡從 (0,0) 走到 (m,n):往下走代表從 s1 取一個字元,往右走代表從 s2 取一個字元。
DP 定義:
dp[i][j]=s1[0..i-1]和s2[0..j-1]能不能組出s3[0..i+j-1]?
關鍵遞迴式:
dp[i][j] = (dp[i-1][j] && s1[i-1] == s3[i+j-1]) // take from s1
|| (dp[i][j-1] && s2[j-1] == s3[i+j-1]) // take from s2
基底情況:
dp[0][0] = true(空 + 空 = 空)- 第一行:
dp[i][0] = dp[i-1][0] && s1[i-1] == s3[i-1](只有 s1 有貢獻) - 第一列:
dp[0][j] = dp[0][j-1] && s2[j-1] == s3[j-1](只有 s2 有貢獻)
提早結束:如果 len(s1) + len(s2) != len(s3),直接回傳 false。
空間最佳化:因為每一列只依賴目前列和上一列,可以壓成一維的 dp[n+1]。
| 做法 | 時間 | 空間 |
|---|---|---|
| 2D DP | O(m×n) | O(m×n) |
| 1D DP(空間最佳化) | O(m×n) | O(min(m,n)) |
| 由上而下記憶化 | O(m×n) | O(m×n) |
| 暴力遞迴 | O(2^(m+n)) | O(m+n) |
類似的 LeetCode 題目:
| 題目 | 相似之處 | 主要差別 |
|---|---|---|
| LC 1143 (LCS) | 雙字串網格,比較前綴 | 求最大長度,不是布林判斷 |
| LC 72 (Edit Distance) | 雙字串網格,三種轉移 | 求最小成本,不是可行性 |
| LC 115 (Distinct Subsequences) | 雙字串網格,數路徑 | 數方法數,不是回答是/否 |
| LC 583 (Delete Operation for Two Strings) | 雙字串網格 | 求最少刪除次數 |
| LC 44 (Wildcard Matching) | 雙字串布林 DP 網格 | 帶 * 和 ? 的 pattern 比對 |
| LC 10 (Regular Expression Matching) | 雙字串布林 DP 網格 | 帶 * 和 . 的 pattern 比對 |
參考:2D DP、1D DP、由上而下與由下而上的實作,見 leetcode_java/src/main/java/LeetCodeJava/DynamicProgramming/InterleavingString.java。
合法括號字串模式(LC 678) 🌟
題目:給一個由 ‘(’、‘)’ 和 ‘’ 組成的字串,其中 '’ 可以當成 ‘(’、‘)’ 或空字串,判斷這個字串是否合法。
這題示範了多種 DP 典範,很適合用來理解:
- 帶萬用字元的狀態追蹤
- 貪婪 vs DP 的取捨
- 區間 DP 模式
- 空間最佳化技巧
做法 1:貪婪(追蹤最小/最大平衡值) ⚡ 最佳解 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
時間:O(n) | 空間:O(1)
關鍵洞見:在每個位置追蹤未配對左括號數量的可能範圍。
// java
// LC 678 - Valid Parenthesis String
// IDEA: greedy — carry the min/max possible open count instead of a DP table
// time = O(n), space = O(1)
public boolean checkValidString(String s) {
int minParenCnt = 0; // minimum possible unmatched '('
int maxParenCnt = 0; // maximum possible unmatched '('
for (char c : s.toCharArray()) {
if (c == '(') {
minParenCnt++;
maxParenCnt++;
} else if (c == ')') {
minParenCnt--;
maxParenCnt--;
} else { // '*' - wildcard
minParenCnt--; // treat '*' as ')'
maxParenCnt++; // treat '*' as '('
}
// If maxParenCnt < 0: too many unmatched ')'
if (maxParenCnt < 0) return false;
// If minParenCnt < 0: reset to 0 (can use '*' as empty)
if (minParenCnt < 0) minParenCnt = 0;
}
// Valid if we can have 0 unmatched '('
return minParenCnt == 0;
}
為什麼行得通:
maxParenCnt < 0→ 不可能平衡了(‘)’ 太多)minParenCnt = 0→ 可以把 ‘*’ 當成空字串,重置掉負的平衡值- 最後
minParenCnt == 0→ 至少存在一種合法的配對方式
做法 2:2D DP(位置 × 未配對數) 📊
時間:O(n²) | 空間:O(n²)
DP 定義:
dp[i][j]:處理完前i個字元後,能不能剛好剩下j個未配對的 ‘(’?
// java
// LC 678 - Valid Parenthesis String
// IDEA: dp[i][open] = can s[i:] close with `open` brackets already outstanding
// time = O(n^2), space = O(n^2)
public boolean checkValidString(String s) {
int n = s.length();
boolean[][] dp = new boolean[n + 1][n + 1];
dp[0][0] = true; // empty string, 0 open parens
for (int i = 1; i <= n; i++) {
char c = s.charAt(i - 1);
for (int j = 0; j <= n; j++) {
if (c == '(') {
// Add one open paren
if (j > 0) dp[i][j] = dp[i - 1][j - 1];
} else if (c == ')') {
// Close one open paren
if (j < n) dp[i][j] = dp[i - 1][j + 1];
} else { // '*'
// Option 1: treat '*' as empty
dp[i][j] = dp[i - 1][j];
// Option 2: treat '*' as '('
if (j > 0) dp[i][j] |= dp[i - 1][j - 1];
// Option 3: treat '*' as ')'
if (j < n) dp[i][j] |= dp[i - 1][j + 1];
}
}
}
return dp[n][0]; // n chars processed, 0 open parens
}
狀態轉移:
'(':dp[i][j] = dp[i-1][j-1](未配對數 +1)')':dp[i][j] = dp[i-1][j+1](未配對數 -1)'*':dp[i][j] = dp[i-1][j] || dp[i-1][j-1] || dp[i-1][j+1](三種都試)
做法 3:區間 DP(判斷區間是否合法) 🎯
時間:O(n³) | 空間:O(n²)
DP 定義:
dp[i][j]:子字串s[i..j]是否合法?
// java
// LC 678 - Valid Parenthesis String
// IDEA: interval DP — dp[i][j] = is s[i..j] a valid string on its own
// time = O(n^3), space = O(n^2)
public boolean checkValidString(String s) {
int n = s.length();
if (n == 0) return true;
boolean[][] dp = new boolean[n][n];
// Base case: single character valid only if '*'
for (int i = 0; i < n; i++) {
if (s.charAt(i) == '*') dp[i][i] = true;
}
// Fill table for increasing lengths
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
// Option A: s[i] and s[j] form a matching pair
if ((s.charAt(i) == '(' || s.charAt(i) == '*') &&
(s.charAt(j) == ')' || s.charAt(j) == '*')) {
if (len == 2 || dp[i + 1][j - 1]) {
dp[i][j] = true;
}
}
// Option B: Split at some point k
if (!dp[i][j]) {
for (int k = i; k < j; k++) {
if (dp[i][k] && dp[k + 1][j]) {
dp[i][j] = true;
break;
}
}
}
}
}
return dp[0][n - 1];
}
關鍵模式:這是經典的區間 DP,類似:
- LC 312 (Burst Balloons)
- LC 1039 (Minimum Score Triangulation)
- LC 1547 (Minimum Cost to Cut a Stick)
做法 4:由上而下 DP(遞迴 + 記憶化) 🔄
時間:O(n²) | 空間:O(n²) + 遞迴堆疊
// java
// LC 678 - Valid Parenthesis String
// IDEA: top-down recursion on (index, open) with memoisation
// time = O(n^2), space = O(n^2)
public boolean checkValidString(String s) {
int n = s.length();
Boolean[][] memo = new Boolean[n + 1][n + 1];
return dfs(0, 0, s, memo);
}
private boolean dfs(int i, int open, String s, Boolean[][] memo) {
// Too many closing parens
if (open < 0) return false;
// End of string: valid if all matched
if (i == s.length()) return open == 0;
// Memoization
if (memo[i][open] != null) return memo[i][open];
boolean result;
if (s.charAt(i) == '(') {
result = dfs(i + 1, open + 1, s, memo);
} else if (s.charAt(i) == ')') {
result = dfs(i + 1, open - 1, s, memo);
} else { // '*'
result = dfs(i + 1, open, s, memo) || // empty
dfs(i + 1, open + 1, s, memo) || // '('
dfs(i + 1, open - 1, s, memo); // ')'
}
memo[i][open] = result;
return result;
}
做法 5:由下而上 DP 📈
時間:O(n²) | 空間:O(n²)
// java
// LC 678 - Valid Parenthesis String
// IDEA: same states as approach 4, filled bottom-up
// time = O(n^2), space = O(n^2)
public boolean checkValidString(String s) {
int n = s.length();
boolean[][] dp = new boolean[n + 1][n + 1];
dp[n][0] = true; // base: end with 0 open parens
for (int i = n - 1; i >= 0; i--) {
for (int open = 0; open < n; open++) {
boolean res = false;
if (s.charAt(i) == '*') {
res |= dp[i + 1][open + 1]; // treat as '('
if (open > 0) res |= dp[i + 1][open - 1]; // treat as ')'
res |= dp[i + 1][open]; // treat as empty
} else {
if (s.charAt(i) == '(') {
res |= dp[i + 1][open + 1];
} else if (open > 0) {
res |= dp[i + 1][open - 1];
}
}
dp[i][open] = res;
}
}
return dp[0][0];
}
做法 6:空間最佳化 DP ⚡
時間:O(n²) | 空間:O(n)
// java
// LC 678 - Valid Parenthesis String
// IDEA: bottom-up rolled down to one row over `open`
// time = O(n^2), space = O(n)
public boolean checkValidString(String s) {
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true;
for (int i = n - 1; i >= 0; i--) {
boolean[] newDp = new boolean[n + 1];
for (int open = 0; open < n; open++) {
if (s.charAt(i) == '*') {
newDp[open] = dp[open + 1] ||
(open > 0 && dp[open - 1]) ||
dp[open];
} else if (s.charAt(i) == '(') {
newDp[open] = dp[open + 1];
} else if (open > 0) {
newDp[open] = dp[open - 1];
}
}
dp = newDp;
}
return dp[0];
}
空間最佳化技巧:滾動陣列 — 只保留目前列和上一列。
做法 7:用堆疊(兩個堆疊) 📚
時間:O(n) | 空間:O(n)
// java
// LC 678 - Valid Parenthesis String
// IDEA: two stacks — one of '(' indices, one of '*' indices; match leftovers by position
// time = O(n), space = O(n)
public boolean checkValidString(String s) {
Stack<Integer> leftStack = new Stack<>(); // indices of '('
Stack<Integer> starStack = new Stack<>(); // indices of '*'
// First pass: match ')' with '(' or '*'
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch == '(') {
leftStack.push(i);
} else if (ch == '*') {
starStack.push(i);
} else { // ')'
if (!leftStack.isEmpty()) {
leftStack.pop();
} else if (!starStack.isEmpty()) {
starStack.pop();
} else {
return false; // unmatched ')'
}
}
}
// Second pass: match remaining '(' with '*'
while (!leftStack.isEmpty() && !starStack.isEmpty()) {
// '*' must come after '(' to be valid
if (leftStack.pop() > starStack.pop()) {
return false;
}
}
return leftStack.isEmpty();
}
關鍵洞見:存索引,才能確保把 ‘*’ 當 ‘)’ 用時,它確實出現在 ‘(’ 之後。
各做法比較總表
| 做法 | 時間 | 空間 | 最適合 | 取捨 |
|---|---|---|---|---|
| 貪婪(min/max) | O(n) | O(1) | 上線用的程式碼 | 第一次看最難懂 |
| 2D DP(位置 × 數量) | O(n²) | O(n²) | 學狀態轉移 | 吃空間但直覺 |
| 區間 DP | O(n³) | O(n²) | 理解區間類問題 | 最慢,但把區間模式攤開來看 |
| 由上而下 DP | O(n²) | O(n²) | 習慣遞迴思考的人 | 有堆疊開銷 |
| 由下而上 DP | O(n²) | O(n²) | 想避開遞迴 | 要反過來想 |
| 空間最佳化 | O(n²) | O(n) | 記憶體吃緊時 | 實作比較複雜 |
| 用堆疊 | O(n) | O(n) | 靠索引追蹤的洞見 | 需要跑兩趟 |
重點整理 💡
- 貪婪是最佳解 — 能認出什麼時候貪婪成立,是這題的關鍵
- 萬用字元的處理:永遠要把所有可能性都考慮進去(‘(’、‘)’、空)
- 平衡值追蹤:很多括號題最後都化約成追蹤未配對左括號的數量
- 位置很重要:萬用字元可以代表不同東西時,它出現的位置就有意義(堆疊解法)
- 多種典範:同一題可以用區間 DP、狀態 DP、貪婪和堆疊解出來
相關題目
- LC 20 (Valid Parentheses) - 沒有 ‘*’ 的簡單版
- LC 32 (Longest Valid Parentheses) - 找最長的合法子字串;1D DP,
dp[i]= 以i結尾的最長合法區段 — 見 stack_examples.md §15 - LC 301 (Remove Invalid Parentheses) - 刪最少字元讓它合法
- LC 921 (Minimum Add to Make Parentheses Valid) - 最少需要補幾個
參考:leetcode_java/src/main/java/LeetCodeJava/String/ValidParenthesisString.java
計算「相異」子序列 — 上次出現位置的扣除(LC 940) 🔢
模式:計數每一個子序列,再精確扣掉之前已經算過的那些。扣除項永遠是「這個相同字元上一次出現之前一刻的狀態」。 這是上面雙字串網格的計數版 —— 一個字串、一個累計總數、不用表格。
核心想法
令 total 為目前看過的前綴中相異子序列的數量,包含空子序列。接上一個字元 ch:
every existing subsequence can be extended by ch -> `total` brand new candidates
but if ch appeared before, the `add` value it produced back then
generated exactly the same set again -> subtract it
add = total # candidates created by this ch
total = total + add - last[ch] # last[ch] = the `add` from ch's previous occurrence
last[ch] = add
在 "aba" 上追蹤 —— 注意答案不含空子序列,所以最後要減 1:
start total = 1 { "" }
'a' add=1 total = 1 + 1 - 0 = 2 { "", a }
'b' add=2 total = 2 + 2 - 0 = 4 { "", a, b, ab }
'a' add=4 total = 4 + 4 - 1 = 7 { "", a, b, ab, aa, ba, aba }
answer = 7 - 1 = 6
在 'a' 處的 - 1,正是重複的那個 "a"(單獨的第二個 a 重新造出了第一個)。
為什麼扣的是 last[ch] 而不是 last_total
這個 ch 產生的集合是 { s + ch : s in prefix_before_ch }。ch 的兩次出現所產生的集合,恰好在較早那次之前就存在的子序列上重疊
—— 而那個數量就是較早那次的 add。改存 total 會差掉兩次出現之間新增的數量,這是這裡最常見的單一 bug。
// java
// LC 940 - Distinct Subsequences II
// IDEA: running count of distinct subsequences incl. the empty one; each character adds
// `total` new ones and re-creates `last[ch]` old ones, so subtract those.
// time = O(n), space = O(1) (26 counters)
public int distinctSubseqII(String s) {
final int MOD = 1_000_000_007;
long[] last = new long[26]; // last[c] = the `add` produced by c's previous occurrence
long total = 1; // the empty subsequence
for (char ch : s.toCharArray()) {
long add = total;
// NOTE !!! + MOD before the second % — `total + add - last[c]` can go negative
total = (total + add - last[ch - 'a'] + MOD) % MOD;
last[ch - 'a'] = add;
}
return (int) ((total - 1 + MOD) % MOD); // drop the empty subsequence
}
# python
# LC 940 - Distinct Subsequences II
# IDEA: same running total; `last[ch]` remembers what this character contributed last time
# time = O(n), space = O(1)
def distinctSubseqII(s):
MOD = 10 ** 9 + 7
last = {}
total = 1 # counts the empty subsequence
for ch in s:
add = total # every current subsequence can be extended by ch
total = (total + add - last.get(ch, 0)) % MOD
last[ch] = add
return (total - 1) % MOD # exclude the empty subsequence
相關題目 — 計數變體
| 題目 | 與 LC 940 的差別 |
|---|---|
| LC 115 Distinct Subsequences | 計算 s 中等於 t 的子序列 —— 回到雙字串網格 |
| LC 1987 Number of Unique Good Subsequences | 二進位字串,外加前導零規則;同一個遞迴式配兩個計數器 |
| LC 730 Count Different Palindromic Subsequences | 同樣是「扣掉重複」的想法,但放在區間 DP 上,用第一次與最後一次出現 |
| 「計算相異子陣列」 | 不是同一題 —— 子陣列是連續的,所以是滑動視窗或後綴結構,絕不是這個遞迴式 |
重點:每當計數 DP 因為某個項目重複而多算,修正項幾乎從來不是常數 —— 而是同一個 DP 在該項目上一次出現時的值。
在累計總數旁邊維護一個 last[item] 對照表。
總結
| 題目在問什麼 | dp[i][j] 存什麼 | 配到時 | 沒配到時 |
|---|---|---|---|
| 最長的共同部分 | LCS 長度 | 1 + dp[i-1][j-1] |
max(dp[i-1][j], dp[i][j-1]) |
| 最便宜的改寫 | 編輯成本 | dp[i-1][j-1] |
1 + min(三個鄰居) |
| 有幾種嵌入方式 | 方法數 | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
| 到底可不可行 | 布林 | 所有合法走法取 OR | False |
避開大多數 bug 的三條規則
- 表格開成
dp[m+1][n+1],取字元時寫s1[i-1]/s2[j-1]— 第0列/第0行是空前綴, 有了它基底情況才寫得出來。 - 主迴圈之前先填好第
0列和第0行;它們編碼的是「跟空字串比對」。 - 答案是
dp[m][n],絕不是dp[m-1][n-1]。