Bài 6 chúng ta tăng tốc truy vấn bằng cách lưu sẵn kết quả ở các node cây. Quy hoạch động (Dynamic Programming — DP) áp dụng đúng ý tưởng "lưu sẵn để khỏi tính lại" đó, nhưng theo một cách tổng quát hơn nhiều — biến hàng loạt bài toán tưởng chừng cần thời gian mũ (exponential) thành đa thức (polynomial).

1. Quy hoạch động là gì? Hai điều kiện bắt buộc

Một bài toán phù hợp với DP cần 2 tính chất: bài toán con chồng lấp (overlapping subproblems — cùng một bài toán con được giải đi giải lại nhiều lần) và cấu trúc con tối ưu (optimal substructure — lời giải tối ưu của bài lớn được xây từ lời giải tối ưu của các bài con). Ví dụ kinh điển: tính Fibonacci bằng đệ quy thuần rất chậm vì tính lại cùng giá trị hàng ngàn lần:

fib-naive.js
// Đệ quy thuần — O(2^n), tính lại fib(3) tới hàng chục lần khi n lớn!
function fibNaive(n) {
  if (n <= 1) return n;
  return fibNaive(n - 1) + fibNaive(n - 2);
}
// fibNaive(40) đã mất vài giây — fib(3) được tính lại hơn 300.000 lần

2. Memoization (Top-down) vs Tabulation (Bottom-up)

Có 2 cách cài DP: Memoization giữ nguyên đệ quy nhưng thêm bộ nhớ đệm (cache) tránh tính lại; Tabulation bỏ đệ quy hẳn, xây bảng kết quả từ dưới lên theo thứ tự tăng dần:

fib-memoized.js
// Memoization (Top-down) — O(n), vẫn đệ quy nhưng cache kết quả
function fibMemo(n, cache = new Map()) {
  if (n <= 1) return n;
  if (cache.has(n)) return cache.get(n); // đã tính rồi — trả về ngay
  const result = fibMemo(n - 1, cache) + fibMemo(n - 2, cache);
  cache.set(n, result);
  return result;
}

// Tabulation (Bottom-up) — O(n), không đệ quy, không tốn stack
function fibTabulation(n) {
  const dp = [0, 1];
  for (let i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
  return dp[n];
}
ℹ️ Lưu ý: Khi nào chọn Memoization, khi nào chọn Tabulation?
Memoization thường dễ viết hơn — chỉ cần thêm cache vào hàm đệ quy tự nhiên, và chỉ tính những trạng thái thực sự cần thiết. Nhưng đệ quy sâu có thể gây tràn stack (stack overflow) với $n$ rất lớn. Tabulation tránh được vấn đề này hoàn toàn và thường dễ tối ưu bộ nhớ hơn (ví dụ chỉ giữ 2 dòng cuối thay vì cả bảng), nhưng đòi hỏi xác định đúng thứ tự tính trước.

3. Bài toán kinh điển: Edit Distance (Khoảng cách chỉnh sửa)

Edit Distance (khoảng cách Levenshtein) đo số thao tác tối thiểu — thêm, xoá, hoặc thay thế 1 ký tự — để biến chuỗi A thành chuỗi B. Đây chính là thuật toán đứng sau git diff, gợi ý chính tả, và so khớp trình tự DNA. Công thức truy hồi:

$$ dp[i][j] = \begin{cases} j & \text{nếu } i = 0 \\ i & \text{nếu } j = 0 \\ dp[i{-}1][j{-}1] & \text{nếu } A_i = B_j \\ 1 + \min\big(dp[i{-}1][j],\ dp[i][j{-}1],\ dp[i{-}1][j{-}1]\big) & \text{ngược lại} \end{cases} $$
edit-distance.js
function editDistance(a, b) {
  const m = a.length, n = b.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

  for (let i = 0; i <= m; i++) dp[i][0] = i; // xoá hết i ký tự của a
  for (let j = 0; j <= n; j++) dp[0][j] = j; // thêm hết j ký tự của b

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (a[i - 1] === b[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1]; // ký tự khớp — không tốn thao tác
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j],   // xoá ký tự a[i-1]
          dp[i][j - 1],   // thêm ký tự b[j-1]
          dp[i - 1][j - 1] // thay thế a[i-1] bằng b[j-1]
        );
      }
    }
  }
  return dp[m][n];
}
🕳️ Cạm bẫy thường gặp: Sai kích thước bảng DP
Lỗi phổ biến nhất: khai báo bảng kích thước $m \times n$ thay vì $(m{+}1) \times (n{+}1)$. Hàng 0 và cột 0 đại diện cho chuỗi rỗng — trường hợp biên bắt buộc phải có để công thức truy hồi hoạt động đúng ngay từ ô $dp[1][1]$. Thiếu hàng/cột 0 sẽ khiến chỉ số bị lệch hoặc code báo lỗi truy cập ngoài mảng.

4. Bài toán kinh điển: 0/1 Knapsack (Bài toán cái túi)

Cho $n$ món đồ, mỗi món có trọng lượng $w_i$ và giá trị $v_i$, và một cái túi chỉ chứa được tối đa trọng lượng $W$. Chọn tập món đồ nào để tổng giá trị lớn nhất mà không vượt quá $W$ — mỗi món chỉ được chọn 0 hoặc 1 lần (không được chọn phần lẻ, khác với "Fractional Knapsack" giải được bằng Greedy):

$$ dp[i][w] = \begin{cases} dp[i{-}1][w] & \text{nếu } w_i > w \\ \max\big(dp[i{-}1][w],\ dp[i{-}1][w{-}w_i] + v_i\big) & \text{nếu } w_i \le w \end{cases} $$
knapsack-01.js
function knapsack01(items, capacity) {
  const n = items.length;
  const dp = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0));

  for (let i = 1; i <= n; i++) {
    const { weight, value } = items[i - 1];
    for (let w = 0; w <= capacity; w++) {
      if (weight > w) {
        dp[i][w] = dp[i - 1][w]; // món này không nhét vừa — bỏ qua
      } else {
        dp[i][w] = Math.max(
          dp[i - 1][w],                    // KHÔNG chọn món i
          dp[i - 1][w - weight] + value    // CÓ chọn món i
        );
      }
    }
  }
  return dp[n][capacity];
}
🔬 Đào sâu: Knapsack là "giả đa thức" (Pseudo-polynomial)
Độ phức tạp $O(nW)$ trông có vẻ đa thức, nhưng $W$ là một con số, không phải kích thước đầu vào thực sự — kích thước đầu vào để biểu diễn $W$ chỉ là $O(\log W)$ bit. Nếu $W$ = 1 tỷ, thuật toán chạy cực chậm dù "trông giống" đa thức. Đây là lý do 0/1 Knapsack được gọi là giả đa thức (pseudo-polynomial) — về bản chất nó vẫn là bài toán NP-khó (NP-hard), DP chỉ giải nhanh khi $W$ đủ nhỏ.

5. So sánh Quy hoạch động, Đệ quy thuần và Greedy

Tiêu chí Đệ quy thuần Quy hoạch động Greedy (tham lam)
Độ phức tạp thời gian Thường mũ $O(2^n)$ Đa thức (số trạng thái × chi phí mỗi trạng thái) Thường nhanh nhất khi áp dụng được
Đảm bảo kết quả tối ưu? Có (nhưng chậm) Có, luôn đúng Chỉ khi bài toán có "greedy choice property"
Bộ nhớ Stack đệ quy $O(n)$ Bảng DP, có thể tối ưu O(1) hàng Thường O(1) hoặc O(n)
Ví dụ điển hình Fibonacci ngây thơ Edit Distance, 0/1 Knapsack Fractional Knapsack, Dijkstra
💡 Mẹo: Quy hoạch động xuất hiện ở đâu ngoài bài tập?
git diff và các công cụ so sánh văn bản dùng biến thể Edit Distance để tìm phần thay đổi tối thiểu giữa 2 phiên bản file. Gợi ý chính tả ("Ý bạn có phải...?") tính Edit Distance giữa từ bạn gõ và các từ trong từ điển. Tin sinh học (bioinformatics) dùng Edit Distance để so khớp trình tự DNA/protein. Bài toán phân bổ ngân sách/tài nguyên giới hạn thường quy về dạng Knapsack.

Sân chơi tương tác: Edit Distance Visualizer

Nhập 2 chuỗi rồi bấm "Tính toán" để xem bảng DP lấp đầy từng ô, kèm đường truy vết (traceback) màu vàng cho biết chính xác chuỗi thao tác (thêm/xoá/thay thế) để biến chuỗi A thành chuỗi B.

📝 Sân chơi tương tác: Edit Distance

Nhập 2 chuỗi

Edit Distance-
Số ô đã tính0

Nhật ký truy vết

edit-distance-live.js

Trắc nghiệm ôn tập

Câu 1: Điều kiện nào KHÔNG phải là yêu cầu bắt buộc để một bài toán phù hợp với quy hoạch động?

Trắc nghiệm ôn tập

Câu 2: Trong bảng DP của Edit Distance kích thước $(m{+}1) \times (n{+}1)$, ô $dp[0][j]$ (hàng đầu tiên) luôn có giá trị bằng $j$. Vì sao?

Trắc nghiệm ôn tập

Câu 3: Vì sao độ phức tạp $O(nW)$ của 0/1 Knapsack được gọi là "giả đa thức" (pseudo-polynomial) thay vì đa thức thực sự?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 8: B-Tree Database Index Bài 6: Segment Tree / Fenwick Tree Quay lại Lộ trình Series DSA Series C: Mảng & Chuỗi Ký Tự