進階DP,DP優化

Topics Covered:

  • 單調隊列優化
  • 斜率優化
  • Slope Trick
  • Permutation Trick*
  • Aliens Trick

*Permutation Trick 是我自己命名的w

熱身一下吧

單調隊列優化

aka Sliding Window

當你要對每個東西找到前\(k\)個東西的最小值的時候...

用deque 維護

斜率優化

當你的DP式長這樣的時候...

\(dp[i] = max_{j < i}(m_j * x_i + k_j)\)

有沒有看起來很像一條直線?

\(dp[i] = max_{j < i}(m_j * x_i + k_j)\)

如果我們能保證 \(m_j, x_i\)遞增,那有什麼好的方法維護?

圖源:學長講義

"一路領先" 性質

實際上,我們需要支援兩種操作:

  • 加入一條直線(或任何有優超性的函數)
  • 詢問某個位置在所有函數下的最大值

優超性: 兩個函數\(f, g\)只在一個點相交

存在一個點\(p\),使得\(\)

\forall x > p, f(x) \geq g(x) \\ \forall x \leq p, f(x) \leq g(x)

每條線段都只有一個最好的區間

跟單調隊列類似的想法

把線段存進去!

  • 新增一條線段時,看看他能不能把後面的線段「完全取代掉」

跟單調隊列類似的想法

把線段存進去!

  • 看某個點的最佳線段時,從前面把已經不會更好的線段淘汰掉

直接來Commando

給你一個正整數序列與一個開口向下的二次函數\(f\),你要把序列分成一些連續區間\(l_1-r_1, ..., l_k-r_k\),使得

最大
\sum_{i = 1}^{k} f(\sum_{j = l}^{r} a[j])

數學推一波

令 \(dp[i]\)為做完前\(i\)個的答案

原本的\(O(n^2)\)解: $$dp[i] = max_{j < i} (dp[j] + f(\sum_{k = j + 1}^{i} a[k]))$$

變成前綴和展開

dp[i] = max_{j < i} (dp[j] + f(p_i - p_j))
dp[i] = max_{j < i} (dp[j] + a * p_i^2 - 2 * a * p_i * p_j + \\ a * p_j + b * p_i - b * p_j + c)
dp[i] = a * p_i^2 + b * p_i + max_{j < i} (- 2 * a * p_i * p_j + \\ a * p_j - b * p_j + c + dp[j])

變成前綴和展開

dp[i] = a * p_i^2 + b * p_i + max_{j < i} (- 2 * a * p_i * p_j + \\ a * p_j - b * p_j + c + dp[j])

把左邊看成斜率為\(- 2 * a * p_j\)的直線,\(p_i\)做為詢問的\(x\)值,去找該\(x\)值最大\(y\)值的直線


題目保證\(p_j, p_i\)遞增,所以斜率跟\(x\)值都是遞增的

#include <iostream>
#include <algorithm>
#include <vector>
#include <deque>
#include <utility>
#define ll long long
#define pii pair<ll, ll>
#define ff first
#define ss second
using namespace std;
ll ax, bx, cx;
inline ll f(ll x) {
	return ax * x * x + bx * x + cx;
}
inline ll val(pii a, ll x) {
	return a.first * x + a.second;
}
inline bool comp(pii a, pii b, ll x) {
	return val(a, x) <= val(b, x);
}
int main() {
	int t;
	cin >> t;
	while (t--) {
		ll n;
		cin >>n;
		cin >> ax >> bx >> cx;
		ll arr[n], pref[n];
		for (int i = 0;i < n;i++) {
			cin >> arr[i];
			pref[i] = arr[i] + (i ? pref[i - 1] : 0);
		}
		ll dp[n];
		deque<pii > deq;
		deq.push_back(make_pair(0, cx));
		for (int i = 0;i < n;i++) {
			while (deq.size() >= 2) {
				pii cur = deq.front();
				deq.pop_front();
				pii rep = deq.front();
				//cout << val(cur, pref[i]) << "  " << val(rep, pref[i]) << endl;
				if (!comp(cur, rep, pref[i])) {
					deq.push_front(cur);
					break;
				}
			}
			/*
			if (deq.size()) {
				cout << deq.front().first << " " << deq.front().second <<endl;
			}
			*/
			dp[i] = ax * pref[i] * pref[i] + bx * pref[i] + (deq.size() ? val(deq.front(), pref[i]) : 0);
			//newline with -2a*pref[i], ax * pref[i] * pref[i] - b * pref[i] + pref[i] + dp[i])
			pii add = make_pair(-2 * ax * pref[i], ax * pref[i] * pref[i] - bx * pref[i] + cx + dp[i]);
			//cout << add.first << " " << add.ss << endl;
			while (deq.size() >= 2) {
				pii cur = deq.back();
				deq.pop_back();
				pii comp = deq.back();
				if ((add.ss - comp.ss) * (comp.ff - cur.ff) <= (cur.ss - comp.ss) * (comp.ff - add.ff)) {
					continue;
				} else {
					deq.push_back(cur);
					break;
				}
			}
			deq.push_back(add);
		}
		cout << dp[n - 1] << endl;
	}
}

斜率不單調的話...

李超線段樹

對值域開一顆線段樹,每個節點內維護在該區間的中點數值最大的一條直線。

令這個位置為\(m[cur]\)

插入線段

藍色比較好,

紅色在左邊才有用

斜率: 紅 < 藍

紅色比較好,

藍色在右邊才有用

當前節點留下

比較好的線段,

比較差的下推!

比較兩線段在\(m[cur]\)的數值

單點查詢

從根節點開始,遇到線段就更新答案,

並往查詢位置的方向遞迴。

想法:

對於某節點的線段,他右邊的線段在\(m[cur]\)都會比較差,但在某個\(>m[cur]\)的地方會超過。

補充: 線段有轉移範圍?

只在轉移範圍的區間放線段,複雜度變\(O(\log^2 n)\)

或是你也可以寫

動態凸包

類題: 吐鈔機2

類題2:花枝遊戲

例題:CF 1603 D

例題: 2020 全國賽 pI - 黑白機

Slope Trick

看個例題: CF 713 C

給一個整數序列,每次操作可以選一個數字+1或-1,問最少需要幾次操作使序列嚴格遞增?

\(n \leq 3000, a_i \leq 10^9\)

\(O(n^2)\)? How about \(n \leq 10^5\)

糟糕作法

首先,讓\(a_i := a_i - i\),問題變成要多少次操作讓\(a\)變非嚴格遞增。

令\(f(i, j)\)代表讓前\(i\)項非嚴格遞增,最後一項\(\leq j\)的最小代價。那麼

\(f(i, j) = min(f(i-1, j) + |a_i - j|, f(i, j-1))\)

 

複雜度\(O(nC)\)欸,好爛

考慮滾動?

考慮以\(j\)為\(x\)軸,\(f(i, j)\)為縱軸的函數圖形。

 

從\(i-1\)變成\(i\)的時候,可以看成是把每個位置\(j\)加上\(|a_i - j|\),然後取前綴min!

ZCK: 你有聽過滾動DP嗎

a_1
a_1
a_2
a_2
a_1
a_1
a_2
a_3
a_2
a_3

用priority_queue 維護斜率改變的點!

假設最右邊斜率一定是零,則點\(x\)的斜率會是pq裡面比\(x\)大的數字個數

實作:

//default code
int a[maxn];
int main() {
	io
	int n;
	cin >> n;
	priority_queue<int, vector<int>, less<int> > pq;
	ll ans = 0;
	for (int i = 0;i < n;i++) cin >> a[i], a[i] -= i;
	for (int i = 0;i < n;i++) {
		pq.push(a[i]);
		if (pq.top() > a[i]) {
			pq.push(a[i]);
			ans += pq.top() - a[i];
			pq.pop();
		}
		//debug(pq.top());
	}
	cout << ans << endl;
}

其他: USACO Guide

Permutation Trick

酷酷題: JOIOC Skycraper

 

有\(n\)棟大樓,第\(i\)棟高度為\(a_i\)。給定\(L\),問有多少種方法將大樓排成一排,使得相鄰大樓的高度差總和\(\sum_{1 \leq i < n} |a_i - a_{i+1}| \leq L\)

\(n \leq 100, a_i, L \leq 1000\)

位元DP?

考慮每棟大樓對答案的貢獻

如果旁邊兩棟大樓都比他的話,貢獻\(2a_i\)

如果旁邊兩棟大樓都比他的話,貢獻\(-2a_i\)

如果他在最左邊/右邊要特判

以此類推...

 

要怎麼枚舉排列?

由高到低放入!

先依\(a_i\)由大到小排序,假設算完前\(i-1\)項,新增\(a_i\)時旁邊的東西時需要知道哪些東西?

9 \ \ \ 5 \ \ \ 8

這裡之後會不會再放東西?

多一個維度

令\(dp[i][j][k][l]\)代表放入前\(i\)個東西,

下一項有\(j\)個中間的空格,\(k\)個兩邊的空格,

總代價為\(l\)的排列數

轉移時,有放中間跟放旁邊兩種狀況:

放中間時,有少一個空格,沒改空格數,多一個空格三種...

放旁邊時以此類推...

 

最後答案是\(\sum_{l \leq L}dp[n][0][0][l]\)

這題的實作細節

實際上,因為我們\(l\)的狀態只能開到\(L \leq 1000\),所以必須讓\(dp\)過程中的總代價遞增。

因此在做\(dp[i]\)時,我們可以把每個空格的高度看成\(a_i\),這樣變成\(i+1\)時就能讓每個空格都多\(a_i - a_{i+1}\)的代價。

這顯然會遞增,我們就能在\(O(n^2L)\)做完這題。

 

實作

Aliens優化

AKA 講師也不會的東西

UPD: 高三還是不太會

換個例題好了

我不想講AI-666了qwq

YTP 2021 程式挑戰賽 p6:

給一個整數陣列,選\(k\)個互不相鄰的數字使得總和最大。\(1 \leq k \leq n \leq 2 \times 10^5\)

簡單的二維DP: \(dp[i][j]\)代表前\(i\)個東西選\(j\)個的最大和。

觀察函數圖形

感覺起來拿越多東西,每個東西的作用會越來越少?

令\(f(k)\)為選\(k\)個東西的最大和,那麼

\(f(k+1) - f(k)\)遞減。

觀察函數圖形

先不考慮選\(k\)個的限制時,看一維的DP做法,可以得到選任意多個的最大值,也就是\(max(f(i))\)

假設現在選一個東西要多\(C\)的代價(可能是正或負),那麼一維DP仍然可做,得到的是\(max(f(i) + Ci)\)

並且因為差分單調(斜率遞減),我們可以二分搜\(C\)使得最大值發生在\(i=k\)

觀察函數圖形

Credit: Wiwiho

凸性的證明

可反悔Greedy!

 

\(k=1\)時,一定是選最大值。

假設後來不選他,一定要選到他旁邊兩個!

把這個「反悔」操作看成選一個東西。

2 4 3 5 -2

2 4 (3 + -2 - 5)

(2 + (-4) - 4)

顯然,每次選的最大值會非嚴格遞減,

也就符合了斜率單調。

OJDL: 疫苗分配

Credits/Other Resources

  • ZCK DP 優化講義 (前面放的)
  • Wiwiho 的簡報 (資讀社團內)
  • USACO Guide

進階DP,DP優化

By justinlai2003

進階DP,DP優化

  • 2,025