重修 Slope Trick(看這篇絕對夠!)

ShaoJia發表於2022-05-10

Slope Trick 演算法存在十餘載了,但是我沒有找到多少拍手叫好的講解 blog,所以憑藉本人粗拙的理解來寫這篇文章。

本文除標明外所有圖片均為本人手繪(若醜見諒),畫圖真的不容易啊 qwq(無恥求贊)。

Slope Trick 是啥?

凸代價函式DP優化。

具體哪種題目?

AcWing273. 分級

CF713C Sonya and Problem Wihtout a Legend

CF13C Sequence

P2893 [USACO08FEB]Making the Grade G

P4331 [BalticOI 2004]Sequence 數字序列

P4597 序列 sequence

P8118 Mystery

千萬題目匯成一句話:

給定長度為 \(N(\le 10^6)\) 的序列 \(A\),構造一個長度為 \(N\) 的非降序列 \(B\),最小化 \(S=\sum\limits^N_{i=1}|A_i−B_i|\),求出 \(S\) 的最小值和 \(B\) 的構造方案。

注意 Slope Trick 不止能夠解決這個問題,這個題目只是便於舉例而已。

暴力咋做?

就是 \(O(n^2)\) 的 DP。

\(f_{i,j}\) 表示 \(A\) 中第 \(i\) 個數變為 \(j\),前 \(i\) 個數變為非降序列的最小代價,即

\[\min_{B_1\le B_2\le\dots\le B_i=j}\sum_{k=1}^{i}|A_k−B_k| \]

則有遞推式

\[f_{i,j}=|A_i-j|+\min_{k=minV}^{j} f_{i-1,k} \]

其中 \(minV\) 指值域下界。

當然,為了後續的擴充,我們設

\[g_{i,j}=\min_{k=minV}^{j}f_{i,k} \]

則遞推式改成

\[f_{i,j}=|A_i-j|+g_{i-1,j} \]

是不是非常美觀?

過渡

在下一步之前,我們需要幾個定義和引理。

引理 A

兩個斜率分別為 \(a,b\) 的一次函式相加的斜率為 \(a+b\)

定義 1

我們稱這樣的函式為美妙的函式

  • 函式連續。
  • 函式由若干條一次函式(或常數函式)拼接而成(所以是分段函式),且一次函式的斜率為整數。
  • 函式下凸,即若干條一次函式的斜率從左往右單調非減。

引理 B

任意兩個美妙的函式相加還是美妙的函式。

定義 2

設一個連續函式 \(f(x)\)字首最小函式 \(g(x)\)

\[g(x)=\min_{x'\le x}f(x') \]

引理 C

一個美妙的函式的字首最小函式還是美妙的函式,且最後一段(至 \(x\to\infty\))為常數函式。

咋擴充到 Slope Trick?(正題)

先回憶一下遞推式

\[f_{i,j}=|A_i-j|+g_{i-1,j} \]

我們設 \(F_i(x)\) 函式

\[F_i(x)=f_{i,x} \]

類似地

\[G_i(x)=g_{i,x} \]

最後設

\[H_i(x)=|x-A_i| \]

我們再次改寫遞推式

\[F_i(x)=H_i(x)+G_{i-1}(x) \]

簡潔美觀!(請牢記這個公式)

由數學歸納法得到 \(F,G,H\) 都是美妙的函式。

我們維護 \(S_1,S_2,\dots,S_c\)\(G\) 函式的轉折點。

來幾張圖演示一下。

設這個 \(G_i\) 從一次函式到常數函式的轉折點為 \(P_i\)

值得注意的是,若一個轉折點左邊斜率 \(>\) 右邊斜率 \(+1\),則這個點是要再重複 \((\) 左邊斜率 \(-\) 右邊斜率 \(-1)\) 次的,即:

然後加上 \(H_i\),要分類:

\(A_i\ge P_{i-1}\)

這樣答案(為最右邊水平部分的 \(y\) 座標)不變,即

\[\begin{aligned} F_i(P_i)&=H_i(P_i)+G_{i-1}(P_i) \\ &=G_{i-1}(P_i) \\ &=G_{i-1}(P_{i-1}) \\ &=F_{i-1}(P_{i-1}) \end{aligned} \]

沒有貢獻。

而且對於 \(\{S\}\),只用將 \(A_i\) 插入 \(\{S\}\) 末尾即可。

\(A_i<P_{i-1}\)

這樣答案為

\[\begin{aligned} F_i(P_i)&=H_i(P_i)+G_{i-1}(P_i) \\ &=P_i-A_i+F_{i-1}(P_i) \\ &=P_i-A_i+F_{i-1}(P_{i-1})+P_{i-1}-P_i \\ &=F_{i-1}(P_{i-1})+P_{i-1}-A_i \end{aligned} \]

即增加

\[F_i(P_i)-F_i(P_{i-1})=P_{i-1}-A_i \]

所以將 \(Ans+\!=P_{i-1}-A_i\)

此時 \(A_i\) 插入 \(\{S\}\)兩次,因為他是絕對值函式的轉折點,所以兩邊斜率差值為 \(2\)


相信很多人都蒙了,我們具象一下。

想象有一條左右無限長的鐵絲。

初始化:一開始平放在高度 \(0\) 的位置(\(F_0(x)=G_0(x)=0\)

然後進行 \(n\) 次操作,第 \(i\) 次:

  1. 鐵絲橫座標為 \(A_i\) 的地方用尖嘴鉗固定住。

  2. 將尖嘴鉗左邊的部分向上翹 \(1\) 單位的斜率(這部分鐵絲每一點都要彎 \(1\) 斜率)。

  3. 尖嘴鉗右邊的部分同理向上翹 \(1\) 單位的斜率。這樣整條鐵絲更像「U」形。

這樣我們從 \(G_{i-1}\) 得到了 \(F_i\)

  1. 將右邊翹起的部分壓平。即在右邊找到一個點使得這裡的鐵絲是水平的(導數為 \(0\))然後從這裡往右全部捋成水平的。

這樣我們從 \(F_i\) 得到了 \(G_i\)

  1. 鬆開尖嘴鉗。

最後答案為整個鐵絲的最低高度值。

相信前文仔細閱讀的小可愛們一定懂了這段扭鐵絲具體在對凸函式幹嘛……


所以用堆維護 \(S_1,S_2,\dots,S_c\) 即可。

總時間 \(O(n\log n)\)

程式碼?

int n,x,ans=0,b[N];
priority_queue<int> q;
int main(){
	cin>>n;
	For(i,1,n){
		cin>>x;
		q.push(x);
		if(q.top()!=x){
			ans+=q.top()-x;
			q.pop();
			q.push(x);
		}
		b[i]=q.top();
	}
	Rof(i,n-1,1) ckmn(b[i],b[i+1]);
	cout<<ans<<endl;
	For(i,1,n) cout<<b[i]<<" "; cout<<endl;
	return 0;
}

更多?

當然,Slope Trick 不止這種建模和推導。

為了證明這一點,我們再舉一個例子。

題面

題目:abc250_g Stonks

大意:

已知接下來 \(n(\le 2\times 10^5)\) 天的股票價格 \(1\le P_1,P_2,\dots,P_n\le 10^9\)

每天你可以(三選一):

  • 買進一股股票
  • 賣出一股股票
  • 什麼也不做

\(n\) 天之後你擁有的股票應為 \(0\)

你最初有足夠多的錢,求 \(n\) 天結束後能獲得的最大利潤。

解答

帶悔貪心

我們可以快速想出一種貪心策略:買入價格最小的股票,在可以賺錢的當天賣出。

顯然我們可以發現,上面的貪心策略是錯誤的,因為我們買入的股票可以等到可以賺最多的當天在賣出。

我們考慮設計一種反悔策略,使所有的貪心情況都可以得到全域性最優解。(即設計反悔自動機的反悔策略)

我們先把當前的價格放入小根堆一次(這次是以上文的貪心策略貪心),判斷當前的價格是否比堆頂大,若是比其大,我們就將差值計入全域性最優解,再將當前的價格放入小根堆(這次是反悔操作)。相當於我們把當前的股票價格若不是最優解,就沒有用,最後可以得到全域性最優解。

上面的等式即被稱為反悔自動機的反悔策略,因為我們並沒有反覆更新全域性最優解,而是通過差值消去中間項的方法快速得到的全域性最優解。

Slope Trick

首先我們考慮最樸素的 \(O(n^2)\) DP 做法:\(f_{i,j}\) 表示前 \(i\) 天過完,現在手上 \(j\) 張股票,所盈利的最大價值。

\[f_{i,j}=\max\{f_{i-1,j+1}+P_i\, ,\, f_{i-1,j}\, ,\, f_{i-1,j-1}-P_i\} \]

然後我們設函式 \(F_i(x)=f_{i,x}\)(老套路了)。

\[F_i(x)=\max\{F_{i-1}(x+1)+P_i\, ,\, F_{i-1}(x)\, ,\, F_{i-1}(x-1)-P_i\} \]

也就是說將函式 \(F_{i-1}\)

  • 向上 \(P_i\) 單位,向左 \(1\) 單位複製一份,設為 \(F^+_{i-1}\)
  • 向下 \(P_i\) 單位,向右 \(1\) 單位複製一份,設為 \(F^-_{i-1}\)
  • 自己 \(F_{i-1}\) 也保留。

再求三者的上凸包(\(\max\))即為 \(F_i\)

這裡引用 Atcoder 官方題解的圖:

我們發現 \(F_{i-1}\) 只有左邊斜率 \(>-P_i\) 且右邊斜率 \(<-P_i\) 的點才會相對於 \(F_{i-1}^+,F_{i-1}^-\)「露在外面」。

這時會在本來的斜率序列中插入兩個斜率為 \(-P_i\) 的線段,同時將本來最靠左的線段去掉。所以用堆維護這個斜率序列,插入兩個 \(P_i\),彈出一次堆頂。

當然,如果 \(P_i\) 小於堆頂,則只要插入 \(P_i\) 即可。

程式碼

兩種解法程式碼一樣神奇吧

#include<bits/stdc++.h>
using namespace std;
#define IOS ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
#define For(i,j,k) for(int i=j;i<=k;i++)
#define int long long
#define N 200010

int n,ans=0,x;
priority_queue<int,vector<int>,greater<int> > q; 
signed main(){IOS;
	cin>>n;
	For(i,1,n){
		cin>>x;
		if(i!=1 && q.top()<x){
			ans+=x-q.top();
			q.pop();
			q.push(x);
		}
		q.push(x);
	}
	cout<<ans<<endl;
return 0;}

相關文章