Quy hoạch động (Dynamic Programming - DP) là phương pháp giải bài toán bằng cách chia bài toán lớn thành các bài toán nhỏ hơn, lưu kết quả của những bài toán nhỏ để sử dụng lại khi cần, giúp tránh tính toán lặp lại nhiều lần.
Ví dụ: Tính tổng các chữ số của số 1545.
Ta có:
1 + 5 + 4 + 5 = 15
Thông thường, chương trình phải tách từng chữ số để tính tổng. Nhưng nếu ta đã biết tổng chữ số của số 154 thì có thể tính tổng chữ số của số 1545 nhanh hơn.
Cụ thể:
tong(154) = 1 + 5 + 4 = 10
tong(1545) = tong(154) + 5
= 10 + 5
= 15
Đây chính là ý tưởng cơ bản của quy hoạch động: Sử dụng kết quả đã biết để tính kết quả mới.
Dãy số Fibonacci được đặt theo tên của nhà toán học người Ý Leonardo Fibonacci,
Ta dùng bài toán Fibonacci đơn giản dưới đây
Ví dụ: Tính số Fibonacci thứ n
Ý tưởng: Muốn tính số Fibonacci thứ i, ta lấy hai số đứng trước cộng lại và lưu kết quả vào mảng.
#include <iostream>
using namespace std;
int main()
{
int n = 10;
int f[100];
f[0] = 0;
f[1] = 1;
for (int i = 2; i <= n; i++)
{
f[i] = f[i - 1] + f[i - 2];
}
cout << f[n];
return 0;
}Khi n = 10, chương trình tính lần lượt:
| i | Công thức | f[i] |
|---|---|---|
| 0 | Gán ban đầu | 0 |
| 1 | Gán ban đầu | 1 |
| 2 | f[1] + f[0] = 1 + 0 | 1 |
| 3 | f[2] + f[1] = 1 + 1 | 2 |
| 4 | f[3] + f[2] = 2 + 1 | 3 |
| 5 | f[4] + f[3] = 3 + 2 | 5 |
| 6 | f[5] + f[4] = 5 + 3 | 8 |
| 7 | f[6] + f[5] = 8 + 5 | 13 |
| 8 | f[7] + f[6] = 13 + 8 | 21 |
| 9 | f[8] + f[7] = 21 + 13 | 34 |
| 10 | f[9] + f[8] = 34 + 21 | 55 |
Kết quả: f[10] = 55.
Ý tưởng: Viết hàm đệ quy tự nhiên, lưu lại kết quả của các bài toán con đã tính vào một mảng/bảng để không phải tính lại.
Ví dụ tính số Fibonacci trong C++:
#include <iostream>
#include <vector>
using namespace std;
long long memo[100];
long long fib(int n) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n]; // Nếu đã tính thì trả về luôn
return memo[n] = fib(n - 1) + fib(n - 2);
}
int main() {
int n = 10;
fill_n(memo, 100, -1);
cout << "Fibonacci(" << n << ") = " << fib(n);
return 0;
}
Ý tưởng: Khởi tạo từ các trường hợp cơ sở nhỏ nhất, dùng vòng lặp để xây dựng dần lên đến bài toán lớn.
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n = 10;
vector<long long> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]; // Công thức truy hồi
}
cout << "Fibonacci(" << n << ") = " << dp[n];
return 0;
}Muốn tính tổng chữ số của một số nguyên dương n, ta có thể tách n thành hai phần:
Ví dụ, với n = 1545:
1545 / 10 = 154 1545 % 10 = 5
Do đó:
tong(1545) = tong(154) + 5
Tổng quát, ta có công thức:
tong[n] = tong[n / 10] + n % 10
Trong đó:
Trước khi tính các số lớn hơn, ta cần biết kết quả của số nhỏ nhất.
Số 0 không có chữ số nào khác 0, vì vậy tổng chữ số của số 0 bằng 0.
tong[0] = 0;
Đây là trạng thái cơ sở để chương trình tính các trạng thái tiếp theo.
Ta sử dụng vòng lặp để tính tổng chữ số của các số từ 1 đến MAXN.
for (int i = 1; i <= MAXN; i++)
{
tong[i] = tong[i / 10] + i % 10;
}Ví dụ với các số từ 0 đến 15:
| Số i | tong[i / 10] | i % 10 | tong[i] |
|---|---|---|---|
| 0 | - | - | 0 |
| 1 | 0 | 1 | 1 |
| 2 | 0 | 2 | 2 |
| 3 | 0 | 3 | 3 |
| 4 | 0 | 4 | 4 |
| 5 | 0 | 5 | 5 |
| 6 | 0 | 6 | 6 |
| 7 | 0 | 7 | 7 |
| 8 | 0 | 8 | 8 |
| 9 | 0 | 9 | 9 |
| 10 | 1 | 0 | 1 |
| 11 | 1 | 1 | 2 |
| 12 | 1 | 2 | 3 |
| 13 | 1 | 3 | 4 |
| 14 | 1 | 4 | 5 |
| 15 | 1 | 5 | 6 |
Ví dụ: Khi tính tong[15], chương trình đã có tong[1] = 1 nên:
tong[15] = tong[1] + 5 = 6
Điều quan trọng là khi tính tong[i], giá trị tong[i / 10] đã được tính trước đó.
#include <iostream>
using namespace std;
const int MAXN = 1000000;
int tong[MAXN + 1];
void khoiTao()
{
for (int i = 1; i <= MAXN; i++)
{
tong[i] = tong[i / 10] + i % 10;
}
}
int tongchuso(int n)
{
if (n < 0 || n > MAXN)
return -1;
return tong[n];
}
int main()
{
khoiTao();
cout << tongchuso(1545) << endl;
cout << tongchuso(12345) << endl;
cout << tongchuso(6789) << endl;
return 0;
}
Kết quả:
15 15 30
Nếu không sử dụng quy hoạch động, mỗi lần tính tổng chữ số, chương trình phải dùng vòng lặp để tách từng chữ số.
Ví dụ, muốn tính tổng chữ số của 1545, chương trình phải thực hiện các phép chia và lấy phần dư nhiều lần.
Với quy hoạch động, chương trình tính trước kết quả cho tất cả các số từ 0 đến MAXN.
Sau đó, khi cần tính tổng chữ số của 1545, chỉ cần lấy:
return tong[1545];
| Tiêu chí | Tính trực tiếp | Quy hoạch động |
|---|---|---|
| Thời gian khởi tạo | Không cần | O(MAXN) |
| Mỗi lần tính mới | O(log n) | O(1) sau khởi tạo |
| Mỗi lần tra cứu | O(log n) | O(1) |
| Bộ nhớ | O(1) | O(MAXN) |
Lưu ý: Quy hoạch động không phải lúc nào cũng nhanh hơn. Nếu chỉ tính tổng chữ số của một vài số, tính trực tiếp thường phù hợp hơn vì không cần khởi tạo cả mảng. Quy hoạch động có lợi khi cần tra cứu nhiều lần trong một phạm vi đã xác định.
Bài 1: Dựa vào công thức quy hoạch động, hãy tính tổng chữ số của các số 123, 4567 và 9999.
Bài 2: Thay MAXN bằng 100000. Hãy tính tổng chữ số của số 98765.
Bài 3: Viết chương trình tính tổng chữ số của nhiều số nhập từ bàn phím bằng cách sử dụng mảng quy hoạch động.
Trạng thái cơ sở → Công thức chuyển → Thứ tự tính → Lưu kết quả → Sử dụng lại.
THUẬT TOÁN QUY HOẠCH ĐỘNG (DYNAMIC PROGRAMMING) TRONG C++