1. 程式人生 > 實用技巧 >斜率優化學習筆記

斜率優化學習筆記

斜率優化是 dp 的一種有力優化方式。常用於處理當 dp 方程 \(f[i] = max(f[j]+ val(i, j))\)\(val(i,j)\) 出現同時關於 \(i\),\(j\) 的項,而無法直接單調佇列優化的情況。

引入

考慮這麼一個問題:

  • 你有一個長度為 \(N\) 的序列,每一位上都有一個數字。現在你要把這個序列分成若干段,一段的得分為 \(ax^2+bx+c\),其中 \(x\) 是這一段內所有數字的和。現在請你求出這段序列各段得分之和的最大值。

  • \(n \leq 5000, a < 0\)

顯然的 dp 問題。令 \(f[i]\) 為以 \(i\) 為某一段的結尾,前面總得分的最大值。顯然我們有狀態轉移方程:\(f[i] = max(f[j]+a \times (sum[i]-sum[j])^2 + b \times (sum[i]-sum[j])+c)\)

,其中 \(sum[i]\) 是字首和。

這樣可以在 \(O(n^2)\) 解決這個問題,但如果資料範圍加強到 \(n \leq 10^6\) 呢?

第一時間想起的可能是單調佇列優化。我們知道,單調佇列優化也是 \(O(n)\) 的複雜度。只要你能把 dp 方程中關於 \(i\) 的項和關於 \(j\) 的項分開就 ok 了。嘗試對方程進行一系列變形(為了明顯,我們把如上的 \(a,b,c\) 係數改成大寫 \(A,B,C\),先把 \(\max\) 去掉,後面再說):

\(f[j]+Asum[j]^2-Bsum[j]+c=sum[j] \times 2Asum[i] + (Asum[i]^2-Bsum[i]+f[i])\)

我們發現,萬事俱備,就是中間的那個 \(sum[j] \times 2Asum[i]\) 非常惱火——它是一個同時關於 \(i\)\(j\) 的項——我們該怎麼處理它呢?

至此,我們引出 dp 優化中一個非常重要的概念——「斜率優化」。

淺談斜率優化

繼續觀察上式,你發現有什麼特點?

我們令 \(y = f[j]+Asum[j]^2-Bsum[j]+c,x=sum[j]\),把之前一個決策 \(j\) 的資訊儲存在一個二元組 \((x,y)\) 內。方程變為 —— \(y=2Asum[i] \times x + (Asum[i]^2-Bsum[i]+f[i])\)

這像個什麼?直線解析式!

可以想象,我們在一個平面直角座標系上把 \(1 \sim i-1\) 的決策對應的二元組 \((x,y)\) 當成點的座標撒下去,如下圖:

現在我們要得到一個正確的 \(f[i]\),顯然必須滿足 \(y=2Asum[i] \times x + (Asum[i]^2-Bsum[i]+f[i])\) 這個等式(這畢竟原來是 \(f[i]\) 的推導公式)。

我們再把當前這個決策點 \(i\) 變成一條直線,一條斜率為 \(2Asum[i]\),截距為 \(Asum[i]^2-Bsum[i]+f[i]\) 的直線(注意當前 \(f[i]\) 尚未確定,其他要素均已確定)。如果上面那個等式成立,是不是就說明——這條直線過 \((x,y)\) 這個點?那麼 \(f[i]\) 就可以解出!

冷靜一下,我們還有一個重大發現——我們要的是最大的 \(f[i]\) (前面移除了個 \(\max\) 還記得嗎),也就是說這條直線的截距要儘量大!如下圖:(注意由於 \(A<0\),斜率小於 \(0\)

顯然,過決策點 \(F\) 的直線截距最大,對應地,此時取得的 \(f[i]\) 最大。

我們現在要做的,就是使程式在均攤 \(O(1)\) 下找到這個點。

再次觀察,哪些點是肯定無用的?

顯然,紅色點不可能取到,因為他們被圈在了一個紫色的邊框內,這個邊框叫做「凸包」。而我們現在要做的,就是維護這個「凸包」。

如何維護?首先我們發現,點的 \(x\) 座標遞增(回到定義看顯然)。因此每次新加入的決策點都是在最右側,我們判斷它是在凸包上還是凸包內就 ok 了。

我們維護一個單調佇列,左邊靠隊頭,右邊靠隊尾。顯然只有當隊尾和在隊尾左側的那個點的斜率大於隊尾和新加入的點的斜率(注意此時斜率為負),也就是這個新加入的決策點在凸包上,才不會影響之前的點。假如上圖 I 是新增的決策,那麼有 \(k_{FH}>k_{HI}\),I 才能進來。否則,就要往前不斷判斷,把一些點彈出佇列,直到 I 滿足入隊的要求(形成凸包)。

記得單調佇列還要維護隊頭,這個也一樣。觀察到,假如隊頭和其下一個點的斜率大於當前決策 \(i\) 的斜率(注意此時斜率為負),隊頭就廢了,因此我們也找到了出隊的方法,如下圖:

假如 \(i\) 增大,斜率會減小(回到定義,注意斜率為負),當其偏離至橙色的直線時,斜率小於了 \(k_{LF}\)\(M\) 是輔助點,不用管,\(L\) 才是隊頭),此時顯然 L 不能再被接下來的直線切到,因此 L 出隊。

至此,斜率優化的全過程已經展示完畢。本題即為 [APIO2010] 特別行動隊,參考程式碼如下:

#include <iostream>
#include <cstring>
#include <cstdio>

#define Maxn 1000010
#define LL long long

using namespace std;

int read() {
	int x = 0, f = 1;
	char c = getchar();
	while(c < '0' || c > '9') {
		if(c == '-') f = -1;
		c = getchar();
	}
	while('0' <= c && c <= '9') {
		x = x * 10 + c - '0';
		c = getchar();
	}
	return x * f;
}

LL N, A, B, C, sum[Maxn], f[Maxn], Q[Maxn];

double k(LL a, LL b) {
	LL xa = sum[a], ya = f[a] + A * sum[a] * sum[a] - B * sum[a] + C;
	LL xb = sum[b], yb = f[b] + A * sum[b] * sum[b] - B * sum[b] + C;
	return (yb - ya) * 1.0 / (xb - xa);
}

int main() {
	N = read();
	A = read(); B = read(); C = read();
	for(int i = 1; i <= N; ++i) sum[i] = read() + sum[i - 1];
	int head = 1, tail = 1;
	for(int i = 1; i <= N; ++i) {
		while(head < tail && k(Q[head], Q[head + 1]) > 2 * A * sum[i]) ++head;
		int j = Q[head];
		f[i] = f[j] + A * sum[j] * sum[j] - B * sum[j] + C - 2 * A * sum[j] * sum[i] + A * sum[i] * sum[i] + B * sum[i];
		while(head < tail && k(Q[tail - 1], Q[tail]) < k(Q[tail], i)) --tail;
		Q[++tail] = i;
	}
	cout << f[N] << endl;
	return 0;
}

例題與分析

[Luogu P2365] 任務安排

首先我們可以化出和之前類似的 dp 方程,如下:

\(f[j] = (S + t[i]) * c[j] + f[i] - t[i] * c[i] - S * c[N]\)

顯然,決策點的座標應為 \((c[j],f[j])\),直線斜率為 \(S+t[i]\),直接斜率優化 dp 即可。

需要注意的是,與上題不同的是,這道題維護的是下凸包。

程式碼如下:

#include <iostream>
#include <cstring>
#include <cstdio>

#define Maxn 5010

using namespace std;

int read() {
	int x = 0, f = 1;
	char c = getchar();
	while(c < '0' || c > '9') {
		if(c == '-') f = -1;
		c = getchar();
	}
	while('0' <= c && c <= '9') {
		x = x * 10 + c - '0';
		c = getchar();
	}
	return x * f;
}

int N, S, t[Maxn], c[Maxn], f[Maxn], Q[Maxn * 2];

double k(int a, int b) {
	return (f[b] - f[a]) * 1.0 / (c[b] - c[a]);
}

int main() {
	N = read(); S = read();
	for(int i = 1; i <= N; ++i) {
		t[i] = read() + t[i - 1];
		c[i] = read() + c[i - 1];
	}
	int head = 1, tail = 1;
	for(int i = 1; i <= N; ++i) {
		while(k(Q[head], Q[head + 1]) < S + t[i] && head < tail) ++head;
		f[i] = f[Q[head]] + t[i] * c[i] + S * c[N] - (S + t[i]) * c[Q[head]];
		while(k(Q[tail - 1], Q[tail]) > k(Q[tail], i) && head < tail) --tail;
		Q[++tail] = i;
	}
	cout << f[N] << endl;
	return 0;
} 

拓展

還是上面的題目,但如果 \(t_i\) 可能是負數,即查詢的斜率不具有單調性,該怎麼辦呢?

思考一下,此時隊尾出隊仍和原來一樣,因為插入的決策點的橫座標仍具有單調性。然而隊頭出隊就不一定了,因為以前我們是根據斜率的單調來彈出隊頭的,現在斜率不再單調,隊頭也必須儲存。

因此這樣維護凸包的資料結構就是一個單調棧。當我們要查詢的時候,由於斜率不單調,不能再直接讀取隊頭,需要二分找到剛好直線切凸包的位置,複雜度只多了一個 \(\log\)

此即 [SDOI2012]任務安排,下面呈現有關程式碼:

#include <iostream>
#include <cstring>
#include <cstdio>

#define Maxn 300010
#define LL long long

using namespace std;

int read() {
	int x = 0, f = 1;
	char c = getchar();
	while(c < '0' || c > '9') {
		if(c == '-') f = -1;
		c = getchar();
	}
	while('0' <= c && c <= '9') {
		x = x * 10 + c - '0';
		c = getchar();
	}
	return x * f;
}

LL N, S, t[Maxn], c[Maxn], f[Maxn], stack[Maxn * 2], top = 1;

double k(int a, int b) {
	return (f[b] - f[a]) * 1.0 / (c[b] - c[a]);
}

int ef(int l, int r, int i) {
	while(l < r) {
		int mid = (l + r) >> 1;
		if(f[stack[mid + 1]] - f[stack[mid]] <= (S + t[i]) * (c[stack[mid + 1]] - c[stack[mid]])) l = mid + 1;
		else r = mid;
	}
	return stack[l];
}

int main() {
	N = read(); S = read();
	for(int i = 1; i <= N; ++i) {
		t[i] = read() + t[i - 1];
		c[i] = read() + c[i - 1];
	}
	for(int i = 1; i <= N; ++i) {
		int j;
		if(top > 1) j = ef(1, top, i);
		else j = stack[1];
		f[i] = f[j] + t[i] *c[i] + S * c[N] - (S + t[i]) * c[j];
		while((f[stack[top]] - f[stack[top - 1]]) * (c[i] - c[stack[top]]) >= (f[i] - f[stack[top]]) * (c[stack[top]] - c[stack[top - 1]]) && top > 1) --top;
		stack[++top] = i;
	}
	cout << f[N] << endl;
	return 0;
}