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 khuôn hay gặp nhất: dãy con, đổi tiền, ba lô, bảng hai chiều và đoạn; phần lớn bài quy hoạch động ở các chương sau là biến thể của chúng.
Từ đệ 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
Trạ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 05) hoặc cây Fenwick (bài 16). 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
Đổi tiền
Có các mệnh giá , mỗi mệnh giá dùng bao nhiêu đồng cũng được. Tạo tổng đúng bằng bằng ít đồng nhất.
Cách nghĩ đầu tiên là tham lam (bài 04): luôn lấy đồng lớn nhất không vượt phần còn lại. Với các mệnh giá và , tham lam lấy (3 đồng), trong khi chỉ cần 2 đồng. Lấy đồng lớn trước có thể phá hỏng phần còn lại, nên ta phải xét mọi khả năng một cách có hệ thống.
Trạng thái: là số đồng ít nhất để tạo tổng đúng , bằng nếu không tạo được. Công thức chuyển: trong một phương án tối ưu cho , lấy ra một đồng bất kỳ, mệnh giá . Phần còn lại tạo tổng và (tối ưu con) phải dùng ít đồng nhất có thể. Ta chưa biết là mệnh giá nào nên thử tất cả:
Số hạng nào bằng thì bỏ qua. Duyệt tăng dần vì phụ thuộc các ô nhỏ hơn. Với ta có ; riêng , đạt khi lấy đồng 3 ở bước cuối.
Đổi câu hỏi thành đếm số cách thì trước hết phải nói rõ hai cách khác nhau khi nào. Hai cách hiểu cho hai bài toán, và hai đoạn code chỉ khác nhau ở thứ tự của hai vòng for.
Nếu và là hai cách (dãy có thứ tự), mỗi dãy có đúng một phần tử cuối , bỏ nó đi còn một dãy tạo tổng . Vậy , tức tổng các qua mọi mệnh giá (ký hiệu là phép cộng, điều kiện viết bên dưới cho biết cộng những số nào), với (dãy rỗng). Tổng nằm ở vòng ngoài, mệnh giá ở vòng trong.
Nếu chỉ quan tâm mỗi mệnh giá dùng bao nhiêu đồng (tập mệnh giá), ta xử lý lần lượt từng mệnh giá để mỗi tập chỉ được sinh một lần. Gọi là số cách tạo chỉ dùng mệnh giá đầu. Hoặc không dùng mệnh giá , còn cách; hoặc dùng ít nhất một đồng , bỏ một đồng ấy đi thì còn cách, và ở đó vẫn được dùng tiếp:
Nén thành một mảng, mệnh giá ở vòng ngoài và tăng dần ở vòng trong: dp[s] += dp[s - c]. Duyệt tăng chính là để dp[s - c] đã chứa các cách dùng . Chương trình dưới in số dãy có thứ tự (f) rồi số tập mệnh giá (g).
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7, N = 1e6 + 5;
ll n, x, c[105], f[N], g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 0; i < n; i++) cin >> c[i];
f[0] = g[0] = 1;
for (ll s = 1; s <= x; s++)
for (ll i = 0; i < n; i++)
if (c[i] <= s) f[s] = (f[s] + f[s - c[i]]) % MOD;
for (ll i = 0; i < n; i++)
for (ll s = c[i]; s <= x; s++) g[s] = (g[s] + g[s - c[i]]) % MOD;
cout << f[x] << '\n' << g[x] << '\n';
}
2 5
1 2
8
3
Với và hai mệnh giá 1, 2: có 3 tập (, , năm đồng 1) nhưng 8 dãy có thứ tự, vì ba tập đó cho lần lượt 3, 4 và 1 cách xếp. Đổi chỗ hai vòng for là đổi sang bài toán khác, nên phải đọc kỹ đề xem “cách” có phân biệt thứ tự không.
Hỏi nhanh
Mệnh giá , , dp[0] = 1. Chương trình có vòng ngoài duyệt từ 1 tới , vòng trong duyệt từng mệnh giá và cộng dp[s] += dp[s - c]. Cuối cùng dp[3] bằng bao nhiêu và nó đếm gì?
Tính tay: dp[1] = dp[0] = 1, dp[2] = dp[1] + dp[0] = 2, dp[3] = dp[2] + dp[1] = 3. Với tổng ở vòng ngoài, mỗi dãy được phân loại theo phần tử cuối của nó, nên và là hai dãy khác nhau. Để đếm tập mệnh giá phải đưa mệnh giá ra vòng ngoài và được 2. Chương trình không có phép nào nên con số không liên quan tới “ít đồng nhất”, và không có chuyện đếm trùng thành 4, vì mỗi dãy có đúng một phần tử cuối nên chỉ rơi vào đúng một số hạng của tổng.
Ba lô 0/1
Khi mỗi vật chỉ lấy được một lần, ta có bài toán ba lô 0/1 (knapsack). Có vật, vật nặng và có giá trị . Chọn một tập vật có tổng trọng lượng không quá sức chứa sao cho tổng giá trị lớn nhất. Vét cạn tập thì với là vô vọng.
Mỗi vật có hai khả năng, lấy hoặc không lấy, và có lấy được hay không còn tùy sức chứa còn lại. Vậy trạng thái cần hai tham số: là tổng giá trị lớn nhất khi chỉ xét vật đầu và tổng trọng lượng không quá . Xét vật : không lấy thì còn ; lấy (nếu ) thì phần còn lại là vật đầu với sức chứa .
Đáp án là . Bảng có ô, mỗi ô , tổng thời gian .
Hình dùng bốn vật và . Ô cuối vì lấy thêm vật 4 chỉ được , kém 9. Ô (lấy vật 3) và (lấy vật 2). Đường truy ngược cho tập , nặng 6 và giá trị 9.
Điều kiện “không quá ” ứng với khởi tạo cho mọi . Nếu đề đòi trọng lượng đúng bằng , chỉ còn các ô khác khởi tạo (hoặc khi tối thiểu hóa).
Hàng chỉ dùng hàng nên ta giữ một mảng dp[c] và sửa tại chỗ: dp[c] = max(dp[c], dp[c - w] + v). Công thức cần dp[c - w] của hàng cũ, tức khi chưa xét vật . Vì , nếu duyệt giảm dần thì ô chưa bị sửa trong lượt này. Duyệt tăng dần thì dp[c - w] đã chứa vật và vật bị lấy lại nhiều lần; đó chính là ba lô không giới hạn số lần, tức bài đổi tiền ở mục trước. Hướng duyệt quyết định bài toán. Với một vật , và : duyệt giảm cho , duyệt tăng cho vì vật được lấy hai lần.
for (ll i = 1; i <= n; i++)
for (ll c = C; c >= w[i]; c--) dp[c] = max(dp[c], dp[c - w[i]] + v[i]);
Bộ nhớ giảm từ xuống . Với và , bảng đầy đủ có ô, tức 400 MB với int, còn mảng một chiều chỉ 400 KB. Mảng nhỏ còn nằm gọn trong bộ nhớ đệm của CPU, nên chạy nhanh hơn hẳn:
Cùng trạng thái, một mảng duyệt giảm nhanh gấp khoảng 7 lần bảng hai chiều và hơn 20 lần đệ quy có nhớ. Không cần truy vết thì hãy nén.
Hỏi nhanh
Ta sửa vòng lặp ba lô thành for (ll c = w; c <= C; c++) (duyệt tăng dần). Chạy trên đúng một vật có , và sức chứa . Chương trình in ra bao nhiêu?
Duyệt tăng: dp[3] = 5, rồi dp[4] = dp[1] + 5 = 5, dp[5] = 5, dp[6] = dp[3] + 5 = 10 (ô 3 đã chứa vật nên vật được lấy lần hai), dp[7] = dp[4] + 5 = 10. Lần lấy thứ ba cần sức chứa 9 nên không tới 15. Đáp án đúng của bài 0/1 là 5; con số 10 cho thấy chương trình đã giải nhầm sang bài ba lô không giới hạn. Ô dp[3] vẫn được cập nhật nên không thể bằng 0.
Bảng hai chiều, truy vết
Khi trạng thái là một ô của bảng, công thức chuyển thường lấy từ các ô kề đã tính. Xét bảng số nguyên (có thể âm). Đi từ ô tới ô , mỗi bước sang phải hoặc xuống dưới. Tìm đường có tổng các ô đi qua lớn nhất và in đường đó. Số đường là , quá nhiều để duyệt.
Trạng thái: là tổng lớn nhất của một đường từ tới . Bước cuối tới đến từ ô trên hoặc ô trái:
Duyệt hàng từ trên xuống, mỗi hàng từ trái sang phải: khi tới cả hai ô nguồn đã có. Các ô ngoài bảng (hàng 0, cột 0) phải là , không phải 0. Nếu đặt 0 thì một đường “xuất phát từ ngoài bảng” sẽ thắng mỗi khi mọi đường thật có tổng âm.
Muốn in đường, ta đi ngược từ . Tại ô chọn ô nguồn có lớn hơn, vì đó chính là ô đã cho ra ; ghi lại bước vừa lùi và dừng khi tới , rồi đảo chuỗi bước. Không cần mảng mũi tên riêng, vì bảng dp đã chứa đủ thông tin để suy ra mọi lựa chọn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll R, C, a[105][105], dp[105][105];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> R >> C;
for (ll i = 1; i <= R; i++)
for (ll j = 1; j <= C; j++) cin >> a[i][j];
for (ll i = 0; i <= R; i++)
for (ll j = 0; j <= C; j++) dp[i][j] = -1e18;
for (ll i = 1; i <= R; i++)
for (ll j = 1; j <= C; j++) {
if (i == 1 && j == 1) dp[i][j] = a[i][j];
else dp[i][j] = a[i][j] + max(dp[i - 1][j], dp[i][j - 1]);
}
string path;
ll i = R, j = C;
while (i > 1 || j > 1) {
if (dp[i - 1][j] >= dp[i][j - 1]) {
path += 'D';
i--;
} else {
path += 'R';
j--;
}
}
reverse(path.begin(), path.end());
cout << dp[R][C] << '\n' << path << '\n';
}
3 4
2 -3 4 1
-1 5 -2 3
3 -4 6 -1
9
DRRDR
Đường tối ưu đi xuống, phải, phải, xuống, phải: . Cái giá của truy vết là phải giữ cả bảng; nén thành một hàng như ở ba lô thì mất đường đi. Cùng khuôn này, thay max bằng phép cộng và ô cấm bằng 0 ta đếm được số đường; thay hai chiều của bảng bằng hai chuỗi ta được ví dụ dưới.
Hỏi nhanh
Bảng một hàng ba ô . Một chương trình dùng công thức cho mọi ô, kể cả , nhưng khởi tạo các ô viền (hàng 0, cột 0) bằng 0. Nó in ra gì cho ô ?
Tính tay: ; ; . Viền 0 giả vờ rằng có đường đi vào từ phía trên với tổng 0, nên đường được “nhảy qua” ô . Đường duy nhất thật có tổng . Viền phải là , vì ô ngoài bảng không có đường nào đi qua. Viền 0 chỉ vô hại khi mọi ô đều không âm, nên lựa chọn đầu tiên sai ngay khi bảng có số âm. Hai con số 5 và là và , tức các ô đứng trước chứ không phải ô được hỏi.
Ví dụChuỗi Chung Dài Nhất
Đề. Cho hai chuỗi chữ thường , , mỗi chuỗi dài tối đa 3000. In một dãy con chung dài nhất (bất kỳ nếu có nhiều); độ dài 0 thì in dòng rỗng.
Lời giải. Trạng thái là một cặp tiền tố: là độ dài dãy con chung dài nhất của ký tự đầu của và ký tự đầu của . Ô chỉ cần xét hai ký tự cuối:
với hàng 0 và cột 0 bằng 0. Khi , một dãy con chung không thể dùng cả hai ký tự ấy (nếu dùng thì cả hai là ký tự cuối của dãy, nên bằng nhau), nên phải bỏ một trong hai. Khi , ghép hai ký tự ấy với nhau không bao giờ thiệt: nếu dãy tối ưu chỉ dùng một trong hai, đổi cách ghép ký tự cuối sang đúng cặp vẫn được dãy cùng độ dài.
Truy vết từ ô : ký tự bằng nhau thì ghi lại và đi chéo, khác thì đi tới ô kề có giá trị lớn hơn. Mỗi lần ghi, giảm đúng 1, nên ghi được đúng ký tự; đảo lại là xong. Bảng có ô; dùng int thì khoảng 36 MB, và phải giữ cả bảng để truy vết. Chuỗi đánh số từ 0 (s[i - 1]) nhưng bảng đánh số từ 1, vì là độ dài tiền tố.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
string s, t;
int dp[3005][3005];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> s >> t;
ll n = s.size(), m = t.size();
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= m; j++) {
if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
string ans;
ll i = n, j = m;
while (i > 0 && j > 0) {
if (s[i - 1] == t[j - 1]) {
ans += s[i - 1];
i--;
j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) i--;
else j--;
}
reverse(ans.begin(), ans.end());
cout << ans << '\n';
}
abcde
ace
ace
Quy 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 41 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
Chọn trạng thái
Phần lớn thời gian của một bài quy hoạch động nằm ở việc chọn trạng thái. Vài nguyên tắc giúp chọn nhanh hơn.
Đếm trước khi code. Thời gian xấp xỉ số trạng thái nhân chi phí chuyển. Giới hạn của đề cho biết cỡ nào còn chạy được: từ đến cho phép trạng thái một chiều với chuyển hoặc ; cho bảng với chuyển ; cho như quy hoạch động trên đoạn; hai tham số nhỏ, ví dụ và tổng giá trị , cho ba lô. Một trạng thái đúng nhưng cần phép thì phải đổi trạng thái, tối ưu code không cứu được.
Thiếu thông tin thì thêm vào trạng thái, và chỉ thêm đúng phần thiếu. Đếm xâu nhị phân độ dài không có hai số 1 liền nhau: muốn biết được thêm bit 1 hay không phải biết bit cuối, nên trạng thái là với là bit cuối. Thêm 0 thì sau bit nào cũng được, thêm 1 chỉ sau bit 0: và . Với có xâu: 000, 001, 010, 100, 101.
Giới hạn lớn thì đổi vai. Nếu một chiều của bảng quá lớn mà một đại lượng khác lại nhỏ, thử đưa đại lượng nhỏ vào trạng thái và dùng đại lượng lớn làm giá trị cần tối ưu. Ví dụ dưới làm đúng như vậy với ba lô.
Chuyển nhiều thì dùng tiền tố hoặc cấu trúc dữ liệu. Nếu là tổng (hoặc min, max) của một dãy ô liên tiếp ở hàng trước, ta tính nhanh bằng tổng tiền tố (bài 02) hay cấu trúc dữ liệu (bài 16, 17), hạ chi phí chuyển từ xuống hoặc . Chẳng hạn truy vấn “ trên mọi có ” của LIS được cây Fenwick trả lời trong , nên cả bài còn .
Modulo và kiểu dữ liệu. Bài đếm thường đòi kết quả theo modulo : lấy dư sau mỗi phép cộng, với phép trừ thì cộng thêm modulo trước khi lấy dư để khỏi âm. Bài tối ưu tràn int rất nhanh (tổng số cỡ đã tới ): chọn long long từ đầu, và chọn giá trị vô cực đủ lớn nhưng cộng thêm không tràn.
Kiểm tra. Hầu hết lời giải sai nằm ở định nghĩa trạng thái hoặc công thức, hiếm khi ở cú pháp. Viết bản vét cạn cho nhỏ (bài 00) và so với bản quy hoạch động trên nhiều test ngẫu nhiên; khi lệch, in bảng dp của một test cỡ 5 rồi tính tay vài ô.
Ví dụBa Lô Theo Tổng Giá Trị
Đề. Có vật, vật nặng và có giá trị ; ba lô chịu tổng trọng lượng . Chọn một tập vật, mỗi vật tối đa một lần, có tổng trọng lượng không quá để tổng giá trị lớn nhất.
Lời giải. Bảng ba lô theo trọng lượng cần ô, không khả thi. Nhưng tổng mọi giá trị lại nhỏ, nên đổi vai: là tổng trọng lượng nhỏ nhất của một tập có tổng giá trị đúng , với và các ô khác . Mỗi vật sửa mảng với giảm dần: . Đáp án là lớn nhất có , và luôn thỏa. Giữ trọng lượng nhỏ nhất cho mỗi giá trị là đủ, vì ràng buộc chỉ là tổng trọng lượng không quá : tập nhẹ hơn cùng giá trị luôn thay được tập nặng hơn.
Thời gian , bộ nhớ . Trọng lượng cộng lại tới nên dùng ll, vô cực đặt để cộng thêm một trọng lượng vẫn không tràn. Với ví dụ, vật 1 và 2 cho giá trị 13, nặng đúng 10; mọi giá trị lớn hơn đều cần từ 11 trở lên.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, C, w[105], v[105], dp[100005];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> C;
ll S = 0;
for (ll i = 0; i < n; i++) {
cin >> w[i] >> v[i];
S += v[i];
}
for (ll x = 1; x <= S; x++) dp[x] = 1e18;
for (ll i = 0; i < n; i++)
for (ll x = S; x >= v[i]; x--) dp[x] = min(dp[x], dp[x - v[i]] + w[i]);
ll ans = 0;
for (ll x = 0; x <= S; x++)
if (dp[x] <= C) ans = x;
cout << ans << '\n';
}
4 10
6 8
4 5
5 7
3 4
13
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 chiều duyệt theo phụ thuộc: ba lô 0/1 duyệt sức chứa giảm dần, đổi tiền và ba lô không giới hạn duyệt tăng dần, đếm tập mệnh giá thì mệnh giá ở vòng ngoài, quy hoạch động đoạn 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.
- Đổi tiền: tham lam có thể sai. Đếm dãy có thứ tự thì tổng ở vòng ngoài, đếm tập mệnh giá thì mệnh giá ở vòng ngoài.
- Ba lô 0/1 nén một mảng và duyệt sức chứa giảm dần; duyệt tăng dần thành ba lô không giới hạn. Khi một chiều quá lớn, đổi vai trọng lượng và giá trị.
- Truy vết dựa trên bảng đầy đủ: đi ngược từ ô đáp án qua ô đã cho ra giá trị; nén mảng làm mất truy vết.
- Bảng hai chiều dùng viền ; quy hoạch động đoạn tính theo độ dài tăng dần với thời gian .
Bài tập
Đọ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