💡 Quy hoạch động
Thử tính số Fibonacci bằng đệ quy thuần túy - hàm tự gọi lại chính nó theo công thức fib(n) = fib(n-1) + fib(n-2):
def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2)int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2);}public static int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2);}fun fib(n: Int): Int { if (n <= 1) return n return fib(n - 1) + fib(n - 2)}int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2);}Trông rất gọn, nhưng thử chạy fib(40) sẽ thấy máy “treo” vài giây. Vấn đề nằm ở chỗ: để tính fib(5), hàm gọi fib(4) và fib(3); nhưng fib(4) lại tự gọi fib(3) một lần nữa - fib(3) bị tính đi tính lại! Vẽ cây gọi hàm ra sẽ thấy số lượng lời gọi trùng lặp bùng nổ theo cấp số nhân, cho độ phức tạp O(2ⁿ) - dù đáp số cuối cùng chỉ có n+1 giá trị khác nhau (fib(0) đến fib(n)) cần tính.
Quy hoạch động (Dynamic Programming - DP) giải quyết đúng sự lãng phí này bằng một ý tưởng cực đơn giản: kết quả nào đã tính rồi thì lưu lại, lần sau cần thì tra ra dùng ngay, không tính lại.
Hai cách nhớ kết quả: từ trên xuống và từ dưới lên
Phần tiêu đề “Hai cách nhớ kết quả: từ trên xuống và từ dưới lên”Cách 1 - Memoization (nhớ lại, từ trên xuống - top-down): giữ nguyên code đệ quy, chỉ thêm một “bộ nhớ đệm” (cache) để lưu kết quả đã tính.
def fib_memo(n, memo={}): if n <= 1: return n if n in memo: # đã tính trước đó -> tra ra dùng luôn return memo[n] memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo) return memo[n]#include <unordered_map>
int fibMemo(int n, std::unordered_map<int, int>& memo) { if (n <= 1) return n; auto it = memo.find(n); if (it != memo.end()) return it->second; // đã tính trước đó -> tra ra dùng luôn int result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo); memo[n] = result; return result;}import java.util.Map;import java.util.HashMap;
public static int fibMemo(int n, Map<Integer, Integer> memo) { if (n <= 1) return n; if (memo.containsKey(n)) { // đã tính trước đó -> tra ra dùng luôn return memo.get(n); } int result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo); memo.put(n, result); return result;}fun fibMemo(n: Int, memo: MutableMap<Int, Int> = mutableMapOf()): Int { if (n <= 1) return n memo[n]?.let { return it } // đã tính trước đó -> tra ra dùng luôn val result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo) memo[n] = result return result}int fibMemo(int n, [Map<int, int>? memo]) { memo ??= {}; if (n <= 1) return n; if (memo.containsKey(n)) { // đã tính trước đó -> tra ra dùng luôn return memo[n]!; } final result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo); memo[n] = result; return result;}Cách 2 - Tabulation (xây bảng, từ dưới lên - bottom-up): bỏ hẳn đệ quy, tính từ những giá trị nhỏ nhất rồi dùng vòng lặp xây dần lên giá trị cần tìm - giống cách bạn tự tính Fibonacci bằng tay: viết 0, 1, rồi cộng liên tiếp.
def fib_tab(n): 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[n]#include <vector>
int fibTab(int n) { if (n <= 1) return n; std::vector<int> dp(n + 1, 0); dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n];}public static int fibTab(int n) { if (n <= 1) return n; int[] dp = new int[n + 1]; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n];}fun fibTab(n: Int): Int { if (n <= 1) return n val dp = IntArray(n + 1) dp[1] = 1 for (i in 2..n) { dp[i] = dp[i - 1] + dp[i - 2] } return dp[n]}int fibTab(int n) { if (n <= 1) return n; final dp = List<int>.filled(n + 1, 0); dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n];}Cả hai cách đều đưa độ phức tạp từ O(2ⁿ) xuống O(n) - vì mỗi giá trị fib(i) giờ chỉ được tính đúng một lần. Khác biệt chỉ nằm ở hướng đi: memoization đi từ bài toán lớn xuống bài toán nhỏ (rồi quay lên khi trả kết quả), tabulation đi thẳng từ bài toán nhỏ lên bài toán lớn, không cần đệ quy nên cũng tiết kiệm bộ nhớ ngăn xếp hơn.
Khi nào một bài toán giải được bằng DP?
Phần tiêu đề “Khi nào một bài toán giải được bằng DP?”Không phải bài toán đệ quy nào cũng cần DP. Hai dấu hiệu nhận biết:
- Bài toán con chồng chéo (overlapping subproblems): nếu giải bằng đệ quy thuần túy, cùng một bài toán con bị gọi lại nhiều lần (như
fib(3)ở trên). Nếu mỗi bài toán con chỉ được gọi đúng một lần, lưu kết quả không giúp ích gì - đó là dấu hiệu của chia để trị chứ không phải DP (Merge Sort chia mảng thành 2 nửa hoàn toàn tách biệt, không nửa nào lặp lại). - Cấu trúc con tối ưu (optimal substructure): nghiệm tối ưu của bài toán lớn có thể ghép từ nghiệm tối ưu của các bài toán con nhỏ hơn. Nếu tính chất này không đúng, giải bài toán con tối ưu không giúp bạn xây được bài toán lớn tối ưu.
Khi cả hai điều kiện trên đều thỏa, quy trình chung để giải một bài toán DP là: xác định trạng thái (state - “bài toán con được tham số hóa bằng gì”), viết ra công thức truy hồi (state transition - trạng thái lớn liên hệ thế nào với các trạng thái nhỏ hơn), xác định điều kiện biên (base case), rồi chọn cài bằng memoization hoặc tabulation.
Coin Change - đổi tiền với số xu tối thiểu
Phần tiêu đề “Coin Change - đổi tiền với số xu tối thiểu”Cho các loại tiền xu coins (mỗi loại dùng không giới hạn số lần) và một số tiền amount, tìm số xu tối thiểu để đủ số tiền đó.
Gọi dp[i] là số xu tối thiểu để đủ số tiền i. Với mỗi đồng xu coin có thể dùng ở bước cuối, bài toán con còn lại là đủ số tiền i - coin, nên:
dp[i] = min(dp[i - coin] + 1) với mọi coin <= idp[0] = 0 (điều kiện biên: 0 đồng không cần xu nào)def coin_change(coins, amount): dp = [float('inf')] * (amount + 1) dp[0] = 0 for i in range(1, amount + 1): for coin in coins: if coin <= i: dp[i] = min(dp[i], dp[i - coin] + 1) return dp[amount] if dp[amount] != float('inf') else -1#include <vector>#include <limits>#include <algorithm>
int coinChange(const std::vector<int>& coins, int amount) { const int INF = std::numeric_limits<int>::max(); std::vector<long long> dp(amount + 1, INF); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (int coin : coins) { if (coin <= i && dp[i - coin] != INF) { dp[i] = std::min(dp[i], dp[i - coin] + 1); } } } return dp[amount] != INF ? (int)dp[amount] : -1;}public static int coinChange(int[] coins, int amount) { int[] dp = new int[amount + 1]; java.util.Arrays.fill(dp, Integer.MAX_VALUE); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (int coin : coins) { if (coin <= i && dp[i - coin] != Integer.MAX_VALUE) { dp[i] = Math.min(dp[i], dp[i - coin] + 1); } } } return dp[amount] != Integer.MAX_VALUE ? dp[amount] : -1;}fun coinChange(coins: IntArray, amount: Int): Int { val dp = IntArray(amount + 1) { Int.MAX_VALUE } dp[0] = 0 for (i in 1..amount) { for (coin in coins) { if (coin <= i && dp[i - coin] != Int.MAX_VALUE) { dp[i] = minOf(dp[i], dp[i - coin] + 1) } } } return if (dp[amount] != Int.MAX_VALUE) dp[amount] else -1}int coinChange(List<int> coins, int amount) { final dp = List<double>.filled(amount + 1, double.infinity); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (final coin in coins) { if (coin <= i) { dp[i] = [dp[i], dp[i - coin] + 1].reduce((a, b) => a < b ? a : b); } } } return dp[amount] != double.infinity ? dp[amount].toInt() : -1;}Bài toán cái túi 0/1 (0-1 Knapsack)
Phần tiêu đề “Bài toán cái túi 0/1 (0-1 Knapsack)”Cho n món đồ, mỗi món có trọng lượng weight[i] và giá trị value[i], và một cái túi sức chứa capacity. Mỗi món chỉ được chọn tối đa 1 lần (“0 hoặc 1” - tên gọi 0-1 Knapsack). Chọn tập món sao cho tổng giá trị lớn nhất mà không vượt sức chứa.
Gọi dp[i][c] là giá trị lớn nhất khi chỉ xét i món đầu tiên với sức chứa c. Với món thứ i, có đúng 2 lựa chọn: bỏ qua nó (dp[i-1][c], giữ nguyên sức chứa) hoặc lấy nó nếu còn đủ chỗ (dp[i-1][c - weight[i]] + value[i], giảm sức chứa và cộng giá trị) - chọn phương án nào cho giá trị lớn hơn:
def knapsack_01(weights, values, capacity): n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): for c in range(capacity + 1): dp[i][c] = dp[i - 1][c] # bỏ qua món i if weights[i - 1] <= c: dp[i][c] = max(dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1]) # lấy món i return dp[n][capacity]#include <vector>#include <algorithm>
int knapsack01(const std::vector<int>& weights, const std::vector<int>& values, int capacity) { int n = weights.size(); std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0)); for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; // bỏ qua món i if (weights[i - 1] <= c) { dp[i][c] = std::max(dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1]); // lấy món i } } } return dp[n][capacity];}public static int knapsack01(int[] weights, int[] values, int capacity) { int n = weights.length; int[][] dp = new int[n + 1][capacity + 1]; for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; // bỏ qua món i if (weights[i - 1] <= c) { dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1]); // lấy món i } } } return dp[n][capacity];}fun knapsack01(weights: IntArray, values: IntArray, capacity: Int): Int { val n = weights.size val dp = Array(n + 1) { IntArray(capacity + 1) } for (i in 1..n) { for (c in 0..capacity) { dp[i][c] = dp[i - 1][c] // bỏ qua món i if (weights[i - 1] <= c) { dp[i][c] = maxOf(dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1]) // lấy món i } } } return dp[n][capacity]}int knapsack01(List<int> weights, List<int> values, int capacity) { final n = weights.length; final dp = List.generate(n + 1, (_) => List<int>.filled(capacity + 1, 0)); for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; // bỏ qua món i if (weights[i - 1] <= c) { dp[i][c] = [dp[i][c], dp[i - 1][c - weights[i - 1]] + values[i - 1]] .reduce((a, b) => a > b ? a : b); // lấy món i } } } return dp[n][capacity];}Biến thể: cái túi không giới hạn (Unbounded Knapsack)
Phần tiêu đề “Biến thể: cái túi không giới hạn (Unbounded Knapsack)”Giống hệt bài toán trên, nhưng mỗi món giờ được chọn không giới hạn số lần (miễn còn đủ chỗ). Chỉ một chữ khác trong công thức: khi lấy món i, ta cho phép lấy lại chính món đó thêm lần nữa, nên tham chiếu dp[i][...] (cùng hàng, vì món i vẫn còn “được phép chọn tiếp”) thay vì dp[i-1][...]:
def unbounded_knapsack(weights, values, capacity): n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): for c in range(capacity + 1): dp[i][c] = dp[i - 1][c] if weights[i - 1] <= c: dp[i][c] = max(dp[i][c], dp[i][c - weights[i - 1]] + values[i - 1]) # dp[i], không phải dp[i-1] return dp[n][capacity]#include <vector>#include <algorithm>
int unboundedKnapsack(const std::vector<int>& weights, const std::vector<int>& values, int capacity) { int n = weights.size(); std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0)); for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; if (weights[i - 1] <= c) { dp[i][c] = std::max(dp[i][c], dp[i][c - weights[i - 1]] + values[i - 1]); // dp[i], không phải dp[i-1] } } } return dp[n][capacity];}public static int unboundedKnapsack(int[] weights, int[] values, int capacity) { int n = weights.length; int[][] dp = new int[n + 1][capacity + 1]; for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; if (weights[i - 1] <= c) { dp[i][c] = Math.max(dp[i][c], dp[i][c - weights[i - 1]] + values[i - 1]); // dp[i], không phải dp[i-1] } } } return dp[n][capacity];}fun unboundedKnapsack(weights: IntArray, values: IntArray, capacity: Int): Int { val n = weights.size val dp = Array(n + 1) { IntArray(capacity + 1) } for (i in 1..n) { for (c in 0..capacity) { dp[i][c] = dp[i - 1][c] if (weights[i - 1] <= c) { dp[i][c] = maxOf(dp[i][c], dp[i][c - weights[i - 1]] + values[i - 1]) // dp[i], không phải dp[i-1] } } } return dp[n][capacity]}int unboundedKnapsack(List<int> weights, List<int> values, int capacity) { final n = weights.length; final dp = List.generate(n + 1, (_) => List<int>.filled(capacity + 1, 0)); for (int i = 1; i <= n; i++) { for (int c = 0; c <= capacity; c++) { dp[i][c] = dp[i - 1][c]; if (weights[i - 1] <= c) { dp[i][c] = [dp[i][c], dp[i][c - weights[i - 1]] + values[i - 1]] .reduce((a, b) => a > b ? a : b); // dp[i], không phải dp[i-1] } } } return dp[n][capacity];}Khoảng cách chỉnh sửa (Edit Distance)
Phần tiêu đề “Khoảng cách chỉnh sửa (Edit Distance)”Cho hai chuỗi s và t, tìm số bước chỉnh sửa tối thiểu (thêm, xóa, hoặc thay 1 ký tự) để biến s thành t. Đây là bài toán nền tảng đằng sau tính năng “gợi ý sửa lỗi chính tả” hay diff giữa hai phiên bản văn bản.
Gọi dp[i][j] là số bước tối thiểu để biến i ký tự đầu của s thành j ký tự đầu của t. Xét ký tự cuối cùng của mỗi tiền tố:
- Nếu
s[i-1] == t[j-1], hai ký tự đã khớp nhau, không cần sửa gì thêm ở bước này:dp[i][j] = dp[i-1][j-1]. - Nếu khác nhau, ta chọn phương án rẻ nhất trong ba khả năng rồi cộng thêm 1 bước sửa:
- Thay ký tự cuối của
sthành ký tự cuối củat:dp[i-1][j-1] + 1 - Xóa ký tự cuối của
s:dp[i-1][j] + 1 - Thêm ký tự cuối của
tvàos:dp[i][j-1] + 1
- Thay ký tự cuối của
Điều kiện biên: biến chuỗi rỗng thành chuỗi có k ký tự (hoặc ngược lại) luôn cần đúng k bước, nên dp[0][j] = j và dp[i][0] = i.
def edit_distance(s, t): n, m = len(s), len(t) dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1): dp[i][0] = i for j in range(m + 1): dp[0][j] = j
for i in range(1, n + 1): for j in range(1, m + 1): if s[i - 1] == t[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min( dp[i - 1][j - 1], # thay dp[i - 1][j], # xóa dp[i][j - 1], # thêm )
return dp[n][m]fun editDistance(s: String, t: String): Int { val n = s.length val m = t.length val dp = Array(n + 1) { IntArray(m + 1) }
for (i in 0..n) dp[i][0] = i for (j in 0..m) dp[0][j] = j
for (i in 1..n) { for (j in 1..m) { dp[i][j] = if (s[i - 1] == t[j - 1]) { dp[i - 1][j - 1] } else { 1 + minOf(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) } } } return dp[n][m]}