Hai dạng quy hoạch động hay gặp có chuyển tiếp tốn mỗi trạng thái. Chia dãy thành đoạn: , tổng . Quy hoạch động trên đoạn: , tổng . Khi điểm đạt min, gọi là , di chuyển đơn điệu theo trạng thái, ta không cần thử mọi hay mọi . Chia để trị đưa dạng thứ nhất về ; Knuth đưa dạng thứ hai về .
Điểm tối ưu đơn điệu
Xét một tầng: , với là tầng trước. Gọi là nhỏ nhất đạt min. Nhiều chi phí thỏa : đoạn cuối kết thúc càng xa thì điểm bắt đầu của nó càng không lùi. Khi đó tính bằng chia để trị: tính cho ở giữa bằng cách thử mọi trong khoảng ứng viên ; được , thì nửa trái chỉ cần thử , nửa phải . Mỗi tầng đệ quy có tổng độ dài các khoảng ứng viên , có tầng, nên một lớp tốn , lớp tốn .
Điều kiện đủ hay dùng là bất đẳng thức tứ giác: với mọi . Bình phương tổng đoạn, số cặp bằng nhau trong đoạn, tổng khoảng cách tới trung vị đều thỏa. Trong lúc thi, cách kiểm nhanh nhất là chạy bản trên dữ liệu ngẫu nhiên nhỏ, in và xem có giảm lần nào không.
Hỏi nhanh
Vì sao tối ưu chia để trị chỉ tốn cho một lớp, dù mỗi có thể thử nhiều ứng viên?
Ở cùng một độ sâu, các đoạn của những lời gọi khác nhau nối tiếp nhau và chỉ dùng chung điểm chia, nên tổng số ứng viên là cộng số lời gọi. Chi phí là cần thiết nhưng không phải lý do cho cận .
Ví dụBình Phương Các Khối
Đề. Dãy số dương, chia thành đúng đoạn, chi phí mỗi đoạn là bình phương tổng của nó. Tìm tổng nhỏ nhất.
Lời giải. là chi phí nhỏ nhất chia phần tử đầu thành đoạn: với , là tổng tiền tố. Điểm chia tốt nhất không giảm theo , nên mỗi tầng tính được bằng chia để trị như trên trong thay vì .
Bước 1: chi phí một đoạn. Tổng tiền tố tính tại chỗ khi đọc.
ll cost(ll l, ll r) {
return (pre[r] - pre[l - 1]) * (pre[r] - pre[l - 1]);
}
Bước 2: chia để trị một tầng. f là tầng , g là tầng . solve(l, r, optl, optr) tính g[mid] bằng cách thử mọi trong khoảng ứng viên và nhớ tốt nhất bo; rồi nửa trái chỉ thử , nửa phải chỉ thử . Bỏ qua f[j] == INF (tiền tố chưa chia được thành đoạn) để khỏi cộng tràn.
void solve(ll l, ll r, ll optl, ll optr) {
if (l > r) return;
ll mid = (l + r) >> 1, best = INF, bo = optl;
for (ll j = optl; j <= min(mid - 1, optr); j++) {
if (f[j] == INF) continue;
ll v = f[j] + cost(j + 1, mid);
if (v < best) {
best = v;
bo = j;
}
}
g[mid] = best;
solve(l, mid - 1, optl, bo);
solve(mid + 1, r, bo, optr);
}
Bước 3: tầng. Tầng 0 chỉ có f[0] = 0; mỗi tầng g[0] = INF vì không chia 0 phần tử thành đoạn, xong thì chép g sang f.
for (ll t = 1; t <= k; t++) {
g[0] = INF;
solve(1, n, 0, n - 1);
for (ll i = 0; i <= n; i++) f[i] = g[i];
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 3005, INF = 4e18;
ll n, k, pre[N], f[N], g[N];
ll cost(ll l, ll r) {
return (pre[r] - pre[l - 1]) * (pre[r] - pre[l - 1]);
}
void solve(ll l, ll r, ll optl, ll optr) {
if (l > r) return;
ll mid = (l + r) >> 1, best = INF, bo = optl;
for (ll j = optl; j <= min(mid - 1, optr); j++) {
if (f[j] == INF) continue;
ll v = f[j] + cost(j + 1, mid);
if (v < best) {
best = v;
bo = j;
}
}
g[mid] = best;
solve(l, mid - 1, optl, bo);
solve(mid + 1, r, bo, optr);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
cin >> pre[i];
pre[i] += pre[i - 1];
f[i] = INF;
}
for (ll t = 1; t <= k; t++) {
g[0] = INF;
solve(1, n, 0, n - 1);
for (ll i = 0; i <= n; i++) f[i] = g[i];
}
cout << f[n] << '\n';
}
5 2
1 3 2 4 2
72
Mỗi tầng đệ quy của solve thử tổng cộng ứng viên, có tầng: phép, so với .
Chi phí từ tổng tiền tố hai chiều
Khi chi phí của đoạn là tổng một ma trận trên hình vuông , như tổng xung đột giữa mọi cặp người cùng toa, tổng tiền tố hai chiều (bài 02) cho nó trong : , và chi phí là một nửa vì mỗi cặp được đếm hai lần. Chi phí này thỏa bất đẳng thức tứ giác khi các phần tử ma trận không âm. Với cỡ vài nghìn, chú ý bộ nhớ: bảng số int khoảng 64 MB.
Ví dụCác Toa Yên Tĩnh
Đề. hành khách xếp hàng, ma trận xung đột là chữ số 0 đến 9. Chia thành đúng toa (đoạn liên tiếp), chi phí mỗi toa là tổng trên các cặp trong toa. Tìm tổng nhỏ nhất.
Lời giải. Cùng khuôn chia để trị như ví dụ trước; chỉ đổi hàm chi phí. Chi phí một toa là tổng trên các cặp trong toa, lấy ra trong từ tổng tiền tố hai chiều.
Bước 1: tổng tiền tố hai chiều khi đọc. Mỗi hàng ma trận là một xâu chữ số, cộng dồn ngay. Giá trị không quá nên dùng int, để bảng chỉ tốn khoảng 64 MB.
for (ll i = 1; i <= n; i++) {
string s;
cin >> s;
for (ll j = 1; j <= n; j++) S[i][j] = S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + (s[j - 1] - '0');
}
Bước 2: chi phí một toa. Lấy tổng hình vuông rồi chia 2 vì mỗi cặp nằm hai lần trong ma trận đối xứng (đường chéo bằng 0).
int cost(ll l, ll r) {
return (S[r][r] - S[l - 1][r] - S[r][l - 1] + S[l - 1][l - 1]) / 2;
}
Bước 3: tầng chia để trị. Hàm solve giống hệt ví dụ trước, đổi sang int.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 4005;
const int INF = 2e9;
ll n, k;
int S[N][N], f[N], g[N];
int cost(ll l, ll r) {
return (S[r][r] - S[l - 1][r] - S[r][l - 1] + S[l - 1][l - 1]) / 2;
}
void solve(ll l, ll r, ll optl, ll optr) {
if (l > r) return;
ll mid = (l + r) >> 1, bo = optl;
int best = INF;
for (ll j = optl; j <= min(mid - 1, optr); j++) {
if (f[j] == INF) continue;
int v = f[j] + cost(j + 1, mid);
if (v < best) {
best = v;
bo = j;
}
}
g[mid] = best;
solve(l, mid - 1, optl, bo);
solve(mid + 1, r, bo, optr);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
string s;
cin >> s;
for (ll j = 1; j <= n; j++) S[i][j] = S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + (s[j - 1] - '0');
}
for (ll i = 1; i <= n; i++) f[i] = INF;
for (ll t = 1; t <= k; t++) {
g[0] = INF;
solve(1, n, 0, n - 1);
for (ll i = 0; i <= n; i++) f[i] = g[i];
}
cout << f[n] << '\n';
}
5 2
02879
20168
81027
76201
98710
12
Khoảng lần tính chi phí.
Chi phí tính bằng hai con trỏ
Có chi phí không tách được thành hiệu các tổng tiền tố, như số cặp phần tử bằng nhau trong đoạn. Nhưng trong chia để trị, các đoạn được hỏi liên tiếp nhau thay đổi rất ít: với một , chạy liên tục trong khoảng ứng viên; sang lời gọi con, và khoảng ứng viên chỉ dời một nửa. Giữ một cửa sổ cùng bảng đếm như thuật toán Mo (bài 25), mỗi lần hỏi thì dời hai biên tới đoạn cần. Tổng số bước dời của một lớp là , nên cả bài với hằng số nhỏ.
Ví dụCặp Trùng Trong Đoạn
Đề. Dãy số, chia thành đúng đoạn. Chi phí một đoạn là số cặp chỉ số trong nó có giá trị bằng nhau. Tìm tổng nhỏ nhất.
Lời giải. Chi phí số cặp trùng thỏa bất đẳng thức tứ giác, nên điểm chia tốt nhất đơn điệu và chia để trị dùng được. Khó khăn là chi phí không có công thức . Nhưng trong solve, các đoạn được hỏi thay đổi từng chút một, nên tính chi phí bằng một cửa sổ kiểu Mo (bài 25): giữ cửa sổ hiện tại và dời hai biên tới đoạn cần hỏi.
Bước 1: cửa sổ đếm cặp trùng. cl, cr, cur là cửa sổ hiện tại và số cặp trùng trong đó; cnt đếm số lần mỗi giá trị. Phần tử mới tạo thêm cnt[a[i]] cặp với các bản đã có, rồi tăng đếm; bỏ thì làm ngược lại.
void add(ll i) {
cur += cnt[a[i]]++;
}
void del(ll i) {
cur -= --cnt[a[i]];
}
Bước 2: dời cửa sổ tới . Mở rộng hai biên trước, rồi mới thu hẹp, để cửa sổ không bao giờ “âm”.
ll cost(ll l, ll r) {
while (cr < r) add(++cr);
while (cl > l) add(--cl);
while (cr > r) del(cr--);
while (cl < l) del(cl++);
return cur;
}
Bước 3: chia để trị giống hệt các ví dụ trước. Trong solve, các đoạn được hỏi theo tăng dần, mỗi lần biên trái dời một bước; giữa các lời gọi, và khoảng ứng viên đổi theo cấu trúc chia đôi. Đề bảo đảm giá trị không quá nên cnt dùng mảng trực tiếp.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, INF = 4e18;
ll n, k, a[N], cnt[N], f[N], g[N], cl = 1, cr = 0, cur = 0;
void add(ll i) {
cur += cnt[a[i]]++;
}
void del(ll i) {
cur -= --cnt[a[i]];
}
ll cost(ll l, ll r) {
while (cr < r) add(++cr);
while (cl > l) add(--cl);
while (cr > r) del(cr--);
while (cl < l) del(cl++);
return cur;
}
void solve(ll l, ll r, ll optl, ll optr) {
if (l > r) return;
ll mid = (l + r) >> 1, best = INF, bo = optl;
for (ll j = optl; j <= min(mid - 1, optr); j++) {
if (f[j] == INF) continue;
ll v = f[j] + cost(j + 1, mid);
if (v < best) {
best = v;
bo = j;
}
}
g[mid] = best;
solve(l, mid - 1, optl, bo);
solve(mid + 1, r, bo, optr);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
f[i] = INF;
}
for (ll t = 1; t <= k; t++) {
g[0] = INF;
solve(1, n, 0, n - 1);
for (ll i = 0; i <= n; i++) f[i] = g[i];
}
cout << f[n] << '\n';
}
7 3
1 1 3 3 3 2 1
1
Tổng số bước dời cửa sổ của một tầng là : cả bài .
Knuth cho quy hoạch động trên đoạn
Ghép các khối kề nhau, cắt thanh theo nhiều điểm, dựng cây tìm kiếm tối ưu: . Nếu thỏa bất đẳng thức tứ giác và đơn điệu theo bao hàm ( khi ), thì điểm chia tốt nhất thỏa
Tính theo độ dài đoạn tăng dần, và với đoạn chỉ thử trong khoảng trên. Tổng số lần thử trên mọi đoạn cùng độ dài là một tổng lồng: , nên cả bài thay cho .
Hỏi nhanh
Trong tối ưu Knuth, đoạn chỉ thử điểm chia từ tới . Vì sao tổng số lần thử của mọi đoạn cùng độ dài là ?
Đoạn tiếp theo bắt đầu thử từ , đúng chỗ đoạn trước dừng. Các khoảng thử nối đuôi nhau, nên tổng độ dài của chúng bị chặn bởi cộng số đoạn; nhân với độ dài được .
Ví dụGhép Theo Thứ Tự
Đề. bộ, mỗi bộ khối xếp hàng. Ghép hai khối kề tốn tổng kích thước của chúng. Tìm tổng chi phí nhỏ nhất để ghép tất cả thành một.
Lời giải. Quy hoạch động khoảng: , với là tổng kích thước đoạn. thỏa cả hai điều kiện của Knuth, nên điểm chia tốt nhất kẹp giữa và . Bảng khá nặng: chi phí tối ưu không quá tổng kích thước nhân (gộp kiểu cây cân bằng), khoảng , nên dp dùng int; opt dùng short vì chỉ số không quá 5000. Tổng hai bảng khoảng 150 MB.
Bước 1: đoạn độ dài 1. dp = 0, opt = i; tổng tiền tố tính ngay khi đọc.
for (ll i = 1; i <= n; i++) {
cin >> pre[i];
pre[i] += pre[i - 1];
dp[i][i] = 0;
opt[i][i] = i;
}
Bước 2: đoạn dài dần, chỉ thử trong khoảng kẹp. Đoạn chỉ thử từ opt[l][r - 1] tới opt[l + 1][r] (kẹp không quá ); hai giá trị này đã có vì đoạn ngắn hơn được tính trước. cộng sau khi chọn , vì không phụ thuộc .
for (ll len = 2; len <= n; len++)
for (ll l = 1; l + len - 1 <= n; l++) {
ll r = l + len - 1, lo = opt[l][r - 1], hi = min((ll)opt[l + 1][r], r - 1);
int best = INF;
ll bm = lo;
for (ll m = lo; m <= hi; m++) {
int v = dp[l][m] + dp[m + 1][r];
if (v < best) {
best = v;
bm = m;
}
}
dp[l][r] = best + (pre[r] - pre[l - 1]);
opt[l][r] = bm;
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5005;
const int INF = 2e9;
int dp[N][N];
short opt[N][N];
ll pre[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll T;
cin >> T;
while (T--) {
ll n;
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> pre[i];
pre[i] += pre[i - 1];
dp[i][i] = 0;
opt[i][i] = i;
}
for (ll len = 2; len <= n; len++)
for (ll l = 1; l + len - 1 <= n; l++) {
ll r = l + len - 1, lo = opt[l][r - 1], hi = min((ll)opt[l + 1][r], r - 1);
int best = INF;
ll bm = lo;
for (ll m = lo; m <= hi; m++) {
int v = dp[l][m] + dp[m + 1][r];
if (v < best) {
best = v;
bm = m;
}
}
dp[l][r] = best + (pre[r] - pre[l - 1]);
opt[l][r] = bm;
}
cout << dp[1][n] << '\n';
}
}
2
3
5 1 4
4
8 2 2 8
15
36
Với một độ dài cố định, khoảng thử của các đoạn liền nhau nối đuôi nhau, tổng độ dài không quá cộng số đoạn: cả bài thay vì .
Khi đi thi
- Chia dãy thành đoạn với vài nghìn tới và chi phí “càng dài càng tệ một cách lồi”: thử chia để trị. Kiểm đơn điệu bằng bản chậm trên dữ liệu ngẫu nhiên nhỏ trước khi tin.
- Chi phí không tách được bằng tiền tố: hai con trỏ kiểu Mo ngay trong hàm chi phí; mở rộng trước, thu hẹp sau.
- Quy hoạch động đoạn : Knuth ; tính bộ nhớ của hai bảng , dùng
inthayshortnếu giá trị cho phép. Khi không cố định mà đề cho phạt mỗi đoạn, nghĩ tới kỹ thuật Alien (bài 78).
Tóm tắt
- Một lớp với đơn điệu: chia để trị trên , khoảng ứng viên thu hẹp theo , mỗi lớp.
- Bất đẳng thức tứ giác của chi phí bảo đảm đơn điệu.
- Chi phí đoạn: tổng tiền tố một hay hai chiều, hoặc cửa sổ Mo khi không tách được.
- Knuth: cho quy hoạch động đoạn, .
Bài tập
Đọc thêm
- Một số kĩ thuật tối ưu hoá thuật toán Quy Hoạch ĐộngVNOI WikiChia để trị, Knuth và các tối ưu khác, viết bằng tiếng Việt.
- Divide and Conquer DPcp-algorithmsĐiều kiện và cài đặt chia để trị.
- Knuth's Optimizationcp-algorithmsChứng minh cận của Knuth.
- Divide & Conquer - DPUSACO GuideBài luyện tối ưu chia để trị.