DP 1D — Climbing Stairs, House Robber, Kadane, Coin Change
Bốn bài DP 1D kinh điển dạy pattern cốt lõi: dp[i] phụ thuộc vài dp trước. Trace bảng dp từng bước, phân tích transition, và tối ưu space từ O(n) xuống O(1).
TL;DR: Bốn bài DP một chiều — climbing stairs, house robber, max subarray (Kadane), coin change — đều có cấu trúc chung: dp[i] là answer cho subproblem kết thúc tại hoặc liên quan đến vị trí i, và được tính từ một vài dp[j] trước đó. Transition đơn giản (2-3 dòng) nhưng cần xác định đúng ý nghĩa state. Climbing stairs và house robber tối ưu được xuống O(1) space; coin change không thể vì cần xét nhiều state trước. Mastery 4 pattern này là nền tảng để tackle bất kỳ bài DP 1D nào khác.
Bạn đã biết framework 5 bước từ bài trước — state, transition, base case, thứ tự, tối ưu space. Bài này áp dụng framework đó vào 4 bài cụ thể, trace bảng dp từng bước để thấy rõ cơ chế.
1. Pattern — dp[i] phụ thuộc vài dp trước
Mọi DP 1D đều có transition dạng:
dp[i] <- f(dp[i-1], dp[i-2], ..., dp[i-k])
Điều khác nhau giữa các bài: ý nghĩa của state và hàm f.
| Bài | State dp[i] nghĩa là | Transition |
|---|---|---|
| Climbing stairs | Số cách lên đến bậc i | dp[i-1] + dp[i-2] |
| House robber | Tiền tối đa khi xét đến nhà i | max(dp[i-1], dp[i-2] + val[i]) |
| Max subarray | Subarray lớn nhất kết thúc tại i | max(val[i], dp[i-1] + val[i]) |
| Coin change | Số xu ít nhất để đạt tổng i | min(dp[i - coin] + 1) cho mọi coin |
Trace bảng dp = kiểm tra transition đúng không. Mỗi khi viết DP mới, điền bảng tay cho input nhỏ (n=5, n=6) trước khi code. Nếu transition đúng, bảng sẽ khớp với brute force.
2. Climbing Stairs
2.1 Bài toán
Bạn cần lên bậc thang thứ n. Mỗi lần có thể leo 1 bậc hoặc 2 bậc. Hỏi có bao nhiêu cách khác nhau để lên đến bậc n?
Ví dụ: n=4 → 5 cách: (1,1,1,1), (1,1,2), (1,2,1), (2,1,1), (2,2).
2.2 State và transition
State: dp[i] = số cách leo lên đến bậc i.
Transition: để đến bậc i, bước cuối phải là từ bậc i-1 (leo 1 bậc) hoặc từ bậc i-2 (leo 2 bậc). Hai trường hợp này không giao nhau → cộng lại:
dp[i] <- dp[i-1] + dp[i-2]
Base case: dp[0] = 1 (1 cách đứng ở mặt đất — không bước), dp[1] = 1 (1 cách leo lên bậc 1).
2.3 Pseudocode và trace
climbing_stairs(n):
if n <= 1: return 1
dp[0] <- 1
dp[1] <- 1
for i <- 2 đến n:
dp[i] <- dp[i-1] + dp[i-2]
return dp[n]
Time: O(n) Space: O(n), tối ưu xuống O(1)
Trace n=5:
| i | dp[i] | Từ đâu |
|---|---|---|
| 0 | 1 | base case |
| 1 | 1 | base case |
| 2 | 2 | dp[1]+dp[0] = 1+1 |
| 3 | 3 | dp[2]+dp[1] = 2+1 |
| 4 | 5 | dp[3]+dp[2] = 3+2 |
| 5 | 8 | dp[4]+dp[3] = 5+3 |
Kết quả: 8 cách leo lên bậc 5. Đây đúng là dãy Fibonacci (shifted) — climbing stairs và Fibonacci có cùng transition.
2.4 Tối ưu space O(1)
dp[i] chỉ cần dp[i-1] và dp[i-2]:
climbing_stairs_opt(n):
if n <= 1: return 1
prev2 <- 1 -- dp[0]
prev1 <- 1 -- dp[1]
for i <- 2 đến n:
cur <- prev1 + prev2
prev2 <- prev1
prev1 <- cur
return prev1
Time: O(n) Space: O(1)
3. House Robber
3.1 Bài toán
Dãy nhà val[0..n-1], mỗi nhà có số tiền. Không thể trộm hai nhà liền kề (hàng xóm sẽ báo cảnh sát). Tìm số tiền tối đa có thể lấy.
Ví dụ: val = [2, 7, 9, 3, 1] → answer 12 (trộm nhà 0, 2, 4: 2+9+1=12).
3.2 State và transition
State: dp[i] = số tiền tối đa có thể lấy khi xét đến nhà i (bao gồm hoặc bỏ qua nhà i).
Transition: tại nhà i, có hai lựa chọn:
- Bỏ qua nhà i: lấy kết quả tốt nhất đến nhà i-1:
dp[i-1]. - Trộm nhà i: không thể trộm nhà i-1, lấy kết quả đến nhà i-2 cộng thêm
val[i]:dp[i-2] + val[i].
dp[i] <- max(dp[i-1], dp[i-2] + val[i])
Base case: dp[0] = val[0], dp[1] = max(val[0], val[1]).
3.3 Pseudocode và trace
house_robber(val, n):
if n = 1: return val[0]
dp[0] <- val[0]
dp[1] <- max(val[0], val[1])
for i <- 2 đến n-1:
dp[i] <- max(dp[i-1], dp[i-2] + val[i])
return dp[n-1]
Time: O(n) Space: O(n), tối ưu xuống O(1)
Trace val = [2, 7, 9, 3, 1]:
| i | val[i] | dp[i-2]+val[i] | dp[i-1] | dp[i] |
|---|---|---|---|---|
| 0 | 2 | — | — | 2 (base) |
| 1 | 7 | — | — | 7 (base: max(2,7)) |
| 2 | 9 | 2+9=11 | 7 | 11 |
| 3 | 3 | 7+3=10 | 11 | 11 |
| 4 | 1 | 11+1=12 | 11 | 12 |
Answer: 12 — trộm nhà 0 (2) + nhà 2 (9) + nhà 4 (1) = 12.
3.4 Tối ưu space O(1)
house_robber_opt(val, n):
if n = 1: return val[0]
prev2 <- val[0]
prev1 <- max(val[0], val[1])
for i <- 2 đến n-1:
cur <- max(prev1, prev2 + val[i])
prev2 <- prev1
prev1 <- cur
return prev1
Time: O(n) Space: O(1)
4. Maximum Subarray — Kadane's Algorithm
4.1 Bài toán
Cho mảng A[0..n-1] có thể có số âm. Tìm subarray liên tiếp có tổng lớn nhất.
Ví dụ: A = [-2, 1, -3, 4, -1, 2, 1, -5, 4] → answer 6 (subarray [4, -1, 2, 1]).
4.2 State và transition — Kadane
State: dp[i] = tổng lớn nhất của subarray kết thúc tại vị trí i (subarray phải include A[i]).
Transition: tại vị trí i, có hai lựa chọn:
- Bắt đầu subarray mới từ A[i]: chỉ lấy
A[i]. - Kéo dài subarray kết thúc tại i-1:
dp[i-1] + A[i].
Nếu dp[i-1] âm, cộng thêm nó sẽ làm giảm tổng — tốt hơn là bắt đầu lại từ A[i].
dp[i] <- max(A[i], dp[i-1] + A[i])
Answer là max(dp[0], dp[1], ..., dp[n-1]) — chú ý không phải dp[n-1].
Base case: dp[0] = A[0].
4.3 Pseudocode và trace
kadane(A, n):
dp[0] <- A[0]
best <- A[0]
for i <- 1 đến n-1:
dp[i] <- max(A[i], dp[i-1] + A[i])
best <- max(best, dp[i])
return best
Time: O(n) Space: O(n), tối ưu xuống O(1)
Trace A = [-2, 1, -3, 4, -1, 2, 1, -5, 4]:
| i | A[i] | dp[i-1]+A[i] | dp[i] | best |
|---|---|---|---|---|
| 0 | -2 | — | -2 | -2 |
| 1 | 1 | -2+1=-1 | max(1,-1)=1 | 1 |
| 2 | -3 | 1+(-3)=-2 | max(-3,-2)=-2 | 1 |
| 3 | 4 | -2+4=2 | max(4,2)=4 | 4 |
| 4 | -1 | 4+(-1)=3 | max(-1,3)=3 | 4 |
| 5 | 2 | 3+2=5 | max(2,5)=5 | 5 |
| 6 | 1 | 5+1=6 | max(1,6)=6 | 6 |
| 7 | -5 | 6+(-5)=1 | max(-5,1)=1 | 6 |
| 8 | 4 | 1+4=5 | max(4,5)=5 | 6 |
Answer: 6 — subarray [4, -1, 2, 1] từ index 3 đến 6.
Tại sao Kadane hoạt động? Khi dp[i-1] âm, cộng nó chỉ làm tổng nhỏ hơn — tốt hơn là "cắt" và bắt đầu subarray mới từ A[i]. Khi dp[i-1] không âm, kéo dài subarray luôn tốt hơn hoặc bằng bắt đầu lại. Transition max(A[i], dp[i-1] + A[i]) encode chính xác logic này.
4.4 Tối ưu space O(1)
kadane_opt(A, n):
cur <- A[0]
best <- A[0]
for i <- 1 đến n-1:
cur <- max(A[i], cur + A[i])
best <- max(best, cur)
return best
Time: O(n) Space: O(1)
5. Coin Change — số xu ít nhất
5.1 Bài toán
Cho tập đồng xu coins[] (mỗi mệnh giá dùng được vô hạn lần) và tổng tiền amount. Tìm số đồng xu ít nhất để đạt tổng đúng bằng amount. Nếu không thể, trả về -1.
Ví dụ: coins = [1, 5, 6, 9], amount = 11 → answer 2 (5+6).
Tại sao không dùng greedy? Greedy chọn đồng lớn nhất trước: 9 → còn 2 → 1+1 = tổng 3 xu. Nhưng 5+6=11 chỉ cần 2 xu. Greedy sai.
5.2 State và transition
State: dp[i] = số xu ít nhất để đạt tổng đúng bằng i. Nếu không thể, dp[i] = INF.
Transition: để đạt tổng i, đồng xu cuối cùng có thể là bất kỳ đồng c nào trong coins, miễn là i >= c. Khi đó:
dp[i] <- min(dp[i - c] + 1) cho mọi c trong coins, với i >= c
Base case: dp[0] = 0 (cần 0 xu để đạt tổng 0).
Answer: dp[amount] nếu dp[amount] != INF, ngược lại -1.
5.3 Pseudocode và trace
coin_change(coins, amount):
dp[0] <- 0
dp[i] <- INF cho mọi i từ 1 đến amount
for i <- 1 đến amount:
for each c trong coins:
if i >= c and dp[i - c] != INF:
dp[i] <- min(dp[i], dp[i - c] + 1)
if dp[amount] = INF: return -1
return dp[amount]
Time: O(amount × |coins|) Space: O(amount)
Trace coins = [1, 5, 6, 9], amount = 11:
| i | dp[i] | Từ đâu |
|---|---|---|
| 0 | 0 | base case |
| 1 | 1 | dp[0]+1 (coin=1) |
| 2 | 2 | dp[1]+1 (coin=1) |
| 3 | 3 | dp[2]+1 (coin=1) |
| 4 | 4 | dp[3]+1 (coin=1) |
| 5 | 1 | dp[0]+1 (coin=5) |
| 6 | 1 | dp[0]+1 (coin=6) |
| 7 | 2 | dp[1]+1 (coin=6) hoặc dp[2]+1 (coin=5) |
| 8 | 2 | dp[2]+1 (coin=6) hoặc dp[3]+1 (coin=5) |
| 9 | 1 | dp[0]+1 (coin=9) |
| 10 | 2 | dp[1]+1 (coin=9) hoặc dp[4]+1 (coin=6) |
| 11 | 2 | dp[5]+1 (coin=6) hoặc dp[6]+1 (coin=5) |
Answer: 2 (dùng coin 5 và coin 6).
flowchart LR
A0["dp[0]=0"] -->|"+coin 5"| A5["dp[5]=1"]
A0 -->|"+coin 6"| A6["dp[6]=1"]
A5 -->|"+coin 6"| A11["dp[11]=2 ✓"]
A6 -->|"+coin 5"| A11
A0 -->|"+coin 9"| A9["dp[9]=1"]
A9 -->|"+coin 1"| A10["dp[10]=2"]
A10 -->|"+coin 1"| A115.4 Tại sao coin change KHÔNG tối ưu về space?
Với climbing stairs và house robber, transition chỉ nhìn lại 1-2 bước → có thể giữ O(1) state. Coin change nhìn lại i - max_coin đến i - 1 — khoảng tới max_coin bước — không thể giảm xuống O(1). Phải giữ toàn bộ mảng dp[0..amount].
6. Pitfall
Pitfall 1 — Sai state: dp[i] là "có trộm nhà i" thay vì "tốt nhất đến nhà i"
-- BUG: định nghĩa state là "tiền nếu trộm nhà i"
-- Khi đó dp[3] = dp[1] + val[3] (nhảy 2 bước)
-- Nhưng solution tối ưu có thể bỏ qua nhà 3 hoàn toàn
-- dp[3] với state này không capture khả năng bỏ qua nhà 3
-- CORRECT: state là "tiền tối đa khi ĐÃ XÉT đến nhà i, bao gồm hoặc bỏ qua"
dp[i] <- max(dp[i-1], dp[i-2] + val[i])
-- dp[i-1] = bỏ qua nhà i, dp[i-2]+val[i] = trộm nhà i
Pitfall 2 — Quên cập nhật best trong Kadane
-- BUG: trả về dp[n-1] thay vì max toàn bộ
kadane_bug(A, n):
dp[0] <- A[0]
for i <- 1 đến n-1:
dp[i] <- max(A[i], dp[i-1] + A[i])
return dp[n-1] -- BUG: subarray tốt nhất có thể kết thúc ở bất kỳ vị trí nào
-- CORRECT: track best qua từng bước
best <- dp[0]
for i <- 1 đến n-1:
dp[i] <- max(A[i], dp[i-1] + A[i])
best <- max(best, dp[i])
return best
Ví dụ: A = [3, -10, 2] — dp[2] = 2 nhưng answer đúng là 3 (chỉ lấy A[0]).
Pitfall 3 — Coin change: không kiểm tra dp[i - c] != INF trước khi cộng
-- BUG: dp[i-c] = INF (không thể đạt), INF + 1 = overflow hoặc sai
dp[i] <- min(dp[i], dp[i - c] + 1) -- dp[i-c] có thể là INF
-- CORRECT: guard trước khi cộng
if i >= c and dp[i - c] != INF:
dp[i] <- min(dp[i], dp[i - c] + 1)
7. Liên hệ các bài khác
- DP Framework: Bài này áp dụng framework 5 bước — đọc bài đó trước để hiểu tại sao state và transition được định nghĩa như trên.
- DP 2D — LCS và Edit Distance: Bước tiếp theo: khi state cần 2 chiều
(i, j)— cùng tư duy transition nhưng bảng 2D. - Knapsack: House robber là phiên bản đơn giản của 0/1 knapsack — "chọn hoặc bỏ" với ràng buộc không kề nhau. Knapsack tổng quát thêm chiều "trọng lượng" vào state.
- Recursion và call stack: Hiểu call stack giúp thấy tại sao tối ưu space từ O(n) xuống O(1) quan trọng — khi n lên đến 10^6, mảng O(n) chiếm hàng MB RAM.
- Amortized analysis: Coin change O(amount × |coins|) — amortized O(|coins|) per amount step. Cùng tư duy phân tích tổng chi phí chia đều.
📚 Deep Dive
Kadane's algorithm:
- Bentley, Jon (1984) — "Algorithm Design Techniques", Communications of the ACM 27(9) (Programming Pearls column): Jay Kadane đề xuất thuật toán O(n) cho max subarray trong buổi thảo luận nhóm; Bentley ghi lại và phổ biến. Kadane không có paper riêng — thuật toán nổi tiếng qua Programming Pearls (Addison-Wesley, 1986), Column 8. Xem thêm GeeksForGeeks — Kadane's Algorithm.
- Introduction to Algorithms (CLRS), Problem 4-1 "Maximum-subarray problem" — phân tích divide-and-conquer O(n log n) vs Kadane O(n).
Coin change:
- Algorithm Design (Kleinberg & Tardos), Chapter 6.4 — Subset sums and knapsacks: coin change là trường hợp unbounded knapsack.
- Bài toán thực tế: currency exchange algorithm trong ATM software, change-making problem trong operations research.
Climbing stairs và Fibonacci:
- Hai bài có cùng transition — climbing stairs với k bước tổng quát hóa thành "k-bonacci" (tổng k số trước). Fibonacci heap (cấu trúc dữ liệu) không liên quan đến dãy Fibonacci.
Tóm tắt
- Climbing stairs:
dp[i] = dp[i-1] + dp[i-2]— tổng hai cách đến bước kế trước. Tối ưu O(1) space. - House robber:
dp[i] = max(dp[i-1], dp[i-2] + val[i])— bỏ qua hoặc trộm nhà i. Tối ưu O(1) space. - Kadane:
dp[i] = max(A[i], dp[i-1] + A[i])— bắt đầu lại hoặc kéo dài; answer là max qua mọi i. - Coin change:
dp[i] = min(dp[i - c] + 1)qua mọi coin c — không tối ưu được space. - Pattern chung: xác định đúng ý nghĩa state, viết transition 2-3 dòng, trace bảng tay để verify.
- Tối ưu space O(n) → O(1) khi transition chỉ phụ thuộc vài state gần.
Tự kiểm tra
Q1Climbing stairs và Fibonacci có cùng transition dp[i] = dp[i-1] + dp[i-2]. Nhưng base case khác nhau thế nào? Tại sao?▸
Fibonacci: dp[0] = 0, dp[1] = 1 — định nghĩa toán học thuần túy.
Climbing stairs: dp[0] = 1, dp[1] = 1 — dp[0] = 1 vì có 1 cách "đứng ở mặt đất mà không leo bậc nào" (quan trọng để đếm cách đúng).
Lý do khác biệt: climbing stairs đếm số cách, không phải giá trị thứ n. dp[0] = 1 đóng vai trò "empty prefix" — không có bậc nào, có 1 cách (không làm gì). Nếu đặt dp[0] = 0, kết quả sẽ sai: dp[2] = dp[1]+dp[0] = 1+0 = 1, nhưng thực tế có 2 cách leo lên bậc 2 (1+1 hoặc 2).
Bài học: cùng transition, base case khác nhau dựa vào ý nghĩa của state — phải hiểu state nghĩa là gì trước khi đặt base case.
Q2House robber: tại sao dp[i] = max(dp[i-1], dp[i-2] + val[i]) chứ không phải max(dp[i-1], dp[i-3] + val[i]) hay các variant khác?▸
Transition encode: (1) bỏ qua nhà i → lấy kết quả tốt nhất đến nhà i-1 (là dp[i-1]). (2) trộm nhà i → không thể trộm nhà i-1, nhưng có thể trộm nhà i-2 trở về trước — kết quả tốt nhất đến nhà i-2 là dp[i-2].
Tại sao không phải dp[i-3] + val[i]? Vì dp[i-2] đã là tối ưu xét đến nhà i-2 — nó đã bao gồm cả khả năng không trộm nhà i-2 (dùng kết quả từ nhà i-3 hoặc trước đó). Optimal substructure đảm bảo: tốt nhất đến i-2 ≥ tốt nhất đến i-3.
Nói ngắn: dp[i-2] "đã lo" mọi lựa chọn từ nhà 0 đến i-2. Không cần xét i-3, i-4 riêng — chúng đã được capture bên trong dp[i-2].
Q3Kadane: khi nào dp[i] = A[i] (bắt đầu lại) thay vì dp[i] = dp[i-1] + A[i] (kéo dài)?▸
Khi dp[i-1] < 0, tức là subarray kết thúc tại i-1 có tổng âm. Khi đó dp[i-1] + A[i] < A[i] — kéo dài subarray âm chỉ làm tổng nhỏ hơn. Tốt hơn là bắt đầu subarray mới chỉ chứa A[i].
Ví dụ: dp[2] = -2 (subarray [-2, 1, -3] có tổng -2). Khi đến A[3] = 4: kéo dài cho -2+4=2, bắt đầu lại cho 4. Chọn max(4, 2) = 4 → bắt đầu subarray mới từ A[3].
Công thức max(A[i], dp[i-1] + A[i]) encode điều này tự động: không cần if/else, phép max xử lý cả hai trường hợp cùng lúc.
Q4Tại sao coin change không tối ưu được space xuống O(1) như climbing stairs?▸
Climbing stairs: dp[i] chỉ phụ thuộc dp[i-1] và dp[i-2] — khoảng nhìn lại cố định là 2. Chỉ cần 2 biến.
Coin change: dp[i] phụ thuộc dp[i - c] cho mọi coin c trong danh sách. Nếu coins = [1, 5, 10], thì dp[i] phụ thuộc dp[i-1], dp[i-5], dp[i-10] — khoảng nhìn lại lên đến max_coin bước. Phải giữ tối thiểu max_coin state, không thể xuống O(1).
Tổng quát: tối ưu space về O(1) chỉ khả thi khi khoảng nhìn lại là hằng số nhỏ (1, 2, k với k cố định không phụ thuộc input). Khi khoảng nhìn lại phụ thuộc input (như coin values), phải giữ nhiều state hơn.
Q5Với input coin change: coins = [2] và amount = 3. dp[] trông như thế nào? Tại sao trả về -1?▸
Trace: dp[0] = 0. dp[1]: coin=2, 1 < 2 → không áp dụng → dp[1] = INF. dp[2]: coin=2, dp[0]+1=1 → dp[2] = 1. dp[3]: coin=2, dp[1]+1=INF+1 → không hợp lệ (guard INF) → dp[3] = INF.
Tại sao INF? Không có tổ hợp đồng xu mệnh giá 2 nào cộng lại thành 3 (số lẻ). Mọi tổ hợp của đồng 2 cho tổng chẵn — không thể đạt tổng lẻ.
Điều này dẫn đến yêu cầu guard dp[i-c] != INF trước khi cộng: nếu không guard, INF + 1 có thể overflow thành số âm và làm sai kết quả (ví dụ INT_MAX + 1 thành INT_MIN — nhỏ hơn mọi giá trị dương, dẫn đến dp chọn đường không tồn tại).
Q6Thiết kế DP mới cho bài toán: đếm số cách leo cầu thang n bậc nếu mỗi lần có thể leo 1, 2, hoặc 3 bậc. State, transition, base case là gì?▸
State: dp[i] = số cách leo lên đến bậc i.
Transition: bước cuối cùng đến bậc i có thể là từ bậc i-1 (leo 1 bậc), bậc i-2 (leo 2 bậc), hoặc bậc i-3 (leo 3 bậc). Ba trường hợp không giao nhau, cộng lại: dp[i] = dp[i-1] + dp[i-2] + dp[i-3].
Base case: dp[0] = 1 (1 cách đứng yên), dp[1] = 1 (chỉ leo 1 bậc), dp[2] = 2 (1+1 hoặc 2). Hoặc xử lý tự động bằng cách đặt dp[-1] = dp[-2] = 0 và dp[0] = 1, dùng max(0, i-k) trong transition.
Tối ưu space: transition nhìn lại 3 bước → chỉ cần 3 biến prev3, prev2, prev1 → O(1) space.
Bài tiếp theo: DP hai chiều — LCS và Edit Distance
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên