寫在前面
$ DP $,是每個資訊學競賽選手所必會的演算法,而 $ DP $ 中狀態的轉移又顯得尤為關鍵。本文主要從狀態的設計和轉移入手,利用各種方法對樸素 $ DP $ 的時間複雜度和空間複雜度進行最佳化與處理,以達到滿足題目要求的目的;
參考文獻:
動態規劃演算法的最佳化技巧 毛子青
c++ DP總結
《演算法競賽進階指南》
一. 環形與後效性處理
我們都知道,一個題能用 $ DP $ 來解,需要滿足以下兩個性質:
- 無後效性
- 最優子結構
但對於有些題目,如果要用 $ DP $ 解決的話,會出現環形與後效性的問題;
所謂環形與後效性,即狀態的轉移與 $ DP $ 的方向並不完全一致;
舉個例子,狀態的轉移可以從左到右,也可以從右到左,但 $ DP $ 的方向只能為從左到右或從右到左,此時稱此 $ DP $ 為有後效性;
當狀態初能夠由狀態末轉移而來(此時構成了一個環形)時,此時稱此 $ DP $ 為環形;
對於前者的處理,我們通常會改變 $ DP $ 的遍歷方向,使其能夠與狀態轉移的方向一致,當無法一致時,可以使用迭代的方法取得最優解;
對於後者,我們通常對初始狀態分類討論,找出幾種不是環形的 $ DP $,破環成鏈,分別處理,最後取最優解;
例題
後效性
Luogu CF24D Broken robot
本題的高斯消元處理 $ DP $ 解法不再敘述,考慮 迭代 $ DP $;
設 $ f[i][j] $ 表示從最後一行走到點 $ (i, j) $ 所需的期望步數,則有狀態轉移方程:
顯然,我們 $ DP $ 的方向是向上的,但狀態轉移的方向是上下左右都有的,所以有後效性,要迭代;
#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
int n, m;
int xx, yy;
double f[1005][1005];
int main() {
cin >> n >> m;
cin >> xx >> yy;
if (xx == n) {
cout << "0.0000000000";
return 0;
}
for (int i = n - 1; i >= xx; i--) {
int tt = 65;
while(tt--) {
if (m == 1) {
f[i][1] = 0.5 * f[i][1] + 0.5 * f[i + 1][1] + 1;
} else {
for (int j = 1; j <= m; j++) {
if (j == 1) {
f[i][j] = 1.0 / 3 * f[i + 1][j] + 1.0 / 3 * f[i][j + 1] + 1.0 / 3 * f[i][j] + 1;
} else if (j == m) {
f[i][j] = 1.0 / 3 * f[i][j - 1] + 1.0 / 3 * f[i + 1][j] + 1.0 / 3 * f[i][j] + 1;
} else {
f[i][j] = 0.25 * f[i][j] + 0.25 * f[i + 1][j] + 0.25 * f[i][j - 1] + 0.25 * f[i][j + 1] + 1;
}
}
}
}
}
cout << fixed << setprecision(4) << f[xx][yy];
return 0;
}
環形
Luogu P6064 Naptime G
設計狀態 $ f[i][j][k] $ 表示在每 $ N $ 個小時的前 $ i $ 個小時中,休息 $ j $ 個小時,且第 $ j $ 個小時的狀態( $ 0 $ 代表醒, $ 1 $ 代表睡),則:
如果我們直接轉移的話,初始狀態的睡或不睡會影響後面的轉移(環形),所以分類討論:
- 當初始狀態為醒的時候(正常轉移):
其中
- 當初始狀態為睡的時候(此時要將初始休息的時間算上):
其中
最後將兩個答案合併起來即可;
可以發現,對於環形的分類討論,狀態轉移方程基本一樣,但初始化會有差異;
另外,此題有空間的限制,需要把 $ f $ 陣列的第一維用滾動陣列滾掉;
#include <iostream>
#include <cstring>
using namespace std;
int n, b;
int a[10000005];
int f[2][3831][2]; //0醒, 1睡;
int f1[2][3831][2];
int main() {
cin >> n >> b;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
memset(f, 0xcf, sizeof(f));
memset(f1, 0xcf, sizeof(f1));
f[0][0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= b; j++) {
if (j > i) continue;
f[i & 1][j][0] = max(f[(i - 1) & 1][j][0], f[(i - 1) & 1][j][1]);
f[i & 1][j][1] = max(f[(i - 1) & 1][j - 1][0], f[(i - 1) & 1][j - 1][1] + a[i]);
if (j == 0) f[i & 1][j][1] = 0xcfcfcfcf;
}
}
f1[1][1][1] = a[1];
for (int i = 2; i <= n; i++) {
for (int j = 0; j <= b; j++) {
if (j > i) continue;
f1[i & 1][j][0] = max(f1[(i - 1) & 1][j][0], f1[(i - 1) & 1][j][1]);
f1[i & 1][j][1] = max(f1[(i - 1) & 1][j - 1][0], f1[(i - 1) & 1][j - 1][1] + a[i]);
if (j == 0) f1[i & 1][j][1] = 0xcfcfcfcf;
}
}
cout << max(max(f[n & 1][b][0], f[n & 1][b][1]), f1[n & 1][b][1]);
return 0;
}
二. 倍增最佳化
倍增最佳化的關鍵是找出一個可以隨意劃分的狀態,最後對狀態進行拼接得到答案;
所謂隨意劃分,即此狀態可以拆分成任意多個長度為 $ 2^n $ 的子狀態,且拼接時任意兩個子狀態不互相影響,並且最後的答案就是要求的正確答案;
為什麼一個狀態能夠隨意劃分成任意多個長度為 $ 2^n $ 的子狀態?
對於任意一個正整數,我們可以給他轉變成一個二進位制數,我們知道,一個二進位制數可以表示成 $ 2 $ 的很多次方相加,所以可以;
例題
Luogu P1081 [NOIP2012 提高組] 開車旅行
本題有三個關鍵資訊:已行駛的天數,所在城市,小A和小B各自行駛的路程長度;
若已知出發城市與天數,即可求得小A和小B各自行駛的路程長度,並且依據題意,天數還能反映誰現在在開車,所以我們可以把“天數” 作為“階段”進行狀態設計;
定義 $ f[i][j][k] $ 表示從城市 $ j $ 出發,兩人共行駛 $ i $ 天,$ k $ 先開車,最終會到達的城市;
很顯然,這樣開會炸記憶體,而天數又可以隨意劃分,可以考慮倍增最佳化;
重定義 $ f[i][j][k] $ 表示從城市 $ j $ 出發,兩人共行駛 $ 2^i $ 天,$ k $ 先開車,最終會到達的城市;
其中 $ 0 $ 代表小A先開車, $ 1 $ 代表小B先開車;
對於初始化,我們現在知道誰先開車,要求到那個城市,只需知道小A或小B在某一個城市時,下一個會到哪裡即可,可以預處理出兩個陣列 $ ga[i] $ 和 $ gb[i] $ 分別表示小A在城市 $ i $ 時,下一個會到哪個城市和小B在城市 $ i $ 時,下一個會到哪個城市;
對於問題 $ 2 $,我們可以同時維護兩個陣列 $ da[i][j][k] $ 和 $ db[i][j][k] $ 分別表示從城市 $ j $ 出發,兩人共行駛 $ 2^i $ 天,$ k $ 先開車,小A行駛的路程總長度以及小B行駛的路程總長度;
對於問題 $ 1 $,我們只需列舉出發點,找最小的即可;
則:
- 對於預處理
因為小A和小B只能往後走,所以我們可以從後往前遍歷,並同時維護一個單調遞增的序列(可以用 $ multiset $)其實應該是平衡樹,但我不會,每次只需找當前節點旁邊一位或兩位的最小值和次小值即可(建議參考下面的程式碼);
- 對於初始化
- 對於狀態轉移方程
- 對於 $ da $ 和 $ db $ 的初始化
對於 $ dis $ 的維護,可以在維護單調遞增的序列同時順便維護;
- 對於$ da $ 和 $ db $的狀態轉移方程
這裡 $ i = 1 $ 時不同,因為 \(2^1\) 只能拆成兩個$ 2^0 $ ,$ 2^0 = 1 $ 是奇數,開車的人不同,其它的是偶數,開車的人相同;
#include <iostream>
#include <set>
#include <cmath>
using namespace std;
int n;
int h[10000005];
int x0, m;
struct sss{
long long id, he;
bool operator <(const sss &A) const {
return he < A.he;
}
};
long long f[18][100005][2]; // 0 a, 1 b;
long long da[18][100005][2];
long long db[18][100005][2];
multiset<sss> p;
void init() {
p.insert({0, 9999999999999999});
p.insert({0, 9999999999999999});
p.insert({n + 1, -9999999999999999});
p.insert({n + 1, -9999999999999999}); //防止訪問越界
for (long long i = n; i >= 1; i--) {
long long ga, gb;
p.insert({i, h[i]});
multiset<sss>::iterator q = p.lower_bound({i, h[i]});
q--;
long long lid = (*q).id, lh = (*q).he;
q++;
q++;
long long rid = (*q).id, rh = (*q).he;
q--;
if (abs(rh - h[i]) >= abs(lh - h[i])) {
gb = lid;
q--; q--;
if (abs(rh - h[i]) < abs((*q).he - h[i])) {
ga = rid;
} else {
ga = (*q).id;
}
} else {
gb = rid;
q++; q++;
if (abs((*q).he - h[i]) < abs(lh - h[i])) {
ga = (*q).id;
} else {
ga = lid;
}
}
f[0][i][0] = ga;
f[0][i][1] = gb;
da[0][i][0] = abs(h[ga] - h[i]);
db[0][i][1] = abs(h[gb] - h[i]);
}
}
pair<long long, long long> w(long long s, long long x) {
long long p = s;
long long la = 0;
long long lb = 0;
for (int i = 17; i >= 0; i--) {
if (f[i][p][0] && la + lb + da[i][p][0] + db[i][p][0] <= x) {
la += da[i][p][0];
lb += db[i][p][0];
p = f[i][p][0];
}
}
return {la, lb};
}
int main() {
cin >> n;
for (long long i = 1; i <= n; i++) cin >> h[i];
cin >> x0;
cin >> m;
init();
long long tt = 10;
for (int i = 1; i <= 17; i++) {
for (int j = 1; j <= n; j++) {
for (int k = 0; k <= 1; k++) {
if (i == 1) {
f[i][j][k] = f[0][f[0][j][k]][1 - k];
da[i][j][k] = da[0][f[0][j][k]][1 - k] + da[0][j][k];
db[i][j][k] = db[0][f[0][j][k]][1 - k] + db[0][j][k];
} else {
f[i][j][k] = f[i - 1][f[i - 1][j][k]][k];
da[i][j][k] = da[i - 1][j][k] + da[i - 1][f[i - 1][j][k]][k];
db[i][j][k] = db[i - 1][j][k] + db[i - 1][f[i - 1][j][k]][k];
}
}
}
}
long double ans = 1.00 * 0x3f3f3f3f;
long long an = 0;
for (int i = 1; i <= n; i++) {
pair<long long, long long> a = w(i, x0);
long long la = a.first;
long long lb = a.second;
if (lb == 0) continue;
long double d = 1.00 * la / (1.00 * lb);
if (d < ans) {
ans = d;
an = i;
} else if (d == ans) {
if (h[an] < h[i]) an = i;
}
}
cout << an << endl;
long long a, b;
for (int i = 1; i <= m; i++) {
cin >> a >> b;
pair<long long, long long> c = w(a, b);
cout << c.first << ' ' << c.second << endl;
}
return 0;
}
一般在設計出狀態以後,發現空間複雜度不符合要求,且有狀態能夠隨意劃分,則可以使用倍增最佳化;
三. 資料結構最佳化
適用範圍:
-
時間複雜度能夠忍受 $ \Theta (n \ log \ n) $
-
狀態轉移方程中要維護 $ \max $ 或 $ \min $ 或 $ sum $ 且區間固定;
一般使用線段樹或樹狀陣列
例題
Luogu P4644 [USACO05DEC] Cleaning Shifts S
我們可以定義 $ f[i] $ 表示到第 $ i $ 個時間點時的最小花費,我們用一個結構體儲存每頭牛的資訊($ st $ 為開始時刻,$ ed $為結束時刻, $ w $ 為工資),顯然有狀態轉移方程:
我們發現, $ DP $ 的方向是按照 $ ed $ 的順序從左向右走的,所以先要將結構體按 $ ed $ 的順序排序;
時間複雜度:$ \Theta (n^2) $ 極限資料會被卡;
考慮最佳化;
發現狀態轉移方程有這一項:
對於一個區間固定求最小值的操作,很容易想到用線段樹維護;
具體實現方法:
- 初始化
-
使用線段樹查詢區間 $ [e[i].st - 1, e[i].ed] $ 的最小值並更新(注意這裡要取到 $ e[i].ed $ 因為後面要插入 $ f[i] $ ,這樣做繼承了原來的最小值,便於後面更新);
-
狀態轉移
這裡的 $ ask $ 是線段樹的詢問操作;
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
struct sss{
int st, ed, w;
bool operator <(const sss &A) const {
return ed < A.ed;
}
}e[10000005];
inline int ls(int x) {
return x << 1;
}
inline int rs(int x) {
return x << 1 | 1;
}
struct sas{
int l, r, mi;
}tr[90000005];
int n, m, t;
int f[10000005];
void bt(int id, int l, int r) {
tr[id].l = l;
tr[id].r = r;
if (l == r) {
tr[id].mi = f[l];
return;
}
int mid = (l + r) >> 1;
bt(ls(id), l, mid);
bt(rs(id), mid + 1, r);
tr[id].mi = min(tr[ls(id)].mi, tr[rs(id)].mi);
}
int ask(int id, int l, int r) {
if (r < l) return 0x3f3f3f3f;
if (tr[id].l >= l && tr[id].r <= r) {
return tr[id].mi;
}
int mid = (tr[id].l + tr[id].r) >> 1;
if (r <= mid) return ask(ls(id), l, r);
else if (l > mid) return ask(rs(id), l, r);
else return min(ask(ls(id), l, mid), ask(rs(id), mid + 1, r));
}
void add(int id, int pos, int d) {
if (tr[id].l == tr[id].r) {
tr[id].mi = d;
return;
}
int mid = (tr[id].l + tr[id].r) >> 1;
if (pos <= mid) add(ls(id), pos, d);
else add(rs(id), pos, d);
tr[id].mi = min(tr[ls(id)].mi, tr[rs(id)].mi);
}
int main() {
cin >> n >> m >> t;
m++;
t++;
bool vi = false;
for (int i = 1; i <= n; i++) {
cin >> e[i].st >> e[i].ed >> e[i].w;
e[i].st++;
e[i].ed++; //將時間段轉化為時間點
}
sort(e + 1, e + 1 + n);
memset(f, 0x3f, sizeof(f));
f[m - 1] = 0;
bt(1, m - 1, t);
for (int i = 0; i <= n; i++) {
f[e[i].ed] = min(f[e[i].ed], ask(1, e[i].st - 1, e[i].ed) + e[i].w);
add(1, e[i].ed, f[e[i].ed]);
}
if (f[t] == 0x3f3f3f3f) { //判斷能不能被更新
cout << -1;
} else {
cout << f[t];
}
return 0;
}
四. 單調佇列最佳化
類比資料結構最佳化,單調佇列最佳化的特徵為區間不固定(滑動視窗),佇列頭部在保證合法的狀態下,是現在的最優決策,在尾部將每次更新的決策插入,同時維護佇列的單調性;
適用範圍:1D/1D動態規劃
所謂1D/1D動態規劃,即狀態轉移方程形如
的動態規劃;
對於純單調佇列的最佳化,其中 $ val(i, j) $ 的每一項僅與 $ i $ 和 $ j $ 之中的一個有關(即不能出現 $ i $ 和 $ j $ 的乘積項),這是用單調佇列最佳化的基本條件;
單調佇列最佳化的基本思路:
對於一個狀態 $ i $,我們要做的就是在決策範圍單調變化的同時,快速找出一個最優決策 $ j $,然後更新現在的狀態,單調佇列就是在維護這樣一個合法的決策集合,使我們能夠快速更新現在的狀態;
依據這個思路,我們來分析一下時間複雜度:
假設現在 $ i \in [1, n] $,則:
樸素:列舉一次 $ i $ ,同時內層迴圈列舉 $ j \in [l(i), r(i)] $(一般是 $ j \in [0, i) $ ),時間複雜度為 $ \Theta (n^2) $;
單調佇列最佳化:每個 $ j $ 至多進隊和出隊一次,時間複雜度為 $ \Theta (n) $;
例題
Luogu P2254 [NOI2005] 瑰麗華爾茲
首先很容易想出一個狀態 $ f[k][i][j] $ 代表在第 $ k $ 時刻,在 $ (i, j) $ 所滑行的最長距離;
很容易想出狀態轉移方程:
其中,$ t[k] $ 代表滑行方向;
可以用滾動陣列將第一位滾掉以滿足記憶體需求;
時間複雜度:$ \Theta (Tnm) $ 需要最佳化;
不妨從狀態設計下手,發現時間段的範圍很小,所以可以重定義狀態 $ f[k][i][j] $ 代表在第 $ k $ 個時間段,在 $ (i, j) $ 所滑行的最長距離;
狀態轉移方程:
發現時間複雜度 $ \Theta (kn^2 m^2) $ 需要最佳化;
不難發現,對於一個相同的時間段,移動的方向是固定的,且隨著 $ (i, j) $ 的變化,決策的範圍也在單調變化,且 $ dis $ 僅和 $ i $ 與 $ j $ 有關,所以對於 $ dis $ 可以用單調佇列最佳化;
具體實現操作:
-
分情況確定移動的方向;
-
在此方向維護一個單調佇列存相應的決策 $ j $,每次更新時首先判斷隊頭是否越界(即 $ dis $ 是否大於時間段),如果越界,彈出隊頭;
-
隊頭即為最優決策,用隊頭更新當前狀態;
-
將當前狀態作為決策從隊尾插入,同時維護單調性;
注意: 單調佇列中維護的是決策(即狀態轉移方程中的 $ j $),每次進行第 $ 4 $ 步時需要將決策帶回狀態轉移方程進行判斷;
第一維可以用滾動陣列滾掉;
具體實現請看程式碼:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <deque>
#include <cmath>
using namespace std;
int n, m, x, y, kk;
int a[505][505];
int t[40005];
int f[2][205][205];
int T;
char c;
struct sss{
int st, ed, d;
}e[10000005];
deque<int> q;
int main() {
cin >> n >> m >> x >> y >> kk;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> c;
if (c == '.') {
a[i][j] = 1;
}
if (c == 'x') {
a[i][j] = 0;
}
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[0][i][j] = 0xcfcfcfcf;
}
}
f[0][x][y] = 0;
int p = 0;
for (int i = 1; i <= kk; i++) {
cin >> e[i].st >> e[i].ed >> e[i].d;
}
for (int k = 1; k <= kk; k++) {
p ^= 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[p][i][j] = 0xcfcfcfcf;
}
}
if (e[k].d == 3) {
for (int i = 1; i <= n; i++) {
q.clear(); //注意每次清空佇列;
for (int j = m; j >= 1; j--) {
if (a[i][j] == 0) {
q.clear(); //碰到障礙物後,前面的決策都不可取,所以要清空佇列;
continue;
}
while(!q.empty() && q.front() > j + (e[k].ed - e[k].st + 1)) q.pop_front();
while(!q.empty() && f[p ^ 1][i][j] + j >= f[p ^ 1][i][q.back()] + q.back()) q.pop_back();
q.push_back(j);
f[p][i][j] = max(f[p][i][j], f[p ^ 1][i][q.front()] + q.front() - j);
}
}
}
else if (e[k].d == 4) {
for (int i = 1; i <= n; i++) {
q.clear();
for (int j = 1; j <= m; j++) {
if (a[i][j] == 0) {
q.clear();
continue;
}
while(!q.empty() && q.front() < j - (e[k].ed - e[k].st + 1)) q.pop_front();
while(!q.empty() && f[p ^ 1][i][j] - j >= f[p ^ 1][i][q.back()] - q.back()) q.pop_back();
q.push_back(j);
f[p][i][j] = max(f[p][i][j], f[p ^ 1][i][q.front()] + j - q.front());
}
}
}
else if (e[k].d == 1) {
for (int j = 1; j <= m; j++) {
q.clear();
for (int i = n; i >= 1; i--) {
if (a[i][j] == 0) {
q.clear();
continue;
}
while(!q.empty() && q.front() > i + (e[k].ed - e[k].st + 1)) q.pop_front();
while(!q.empty() && f[p ^ 1][i][j] + i >= f[p ^ 1][q.back()][j] + q.back()) q.pop_back();
q.push_back(i);
f[p][i][j] = max(f[p][i][j], f[p ^ 1][q.front()][j] + q.front() - i);
}
}
}
else if (e[k].d == 2) {
for (int j = 1; j <= m; j++) {
q.clear();
for (int i = 1; i <= n; i++) {
if (a[i][j] == 0) {
q.clear();
continue;
}
while(!q.empty() && q.front() < i - (e[k].ed - e[k].st + 1)) q.pop_front();
while(!q.empty() && f[p ^ 1][i][j] - i >= f[p ^ 1][q.back()][j] - q.back()) q.pop_back();
q.push_back(i);
f[p][i][j] = max(f[p][i][j], f[p ^ 1][q.front()][j] - q.front() + i);
}
}
}
}
int ans = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
ans = max(ans, f[p][i][j]);
}
}
cout << ans;
return 0;
}
不難發現,在單調佇列最佳化時,我們通常將狀態看做常量,將決策看做變數,每次保證決策的單調性,是做這部分題的小技巧;
推薦一道題
Luogu P2569 [SCOI2010] 股票交易
五. 斜率最佳化
剛剛我們探討了單調佇列最佳化的基本操作,讓我們回顧一下1D/1D動態規劃的狀態轉移方程:
我們知道,當$ val(i, j) $ 的每一項僅與 $ i $ 和 $ j $ 之中的一個有關(即不能出現 $ i $ 和 $ j $ 的乘積項)時,可以用單調佇列最佳化;
如果有乘積項,就需要用到斜率最佳化;
當出現乘積項時,我們很容易聯想到平面直角座標系中形如 $ y = kx + b $ 的一次函式,依據這個思想,我們來探討斜率最佳化;
斜率最佳化的主要思想:及時排除無用決策;
接下來,我們依據例題來解釋斜率最佳化;
例題
Luogu P5785 [SDOI2012] 任務安排
首先求出 $ T $, $ C $ 的字首和 $ sumt $ 和 $ sumc $ ;
定義$ f[i][j] $ 表示前 $ i $ 個任務分成 $ j $ 批施行的最小費用,很容易得出狀態轉移方程:
時間複雜度:$ \Theta (n^3) $
考慮最佳化;
不妨設題目中 $ t $ 都為正整數;
不難發現,狀態的第二維(批次)是一個附加狀態,即它不是求答案的直接手段,只是求答案的一個附加手段;
考慮最佳化掉這一維;
其實當我們在執行一批任務時,並不是關心它之前機器啟動了多少次,而是應該關心機器的啟動對現在狀態所耽誤的時間;
如果不知道之前機器啟動了多少次,怎麼去求機器的啟動對現在狀態所耽誤的時間?
我們可以換一個角度思考,機器因執行一批任務所花費的啟動時間 $ S $ 會累加到後續所有任務完成的時間之中,對此,我們引入一個思想,叫做費用提前計算,也就是說,當每啟動一次機器時,就把它對之後的所有影響(一直到 $ n $)全部計算在內,這樣就達到了求出最小費用的最佳化;
依據上述思路,我們定義 $ f[i] $ 表示把前 $ i $ 個任務分成若干批執行的最小費用,有狀態轉移方程:
值得注意的是,在費用提前計算的思想下,只有目標 $ f[n] $ 是正確的,其它結果(例如 $ f[n - 1] $)是偏大的;
時間複雜度:$ \Theta (n^2) $
還是不行,再次考慮最佳化;
發現這個最佳化過的狀態轉移方程滿足1D/1D的動態規劃,且 $ val $ 項有乘積項,考慮斜率最佳化;
將方程展開,得到:
因為在平面直角座標系中,縱座標是隨橫座標變化的,所以我們去掉 $ \min $,將只含 $ j $ 的項移到等式左面,剩下的項移到等式右面,可得:
在以 $ sumc[j] $ 為橫座標,$ f[j] $ 為縱座標的平面直角座標系中,這是一條以 $ S + sumt[i] $ 為斜率,$ f[i] - sumt[i] * sumc[i] - S * sumc[n] $ 為截距的直線;
這也就是說,我們的決策其實也就對應這個平面直角座標系中的一個個點,這條直線就對應著我們現在的狀態,要用決策去更新狀態,需要讓這條線去撞決策點;
如圖所示;
現在我們想要讓 $ f[i] $ 最小,觀察得截距中其它項都是常數,那麼我們讓截距最小,所經過的點即為最優決策;
現在我們來考慮一個點能成為最優決策的條件,並依此排除無用決策;
如圖,發現當這三個點構成一個“上凸”形時,點 $ 2 $ 不可能成為最優決策;
如圖,發現當這三個點構成一個“下凸”形時,這三個點都可能成為最優決策;
發現:一個決策點 $ 2 $ 能夠成為最優決策,當且僅當:
這裡有個技巧,當我們求斜率時,最好保證兩邊都為正數,以省去不必要的計算與變號
當取等號時, \(j_2\) 與 \(j_3\) 同時成為最優決策;
於是,我們只需用單調佇列維護一個下凸殼(如第二張圖)即可,其中斜率單調遞增;
當我們找最優決策時,可以發現最優決策點與左邊點的連線斜率比 $ S + sumt[i] $ 小,右邊比它大;
所以,總的操作為:
-
檢查隊首斜率與直線斜率 $ S + sumt[i] $ 的關係,若前者小於等於後者,根據斜率 $ S + sumt[i] $ 的單調遞增性,需使隊首出隊;
-
此時,隊首即為最優決策,更新現在的狀態;
-
依據 $ sumc[j] $ 的單調遞增性,我們從隊尾將已經更新的狀態作為一個新的決策插入,同時維護決策斜率的單調遞增性;
在進行第三步時,我們將 $ i $ 與隊尾建立聯絡,求出斜率,同時將此斜率與隊尾斜率進行比較,以維護斜率的單調遞增性;
時間複雜度最佳化到了 $ \Theta (n) $;
到這,實現的程式碼為:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
int n, s;
int t[1000005], c[1000005];
int f[1000005];
int sumt[1000005], sumc[1000005];
int main() {
cin >> n >> s;
for (int i = 1; i <= n; i++) cin >> t[i] >> c[i];
sumt[1] = t[1];
sumc[1] = c[1];
for (int i = 1; i <= n; i++) {
sumt[i] = sumt[i - 1] + t[i];
sumc[i] = sumc[i - 1] + c[i];
}
memset(f, 0x3f, sizeof(f));
f[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
f[i] = min(f[i], f[j] + sumt[i] * (sumc[i] - sumc[j]) + s * (sumc[n] - sumc[j]));
}
}
cout << f[n];
return 0;
}
但我們討論的都是 $ t $ 都為正整數的情況,如果不是呢(即 $ sumt $ 並不是單調遞增的)?
很顯然,我們上述過程中的第一步就不對了,那也沒有關係,此時問題是隊首不一定是最優決策,所以我們不能彈出隊首,而必須維護整個凸殼,依據決策斜率的單調性,每次二分查詢滿足上述過程中的第一步並更新即可;
隊尾操作不變;
時間複雜度:$ \Theta (n \ log \ n) $,可以透過本題;
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
#define int long long
int n, s;
int t[1000005], c[1000005];
int f[1000005];
int sumt[1000005], sumc[1000005];
int q[10000005];
int l, r;
int bi(int k) {
if (l == r) return q[l];
int L = l, R = r;
while(L <= R) {
int mid = (L + R) >> 1;
if (f[q[mid + 1]] - f[q[mid]] <= k * (sumc[q[mid + 1]] - sumc[q[mid]])) L = mid + 1;
else R = mid - 1;
}
return q[L];
}
main() {
cin >> n >> s;
for (int i = 1; i <= n; i++) cin >> t[i] >> c[i];
sumt[1] = t[1];
sumc[1] = c[1];
for (int i = 1; i <= n; i++) {
sumt[i] = sumt[i - 1] + t[i];
sumc[i] = sumc[i - 1] + c[i];
}
memset(f, 0x3f, sizeof(f));
f[0] = 0;
l = 0, r = 0;
q[0] = 0;
for (int i = 1; i <= n; i++) {
int j = bi(s + sumt[i]);
f[i] = min(f[i], f[j] - (s + sumt[i]) * sumc[j] + sumt[i] * sumc[i] + s * sumc[n]);
while(l < r && (f[q[r]] - f[q[r - 1]]) * (sumc[i] - sumc[q[r]]) >= (f[i] - f[q[r]]) * (sumc[q[r]] - sumc[q[r - 1]])) r--;
q[++r] = i;
}
cout << f[n];
return 0;
}
推薦題目
Luogu CF311B Cats Transport
斜率最佳化一般會結合字首和,在求解斜率時,可以化除為乘,但不易查錯。也可以將斜率寫為函式,但容易丟精度;
六. 四邊形不等式最佳化
四邊形不等式定義:
其中 $ w(a, b) $ 表示定義在整數集合上的二元函式;
定理
對於形如
的狀態轉移方程,設 $ p[i] $ 是 $ f[i] $ 的最優決策,若 $ p $ 在定義域內單調不減,則稱 $ f $ 具有決策單調性;
在狀態轉移方程
中,若函式 $ val $ 滿足四邊形不等式,則稱 $ f $ 具有決策單調性;
顯然,在 $ p $ 陣列中,決策是連續的,如圖:
這顯示了 $ p $ 陣列中儲存的決策;
維護一個三元組 $ j, l, r $, $ j $ 代表當前段內的決策,$ l $ $ r $ 分別代表當前決策的左右區間(管轄範圍);
用單調佇列維護 $ p $ 陣列,可以概括為以下幾個步驟:
- 檢查隊頭,設隊頭三元組為 $ j, l, r $,若 $ r < i $,彈出隊頭;
設隊尾三元組為 $ j_0, l_0, r_0 $,則:
-
若對於 $ f[l_0] $ 來說,$ i $ 比 $ j_0 $ 更優,則刪除隊尾;
-
否則,在隊尾二分查詢,找到一個位置,使得在這個位置及右邊 $ i $ 比 $ j_0 $ 更優,左邊$ j_0 $ 比 $ i $ 更優,這個位置記為 $ pos $;
-
將三元組 $ i, pos, n $ 插入隊尾;
結語
概括來講,$ DP $ 最佳化思路為:
-
有可隨意劃分的,用倍增最佳化;
-
發現環形,破環成鏈,或者複製一倍在末尾,用環形處理的思路;
-
狀態轉移的方向與 $ DP $ 方向不一,用後效性處理的思路;
-
狀態轉移方程需要在定區間內查詢最值等等,用資料結構最佳化;
-
1D/1D的動態規劃,需要維護動態區間,當 $ val $ 中有乘積項時,用斜率最佳化。沒有時用單調佇列最佳化;
-
當 $ val $ 滿足四邊形不等式時,依據決策的單調性最佳化;
總之, $ DP $ 最佳化因題而異,做好最佳化需要我們快速且正確的設計出狀態轉移方程,打好基礎,才能做到掌握最佳化;