《破曉傳說》12分鐘實機演示和全新截圖公開 9月10日發售
斜率優化的中心思想就是利用一次函式的斜率來優化某些 \(DP\) 轉移方程。斜率優化的題目狀態轉移方程通常比單調佇列優化更為複雜,同時斜率優化通常也會用到單調佇列優化。
以下記錄的題目基本上都為斜率優化的模板題。
[SDOI2012]任務安排
題意
本題的題意較為複雜。一臺機器需要按順序處理 \(n\) 個任務,每個任務都有一個花費時間 \(t_i\) 和花費係數 \(c_i\)。將 \(n\) 個任務分成若干組處理,每組任務處理前都要花費 \(s\) 的啟動時間。 每組任務的花費為從第一組任務開始前到本組任務結束後的總時間乘以該組任務的花費係數之和。求最小的花費總和。
思路
本題可以分為三個步驟來思考。
Part 1
首先考慮樸素的狀態轉移。
為了表示方便,以下的 \(t_i\) 和 \(c_i\) 均表示 \(t_i\) 和 \(c_i\) 的字首和。
定義 \(f[i]\) 表示完成前 \(i\) 個任務的最小花費。通過理解題意可以發現,如果將 \(j+1\) ~ \(i\) 分為一組,那麼在第 \(j+1\) 個任務之前的啟動時間 \(s\) 會對之後的所有任務的花費造成影響,即花費增加了 \((c_n-c_j)*s\) 。得到這個性質後,就可以寫出樸素的狀態轉移方程:
\(f[i]=min(f[j]+(c[i]-c[j])*t[i]+(c[n]-c[j])*s)\) 。其中 \(0 \leq j \leq i-1\)
顯然,時間複雜度為 \(O(n^2)\) ,無法通過本題。但可以通過簡化版,其中 \(n \leq5000\) 。
核心code:
for(int i=1;i<=n;i++)
for(int j=0;j<i;j++)
f[i]=min(f[i],f[j]+(c[i]-c[j])*t[i]+(c[n]-c[j])*s);
Part 2
斜率優化的推導。
先省略 \(min\) ,通過拆項、移項可以得到以下方程 :
\(f[j]=(t[i]+s)*c[j]+f[i]-c[i]*t[i]-c[n]*s\) 。
如果將 \(f[j]\) 看成 \(y\) ,將 \(c[j]\)
而現在需要求的是 \(f[i]\) 的最小值,也就是 \(b\) 的最小值。同時對於每一個 \(i,k\) 都是確定的。那麼也就可以將 \(y=k*x-inf\) 這條直線向上平移,遇到的第一個 \((c[j],f[j])\) 就是最優解。如下圖所示。
那麼哪些點可以成為最優解呢,通過觀察可以發現,只有最外側的點可以成為最優解,在數學上稱為凸包,此時就可以將其餘的點刪去。如下圖所示
可以發現,對於凸包上兩點間的直線的斜率是單調遞增的。
接下來的問題就是怎麼求出每一個 \(k\) 所對應的點座標,由於最壞情況下凸包的點的數量會達到 \(n\) ,可能會被良心出題人卡,所以無法直接使用列舉。
通過觀察又可以發現,對於每一個 \(k\) ,向上平移的直線第一個遇到的點的\(k_j\)一定是大於\(k\) 的最小值 ,同時 \(k=t[i]+s\) 顯然是單調遞增的。那麼對於一個點,它和凸包上後一個點的斜率 \(k_j\) 如果小於當前的 \(t[i]+s\),那麼肯定也小於之後的 \(t[i']+s\),於是就可以用到單調佇列優化。
在查詢之前,將所有斜率小於 \(k\) 的點直接刪去即可。同時對於每一個新加入的點 \((c[i],f[i])\) ,這個點的座標顯然是在所有點的右側,那麼這個點顯然是當前凸包內的一點,那麼在插入的時候刪去所有不在凸包上的點即可。刪除的情況如下圖所示
橙色邊所指向的點原來是在凸包上,當新新增藍色邊所指向的點時就不在凸包上了,直接刪去這個點就可以了。用不等式來表示。即
當 \(\dfrac{f_{h+1}-f_h}{c_{h+1}-c_h} \leq t_i+s\) 時隊首出隊;
當 \(\dfrac{f_{tt}-f_{tt-1}}{c_{tt}-c_{tt-1}} \geq \dfrac{f_{i}-f_{tt}}{c_{i}-c_{tt}}\) 時隊尾出隊。
在實際運用時,為了避免精度誤差,通常會交叉相乘避免除法運算。
核心code:
for(int i=1;i<=n;i++)
{
while(hh<tt&&f[q[hh+1]]-f[q[hh]]<=(t[i]+s)*(c[q[hh+1]]-c[q[hh]])) hh++;
f[i]=f[q[hh]]+(c[i]-c[q[hh]])*t[i]+(c[n]-c[q[hh]])*s;
while(hh<tt&&(f[q[tt]]-f[q[tt-1]])*(c[i]-c[q[tt]])>=(f[i]-f[q[tt]])*(c[q[tt]]-c[q[tt-1]])) tt--;
q[++tt]=i;
}
但是程式碼提交上去也只能得到 \(60\) 分。再次觀察題目,會發現一個很有趣的地方,\(|t_i| \leq 2^8\),也就是說, \(t_i\) 可能為負數(這裡的 \(t_i\) 指題意中的 \(t_i\)),那麼就不滿足 \(t_i\) (這裡的 \(t_i\) 指字首和) 單調遞增了,那麼也就不滿足 \(k\) 單調遞增了。於是還需進一步優化。
Part 3
最終的解法。
雖然說每一個任務的完成時間為負數,但是對於隊尾的出隊判斷和狀態轉移方程是無影響的,所以只需要改變隊頭出隊。其實這裡就不需要出隊了,也就是可以將佇列改成棧。而查詢的工作就交給了二分法。於是就可以愉快的通過本題了。
完整code:
#include<cstdio>
#include<cstring>
const int N=3e5+10;
#define int long long
int f[N],n,s,t[N],c[N],q[N];
int min(int a,int b){return a<b?a:b;}
signed main()
{
scanf("%lld%lld",&n,&s);
for(int i=1;i<=n;i++)
{
scanf("%lld%lld",&t[i],&c[i]);
t[i]+=t[i-1];
c[i]+=c[i-1];
}
memset(f,0x3f,sizeof(f));
f[0]=0;
int hh=1,tt=1;
q[1]=0;
for(int i=1;i<=n;i++)
{
int l=hh,r=tt;
while(l<r)
{
int mid=(l+r)>>1;
if((f[q[mid+1]]-f[q[mid]])> (t[i]+s)*(c[q[mid+1]]-c[q[mid]])) r=mid;
else l=mid+1;
}
f[i]=f[q[r]]+(c[i]-c[q[r]])*t[i]+(c[n]-c[q[r]])*s;
while(hh<tt&&(double)(f[q[tt]]-f[q[tt-1]])*(c[i]-c[q[tt]])>=(double)(f[i]-f[q[tt]])*(c[q[tt]]-c[q[tt-1]])) tt--;
q[++tt]=i;
}
printf("%lld\n",f[n]);
return 0;
}
Cats Transport
本題需要一定的轉化變形技巧。
題意
給定 \(n\) 座山(每座山的大小都可以忽略不計),第 \(i\) 座山和第 \(i-1\) 座山之間的距離為 \(D_i\),有\(m\) 只貓,\(p\) 位飼養員。第\(i\) 只貓要在第 \(h_i\) 座山上玩 \(T_i\) 時間。每個飼養員從 \(1\) 號山出發,出發的時間任意(可以從負數出發),每單位時間走單位路程,路上遇到已經玩好的貓會將它帶走。求所有貓等待時間之和的最小值。
思路
首先可以發現,每一個飼養員都從 \(1\) 號出發,那麼就可以用字首和先求出每一座山到起點的距離,也就是將 \(D_i\) 的含義轉化為第 \(i\) 座山到第 \(1\) 座山的距離。對於每一位飼養員的出發時間 \(s_i\) ,他能接到第 \(j\) 只貓的充要條件為 \(s_i+d_{h_j}\geq t_j\) ,移項可以得到 \(s_i\geq t_j-d_{h_j}\)。
而對於每一隻貓,等待的時間就為 \(s_i-(t_j-d_{h_j})\)。可以發現,對於題目有用的是 \((t_j-d_{h_j})\),於是就可以定義 \(a_j=t_j-d_{h_j}\)。其實也就是表示第 \(j\) 只貓恰好剛玩好就被接走時,飼養員出發的時間點。
由於題目中並未要求按順序接走每一隻貓。於是就可以將 \(a\) 陣列從小到大排序,那麼題目就轉化為將這 \(n\) 只貓分成至多連續的 \(p\) 組。設第 \(i\) 組的第一隻貓排序後的編號為 \(l\),最後一隻貓排序後的編號為 \(r\)。顯然恰好接走最後一隻貓等待的時間最少,即出發時間點為 \(a_r\),那麼那麼這一組貓等待的總時間就為 \((a_r-a_l+a_r-a_{l+1}+...+a_r-a_r)\),
稍微變化一下,就可以得到
\((a_r*(r-l+1)-(a_l+a_{l+1}+...+a_r))\)。
於是就可以定義 \(sum\) 陣列來記錄 \(a\) 的字首和。
定義 \(f[j][i]\) 表示 \(j\) 個飼養員一共接走了 \(i\) 只貓時所等待的最小總時間。那麼就可以得出狀態轉移方程:
\(f[j][i]=min(f[j-1][k]+a_i*(i-k)-(sum_i-sum_k))\),其中 \(0 \leq k \leq i-1\) 。
考慮斜率優化。
去括號、移項,得:\(f[j-1][k]+sum_k=a_i*k+f[j][i]-a_i*i+sum_i\)。
於是 \(y=f[j-1][k]+sum_k,x=k,k=a_i,b=f[j][i]-a_i*i+sum_i\)。
接下來的步驟就和上一題中的 \(Part\) \(2\) 相似了。最終的時間複雜度就為 \(O(pm)\) 。
code:
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N=1e5+10;
const int M=1e5+10;
const int P=110;
#define int long long
int n,m,p,d[N],a[N],sum[N],f[P][M],q[N];
signed main()
{
scanf("%lld%lld%lld",&n,&m,&p);
for(int i=2;i<=n;i++)
{
scanf("%d",&d[i]);
d[i]+=d[i-1];
}
for(int h,t,i=1;i<=m;i++)
{
scanf("%lld%lld",&h,&t);
a[i]=t-d[h];
}
sort(a+1,a+m+1);
for(int i=1;i<=m;i++) sum[i]=a[i]+sum[i-1];
memset(f,0x3f,sizeof(f));
for(int i=0;i<=p;i++) f[i][0]=0;
for(int j=1;j<=p;j++)
{
int hh=1,tt=1;
q[1]=0;
for(int i=1;i<=m;i++)
{
while(hh<tt&&f[j-1][q[hh+1]]+sum[q[hh+1]]-f[j-1][q[hh]]-sum[q[hh]]<=a[i]*(q[hh+1]-q[hh])) hh++;
int k=q[hh];
f[j][i]=f[j-1][k]+a[i]*(i-k)-(sum[i]-sum[k]);
while(hh<tt&&(f[j-1][q[tt]]+sum[q[tt]]-f[j-1][q[tt-1]]-sum[q[tt-1]])*(i-q[tt])>=(f[j-1][i]+sum[i]-f[j-1][q[tt]]-sum[q[tt]])*(q[tt]-q[tt-1])) tt--;
q[++tt]=i;
}
}
printf("%lld\n",f[p][m]);
return 0;
}
[USACO08MAR]Land Acquisition G
題意
給定 \(n\) 塊土地,每塊土地有一個長度 \(w\) 和寬度 \(l\) 將若干個土地一起購買的花費為\(maxw*maxl\)。求購買所有的土地的花費的最小值。
思路
首先可以注意到,題意中並未要求按順序購買,那麼就可以先對於長和寬其中之一從小到大進行排序,這裡對 \(l\) 先進行排序。如果對於 \(j \leq i-1\) ,且滿足 \(w[j] <w[i],l[j]<l[i]\),那麼 \(i\) 和 \(j\) 一起購買顯然更優,那麼也就沒有必要考慮 \(j\) 了,所以真正有用的 \(j\) 需要滿足 \(l[j]>l[i]\) ,那麼就可以先將排序,求出有用的 \(i\) 排成的序列,可以發現對於這個序列,\(l\) 單調遞增, \(w\) 單調遞減。那麼對於連續的 \(h\) ~\(t\) 這些土地,將這些土地一起購買的花費顯然為 \(w[k]*l[t]\) 。可以發現,這樣的購買方法一定比其他購買方法更優。於是接下來又是將這個序列分成若干組,求最小值了。
定義 \(f[i]\) 表示購買排序後前 \(i\) 個有用的土地所花費的最小值。那麼就有:
\(f[i]=min(f[j]+w[j+1]*l[i])\) ,其中 \((0\leq j \leq i-1)\)。
時間複雜度為 \(O(n^2)\),於是要用到斜率優化。
移項,最終得:\(f[j]=-l[i]*w[j+1]+f[i]\)。
\(y=f[j],x=w[j+1],k=-l[i],b=f[i]\)。
那麼最終需要維護的凸包就要滿足 $\dfrac{f[i]-f[j]}{w[i+1]-w[j+1]} $
單調遞增。但是需要注意的是,這裡的斜率是負數,因為 \(k=-l[i]\),同時 \(w[i]\) 是單調遞減的。所以說在交叉相乘的時候需要改變符號方向。
code:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=5e4+10;
#define int long long
int n,f[N],q[N];
struct node{
int w,l;
bool operator <(const node &t)const{
if(t.w==w) return l>t.l;
return w>t.w;
}
}a[N],b[N];
signed main()
{
//freopen("233.in","r",stdin);
scanf("%lld",&n);
for(int i=1;i<=n;i++) scanf("%lld%lld",&a[i].w,&a[i].l);
sort(a+1,a+n+1);
int tot=0;
for(int i=1;i<=n;i++)
{
if(a[i].l>b[tot].l) b[++tot]=a[i];
}
n=tot;
int hh=1,tt=1;
q[1]=0;
for(int i=1;i<=n;i++)
{
while(hh<tt&&(f[q[hh]]-f[q[hh+1]])>=b[i].l*(b[q[hh+1]+1].w-b[q[hh]+1].w)) hh++;
f[i]=f[q[hh]]+b[i].l*b[q[hh]+1].w;
while(hh<tt&&(f[q[tt]]-f[q[tt-1]])*(b[i+1].w-b[q[tt]+1].w)<=(f[i]-f[q[tt]])*(b[q[tt]+1].w-b[q[tt-1]+1].w)) tt--;
q[++tt]=i;
}
printf("%lld\n",f[n]);
return 0;
}
[HNOI2008]玩具裝箱
推導公式的時候一定要仔細。
題意
給定 \(n\) 個長度為 \(c_i\),寬度為 \(1\) 的玩具,將他們按順序放到若干個箱子裡,每一個箱子中的兩個玩具之間需要單位長度的空隙。那麼每一個箱子就有一個長度 \(x\),每一個箱子的花費就為 \((x-L)^2\) ,其中 \(L\) 是一個常數,在輸入時給出。求最小的總花費。
思路
首先可以發現,只需要知道每一段連續的玩具長度,那麼就可以先用字首和處理 \(c_i\),那麼對於一個左端點為 \(i\) ,右端點為 \(r\) 的箱子,長度 \(x=i-j+s[i]-s[j-1]\) 。同時為了方便計算,可以將 \(c_i\) 變成 \(c_i+1\),那麼 \(x=s[i]-s[j-1]-1-l\),又可以將 \(L\) 提前 \(+1\) ,於是就可以定義 \(f[i]\) 表示前 \(i\) 個箱子的最小花費。可以得出狀態轉移方程,即:
\(f[i]=min(f[j]+(x-l)*(x-l))\),其中 \(x=s[i]-s[j],0 \leq j \leq i-1\)。
接著考慮斜率優化。
拆項,移項,最終得:
\(f[j]+l^2+s[j]^2+2*i*s[j]=2*s[i]*s[j]+f[i]+s[i]^2-2*s[i]*l\)。
於是 \(y=f[j]+l^2+s[j]^2+2*i*s[j],x=s[j],k=2*s[i]\)。
同時注意到 \(k\) 是單調遞增的,於是就可以用單調佇列優化凸包。
code:
#include<cstdio>
using namespace std;
const int N=5e4+10;
#define int long long
int n,l,s[N],f[N],q[N];
int min(int a,int b){return a<b?a:b;}
int get_y(int j){ return f[j]+2*s[j]*l+s[j]*s[j]+l*l;}
int get_x(int j){ return s[j];}
signed main()
{
//freopen("233.in","r",stdin);
scanf("%lld%lld",&n,&l);
for(int i=1;i<=n;i++) scanf("%lld",&s[i]),s[i]+=s[i-1]+1;
int hh=1,tt=1;
l++;
q[1]=0;
for(int i=1;i<=n;i++)
{
while(hh<tt&&get_y(q[hh+1])-get_y(q[hh])<=2*s[i]*(get_x(q[hh+1])-get_x(q[hh]))) hh++;
int j=q[hh],x=s[i]-s[j]-l;
f[i]=f[j]+x*x;
while(hh<tt&&(get_y(q[tt])-get_y(q[tt-1]))*((get_x(i))-get_x(q[tt]))>=(get_y(i)-get_y(q[tt]))*((get_x(q[tt]))-get_x(q[tt-1]))) tt--;
q[++tt]=i;
}
printf("%lld\n",f[n]);
return 0;
}
總結
對於斜率優化 \(DP\) ,首先需要確定樸素的狀態轉移方程。然後進行移項,對於一次函式 \(y=kx+b\) ,通常情況下將在 \(j\) 確定時的常量(也就是隻和 \(j\) 有關或本身就是常量)放在等式的左邊作為 \(y\) ,將同時與 \(i,j\) 有關的項放在等式右邊(通常是幾個單項式相乘),其中與 \(j\) 有關的單項式作為 \(x\) ,與 \(i\) 有關的項作為 \(k\) ,其餘的只和 \(i\) 有關的項作為 \(b\) 。
如果 \(k\) 的值單調遞增,那麼就可以用單調佇列優化,小於當前 \(k\) 的凸包上的點直接刪去,如果 \(k\) 的值無單調性,就可以用二分法找到第一個和下一個點之間連線的斜率大於 \(k\) 的點。最後在新添 \(i\) 點的時候維護一下凸包即可。此時通常可以用交叉相乘避免精度誤差。
最後得到的即為答案。