Dynamic Programming — framework 5 bước để giải quyết bài toán tối ưu
DP loại bỏ tính lại subproblem trùng bằng memoization/tabulation, cắt từ O(2^n) xuống O(n). Nắm 2 điều kiện DP, state + transition + base case qua quy trình 5 bước.
TL;DR: Dynamic Programming (DP) là kỹ thuật tối ưu hoá bằng cách chia bài toán lớn thành subproblem nhỏ hơn, lưu kết quả để không tính lại. Hai điều kiện cần: (1) overlapping subproblems — cùng subproblem xuất hiện nhiều lần; (2) optimal substructure — solution tối ưu của bài lớn được xây từ solution tối ưu của subproblem. Có hai cách implement: memoization (top-down, đệ quy + cache) và tabulation (bottom-up, điền bảng). Quy trình thiết kế 5 bước: xác định state → viết transition → base case → thứ tự tính → tối ưu không gian.
Một sinh viên code Fibonacci naive và chờ 30 giây để tính fib(45). Thêm 4 dòng cache, thời gian giảm xuống dưới 1 millisecond. Không phải thuật toán khác — vẫn cùng công thức, chỉ không tính lại subproblem đã có đáp án.
Đây là cốt lõi của Dynamic Programming: nhận ra rằng bài toán đang tính đi tính lại cùng một thứ, và ngăn chặn điều đó bằng cách lưu lại kết quả. DP không phải một công thức ma thuật — nó là một tư duy hệ thống để chuyển đệ quy mũ thành đa thức.
Module này dạy DP framework: hai điều kiện, hai cách implement, và quy trình 5 bước để thiết kế solution từ đầu.
1. Analogy — Sổ tay tính toán
Hãy tưởng tượng bạn đang giải một bài toán có nhiều câu hỏi con. Cách naive: mỗi lần gặp câu hỏi con, bạn ngồi tính lại từ đầu — dù đã tính câu đó hàng chục lần trước. Cách thông minh: mỗi khi tính xong câu hỏi nào, ghi đáp án vào sổ tay. Lần sau gặp lại, tra sổ thay vì tính lại.
DP là chiếc sổ tay đó — chỉ tính mỗi subproblem đúng một lần.
| Sổ tay | Dynamic Programming |
|---|---|
| Câu hỏi con | Subproblem |
| Đáp án trong sổ | Giá trị đã cache (memo[] hoặc dp[]) |
| Tra sổ thay vì tính lại | Overlapping subproblems được giải quyết |
| Câu hỏi lớn = tổ hợp câu nhỏ | Optimal substructure |
| Thứ tự điền sổ | Thứ tự tính dp (bottom-up) |
| Trang cuối = đáp án bài lớn | dp[n] = kết quả cần tìm |
DP = đệ quy + cache. Nếu subproblem không trùng nhau (mỗi subproblem chỉ gặp 1 lần), cache không giúp gì — đó là divide and conquer (merge sort, quicksort), không phải DP.
2. Hai điều kiện của DP
2.1 Overlapping subproblems
Bài toán có overlapping subproblems khi cùng một subproblem nhỏ hơn xuất hiện nhiều lần trong quá trình giải bài toán lớn.
Fibonacci là ví dụ kinh điển. Để tính fib(5), cần fib(4) và fib(3). Để tính fib(4), lại cần fib(3) và fib(2). fib(3) xuất hiện ít nhất 2 lần — và với n lớn hơn, số lần trùng lặp tăng theo cấp số nhân.
graph TD
F5["fib(5)"]
F4a["fib(4)"]
F3a["fib(3)"]
F3b["fib(3) ← trùng!"]
F2a["fib(2)"]
F2b["fib(2) ← trùng!"]
F2c["fib(2) ← trùng!"]
F1a["fib(1)"]
F1b["fib(1)"]
F1c["fib(1)"]
F0a["fib(0)"]
F0b["fib(0)"]
F5 --> F4a
F5 --> F3b
F4a --> F3a
F4a --> F2a
F3a --> F2b
F3a --> F1a
F3b --> F2c
F3b --> F1b
F2a --> F1c
F2a --> F0a
F2b --> F0bCây đệ quy naive có fib(3) xuất hiện 2 lần, fib(2) xuất hiện 3 lần. Với fib(50), có hàng tỷ lần tính trùng — đó là lý do complexity là O(2^n).
Ngược lại: merge sort chia mảng thành hai nửa không giao nhau — subproblem không trùng lặp, nên không dùng DP mà dùng divide and conquer.
2.2 Optimal substructure
Bài toán có optimal substructure khi solution tối ưu của bài toán lớn có thể được xây dựng từ solution tối ưu của các subproblem nhỏ hơn.
Ví dụ: shortest path từ A đến C qua B. Nếu path A → B → C là shortest từ A đến C, thì A → B phải là shortest từ A đến B, và B → C phải là shortest từ B đến C. Nếu có path ngắn hơn từ A đến B, ta có thể thay vào để cải thiện path A đến C — mâu thuẫn với giả thiết.
Bài toán không có optimal substructure: longest simple path (đường đi dài nhất không lặp đỉnh). Path dài nhất từ A đến C không nhất thiết đi qua path dài nhất từ A đến B — vì thêm đoạn B→C có thể làm path từ A đến B phải ngắn hơn để tránh lặp đỉnh.
Chỉ overlapping subproblems thì chưa đủ — cần optimal substructure để đảm bảo solution tối ưu xây từ subproblem. Và ngược lại: optimal substructure mà không có overlapping subproblems thì dùng greedy hoặc divide and conquer đơn giản hơn.
3. Hai cách implement DP
3.1 Memoization — top-down
Memoization (hay top-down DP) giữ nguyên cấu trúc đệ quy nhưng thêm một bảng cache. Trước khi tính, kiểm tra cache; nếu đã có, trả về ngay.
-- Top-down: đệ quy + cache
memo <- mảng rỗng (chưa tính)
fib_memo(n):
if n <= 1: return n
if memo[n] != UNSET: return memo[n] -- đã tính, tra cache
memo[n] <- fib_memo(n-1) + fib_memo(n-2)
return memo[n]
Time: O(n) Space: O(n) cho memo + O(n) call stack
Mỗi giá trị fib(k) chỉ được tính đúng một lần. Lần đầu: đệ quy xuống, tính rồi lưu vào memo[k]. Mọi lần sau: tra cache, trả về O(1).
3.2 Tabulation — bottom-up
Tabulation (hay bottom-up DP) loại bỏ đệ quy hoàn toàn. Thay vào đó, điền bảng dp[] từ base case nhỏ nhất lên đến answer.
-- Bottom-up: điền bảng từ nhỏ đến lớn
fib_tab(n):
dp[0] <- 0
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)
Không có đệ quy — không có stack overflow risk. Thứ tự vòng lặp i = 2, 3, ..., n đảm bảo khi tính dp[i], cả dp[i-1] và dp[i-2] đã sẵn sàng.
3.3 So sánh hai cách
| Tiêu chí | Memoization (top-down) | Tabulation (bottom-up) |
|---|---|---|
| Cấu trúc code | Đệ quy + cache | Vòng lặp + mảng |
| Thứ tự tính | Lazy — chỉ tính subproblem cần thiết | Eager — tính tất cả theo thứ tự |
| Stack overflow | Có nguy cơ khi n lớn | Không |
| Dễ code | Thường dễ hơn (giữ logic đệ quy) | Cần nghĩ thứ tự điền bảng |
| Tối ưu không gian | Khó (cache thường giữ tất cả) | Dễ — chỉ giữ vài state gần |
| Performance constant | Nhỏ hơn (function call overhead) | Nhỏ hơn (cache-friendly array) |
Bắt đầu bằng memoization khi mới học — code gần với công thức toán hơn, dễ verify đúng hơn. Chuyển sang tabulation khi cần tối ưu space (chỉ giữ vài state gần) hoặc khi đệ quy quá sâu gây stack overflow.
4. Ví dụ đầy đủ — Fibonacci: từ O(2^n) xuống O(n)
4.1 Naive — O(2^n)
fib_naive(n):
if n <= 1: return n
return fib_naive(n-1) + fib_naive(n-2)
Time: O(2^n) Space: O(n) call stack
Với n = 50: khoảng 2^50 ≈ 10^15 lần gọi hàm. Không thực tế.
4.2 Sau khi thêm memo — O(n)
memo <- array[-1, -1, ..., -1] độ dài n+1 -- -1 = chưa tính
fib_memo(n):
if n <= 1: return n
if memo[n] != -1: return memo[n]
memo[n] <- fib_memo(n-1) + fib_memo(n-2)
return memo[n]
Time: O(n) Space: O(n)
Cây đệ quy "phẳng" ra: mỗi node chỉ được tính một lần, tổng n lần gọi thực sự.
4.3 Tối ưu space xuống O(1)
dp[i] chỉ phụ thuộc dp[i-1] và dp[i-2] — không cần giữ toàn bộ mảng:
fib_opt(n):
if n <= 1: return n
prev2 <- 0 -- fib(0)
prev1 <- 1 -- fib(1)
for i <- 2 đến n:
cur <- prev1 + prev2
prev2 <- prev1
prev1 <- cur
return prev1
Time: O(n) Space: O(1)
5. Quy trình 5 bước thiết kế DP
Mọi bài DP đều có thể thiết kế theo 5 bước sau:
Bước 1 — Xác định state
State là "tham số" của subproblem — đủ thông tin để tính kết quả của subproblem đó mà không cần biết lịch sử đã đến state này như thế nào.
Câu hỏi: "Tôi cần biết gì để giải subproblem?"
- Fibonacci: state =
n(index cần tính) - Climbing stairs: state =
i(đang ở bậc thứ i, cần lên bậcn) - Knapsack 0/1: state =
(i, w)— đang xét item i, cònwkg trọng lượng tối đa
Bước 2 — Viết transition (recurrence relation)
Transition là công thức tính dp[state] từ các state nhỏ hơn đã được giải.
Câu hỏi: "State này được tính từ state nào?"
-- Fibonacci:
dp[i] = dp[i-1] + dp[i-2]
-- Climbing stairs (leo 1 hoặc 2 bậc):
dp[i] = dp[i-1] + dp[i-2]
-- Coin change (tìm số xu ít nhất):
dp[i] = min(dp[i - coin] + 1) cho mọi coin trong danh sách xu
Bước 3 — Base case
Base case là các subproblem nhỏ nhất có thể trả lời trực tiếp mà không cần chia nhỏ hơn nữa.
-- Fibonacci:
dp[0] = 0, dp[1] = 1
-- Climbing stairs:
dp[0] = 1 -- 1 cách đứng ở mặt đất (không bước)
dp[1] = 1 -- 1 cách lên bậc 1 (1 bước)
-- Coin change:
dp[0] = 0 -- cần 0 xu để đạt tổng 0
Bước 4 — Thứ tự tính (nếu dùng tabulation)
Đảm bảo khi tính dp[state], tất cả state nó phụ thuộc đã được tính trước.
- 1D DP (Fibonacci, climbing stairs): tính từ
0đếnn. - 2D DP (LCS, edit distance): thường tính từ
(0,0)đến(m,n)— hàng trước cột trước.
Bước 5 — Tối ưu không gian (tuỳ chọn)
Nếu transition chỉ phụ thuộc vào một vài state gần, có thể giảm space từ O(n) xuống O(1) hoặc từ O(mn) xuống O(n).
-- Chỉ cần 2 state gần nhất → dùng 2 biến thay mảng:
prev2, prev1 -- thay dp[0..n]
6. Pitfall — Thiếu base case hoặc sai thứ tự
Pitfall 1 — Quên base case
-- BUG: không có base case, infinite recursion
fib_bug(n):
return fib_bug(n-1) + fib_bug(n-2) -- không dừng khi n <= 1
-- CORRECT: base case dừng đệ quy
fib_correct(n):
if n <= 1: return n -- base case
return fib_correct(n-1) + fib_correct(n-2)
Pitfall 2 — Sai thứ tự tính trong tabulation
-- BUG (bottom-up coin change): tính dp[i] trước khi dp[i - coin] sẵn sàng
for coin in coins:
for i <- 0 đến amount: -- thứ tự sai cho unbounded knapsack
...
-- CORRECT: với unbounded coin change, tính theo amount tăng dần
for i <- 1 đến amount:
for coin in coins:
if i >= coin:
dp[i] <- min(dp[i], dp[i - coin] + 1)
Pitfall 3 — State không đủ thông tin
-- BUG: state thiếu — không thể xác định kết quả chỉ từ position
-- Ví dụ robot đi trên grid có obstacle — state chỉ lưu (i, j) là đủ
-- NHƯNG: nếu có thêm ràng buộc "đã dùng bao nhiêu bước đặc biệt"
-- thì state phải là (i, j, k) — thiếu k sẽ cho kết quả sai
Khi gặp kết quả sai, câu hỏi đầu tiên: "State định nghĩa có đủ thông tin chưa?"
7. Liên hệ các bài khác
- Recursion và call stack: Memoization là đệ quy + cache — phải hiểu call stack trước để thấy tại sao top-down có stack overflow risk với n lớn.
- Amortized analysis: DP đạt O(n) nhờ mỗi subproblem tính đúng 1 lần — amortized cost O(1) mỗi subproblem. Cùng tư duy "trải đều chi phí".
- DP một chiều: Bài tiếp theo áp dụng framework 5 bước vào 4 pattern 1D: climbing stairs, house robber, Kadane, coin change.
- LCS và Edit Distance: Mở rộng lên state 2 chiều
dp[i][j]— transition phức tạp hơn nhưng cùng framework. - Knapsack: Bài toán DP kinh điển có state 2 chiều
(item, capacity).
📚 Deep Dive
Sách kinh điển:
- Introduction to Algorithms (CLRS), Chapter 14 — Dynamic Programming: định nghĩa chính thức overlapping subproblems và optimal substructure, với proof. Rod cutting và matrix chain multiplication là ví dụ chuẩn.
- Algorithm Design (Kleinberg & Tardos), Chapter 6 — Dynamic Programming: tiếp cận từ bài toán thực tế (weighted interval scheduling, sequence alignment).
Quan sát lịch sử:
- Bellman (1957) đặt tên "dynamic programming" — chọn từ "dynamic" để tránh bị bộ quốc phòng Mỹ cắt funding (họ ghét chữ "mathematical" và "research"). Tên không phản ánh nội dung — đây là ví dụ naming vì lý do chính trị, không phải kỹ thuật.
Cross-link:
- Bài 02 (DP 1D) và bài 03 (DP 2D) trong module này áp dụng trực tiếp framework 5 bước.
- Module 1 bài 05 — Greedy: khi optimal substructure có, đôi khi greedy đơn giản hơn DP — phân biệt khi nào dùng cái nào.
Tóm tắt
- DP giải bài toán tối ưu bằng cách chia thành subproblem, lưu kết quả để không tính lại.
- Hai điều kiện: overlapping subproblems (subproblem trùng lặp) + optimal substructure (giải tối ưu xây từ subproblem tối ưu).
- Memoization (top-down): đệ quy + cache — giữ logic đệ quy, lazy evaluation.
- Tabulation (bottom-up): vòng lặp + mảng — không stack overflow, dễ tối ưu space.
- Quy trình 5 bước: xác định state → viết transition → base case → thứ tự tính → tối ưu space.
- Fibonacci naive O(2^n) → với memo O(n) → tối ưu space O(1).
- Pitfall hay gặp: quên base case, sai thứ tự tabulation, state không đủ thông tin.
Tự kiểm tra
Q1Hai điều kiện để một bài toán có thể giải bằng DP là gì? Cho ví dụ bài toán THIẾU một trong hai điều kiện.▸
Hai điều kiện: (1) Overlapping subproblems — cùng subproblem nhỏ xuất hiện nhiều lần khi giải bài lớn. (2) Optimal substructure — solution tối ưu của bài lớn được xây từ solution tối ưu của subproblem.
Thiếu overlapping subproblems: merge sort chia mảng thành hai nửa không giao nhau — mỗi subproblem chỉ xuất hiện một lần, không có gì để cache. Dùng divide and conquer, không phải DP.
Thiếu optimal substructure: longest simple path (đường dài nhất không lặp đỉnh). Path dài nhất từ A đến C không thể xây từ path dài nhất từ A đến B và từ B đến C — vì hai path đó có thể dùng chung đỉnh, vi phạm điều kiện "simple". Bài toán này là NP-hard, không giải được bằng DP polynomial.
Q2Memoization khác tabulation như thế nào? Khi nào bạn chọn cái nào?▸
Memoization (top-down): giữ cấu trúc đệ quy, thêm cache trước mỗi lần tính. Tính lazy — chỉ tính subproblem thực sự được gọi đến. Code gần với công thức toán hơn, dễ verify. Nhược điểm: stack overflow khi n lớn (mỗi call thêm một frame vào call stack).
Tabulation (bottom-up): vòng lặp điền mảng từ base case lên. Tính eager — tính tất cả subproblem theo thứ tự. Không có stack overflow risk. Dễ tối ưu space vì biết rõ cần giữ state nào.
Chọn memoization khi mới thiết kế — dễ code và debug. Chuyển sang tabulation khi cần tối ưu space (chỉ giữ vài state gần), hoặc khi đệ quy quá sâu (n lên đến hàng triệu).
Q3Tại sao Fibonacci naive có complexity O(2^n)? Sau khi thêm memo, tại sao giảm xuống O(n)?▸
Naive: mỗi lần gọi fib(n) tạo ra 2 lần gọi con (fib(n-1) và fib(n-2)), mỗi lần gọi đó lại tạo ra 2 lần gọi nữa — cây nhị phân có chiều sâu n, số node là O(2^n). fib(3) được tính ít nhất 2 lần, fib(2) ít nhất 3 lần, số lần tăng theo cấp số nhân.
Với memo: mỗi giá trị fib(k) chỉ được tính đúng một lần — lần đầu tiên fib(k) được gọi, kết quả được lưu vào memo[k]. Mọi lần gọi tiếp theo trả về memo[k] trong O(1). Có n giá trị phân biệt từ 0 đến n, mỗi giá trị tính đúng 1 lần — tổng n lần tính thực sự, complexity O(n).
Q4Trong quy trình 5 bước, tại sao 'xác định state' là bước quan trọng nhất? State cần có đặc tính gì?▸
State là nền tảng của toàn bộ solution DP. Nếu state sai hoặc thiếu thông tin, transition sẽ không đúng, base case không rõ, và kết quả cuối cùng sai — dù code hoàn toàn đúng về mặt syntax.
State cần có tính Markov: đủ thông tin để xác định kết quả của subproblem mà không cần biết lịch sử đã đến state này như thế nào. Ví dụ: nếu bài toán grid có thêm ràng buộc "đã dùng k bước đặc biệt", thì state (i, j) không đủ — phải dùng (i, j, k). Thiếu k, hai tình huống khác nhau sẽ bị merge vào cùng một cell dp, dẫn đến kết quả sai.
Nguyên tắc: thêm state cho đến khi "biết state là biết tất cả". Bắt đầu với state đơn giản nhất, nếu không đủ thì thêm chiều.
Q5Fibonacci tối ưu xuống O(1) space được vì sao? Bài toán nào KHÔNG thể tối ưu space theo cách này?▸
Fibonacci tối ưu space được vì transition dp[i] = dp[i-1] + dp[i-2] chỉ phụ thuộc 2 state liền trước — không cần giữ toàn bộ mảng. Chỉ cần 2 biến prev1 và prev2, cập nhật theo từng bước.
Bài toán KHÔNG thể tối ưu tương tự: Edit Distance hay LCS với bảng 2D dp[i][j]. Transition phụ thuộc cả hàng trên (dp[i-1][j], dp[i-1][j-1]) và cột trái (dp[i][j-1]). Có thể giảm từ O(mn) xuống O(n) bằng cách chỉ giữ 2 hàng (hàng hiện tại và hàng trên), nhưng không thể xuống O(1) vì vẫn cần toàn bộ hàng trước đó.
Ngoài ra, nếu cần reconstruct path (truy vết), không thể tối ưu space — phải giữ toàn bộ bảng để trace back.
Q6Greedy vs DP — cả hai đều có optimal substructure. Làm sao phân biệt khi nào dùng greedy, khi nào cần DP?▸
Greedy cũng cần optimal substructure — nhưng thêm một điều kiện mạnh hơn: greedy choice property — lựa chọn tham lam cục bộ (locally optimal) luôn dẫn đến solution tối ưu toàn cục, không cần xét lại. Không cần cân nhắc nhiều lựa chọn.
DP cần cân nhắc nhiều lựa chọn tại mỗi state và lấy kết quả tốt nhất — vì lựa chọn cục bộ tốt nhất chưa chắc là tốt nhất toàn cục. Ví dụ coin change với đồng xu 1, 3, 4 và cần đổi 6: greedy chọn 4 trước → còn 2, chọn 1+1 = tổng 3 đồng. DP tìm được 3+3 = 2 đồng. Greedy sai.
Heuristic phân biệt: thử greedy trước — nếu có thể chứng minh greedy choice property (thường qua exchange argument), dùng greedy. Nếu không chứng minh được hoặc có counter-example, dùng DP.
Q7Vẽ lại cây đệ quy của fib(4) với naive và với memoization. Số lần tính fib(2) là bao nhiêu trong mỗi trường hợp?▸
Naive fib(4):
fib(4) → fib(3) + fib(2)
fib(3) → fib(2) + fib(1)
fib(2) → fib(1) + fib(0) (lần 1, từ fib(3))
fib(2) → fib(1) + fib(0) (lần 2, trực tiếp từ fib(4))
Tổng: fib(2) được tính 2 lần, fib(1) được tính 3 lần.
Với memoization: Lần đầu fib(2) được tính (từ nhánh fib(3)), kết quả ghi vào memo[2]. Khi fib(4) cần fib(2) lần hai, tra cache trả về ngay — không tính lại. Tổng: fib(2) được tính thực sự 1 lần. Đây là toàn bộ sức mạnh của memoization.
Bài tiếp theo: DP một chiều — climbing stairs, house robber, Kadane, coin change
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