[DP] DP最佳化總結

Peppa_Even_Pig發表於2024-06-12

寫在前面

$ DP $,是每個資訊學競賽選手所必會的演算法,而 $ DP $ 中狀態的轉移又顯得尤為關鍵。本文主要從狀態的設計和轉移入手,利用各種方法對樸素 $ DP $ 的時間複雜度和空間複雜度進行最佳化與處理,以達到滿足題目要求的目的;

參考文獻:
動態規劃演算法的最佳化技巧 毛子青
c++ DP總結
《演算法競賽進階指南》

一. 環形與後效性處理

我們都知道,一個題能用 $ DP $ 來解,需要滿足以下兩個性質:

  1. 無後效性
  2. 最優子結構

但對於有些題目,如果要用 $ DP $ 解決的話,會出現環形與後效性的問題;

所謂環形與後效性,即狀態的轉移與 $ DP $ 的方向並不完全一致;

舉個例子,狀態的轉移可以從左到右,也可以從右到左,但 $ DP $ 的方向只能為從左到右從右到左,此時稱此 $ DP $ 為有後效性;

當狀態初能夠由狀態末轉移而來(此時構成了一個環形)時,此時稱此 $ DP $ 為環形;

對於前者的處理,我們通常會改變 $ DP $ 的遍歷方向,使其能夠與狀態轉移的方向一致,當無法一致時,可以使用迭代的方法取得最優解;

對於後者,我們通常對初始狀態分類討論,找出幾種不是環形的 $ DP $,破環成鏈,分別處理,最後取最優解;

例題

後效性

Luogu CF24D Broken robot

本題的高斯消元處理 $ DP $ 解法不再敘述,考慮 迭代 $ DP $;

設 $ f[i][j] $ 表示從最後一行走到點 $ (i, j) $ 所需的期望步數,則有狀態轉移方程:

\[\begin{equation} f[i][j] \ \begin{cases} \frac{f[i][1] + f[i + 1][1]}{2} + 1 \ (m = 1) \\ \frac{f[i + 1][j] + f[i][j + 1] + f[i][j]}{3} + 1 \ (j == 1) \\ \frac{f[i][j - 1] + f[i + 1][j] + f[i][j]}{3} + 1 \ (j = m) \\ \frac{f[i][j] + f[i + 1][j] + f[i][j - 1] + f[i][j + 1]}{4} + 1 \ (其他情況) \\ \end{cases} \end{equation} \]

顯然,我們 $ 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 $ 代表睡),則:

如果我們直接轉移的話,初始狀態的睡或不睡會影響後面的轉移(環形),所以分類討論:

  1. 當初始狀態為醒的時候(正常轉移):

\[ f[i][j][0] = \max(f[i - 1][j][0], f[i - 1][j][1]) \]

\[ f[i][j][1] = \max(f[i - 1][j - 1][0], f[i - 1][j - 1][1] + a[i]) \ (j \neq 0) \]

其中

\[f[0][0][0] = 0 \]

  1. 當初始狀態為睡的時候(此時要將初始休息的時間算上):

\[f[i][j][0] = \max(f[i - 1][j][0], f[i - 1][j][1]) \]

\[f[i][j][1] = \max(f[i - 1][j - 1][0], f[i - 1][j - 1][1] + a[i]) \ (j \neq 0) \]

其中

\[f[1][1][1] = a[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 $,我們只需列舉出發點,找最小的即可;

則:

  1. 對於預處理

因為小A和小B只能往後走,所以我們可以從後往前遍歷,並同時維護一個單調遞增的序列(可以用 $ multiset $)其實應該是平衡樹,但我不會,每次只需找當前節點旁邊一位或兩位的最小值和次小值即可(建議參考下面的程式碼);

  1. 對於初始化

\[f[0][j][0] = ga[j] \]

\[f[0][j][1] = gb[j] \]

  1. 對於狀態轉移方程

\[f[1][j][k] = f[0][f[0][j][k]][1 - k] \]

\[f[i][j][k] = f[i - 1][f[i - 1][j][k]][k] \ (i \neq 1) \]

  1. 對於 $ da $ 和 $ db $ 的初始化

\[da[0][j][0] = dis[j][ga[j]] \]

\[da[0][j][1] = 0 \]

\[db[0][j][0] = 0 \]

\[db[0][j][1] = dis[j][gb[j]] \]

對於 $ dis $ 的維護,可以在維護單調遞增的序列同時順便維護;

  1. 對於$ da $ 和 $ db $的狀態轉移方程

\[da[1][j][k] = da[0][j][k] + da[0][f[0][j][k]][1 - k] \ (i = 1) \]

\[da[i][j][k] = da[i - 1][j][k] + da[i - 1][f[i - 1][j][k]][k] \ (i > 1) \]

\[db[1][j][k] = db[0][j][k] + db[0][f[0][j][k]][1 - k] \ (i = 1) \]

\[db[i][j][k] = db[i - 1][j][k] + db[i - 1][f[i - 1][j][k]][k] \ (i > 1) \]

這裡 $ 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;
}

一般在設計出狀態以後,發現空間複雜度不符合要求,且有狀態能夠隨意劃分,則可以使用倍增最佳化;

三. 資料結構最佳化

適用範圍:

  1. 時間複雜度能夠忍受 $ \Theta (n \ log \ n) $

  2. 狀態轉移方程中要維護 $ \max $ 或 $ \min $ 或 $ sum $ 且區間固定;

一般使用線段樹或樹狀陣列

例題

Luogu P4644 [USACO05DEC] Cleaning Shifts S

我們可以定義 $ f[i] $ 表示到第 $ i $ 個時間點時的最小花費,我們用一個結構體儲存每頭牛的資訊($ st $ 為開始時刻,$ ed $為結束時刻, $ w $ 為工資),顯然有狀態轉移方程:

\[f[e[i].ed] = \min_{j \in [e[i].st - 1, e[i].ed - 1]} {f[j]} + e[i].w \]

我們發現, $ DP $ 的方向是按照 $ ed $ 的順序從左向右走的,所以先要將結構體按 $ ed $ 的順序排序;

時間複雜度:$ \Theta (n^2) $ 極限資料會被卡;

考慮最佳化;

發現狀態轉移方程有這一項:

\[\min_{j \in [e[i].st - 1, e[i].ed - 1]}{f[j]} \]

對於一個區間固定求最小值的操作,很容易想到用線段樹維護;

具體實現方法:

  1. 初始化

\[f[m - 1] = 0 \]

  1. 使用線段樹查詢區間 $ [e[i].st - 1, e[i].ed] $ 的最小值並更新(注意這裡要取到 $ e[i].ed $ 因為後面要插入 $ f[i] $ ,這樣做繼承了原來的最小值,便於後面更新);

  2. 狀態轉移

\[f[e[i].ed] = \min(f[e[i].ed], ask(1, e[i].st - 1, e[i].ed) + e[i].w); \]

這裡的 $ 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動態規劃,即狀態轉移方程形如

\[f[i] = \min_{j \in [l(i), r(i)]} f[j] + val(i, j) \]

的動態規劃;

對於純單調佇列的最佳化,其中 $ 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) $ 所滑行的最長距離;

很容易想出狀態轉移方程:

\[\begin{equation} f[k][i][j] \begin{cases} \max(f[k][i][j], f[k - 1][i][j]) \ (所有情況)\\ \max(f[k][i][j], f[k - 1][i + 1][j] + 1) \ (t[k] = 1) \\ \max(f[k][i][j], f[k - 1][i - 1][j] + 1) \ (t[k] = 2) \\ \max(f[k][i][j], f[k - 1][i][j + 1] + 1) \ (t[k] = 3) \\ \max(f[k][i][j], f[k - 1][i][j - 1] + 1) \ (t[k] = 4) \\ \end{cases} \end{equation} \]

其中,$ t[k] $ 代表滑行方向;

可以用滾動陣列將第一位滾掉以滿足記憶體需求;

時間複雜度:$ \Theta (Tnm) $ 需要最佳化;

不妨從狀態設計下手,發現時間段的範圍很小,所以可以重定義狀態 $ f[k][i][j] $ 代表在第 $ k $ 個時間段,在 $ (i, j) $ 所滑行的最長距離;

狀態轉移方程:

\[f[k][i][j] = \max(f[k - 1][i][j], \ \max(f[k][i^`][j^`] + dis((i, j) , (i^`, j^`)))) \]

發現時間複雜度 $ \Theta (kn^2 m^2) $ 需要最佳化;

不難發現,對於一個相同的時間段,移動的方向是固定的,且隨著 $ (i, j) $ 的變化,決策的範圍也在單調變化,且 $ dis $ 僅和 $ i $ 與 $ j $ 有關,所以對於 $ dis $ 可以用單調佇列最佳化;

具體實現操作:

  1. 分情況確定移動的方向;

  2. 在此方向維護一個單調佇列存相應的決策 $ j $,每次更新時首先判斷隊頭是否越界(即 $ dis $ 是否大於時間段),如果越界,彈出隊頭;

  3. 隊頭即為最優決策,用隊頭更新當前狀態;

  4. 將當前狀態作為決策從隊尾插入,同時維護單調性;

注意: 單調佇列中維護的是決策(即狀態轉移方程中的 $ 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動態規劃的狀態轉移方程:

\[f[i] = \min_{j \in [l(i), r(i)]} f[j] + val(i, j) \]

我們知道,當$ val(i, j) $ 的每一項僅與 $ i $ 和 $ j $ 之中的一個有關(即不能出現 $ i $ 和 $ j $ 的乘積項)時,可以用單調佇列最佳化;

如果有乘積項,就需要用到斜率最佳化

當出現乘積項時,我們很容易聯想到平面直角座標系中形如 $ y = kx + b $ 的一次函式,依據這個思想,我們來探討斜率最佳化;

斜率最佳化的主要思想:及時排除無用決策

接下來,我們依據例題來解釋斜率最佳化;

例題

Luogu P5785 [SDOI2012] 任務安排

首先求出 $ T $, $ C $ 的字首和 $ sumt $ 和 $ sumc $ ;

定義$ f[i][j] $ 表示前 $ i $ 個任務分成 $ j $ 批施行的最小費用,很容易得出狀態轉移方程:

\[f[i][j] = \min_{k \in [0, i)} f[k, j - 1] + (S * j + sumt[i]) * (sumc[i] - sumc[k]) \]

時間複雜度:$ \Theta (n^3) $

考慮最佳化;

不妨設題目中 $ t $ 都為正整數;

不難發現,狀態的第二維(批次)是一個附加狀態,即它不是求答案的直接手段,只是求答案的一個附加手段;

考慮最佳化掉這一維;

其實當我們在執行一批任務時,並不是關心它之前機器啟動了多少次,而是應該關心機器的啟動對現在狀態所耽誤的時間

如果不知道之前機器啟動了多少次,怎麼去求機器的啟動對現在狀態所耽誤的時間?

我們可以換一個角度思考,機器因執行一批任務所花費的啟動時間 $ S $ 會累加到後續所有任務完成的時間之中,對此,我們引入一個思想,叫做費用提前計算,也就是說,當每啟動一次機器時,就把它對之後的所有影響(一直到 $ n $)全部計算在內,這樣就達到了求出最小費用的最佳化;

依據上述思路,我們定義 $ f[i] $ 表示把前 $ i $ 個任務分成若干批執行的最小費用,有狀態轉移方程:

\[f[i] = \min_{j \in [0, i)} f[j] + sumt[i] * (sumc[i] - sumc[j]) + S * (sumc[n] - sumc[j]) \]

值得注意的是,在費用提前計算的思想下,只有目標 $ f[n] $ 是正確的,其它結果(例如 $ f[n - 1] $)是偏大的;

時間複雜度:$ \Theta (n^2) $

還是不行,再次考慮最佳化;

發現這個最佳化過的狀態轉移方程滿足1D/1D的動態規劃,且 $ val $ 項有乘積項,考慮斜率最佳化;

將方程展開,得到:

\[f[i] = \min_{j \in [0, i)} f[j] - (S + sumt[i]) * sumc[j] + sumt[i] * sumc[i] + S * sumc[n] \]

因為在平面直角座標系中,縱座標是隨橫座標變化的,所以我們去掉 $ \min $,將只含 $ j $ 的項移到等式左面,剩下的項移到等式右面,可得:

\[f[j] = (S + sumt[i]) * sumc[j] + f[i] - sumt[i] * sumc[i] - S * sumc[n] \]

在以 $ sumc[j] $ 為橫座標,$ f[j] $ 為縱座標的平面直角座標系中,這是一條以 $ S + sumt[i] $ 為斜率,$ f[i] - sumt[i] * sumc[i] - S * sumc[n] $ 為截距的直線;

這也就是說,我們的決策其實也就對應這個平面直角座標系中的一個個點,這條直線就對應著我們現在的狀態,要用決策去更新狀態,需要讓這條線去撞決策點;

image

如圖所示;

現在我們想要讓 $ f[i] $ 最小,觀察得截距中其它項都是常數,那麼我們讓截距最小,所經過的點即為最優決策;

現在我們來考慮一個點能成為最優決策的條件,並依此排除無用決策;

image

如圖,發現當這三個點構成一個“上凸”形時,點 $ 2 $ 不可能成為最優決策;

image

如圖,發現當這三個點構成一個“下凸”形時,這三個點都可能成為最優決策;

發現:一個決策點 $ 2 $ 能夠成為最優決策,當且僅當:

\[\frac{f[j_2] - f[j_1]}{sumc[j_2] - sumc[j_1]} < \frac{f[j_3] - f[j_2]}{sumc[j_3] - sumc[j_2]} \]

這裡有個技巧,當我們求斜率時,最好保證兩邊都為正數,以省去不必要的計算與變號

當取等號時, \(j_2\)\(j_3\) 同時成為最優決策;

於是,我們只需用單調佇列維護一個下凸殼(如第二張圖)即可,其中斜率單調遞增;

當我們找最優決策時,可以發現最優決策點與左邊點的連線斜率比 $ S + sumt[i] $ 小,右邊比它大;

所以,總的操作為:

  1. 檢查隊首斜率與直線斜率 $ S + sumt[i] $ 的關係,若前者小於等於後者,根據斜率 $ S + sumt[i] $ 的單調遞增性,需使隊首出隊;

  2. 此時,隊首即為最優決策,更新現在的狀態;

  3. 依據 $ 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, d) + w(b, c) \geq w(a, c) + w(b, d) \ (a \leq b \leq c \leq d) \]

其中 $ w(a, b) $ 表示定義在整數集合上的二元函式;

定理

對於形如

\[f[i] = \min_{j \in [0, i)} f[j] + val(i, j) \]

的狀態轉移方程,設 $ p[i] $ 是 $ f[i] $ 的最優決策,若 $ p $ 在定義域內單調不減,則稱 $ f $ 具有決策單調性;

在狀態轉移方程

\[f[i] = \min_{j \in [0, i)} f[j] + val(i, j) \]

中,若函式 $ val $ 滿足四邊形不等式,則稱 $ f $ 具有決策單調性;

顯然,在 $ p $ 陣列中,決策是連續的,如圖:

image

這顯示了 $ p $ 陣列中儲存的決策;

維護一個三元組 $ j, l, r $, $ j $ 代表當前段內的決策,$ l $ $ r $ 分別代表當前決策的左右區間(管轄範圍);

用單調佇列維護 $ p $ 陣列,可以概括為以下幾個步驟:

  1. 檢查隊頭,設隊頭三元組為 $ j, l, r $,若 $ r < i $,彈出隊頭;

設隊尾三元組為 $ j_0, l_0, r_0 $,則:

  1. 若對於 $ f[l_0] $ 來說,$ i $ 比 $ j_0 $ 更優,則刪除隊尾;

  2. 否則,在隊尾二分查詢,找到一個位置,使得在這個位置及右邊 $ i $ 比 $ j_0 $ 更優,左邊$ j_0 $ 比 $ i $ 更優,這個位置記為 $ pos $;

  3. 將三元組 $ i, pos, n $ 插入隊尾;

結語

概括來講,$ DP $ 最佳化思路為:

  1. 有可隨意劃分的,用倍增最佳化;

  2. 發現環形,破環成鏈,或者複製一倍在末尾,用環形處理的思路;

  3. 狀態轉移的方向與 $ DP $ 方向不一,用後效性處理的思路;

  4. 狀態轉移方程需要在定區間內查詢最值等等,用資料結構最佳化;

  5. 1D/1D的動態規劃,需要維護動態區間,當 $ val $ 中有乘積項時,用斜率最佳化。沒有時用單調佇列最佳化;

  6. 當 $ val $ 滿足四邊形不等式時,依據決策的單調性最佳化;

總之, $ DP $ 最佳化因題而異,做好最佳化需要我們快速且正確的設計出狀態轉移方程,打好基礎,才能做到掌握最佳化;