Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
~15 phútQuyết định dưới constraint — DP, Greedy, BacktrackingMiễn phí

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 chia bài toán lớn thành các bài con nhỏ hơn, giải mỗi bài con một lần rồi lưu đáp án để khỏi tính lại. Dùng được khi có hai đặc điểm. Một: cùng một bài con bị hỏi đi hỏi lại nhiều lần (overlapping subproblems). Hai: đáp án tốt nhất của bài lớn ghép được từ đáp án tốt nhất của các bài con (optimal substructure). Có hai cách viết: memoization (top-down, đệ quy kèm 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, đặt base case, chọn thứ tự tính, nén bộ nhớ.

Một sinh viên code Fibonacci naive rồi ngồi chờ cả chục giây để tính fib(45). Thêm vài dòng cache, thời gian tụt xuống dưới 1 millisecond. Thuật toán không đổi, vẫn cùng công thức, chỉ là không tính lại những bài con đã có đáp án.

Đó là cốt lõi của Dynamic Programming: nhận ra bài toán tính đi tính lại cùng một thứ, rồi lưu kết quả lại. Bài này dạy khung làm DP: hai điều kiện, hai cách cài đặt, và quy trình 5 bước.

1. Hình dung: sổ tay tính toán

Cách naive: mỗi lần gặp câu hỏi con, bạn tính lại từ đầu. Cách thông minh: tính xong thì ghi đáp án vào sổ tay, lần sau tra sổ.

DP chính là chiếc sổ tay đó: mỗi bài con chỉ tính đúng một lần.

Chiếc sổ tayTrong DP
Trang sổ / câu hỏi constate (bài con nào)
Tra sổ, đã có thì dùng luôncache hit trong memo hoặc dp[]
Tính từ các dòng đã ghi, rồi ghi lạitransition + lưu kết quả

Nếu chưa quen việc điền bảng bằng tay, ôn lại bảng trace: bảng dp chính là một bảng trace do bạn tự thiết kế.

Cách nhớ

DP = đệ quy + cache. Nếu các bài con không trùng nhau (mỗi bài con chỉ gặp 1 lần) thì 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

Nói bằng lời thường: bài lớn được chia ra, nhưng các bài con lại giống nhau và cứ bị hỏi đi hỏi lại. Dân thuật toán gọi đó là overlapping subproblems (bài con chồng lấn).

Fibonacci là ví dụ kinh điển. Để tính fib(4), cần fib(3) và fib(2). Nhưng nhánh fib(3) bên trong lại cần fib(2) thêm một lần nữa, và dựng lại nguyên cây con đó từ đầu, dù đáp án vừa được tính xong ở nhánh bên cạnh.

Bên trái là cây gọi của fib(4) với 9 lời gọi và fib(2) xuất hiện hai lần; bên phải là bảng memo chỉ 5 ô, mỗi giá trị tính đúng một lần, điền từ dưới lên

Chỉ với n = 4 đã có 9 lời gọi cho một bài toán chỉ có 5 giá trị phân biệt: fib(2) tính 2 lần, fib(1) tính 3 lần. Với fib(50), có hàng tỷ lần tính trùng, và đó là lý do độ phức tạp là O(2^n).

2.2 Optimal substructure

Nói bằng lời thường: muốn có đáp án tốt nhất của bài lớn, chỉ cần ghép đáp án tốt nhất của các bài con, không phải xét thêm gì khác. Tên gọi là optimal substructure (cấu trúc con tối ưu).

Ví dụ: đường đi ngắn nhất (shortest path) từ A đến C qua B. Nếu A → B → C là đường ngắn nhất từ A đến C, thì A → B phải là đường ngắn nhất từ A đến B, và B → C phải là đường ngắn nhất từ B đến C. Nếu có đường ngắn hơn từ A đến B, thay vào là cải thiện được đường từ 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). Đường dài nhất từ A đến C không nhất thiết đi qua đường dài nhất từ A đến B, vì thêm đoạn B→C có thể buộc đoạn từ A đến B phải ngắn lại để tránh lặp đỉnh.

Cả hai điều kiện phải đồng thời có mặt

Chỉ có overlapping subproblems thì chưa đủ: cần thêm optimal substructure để chắc rằng nghiệm tối ưu ghép được từ các bài con. Ngược lại, có optimal substructure mà không có overlapping subproblems thì dùng greedy hoặc divide and conquer cho đơn giả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 thì kiểm tra cache, nếu đã có đáp án thì trả về ngay.

Plain Text
-- 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ỉ tính đúng một lần. Lần đầu thì đệ quy xuống, tính rồi lưu vào memo[k]. Các lần sau chỉ tra cache, mất O(1).

Cache này giống Spring Cache (@Cacheable: tra trước, chưa có mới tính), chỉ khác là key nằm trong tham số hàm.

3.2 Tabulation (bottom-up)

Tabulation (hay bottom-up DP) bỏ hẳn đệ quy. Thay vào đó, bạn điền bảng dp[] từ base case nhỏ nhất lên dần tới đáp án.

Plain Text
-- 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 nên không lo stack overflow. Thứ tự vòng lặp i = 2, 3, ..., n đảm bảo khi tính dp[i] thì cả dp[i-1] và dp[i-2] đã có sẵn.

3.3 So sánh hai cách

Tiêu chíMemoization (top-down)Tabulation (bottom-up)
Cấu trúc codeĐệ quy + cacheVòng lặp + mảng
Thứ tự tínhLazy: chỉ tính bài con nào cần tớiEager: tính hết theo thứ tự
Stack overflowCó nguy cơ khi n lớnKhông
Dễ codeThường dễ hơn (giữ logic đệ quy)Cần nghĩ thứ tự điền bảng
Tiết kiệm bộ nhớKhó (cache thường giữ tất cả)Dễ: chỉ giữ vài state gần nhất
Hằng số ẩn trong tốc độLớn hơn một chút do chi phí gọi hàmNhỏ hơn nhờ mảng thân thiện với cache CPU

Thử ngẫmbạn cần fib(n) với n cỡ 10^6 trong ngôn ngữ có stack giới hạn. Cách nào hỏng trước, vì sao, và đổi sang cách kia thì transition có phải viết lại không?

4. Ví dụ đầy đủ: Fibonacci từ O(2^n) xuống O(n)

Plain Text
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: số lần gọi thật là khoảng 2·fib(51) − 1 ≈ 4×10^10 (2^50 ≈ 10^15 chỉ là chặn trên lỏng). Bản có memo (mục 3.1) chỉ cần cỡ 50 lần tính.

4.2 Nén bộ nhớ xuống O(1)

dp[i] chỉ phụ thuộc dp[i-1] và dp[i-2], nên không cần giữ cả mảng:

Plain Text
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)

Chạy tay với n = 5 để thấy hai biến "trượt" dọc dãy (giá trị sau mỗi vòng lặp):

icurprev2prev1
bắt đầu01
2111
3212
4323
5535

Kết quả prev1 = 5 = fib(5) mà không cần cả mảng.

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. Trước khi code, hãy làm tay trên ví dụ nhỏ để thấy bài con nào cần bài con nào. Bước 1 và 2 thực chất là phân rã bài lớn thành bài con cùng dạng.

Bước 1: xác định state

State (trạng thái) là bộ số nhận diện một bài con, kiểu "đang hỏi về bậc thứ mấy". Nó phải đủ thông tin để tính ra đáp án của bài con đó mà không cần biết bạn đã đến đây bằng đường nào.

Câu hỏi: "Tôi cần biết gì để giải bài con này?"

  • Fibonacci: state = n (số thứ tự cần tính)
  • Climbing stairs: state = i (đang ở bậc thứ i, cần lên bậc n)
  • Knapsack 0/1: state = (i, w), tức đang xét món đồ thứ i và túi còn chứa được w kg

Bước 2: viết transition

Transition (công thức chuyển trạng thái, còn gọi là recurrence) là công thức biến đáp án của các bài nhỏ hơn thành đáp án của bài đang xét, tức tính dp[state] từ các state đã giải.

Câu hỏi: "State này được tính từ state nào?"

Plain Text
-- Fibonacci:
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 bài con nhỏ nhất, trả lời thẳng được mà không cần chia nhỏ hơn nữa.

Plain Text
-- Fibonacci:
dp[0] = 0,  dp[1] = 1

-- 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)

Phải đảm bảo khi tính dp[state] thì mọi state nó phụ thuộc đã được tính từ trước.

  • 1D DP (Fibonacci, climbing stairs): tính từ 0 đến n.
  • 2D DP (LCS, edit distance): thường tính từ (0,0) đến (m,n), hàng trước rồi mới đến cột.

Bước 5: nén bộ nhớ (tuỳ chọn)

Nếu transition chỉ phụ thuộc vào vài state gần nhất thì có thể giảm bộ nhớ từ O(n) xuống O(1), hoặc từ O(mn) xuống O(n).

Plain Text
-- Chỉ cần 2 state gần nhất, nên dùng 2 biến thay mảng:
prev2, prev1  -- thay dp[0..n]

Thử ngẫmnếu transition mới là dp[i] = dp[i-1] + dp[i-5], bước 5 phải giữ bao nhiêu biến thay cho cả mảng, và con số đó đọc ra từ đâu trong transition?

6. Bẫy: thiếu base case hoặc sai thứ tự

Bẫy 1: Quên base case

Plain Text
-- 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
Plain Text
-- 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)

Bẫy 2: Sai thứ tự tính trong tabulation

Plain Text
-- BUG: điền từ phải sang trái, dp[i-1] và dp[i-2] chưa có giá trị
for i <- n về 2:
    dp[i] <- dp[i-1] + dp[i-2]
Plain Text
-- CORRECT: điền từ base case lên, mỗi ô chỉ dùng các ô đã điền
for i <- 2 đến n:
    dp[i] <- dp[i-1] + dp[i-2]

Quy tắc: hướng của vòng lặp phải ngược chiều với mũi tên phụ thuộc. Có bài hướng này sai, đổi hướng thì đúng (knapsack 1D ở bài 04 là ví dụ điển hình).

Bẫy 3: State không đủ thông tin

Robot đi trên lưới có chướng ngại vật thì state (i, j) là đủ. Thêm ràng buộc "chỉ được dùng tối đa k bước đặc biệt" thì hai tình huống khác nhau bị gộp vào cùng một ô dp.

Plain Text
-- BUG: thiếu k, hai tình huống (đã dùng 0 bước, đã dùng k bước) chung một ô
dp[i][j] <- đường ngắn nhất tới (i, j)
Plain Text
-- CORRECT: k nằm trong state
dp[i][j][k] <- đường ngắn nhất tới (i, j) khi đã dùng k bước đặc biệt

Khi kết quả sai, câu hỏi đầu tiên là: "State đã đủ thông tin chưa?"

Đào sâu

Đào sâu: tài liệu tham khảo
  • Introduction to Algorithms (CLRS), ấn bản 4, Chapter 14, Dynamic Programming: định nghĩa chính thức của overlapping subproblems và optimal substructure, kèm rod cutting và matrix chain multiplication.
  • Algorithm Design (Kleinberg & Tardos), Chapter 6: tiếp cận từ bài toán thực tế như weighted interval scheduling và sequence alignment.
  • Bài 05: Greedy: khi bài có optimal substructure, đôi khi greedy đơn giản hơn DP. Bài đó chỉ cách phân biệt.

Tóm tắt

  • Trước khi viết DP, hỏi hai câu: bài con có lặp lại không, và đáp án bài lớn có ghép được từ đáp án tốt nhất của bài con không.
  • Thiết kế theo thứ tự: state, transition, base case, thứ tự tính, nén bộ nhớ.
  • Fibonacci là mốc đối chiếu: O(2^n) khi naive, O(n) khi có memo, O(1) bộ nhớ khi chỉ giữ hai biến.

Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Bạn gặp hai hàm đệ quy: mergeSort(a) chia mảng làm đôi, và ways(n) = ways(n-1) + ways(n-2) đếm số cách leo n bậc thang. Bài nào hợp DP, bài còn lại thiếu điều kiện nào, và vì sao cache ở đó vô dụng?
  2. Q2
    Memoization khác tabulation như thế nào? Khi nào bạn chọn cái nào?
  3. Q3
    Vì sao Fibonacci naive có độ phức tạp O(2^n)? Sau khi thêm memo, vì sao giảm xuống O(n)?
  4. Q4
    Trong quy trình 5 bước, vì sao 'xác định state' là bước quan trọng nhất? State cần có đặc tính gì?
  5. Q5
    Vì sao Fibonacci tối ưu được xuống O(1) bộ nhớ? Bài toán nào KHÔNG thể tối ưu theo cách này?
  6. Q6
    Hàm đệ quy paths(i, j) = paths(i-1, j) + paths(i, j-1) đếm số đường đi từ góc trên trái tới ô (i, j) trên lưới, chỉ được đi xuống hoặc sang phải. Hãy viết state, transition và base case để chuyển nó thành tabulation.

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?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

DP 1D — Climbing Stairs, House Robber, Kadane, Coin Change