Tính số Fibonacci thứ 50 đúng theo định nghĩa tốn hơn lần gọi hàm, dù chỉ có 51 số khác nhau cần biết: cùng một số bị tính đi tính lại vô số lần. Ghi mỗi số lại khi tính xong thì chỉ còn 51 bước.
Quy hoạch động (dynamic programming, viết tắt DP hay QHĐ) là đúng ý đó: đáp án của bài lớn ghép từ đáp án của vài bài nhỏ cùng loại, và mỗi bài nhỏ chỉ giải một lần. Cái khó nằm ở thiết kế: bài nhỏ là gì, ghép ra sao, tính theo thứ tự nào. Bài này luyện ba câu hỏi đó trên các bài một chiều quen thuộc và trên quy hoạch động theo đoạn.
ITừ đệ quy đến bảng
Dãy Fibonacci có , và . Viết đúng như định nghĩa:
ll fib(ll n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
Gọi là số lệnh gọi hàm (kể cả lệnh gọi đầu tiên) khi tính fib(n). Ta có và , từ đó chứng minh được (thử với vài nhỏ để thấy). Vì cứ mỗi bước gấp lên khoảng 1,618 lần nên và , trong khi chỉ có giá trị khác nhau.
Nguyên nhân thấy rõ trên hình: fib(3) luôn trả về cùng một giá trị dù được gọi từ đâu, vì kết quả của một bài toán con chỉ phụ thuộc vào đối số. Vậy lần đầu tính xong ta cất kết quả đi, các lần sau lấy ra. Có hai cách cài.
Đệ quy có nhớ (memoization) giữ nguyên hàm trên, thêm một bảng memo ghi giá trị đã biết; trước khi gọi, mọi ô được gán , nghĩa là chưa tính:
ll memo[100];
ll fib(ll n) {
if (n < 2) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n - 1) + fib(n - 2);
}
Thân hàm của mỗi fib(k) chạy nhiều nhất một lần và tốn ngoài các lệnh gọi con, nên cả bài là . Đây là quy tắc đếm dùng suốt bài: thời gian xấp xỉ số trạng thái nhân chi phí chuyển của mỗi trạng thái.
Lập bảng (bottom-up) bỏ hẳn đệ quy: tính theo thứ tự tăng, mỗi ô chỉ dùng các ô đã có.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, f[100];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
f[1] = 1;
for (ll i = 2; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
cout << f[n] << '\n';
}
50
12586269025
là số Fibonacci lớn nhất còn vừa long long; đã vượt . Vòng lặp chỉ nhìn hai ô liền trước nên bộ nhớ có thể rút xuống hai biến.
Đệ quy có nhớ viết thẳng từ công thức và chỉ chạm tới những trạng thái thật sự cần, nhưng tốn chi phí gọi hàm và độ sâu đệ quy (hàng triệu tầng dễ tràn ngăn xếp). Lập bảng nhanh hơn và dễ nén bộ nhớ, đổi lại ta phải tự xác định thứ tự tính. Các mục sau dùng bảng; đệ quy có nhớ là phương án dự phòng khi thứ tự tính khó nhìn ra.
Ví dụBậc Đá Tối Ưu
Đề. Có bậc đá, bậc cao . Robot đứng ở bậc 1, mỗi lần nhảy sang bậc hoặc , tốn . In tổng chi phí nhỏ nhất để tới bậc .
Lời giải. Gọi là chi phí nhỏ nhất để tới bậc . Bước nhảy cuối cùng tới đến từ bậc hoặc , nên , với và . Giống hệt Fibonacci, chỉ thay cộng bằng lấy min. Vét cạn mọi đường nhảy là đường; bảng chỉ có ô, mỗi ô . Với ví dụ, .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, h[N], dp[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> h[i];
dp[2] = abs(h[2] - h[1]);
for (ll i = 3; i <= n; i++)
dp[i] = min(dp[i - 1] + abs(h[i] - h[i - 1]), dp[i - 2] + abs(h[i] - h[i - 2]));
cout << dp[n] << '\n';
}
5
8 13 4 10 7
7
IITrạng thái và công thức
Fibonacci cho sẵn công thức. Ở đa số bài, phần khó nhất là tự tìm ra nó. Một lời giải quy hoạch động luôn trả lời đủ bốn câu:
- Trạng thái. Bài toán con được mô tả bởi những tham số nào, và
dp[...]nghĩa chính xác là gì (đếm gì, tối ưu gì, với điều kiện gì)? - Công thức chuyển.
dpcủa một trạng thái tính từdpcủa những trạng thái nào? Cách tìm: xét lựa chọn cuối cùng (phần tử cuối, bước cuối, vật cuối) rồi hỏi phần còn lại là bài toán con nào. - Giá trị cơ sở. Trạng thái nhỏ nhất nào có đáp án biết sẵn?
- Thứ tự tính và đáp án. Thứ tự nào bảo đảm khi cần một ô thì mọi ô nó phụ thuộc đã có, và đáp án nằm ở ô nào?
Công thức chuyển chỉ đúng khi có hai tính chất. Thứ nhất là tối ưu con: phần còn lại của một phương án tối ưu cũng phải tối ưu cho bài con của nó, vì nếu không ta thay bằng phương án tốt hơn thì cả phương án lớn cũng tốt hơn. Với bài đếm, tính chất này đổi thành: các trường hợp (ví dụ “phần tử cuối là gì”) phải rời nhau và phủ hết mọi cấu hình, để mỗi cấu hình được đếm đúng một lần. Thứ hai là trạng thái đủ thông tin: khi đã biết dp của trạng thái, các quyết định về sau không cần biết ta tới đó bằng đường nào. Tính chất thứ hai dễ bị bỏ quên nhất.
Dãy con tăng dài nhất (longest increasing subsequence, LIS). Cho dãy . Dãy con là dãy thu được bằng cách xóa một số phần tử và giữ nguyên thứ tự những phần tử còn lại. Tìm độ dài lớn nhất của một dãy con tăng nghiêm ngặt. Có dãy con nên vét cạn không đi xa được.
Trạng thái: là độ dài dãy con tăng dài nhất kết thúc đúng tại . Phần tử cuối nằm trong định nghĩa vì chỉ biết nó ta mới biết có nối thêm được hay không. Công thức chuyển: xét phần tử đứng ngay trước trong dãy tối ưu. Nếu không có thì dãy chỉ gồm , độ dài 1. Nếu đó là với và thì phần còn lại là một dãy tăng kết thúc tại , và dài nhất có thể là :
Ký hiệu có điều kiện viết bên dưới nghĩa là lấy số lớn nhất trong các giá trị thỏa điều kiện đó: ở đây là lớn nhất trong các có , và số 0 lo trường hợp không có nào. viết kiểu này cũng đọc tương tự.
Vì sao chỉ cần ? Nếu phần đứng trước kết thúc ở mà chưa phải dãy dài nhất kết thúc ở , thay nó bằng dãy dài nhất ấy vẫn cho một dãy tăng hợp lệ (điều kiện nối không đổi) mà dài hơn: mâu thuẫn. Thứ tự tính là tăng dần, đáp án là số lớn nhất trong các . Với ta được , đáp án 4.
Mảng par[i] ghi phần tử đứng trước trong dãy tốt nhất. Đi ngược từ ô tốt nhất theo par rồi đảo lại ta in được chính dãy con. Đó là truy vết (reconstruction), dùng lại nhiều lần ở các mục sau.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 5005;
ll n, a[N], dp[N], par[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
ll best = 1;
for (ll i = 1; i <= n; i++) {
dp[i] = 1;
for (ll j = 1; j < i; j++)
if (a[j] < a[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
par[i] = j;
}
if (dp[i] > dp[best]) best = i;
}
vector<ll> seq;
for (ll i = best; i; i = par[i]) seq.push_back(a[i]);
reverse(seq.begin(), seq.end());
cout << dp[best] << '\n';
for (ll x : seq) cout << x << ' ';
cout << '\n';
}
8
5 2 8 6 3 6 9 7
4
2 3 6 9
Thời gian là : với có khoảng cặp , thoải mái; với thì không, và khi đó ta tính nhanh hơn bằng chặt nhị phân (bài 0.6) hoặc cây Fenwick (bài 1.7). Code trên theo kiểu kéo: mỗi nhìn lại các đứng trước. Có thể viết kiểu đẩy: khi đã xong thì cập nhật cho mọi nối được. Hai kiểu tính cùng một công thức, chọn kiểu nào cho vòng lặp gọn hơn.
Hỏi nhanh
Một học sinh đặt là độ dài dãy con tăng dài nhất nằm trong phần tử đầu (không bắt buộc kết thúc tại ), rồi thử viết qua các giá trị nhỏ hơn. Vì sao cách đặt này không đi tiếp được?
không giảm khi tăng và bảng chỉ có ô, nên lựa chọn thứ nhất và thứ ba không phải lý do. Bài toán vẫn có tối ưu con, ta đã chứng minh với . Chỗ hỏng là thiếu thông tin: có thể ứng với dãy kết thúc ở 4 hoặc kết thúc ở 100, và chỉ nối được trong trường hợp đầu. Khi công thức cần một thông tin mà trạng thái không giữ, ta đưa thông tin ấy vào trạng thái, như điều kiện “kết thúc tại ” ở trên.
Ví dụLịch Hoạt Động
Đề. Kế hoạch ngày; mỗi ngày chọn đúng một trong ba hoạt động , nhận điểm, và không được chọn cùng một hoạt động hai ngày liền. In tổng điểm lớn nhất.
Lời giải. Thử đặt là tổng điểm lớn nhất sau ngày thì hỏng đúng như câu hỏi trên: muốn biết hôm nay chọn được hoạt động nào, phải biết hôm qua đã chọn gì. Đưa thông tin đó vào trạng thái: là tổng điểm lớn nhất sau ngày với ngày chọn hoạt động . Hôm qua là một trong hai hoạt động khác , nên , với . Đáp án là số lớn nhất trong ba ô . Trạng thái chỉ thêm đúng phần thiếu: một trong ba giá trị, nên bảng ô.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, a[N][3], dp[N][3];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++)
for (ll j = 0; j < 3; j++) cin >> a[i][j];
for (ll i = 1; i <= n; i++)
for (ll j = 0; j < 3; j++)
for (ll k = 0; k < 3; k++)
if (k != j) dp[i][j] = max(dp[i][j], dp[i - 1][k] + a[i][j]);
cout << max({dp[n][0], dp[n][1], dp[n][2]}) << '\n';
}
4
8 3 5
6 9 4
7 2 10
5 11 6
38
IIIQuy hoạch động trên đoạn
Có bài mà bài con tự nhiên là một đoạn liên tục của dãy: lời giải của đoạn dài ghép từ lời giải của hai đoạn ngắn hơn, nối nhau ở một điểm tách .
Ví dụ: một thanh gỗ dài đã đánh dấu vị trí cần cắt, . Mỗi nhát cắt một mảnh gỗ dài tốn . Phải cắt tại mọi vị trí đánh dấu theo thứ tự tùy ý; tìm tổng chi phí nhỏ nhất. Thứ tự có ảnh hưởng: thanh dài 10 đánh dấu ở 2, 4, 7. Cắt ở 2 trước thì tốn , cắt ở 7 trước tốn , cắt ở 4 trước tốn . Có thứ tự nên không thể thử hết.
Ta nhìn vào nhát cắt đầu tiên trên một mảnh. Nó cắt tại một vị trí nào đó và tốn đúng chiều dài mảnh; sau nhát này hai nửa tách rời hẳn và được cắt xong một cách độc lập. Đặt , và gọi là chi phí nhỏ nhất để cắt xong mảnh từ tới :
Đáp án là . Hai bài con ở vế phải đều là đoạn ngắn hơn, nên thứ tự tính phải theo độ dài tăng dần. Với : , , ; rồi , ; cuối cùng .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll L, m, p[505], dp[505][505];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> L >> m;
for (ll i = 1; i <= m; i++) cin >> p[i];
p[m + 1] = L;
for (ll len = 2; len <= m + 1; len++)
for (ll i = 0; i + len <= m + 1; i++) {
ll j = i + len;
dp[i][j] = 1e18;
for (ll k = i + 1; k < j; k++) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
dp[i][j] += p[j] - p[i];
}
cout << dp[0][m + 1] << '\n';
}
10 3
2 4 7
20
Có trạng thái, mỗi trạng thái thử điểm tách, tổng . Số phép tính thật là khoảng (số bộ ba ), nên tốn cỡ còn thì không chạy nổi. Bài 4.2 sẽ cho thấy khi nào hạ được xuống .
Hỏi nhanh
Trong quy hoạch động trên đoạn, phụ thuộc và với . Cách duyệt nào đảm bảo mọi ô cần dùng đã được tính?
Mỗi ô cần các đoạn ngắn hơn nên phải tính từ ngắn đến dài. Với tăng dần, ô có nằm ở hàng chưa tới. Với giảm dần, ô có nằm ở cột chưa tới. Duyệt độ dài giảm dần tính đoạn dài trước, lúc đó chưa đoạn ngắn nào có giá trị. Một cách đúng khác không có trong danh sách là cho giảm dần rồi tăng dần từ .
Ví dụGộp Các Khối Kề Nhau
Đề. Có khối thành hàng, khối cỡ . Mỗi thao tác gộp hai khối kề nhau cỡ , thành một khối cỡ , tốn . In tổng chi phí nhỏ nhất để còn một khối.
Lời giải. Lần ghép cuối cùng gộp một khối tạo từ đoạn với một khối tạo từ đoạn , và tốn đúng tổng cả đoạn, bất kể trước đó gộp ra sao. Hai nửa được gộp độc lập. Vậy , chi phí nhỏ nhất để gộp đoạn khối thành một khối, bằng , với là tổng tiền tố và . Ở đây đoạn là đoạn khối đóng chứ không phải đoạn giữa hai vị trí cắt như thanh gỗ, nên điểm tách chạy từ tới . Với ví dụ : tách giữa và cho . Thời gian cỡ ; chi phí tới cỡ nên dùng ll.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, s[405], dp[405][405];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> s[i];
s[i] += s[i - 1];
}
for (ll len = 2; len <= n; len++)
for (ll l = 1; l + len - 1 <= n; l++) {
ll r = l + len - 1;
dp[l][r] = 1e18;
for (ll k = l; k < r; k++) dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
dp[l][r] += s[r] - s[l - 1];
}
cout << dp[1][n] << '\n';
}
4
2 2 1 2
14
Khi đi thi
- Trước khi gõ code, viết ra ba dòng:
dp[...]nghĩa là gì, công thức chuyển, thứ tự tính; rồi lấy số trạng thái nhân chi phí chuyển so với giới hạn. Thử công thức bằng tay trên một test cỡ 5 trước khi cài. - Chọn thứ tự tính theo phụ thuộc: trạng thái được dùng phải tính xong trước; quy hoạch động trên đoạn thì tính theo độ dài tăng dần.
- Trạng thái không tồn tại phải là chứ không phải 0 khi có số âm; kiểm tra trước khi cộng vào vô cực, và dùng
long longhay modulo ngay khi đề nhắc tới tổng lớn hoặc “số cách”.
Tóm tắt
- Quy hoạch động là giải mỗi bài con đúng một lần và lưu lại; thời gian xấp xỉ số trạng thái nhân chi phí chuyển.
- Bốn việc phải làm: định nghĩa trạng thái bằng lời, tìm công thức chuyển bằng cách xét lựa chọn cuối, đặt giá trị cơ sở, chọn thứ tự tính. Trạng thái phải đủ thông tin cho các quyết định sau; thiếu thì thêm đúng phần thiếu.
- Quy hoạch động trên đoạn tính theo độ dài tăng dần, thường tốn .
Bài tập
Bài tập ngoài
Trên đoạn
DequeAtCoderIIIĐọc thêm
- Quy hoạch động cơ bản (Phần 1)VNOI Wiki
- Introduction to Dynamic Programmingcp-algorithms
- Introduction to DPUSACO Guide
- KnapsackUSACO Guide
- 动态规划基础OI Wiki
- E - Knapsack 2, lời giải trên AtCoder