Đây là bản xem thử. Phiên bản đầy đủ trong ứng dụng có bài tập, AI chấm ngay và theo dõi tiến độ học.

Bài học

Từ đệ quy có nhớ đến bảng trạng thái DP

Mục tiêu bài học

Sau bài học này, em thiết kế được một bài toán quy hoạch động (QHĐ) từ đầu: xác định trạng thái, viết công thức truy hồi, chọn đúng thứ tự tính, và xác định khởi tạo cùng đáp số cuối — thay vì học thuộc từng bài mẫu riêng lẻ.

Cần nhớ trước

Bài này dùng lại đệ quy cơ bản và mảng hai chiều từ Tin học 10–11: viết một hàm gọi chính nó, đọc/ghi một ô luoi[i][j], và nhận ra vì sao đệ quy không có nhớ có thể chạy chậm khi cây gọi hàm phình to theo cấp số nhân.

Ở mức chuẩn lớp 12, em đã quen viết một hàm đệ quy đúng theo định nghĩa toán học — ví dụ số Fibonacci `fib(n) = fib(n-1) + fib(n-2)` — và chấp nhận nó chạy chậm khi `n` lớn. Đề HSG không còn hỏi thẳng "viết hàm đệ quy" nữa. Đề cho một ràng buộc `n` đủ lớn để buộc em tự nhận ra: hàm đệ quy đang tính đi tính lại cùng một giá trị hàng triệu lần, và em phải tự thiết kế cách lưu lại kết quả đã tính. Đó chính là quy hoạch động.

Bài toán mở đầu — vì sao đệ quy thẳng không đủ

Thử tính `fib(40)` bằng đúng hàm đệ quy viết theo định nghĩa, không lưu gì cả. Mỗi lần gọi `fib(n)` lại gọi tiếp `fib(n-1)` và `fib(n-2)` — hai lời gọi mới, mỗi lời gọi lại tiếp tục phân nhánh. Số lời gọi hàm tăng theo hàm mũ với cơ số vàng ≈ 1,618 (thường được ước lượng an toàn bằng `O(2^n)` khi so sánh độ lớn). Với `n = 40`, con số đó đã vượt một tỷ lời gọi — trong khi bản thân dãy Fibonacci chỉ có 41 giá trị phân biệt cần tính. Phần lớn công sức bị lãng phí vào việc tính lại những gì đã tính.

Ý tưởng cốt lõi — trạng thái là câu trả lời cho "bài toán con nào lặp lại"

Ý tưởng cốt lõi của QHĐ nằm ở một câu hỏi duy nhất: bài toán con nào lặp lại, và cần lưu giá trị của nó theo những tham số nào? Câu trả lời chính là **trạng thái** QHĐ. Với `fib(n)`, trạng thái chỉ cần một số nguyên `i` — "giá trị Fibonacci thứ i" — vì kết quả của `fib(i)` không đổi dù nó được gọi từ nhánh nào trong cây đệ quy. Chính vì kết quả không đổi theo ngữ cảnh gọi, ta mới lưu được nó vào một ô nhớ duy nhất.
Từ nhận xét đó, một quy trình bốn bước áp dụng được cho mọi bài QHĐ, không riêng gì Fibonacci: (1) xác định trạng thái — biến nào đủ mô tả một bài toán con; (2) viết công thức truy hồi — trạng thái hiện tại liên hệ thế nào với các trạng thái nhỏ hơn; (3) xác định thứ tự tính — trạng thái nhỏ phải có giá trị TRƯỚC trạng thái phụ thuộc vào nó; (4) xác định khởi tạo và đáp số — trạng thái nào có sẵn không cần công thức, đáp số nằm ở trạng thái nào. Quy trình này tự nhiên vì nó chỉ là cách diễn đạt lại chính đệ quy có nhớ: trạng thái là tham số hàm, công thức truy hồi là thân hàm, thứ tự tính là thứ tự hàm được gọi, và khởi tạo là điều kiện dừng.
Quy trình bốn bước thiết kế quy hoạch động1Xác định trạngtháiBiến nào đủ mô tả mộtbài toán con, ví dụ itrong fib(i)2Viết công thứctruy hồiTrạng thái hiện tạiliên hệ thế nào vớitrạng thái nhỏ hơn3Xác định thứ tựtínhTrạng thái nhỏ phảicó giá trị trướctrạng thái phụ thuộc4Khởi tạo và đápsốTrạng thái có sẵnkhông cần công thức,đáp số nằm ở đâu
Bốn bước áp dụng cho mọi bài QHĐ, từ Fibonacci đến bảng hai chiều.
Ví dụ
Ví dụ 1 — Fibonacci: từ đệ quy có nhớ đến bảng
  1. 1
    Bài chuẩn dừng lại ở việc viết đúng hàm đệ quy `fib(n) = fib(n-1) + fib(n-2)` và chạy thử với `n` nhỏ. Đề HSG cho `n` đủ lớn (ví dụ `n ≤ 10^6`) để hàm đệ quy thẳng chắc chắn TLE (Time Limit Exceeded) — em buộc phải tự chuyển sang QHĐ.
  2. 2
    Trạng thái: `i` — cần tính `fib(i)`. Công thức truy hồi: `fib(i) = fib(i-1) + fib(i-2)`. Thứ tự tính: `i` tăng dần từ 0, vì `fib(i)` cần hai giá trị nhỏ hơn đã có sẵn. Khởi tạo: `fib(0) = 0`, `fib(1) = 1`; đáp số là `fib(n)`.
  3. 3
    Vẫn viết hàm đệ quy đúng như bài chuẩn, chỉ thêm một `cache` để không tính lại trạng thái đã biết: ```python def fib_nho_tren_xuong(n, cache=None): """Fibonacci bang de quy co nho (memoization, top-down).""" if cache is None: cache = {} if n in cache: return cache[n] if n <= 1: return n cache[n] = fib_nho_tren_xuong(n - 1, cache) + fib_nho_tren_xuong(n - 2, cache) return cache[n] ```
  4. 4
    Bỏ hẳn đệ quy, điền thẳng một mảng `dp[]` theo đúng thứ tự tính đã xác định ở bước 3: ```python def fib_bang_duoi_len(n): """Fibonacci bang bang dp[], duyet tu duoi len (tabulation, bottom-up).""" if n <= 1: return n dp = [0] * (n + 1) dp[1] = 1 for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp ```
  5. 5
    Cả hai đều `O(n)` thời gian thay vì `O(1,618^n)`. Memoization giữ nguyên tư duy đệ quy, dễ viết hơn khi trạng thái phức tạp, nhưng độ sâu ngăn xếp gọi hàm lớn bằng `n` — với `n` cỡ `10^6`, Python sẽ vượt giới hạn đệ quy mặc định. Tabulation không có ngăn xếp gọi hàm, an toàn hơn với `n` lớn, nhưng đòi hỏi em tự nghĩ ra đúng thứ tự tính trước khi viết vòng lặp — đây là lý do bước 3 trong quy trình bốn bước không thể bỏ qua.
  6. 6
    Ta có trạng thái `dp[i] = fib(i)`. Công thức truy hồi `dp[i] = dp[i-1] + dp[i-2]` với `i ≥ 2`, khởi tạo `dp[0] = 0`, `dp[1] = 1`. Tính tăng dần `i` từ 2 đến `n`. Với `n = 10`: `dp = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]`. Vậy fib(10) = 55, tính trong `O(n)` thời gian và `O(n)` bộ nhớ (có thể nén xuống `O(1)` vì mỗi bước chỉ cần hai giá trị liền trước).

Trạng thái hai chiều — khi một chỉ số không đủ

Fibonacci có trạng thái một chiều vì chỉ một chỉ số `i` là đủ mô tả bài toán con. Nhiều bài toán cần hai chỉ số cùng lúc. Bài đếm số đường đi trên lưới ô vuông, chỉ được đi sang phải hoặc xuống dưới, là ví dụ kinh điển: trạng thái phải là cặp `(i, j)` — vị trí trên lưới — vì số đường đi đến một ô phụ thuộc vào CẢ hai ô lân cận, ô bên trái và ô phía trên.
Ví dụ
Ví dụ 2 — Đếm số đường đi trên lưới ô vuông
  1. 1
    Bài chuẩn cho lưới trống hoàn toàn, và đáp số tính thẳng bằng công thức tổ hợp $C_{m+n-2}^{m-1}$ — chọn `m-1` bước xuống trong tổng `m+n-2` bước. Đề HSG thường thêm chướng ngại vật: khi đó công thức tổ hợp không còn dùng được, vì không phải mọi đường đi hình học đều hợp lệ. Quy hoạch động vẫn giải được, công thức tổ hợp thì không.
  2. 2
    Trạng thái: `(i, j)` — vị trí trên lưới. Công thức truy hồi: `dp[i][j] = dp[i-1][j] + dp[i][j-1]` — đến ô `(i, j)` chỉ có thể đi từ ô trên hoặc ô trái. Thứ tự tính: hàng tăng dần, trong mỗi hàng cột tăng dần (mọi ô lân cận trái/trên đã tính trước). Khởi tạo: hàng 0 và cột 0 chỉ có một cách đi tới (đi thẳng một chiều), nên `dp[0][j] = dp[i][0] = 1`; đáp số là `dp[m-1][n-1]`.
  3. 3
    Với lưới `3 × 4` không chướng ngại vật: ```python def dem_duong_di(m, n): """So duong di tu (0,0) den (m-1,n-1) tren luoi m x n, chi di phai/xuong. dp[i][j] = dp[i-1][j] + dp[i][j-1]; hang 0 va cot 0 la bien (= 1).""" dp = [[0] * n for _ in range(m)] for i in range(m): for j in range(n): if i == 0 or j == 0: dp[i][j] = 1 else: dp[i][j] = dp[i - 1][j] + dp[i][j - 1] return dp ```
  4. 4
    Đề HSG thường đặt thêm một số ô "vật cản" mà đường đi không được phép bước qua. Chỉ cần sửa một dòng trong công thức truy hồi: ô vật cản luôn có `dp[i][j] = 0`, kể cả khi nó nằm trên hàng 0 hoặc cột 0 (một vật cản ở cột 0 chặn đứng toàn bộ phần lưới phía dưới nó trên cùng cột): ```python def dem_duong_di_co_vat_can(luoi): """Giong dem_duong_di nhung o vat can (gia tri 1) luon co dp = 0.""" m, n = len(luoi), len(luoi[0]) dp = [[0] * n for _ in range(m)] for i in range(m): for j in range(n): if luoi[i][j] == 1: dp[i][j] = 0 elif i == 0 and j == 0: dp[i][j] = 1 else: tu_trai = dp[i][j - 1] if j > 0 else 0 tu_tren = dp[i - 1][j] if i > 0 else 0 dp[i][j] = tu_trai + tu_tren return dp ```
  5. 5
    Với lưới không vật cản `3 × 4`, áp dụng công thức truy hồi theo đúng thứ tự hàng tăng dần, ta được `dp[2][3] = 10` — khớp với công thức tổ hợp $C_5^2 = 10$ (kiểm tra chéo hai cách). Khi thêm vật cản tại ô `(1, 2)`, số đường đi giảm còn `dp[2][3] = 4`, vì mọi đường đi buộc phải né ô đó. Vậy đáp số phụ thuộc hoàn toàn vào cách lưới được cho, và chỉ QHĐ mới xử lý được trường hợp tổng quát này.
Trạng thái bảng DP ở bước 1: khởi tạo hàng 0 và cột 0 bằng 1 (chỉ một cách đi thẳng một chiều)j=0j=1j=2j=3111111
Bước 1: khởi tạo hàng 0 và cột 0 bằng 1 (chỉ một cách đi thẳng một chiều)
Trạng thái bảng DP ở bước 2: điền hàng 1 theo dp[i][j] = dp[i-1][j] + dp[i][j-1]j=0j=1j=2j=3111112341
Bước 2: điền hàng 1 theo dp[i][j] = dp[i-1][j] + dp[i][j-1]
Trạng thái bảng DP ở bước 3: điền hàng 2 — đáp số 10 nằm ở góc dưới-phải dp[2][3]j=0j=1j=2j=31111123413610
Bước 3: điền hàng 2 — đáp số 10 nằm ở góc dưới-phải dp[2][3]

Khi bài toán vừa đếm vừa tối ưu

Không phải bài lưới nào cũng đếm số đường đi. Một dạng khác — đường đi có tổng chi phí nhỏ nhất — thay phép cộng hai đường đi bằng phép chọn đường tốt hơn trong hai lựa chọn. Đây là ví dụ mà tính đúng đắn của công thức truy hồi cần một lời giải thích, không chỉ chép công thức.

Ví dụ
Ví dụ 3 — Đường đi có tổng chi phí nhỏ nhất, kèm truy vết đường đi cụ thể
  1. 1

    Bài chuẩn dừng ở việc tính GIÁ TRỊ nhỏ nhất. Đề HSG thường yêu cầu thêm: chỉ ra MỘT đường đi cụ thể đạt giá trị đó — buộc em phải lưu thêm thông tin truy vết song song với bảng dp, không chỉ tính xong rồi bỏ.

  2. 2
    Trạng thái: `(i, j)`. Công thức truy hồi: `dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + luoi[i][j]` — đến ô `(i, j)` bằng đường tốt hơn trong hai đường khả dĩ, cộng chi phí ô hiện tại. Thứ tự tính: hàng tăng dần, cột tăng dần, giống Ví dụ 2. Khởi tạo: `dp[0][0] = luoi[0][0]`; hàng 0 và cột 0 chỉ có một đường đi duy nhất nên cộng dồn thẳng.
  3. 3
    Giả sử đường đi tối ưu đến `(i, j)` đi qua ô trên `(i-1, j)`. Khi đó đoạn đường từ điểm xuất phát đến `(i-1, j)` PHẢI là đường tối ưu đến `(i-1, j)` — nếu không, thay đoạn đó bằng đường tốt hơn sẽ cho một đường đến `(i, j)` tốt hơn nữa, mâu thuẫn với giả thiết tối ưu. Đây gọi là tính chất cấu trúc con tối ưu (optimal substructure): lời giải tối ưu của bài toán lớn được ghép từ lời giải tối ưu của các bài toán con. Chính tính chất này cho phép công thức `min` hoạt động đúng.
  4. 4
    Lưu thêm mảng `cha[i][j]` — ô nào dẫn tới `(i, j)` trong đường tối ưu — song song với bảng `dp`, rồi đi ngược từ đáp số về điểm xuất phát: ```python def duong_di_tong_nho_nhat(luoi): """dp[i][j] = tong nho nhat tu (0,0) den (i,j); tra ve ca bang dp va duong di cu the (truy vet bang mang cha).""" m, n = len(luoi), len(luoi[0]) dp = [[0] * n for _ in range(m)] cha = [[None] * n for _ in range(m)] for i in range(m): for j in range(n): if i == 0 and j == 0: dp[i][j] = luoi[i][j] elif i == 0: dp[i][j] = dp[i][j - 1] + luoi[i][j] cha[i][j] = (i, j - 1) elif j == 0: dp[i][j] = dp[i - 1][j] + luoi[i][j] cha[i][j] = (i - 1, j) else: if dp[i - 1][j] <= dp[i][j - 1]: dp[i][j] = dp[i - 1][j] + luoi[i][j] cha[i][j] = (i - 1, j) else: dp[i][j] = dp[i][j - 1] + luoi[i][j] cha[i][j] = (i, j - 1) duong_di = [(m - 1, n - 1)] hien_tai = (m - 1, n - 1) while cha[hien_tai[0]][hien_tai[1]] is not None: hien_tai = cha[hien_tai[0]][hien_tai[1]] duong_di.append(hien_tai) duong_di.reverse() return dp, duong_di ```
  5. 5
    Với lưới chi phí `[[1,3,1,2],[1,5,1,3],[4,2,1,2]]`, áp dụng công thức truy hồi theo thứ tự hàng tăng dần, cột tăng dần, ta được `dp[2][3] = 9`. Truy vết ngược từ `(2,3)` theo mảng cha cho đường đi cụ thể `(0,0) → (0,1) → (0,2) → (1,2) → (2,2) → (2,3)`, với tổng `1+3+1+1+1+2 = 9` — khớp giá trị `dp[2][3]`. Vậy tổng chi phí nhỏ nhất bằng 9, đạt được theo đúng đường đi vừa truy vết.
Mở rộng dành cho HSG
Mở rộng dành cho HSG: trước khi cài đặt bất kỳ bảng QHĐ nào, hãy ước lượng bộ nhớ nó chiếm — kỹ thuật này KHÔNG nằm trong yêu cầu chuẩn của lớp 12, nhưng gần như bắt buộc trong đề HSG QG vì đề luôn cho ràng buộc `n` sát giới hạn thời gian VÀ bộ nhớ. Một bảng `dp[i][j]` kiểu số nguyên chiếm khoảng `4 × (số trạng thái)` byte. Với lưới `m = n = 10^4`, số trạng thái là `10^8`, bảng cần khoảng `4 × 10^8` byte ≈ 400 MB — vượt giới hạn thường gặp là 256 MB, gây lỗi MLE (Memory Limit Exceeded) dù thuật toán hoàn toàn đúng. Cách khắc phục: nhận ra `dp[i][j]` chỉ phụ thuộc hàng `i-1` và hàng `i`, nên chỉ cần lưu hai hàng luân phiên (rolling array) thay vì toàn bộ bảng — bộ nhớ giảm từ `O(mn)` xuống `O(n)`.
Sai lầm thường gặp
Sai lầm thường gặp: nhầm thứ tự tính. Nếu tính `dp[i][j]` trước khi `dp[i-1][j]` hoặc `dp[i][j-1]` đã có giá trị đúng, kết quả sẽ dùng phải ô còn mang giá trị khởi tạo (0) thay vì giá trị thật — lỗi này không gây crash, chương trình vẫn chạy và in ra một con số, nhưng con số đó sai. Luôn kiểm tra thứ tự vòng lặp khớp với thứ tự tính đã xác định ở bước 3 trước khi tin vào kết quả.

Luyện tập có hướng dẫn

Luyện tập độc lập

Thử thách tổng hợp

Ghi nhớ

Mọi bài QHĐ đều trả lời được bốn câu hỏi: trạng thái là gì, công thức truy hồi ra sao, thứ tự tính thế nào, khởi tạo và đáp số nằm ở đâu. Khi trạng thái là một chỉ số, ta có dãy dp một chiều như Fibonacci; khi trạng thái là một cặp chỉ số, ta có bảng dp hai chiều như bài toán lưới. Trước khi cài đặt, luôn ước lượng số trạng thái để tránh MLE.

Bài tiếp theo áp dụng đúng khung tư duy này cho một lớp bài toán khác: cái túi 0/1, nơi trạng thái không còn là vị trí trên lưới mà là cặp (số vật đã xét, sức chứa còn lại).