Mở rộng từ 1D state lên 2D — hai chuỗi, knapsack 0/1, grid, interval. Bộ công cụ hoàn chỉnh cho mọi bài DP cấp Medium/Hard trên LeetCode.
1D DP đủ khi state chỉ phụ thuộc vào "ta đang ở đâu" (vị trí i). 2D cần thiết khi state có hai chiều độc lập:
| Loại bài | Hai chiều | Ví dụ |
|---|---|---|
| Hai chuỗi | Vị trí trên chuỗi 1, vị trí trên chuỗi 2 | LCS, Edit Distance |
| Knapsack 0/1 | Item đang xét, capacity còn lại | Partition Subset Sum |
| Grid | Hàng, cột | Unique Paths, Min Path Sum |
| Interval | Biên trái, biên phải | Burst Balloons, Matrix Chain |
| Game / Decision với state phụ | Vị trí, trạng thái thêm | Stock with Multiple Transactions |
Khi state cần "ghi nhớ" cả vị trí + một chiều khác → 2D. Khi không thể tóm gọn vào 1 số → 2D.
s1, s2 có độ dài m, n. State dp[i][j] đại diện cho
"câu trả lời cho bài con trên prefix s1[0..i-1] và prefix s2[0..j-1]".
Bảng dp có kích thước (m+1) × (n+1). Hàng/cột 0 đại diện cho "chuỗi rỗng" — đơn giản hoá base case.
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + something # ký tự khớp
else:
dp[i][j] = combine(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
Cụ thể "combine" tuỳ bài: max cho LCS, min cho Edit Distance, add cho Distinct Subsequences.
i tương ứng với s1 character s1[i-1] (1-indexed view của bảng dp = 0-indexed view của chuỗi).
Hình dung bảng dp giúp viết recurrence nhanh và đúng:
| "" | a | b | c | |
| "" | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 |
| c | 0 | 1 | 1 | 2 |
| e | 0 | 1 | 1 | 2 |
DP table cho LCS("ace", "abc") = 2
dp[i-1][j-1]: kết quả khi cả hai chuỗi giảm 1 ký tự cuối.dp[i-1][j]: chỉ s1 giảm 1 ký tự.dp[i][j-1]: chỉ s2 giảm 1 ký tự.Recurrence dùng ↖ khi ký tự khớp; dùng ↑/← khi không khớp. Nhìn vào bảng → dễ debug.
2D DP tốn O(m × n) space. Khi recurrence chỉ phụ thuộc vào hàng i-1 hoặc cell (i-1, j-1), có thể giảm xuống O(min(m, n)).
i-1 và hàng i, swap qua mỗi vòng. O(2n).# Two rows version cho LCS
def lcs(s1, s2):
m, n = len(s1), len(s2)
if m < n: s1, s2, m, n = s2, s1, n, m # đảm bảo n nhỏ
prev = [0] * (n + 1)
cur = [0] * (n + 1)
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
cur[j] = prev[j-1] + 1
else:
cur[j] = max(prev[j], cur[j-1])
prev, cur = cur, prev
return prev[n]
Time vẫn O(m × n); Space O(min(m, n)).
dp[i][j] = câu trả lời cho prefix s1[0..i-1] và prefix s2[0..j-1].
if s1[i-1] == s2[j-1]:
# KHỚP — kế thừa từ dp[i-1][j-1] (cả hai prefix giảm 1)
dp[i][j] = dp[i-1][j-1] + (1 nếu cần đếm)
else:
# KHÔNG KHỚP — phải "bỏ" 1 ký tự ở 1 trong 2 bên
dp[i][j] = combine(dp[i-1][j], dp[i][j-1])
# combine = max cho LCS, min cho Edit Distance, sum cho count
Đề (LeetCode #1143): Hai chuỗi text1, text2. Tìm độ dài chuỗi con chung dài nhất (subsequence, không cần liên tiếp).
Ví dụ: "abcde", "ace" → 3 ("ace").
def longestCommonSubsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
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]
Time O(m × n), Space O(m × n) (hoặc O(min(m,n)) với rolling).
dp[i-1][j-1] + 1.text1 = "abcde", text2 = "ace". Kỳ vọng: 3.
| "" | 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 |
Các cell bold là điểm "khớp" (text1[i-1] == text2[j-1])
Đáp án ở góc dưới phải = 3.
Đề: Hai chuỗi word1, word2. Tìm số phép biến đổi tối thiểu để biến word1 thành word2. Ba loại phép:
Ví dụ: "horse" → "ros" cần 3 phép (rorse → rose → ros).
dp[i][j] = số phép tối thiểu để biến word1[0..i-1] thành word2[0..j-1].
dp[i][0] = i (xoá i ký tự để rỗng).dp[0][j] = j (insert j ký tự).Khi word1[i-1] == word2[j-1]: ký tự khớp, không cần biến đổi → dp[i][j] = dp[i-1][j-1].
Khi không khớp, ba lựa chọ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
for j in range(n + 1): dp[0][j] = j
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]
else:
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
return dp[m][n]
Time O(m × n).
word1 = "horse", word2 = "ros". Kỳ vọng: 3.
| "" | 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 |
Đáp án ở góc dưới phải = 3
Đề (Hard): Hai chuỗi s, t. Đếm số cách t xuất hiện trong s dưới dạng subsequence.
Ví dụ: s = "rabbbit", t = "rabbit" → 3 (3 cách chọn các ký tự).
dp[i][j] = số cách t[0..j-1] xuất hiện trong s[0..i-1].
Khi s[i-1] == t[j-1]: hai lựa chọn — dùng s[i-1] hoặc bỏ qua nó:
Khi không khớp: phải bỏ qua s[i-1] → dp[i][j] = dp[i-1][j].
def numDistinct(s, t):
m, n = len(s), len(t)
if n > m: return 0
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1): dp[i][0] = 1 # 1 cách ghép chuỗi rỗng
for i in range(1, m + 1):
for j in range(1, n + 1):
dp[i][j] = dp[i-1][j]
if s[i-1] == t[j-1]:
dp[i][j] += dp[i-1][j-1]
return dp[m][n]
Đề: Cho string s. Tìm độ dài subsequence palindrome dài nhất.
Ví dụ: "bbbab" → 4 ("bbbb").
Subsequence palindrome của s = subsequence chung của s và reverse(s).
def longestPalindromeSubseq(s):
return longestCommonSubsequence(s, s[::-1])
Time O(n²).
State: dp[i][j] = độ dài LPS trong s[i..j]. Đây là Interval DP (xem Phần 5):
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n): dp[i][i] = 1
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
dp[i][j] = (dp[i+1][j-1] if length > 2 else 0) + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][n-1]
(m+1) × (n+1) chứ không phải m × n?dp[i][w] = value max khi xét i items đầu tiên với capacity w.
Take chỉ khả dĩ khi w ≥ w_i. Khi đảo ngược (unbounded), thay dp[i-1][w-w_i] bằng dp[i][w-w_i] để cho phép dùng lại.
Có thể giảm xuống dp[w] bằng cách lặp w từ phải sang trái (đảm bảo dp[w-w_i] vẫn là giá trị "trước khi xét item i"):
for i in range(n):
for w in range(W, weights[i] - 1, -1): # ngược lại
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
Đề (LeetCode #416): Mảng số dương nums. Trả True nếu có thể chia thành 2 subset có tổng bằng nhau.
Tổng = S. Nếu S lẻ → False. Nếu S chẵn → tìm subset có tổng = S/2.
State boolean: dp[w] = True nếu có subset tổng = w.
def canPartition(nums):
s = sum(nums)
if s % 2: return False
target = s // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for w in range(target, num - 1, -1): # 0/1 → ngược
dp[w] = dp[w] or dp[w - num]
return dp[target]
Time O(n × target), Space O(target).
dp[w - num] đã được cập nhật ở vòng lặp này → dùng item nhiều lần → unbounded. Loop ngược giữ dp[w - num] là giá trị "trước item này" → 0/1 đúng.
Đề (LeetCode #494): Mảng số dương nums, số target. Đặt + hoặc - trước mỗi số. Đếm số cách để tổng = target.
Đặt P = tập các số có dấu +, N = tập có dấu -. Khi đó:
Quy về: đếm số cách chọn subset có tổng = (S + target) / 2.
def findTargetSumWays(nums, target):
s = sum(nums)
if (s + target) % 2 or s < abs(target): return 0
new_target = (s + target) // 2
dp = [0] * (new_target + 1)
dp[0] = 1
for num in nums:
for w in range(new_target, num - 1, -1):
dp[w] += dp[w - num]
return dp[new_target]
Time O(n × target), Space O(target).
| Aspect | 0/1 Knapsack | Unbounded Knapsack |
|---|---|---|
| Dùng item nhiều lần? | Không (max 1) | Có |
| Loop direction trong 1D | Ngược (W → 0) | Xuôi (0 → W) |
| Ví dụ bài | Partition Subset Sum, Target Sum, Last Stone II | Coin Change, Word Break |
| Recurrence 2D | dp[i][w] dùng dp[i-1][w-w_i] | dp[i][w] dùng dp[i][w-w_i] |
Nếu kết quả vượt kỳ vọng (đếm trùng) → bạn đang vô tình unbounded. Check loop direction.
Nếu kết quả thấp hơn kỳ vọng → bạn đang vô tình 0/1 nhưng bài cho phép unbounded. Đảo loop direction.
dp[r][c] đại diện cho "câu trả lời cho subproblem trên submatrix top-left là (r, c)" hoặc "đến ô (r, c) như thế nào".
Recurrence thường:
$$ dp[r][c] = f(dp[r-1][c], dp[r][c-1], dp[r-1][c-1]) $$Tuỳ bài: tổng (Min Path Sum), đếm cách (Unique Paths), boolean (Dungeon Game).
Đề: Lưới m × n. Robot ở (0,0), cần đến (m-1, n-1). Mỗi bước chỉ phải hoặc xuống. Đếm số đường đi.
dp[r][c] = số đường từ (0,0) đến (r,c).
Recurrence: đến (r,c) chỉ có thể từ (r-1,c) hoặc (r,c-1):
$$ dp[r][c] = dp[r-1][c] + dp[r][c-1] $$def uniquePaths(m, n):
dp = [[1] * n for _ in range(m)] # hàng/cột 0 đều có 1 cách
for r in range(1, m):
for c in range(1, n):
dp[r][c] = dp[r-1][c] + dp[r][c-1]
return dp[m-1][n-1]
Time O(m × n), Space O(m × n) hoặc O(n) với rolling.
C(m+n-2, m-1). Time O(m+n) — nhưng DP rõ ràng hơn cho phỏng vấn.
Đề: Lưới grid[i][j] > 0. Tìm đường từ top-left đến bottom-right có tổng nhỏ nhất. Mỗi bước phải hoặc xuống.
def minPathSum(grid):
m, n = len(grid), len(grid[0])
# in-place modification
for r in range(m):
for c in range(n):
if r == 0 and c == 0: continue
elif r == 0: grid[r][c] += grid[r][c-1]
elif c == 0: grid[r][c] += grid[r-1][c]
else: grid[r][c] += min(grid[r-1][c], grid[r][c-1])
return grid[m-1][n-1]
Time O(m × n), Space O(1) (in-place).
| 1 | 4 | 5 |
| 2 | 7 | 6 |
| 6 | 8 | 7 |
Đường tối ưu: 1→3→1→1→1 = 7.
dp[i][j] = câu trả lời cho subproblem trên khoảng [i..j]. Recurrence thường lặp qua điểm "split" k bên trong khoảng:
$$ dp[i][j] = \min/\max_{i \leq k \leq j} \{ \text{combine}(dp[i][k], dp[k+1][j]) \} $$
Tính theo chiều dài khoảng tăng dần: dp[i][i] (size 1), dp[i][i+1] (size 2), …
for length in range(1, n + 1):
for i in range(n - length + 1):
j = i + length - 1
# tính dp[i][j] dựa trên dp con (length nhỏ hơn)
Time thường O(n³) (n² cells × n splits).
Đề: Mảng nums (giá trị bóng bay). Mỗi lần nổ bóng i được nums[i-1] × nums[i] × nums[i+1] điểm (với biên giả lập = 1). Tìm tổng điểm tối đa khi nổ hết.
Quan trọng: nghĩ "nổ ngược" — chọn bóng nổ cuối cùng trong khoảng [i, j].
Khi nổ k cuối cùng trong [i, j], hai biên là nums[i-1] và nums[j+1] (đã được "bảo vệ" trong các bài con):
$$ dp[i][j] = \max_{i \leq k \leq j} \{ dp[i][k-1] + dp[k+1][j] + \text{nums}[i-1] \cdot \text{nums}[k] \cdot \text{nums}[j+1] \} $$def maxCoins(nums):
nums = [1] + nums + [1] # padding biên
n = len(nums)
dp = [[0] * n for _ in range(n)]
for length in range(1, n - 1):
for i in range(1, n - length):
j = i + length - 1
for k in range(i, j + 1):
dp[i][j] = max(dp[i][j],
dp[i][k-1] + dp[k+1][j] + nums[i-1]*nums[k]*nums[j+1])
return dp[1][n-2]
Time O(n³).
Cách tự nhiên — "nổ xuôi" — nghĩ bóng nào nổ trước. Nhưng có vấn đề:
Khi k là bóng cuối trong [i, j], lúc đó hai bóng kế là nums[i-1] và nums[j+1] (vì mọi bóng trong [i,j] đã bị nổ).
Hai khoảng con [i, k-1] và [k+1, j] giải độc lập — không "ăn vào" nhau vì k còn nguyên.
Đề: Cho string s. Đếm số substring là palindrome.
dp[i][j] = True nếu s[i..j] là palindrome.
Recurrence: s[i..j] palindrome ⟺ s[i] == s[j] và s[i+1..j-1] palindrome (hoặc length ≤ 2):
def countSubstrings(s):
n = len(s)
dp = [[False] * n for _ in range(n)]
count = 0
for i in range(n):
dp[i][i] = True
count += 1 # single char palindromes
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
if length == 2 or dp[i+1][j-1]:
dp[i][j] = True
count += 1
return count
Time O(n²), Space O(n²).
| Tín hiệu trong đề | Pattern |
|---|---|
| "hai chuỗi": LCS, edit, transform | Two-string DP với dp[i][j] |
| "đếm số cách" | Two-string DP cộng (Distinct Subsequences) |
| "palindrome" | LCS với reverse, hoặc Interval DP |
| "chia subset bằng nhau", "target sum với ±" | 0/1 Knapsack với boolean/count dp |
| "unique paths / min path sum trên grid" | Grid DP với hướng phải/xuống |
| "interval [i, j], split point" | Interval DP O(n³) |
| "burst balloons / merge stones" | Interval DP với "thao tác ngược" |
| "matrix chain multiplication" | Interval DP cổ điển |
| "stock with K transactions" | State machine 2D — dp[k][i] |
Bit Manipulation, Trie và Mock Interview
Tuần cuối cùng. Học các kỹ thuật bitwise (XOR, bitmask DP), cấu trúc Trie cho bài về string nâng cao, và đặc biệt là quy trình mock interview chuẩn — phân tích đề, viết pseudocode, code, debug, complexity. Tổng hợp lại 13 tuần và chuẩn bị tâm thế phỏng vấn thực tế.
Nhấn T hoặc Esc để đóng