【DP】斜率優化初步

HinanawiTenshi發表於2021-03-04

向y總學習了斜率優化,寫下這篇blog加深一下理解。

模板題:https://www.acwing.com/problem/content/303/

分析

因為本篇的重點在於斜率優化,故在此給出狀態轉移方程:
\(f[i]=\min(f[j]-(t[i]+s)*c[j]+t[i]*c[i]+s*c[n])\) ,其中 \(j \in [1,i-1]\)

其中 \(f[i]\) 表示選取前 \(i\) 個物品後的最小貢獻,\(t[i],c[i]\) 分別表示題目中前 \(i\) 個物品 \(t,c\)字首和\(n,s\) 與題面含義一致。

因為重點在於推式子,所以不理解裡面的記號表示什麼也不會有太大影響,不過還是儘量先看看樸素的DP怎麼寫。

如果採取樸素的遞推方式解決,那麼複雜度是 \(O(N^2)\) ,在 \(N\) 範圍較大的時候會超時。
這時候,就要用到主角:斜率優化 了。

首先,因為要讓 \(i\)\(1-n\) 一直推過去,所以這裡無法優化,因此考慮用更快的方式來找到 \(j\)

我們將上面的狀態轉移方程化為:
\(f[j]=(t[i]+s)*c[j]+f[i]-t[i]*c[i]-s*c[n]\)

注意到列舉 \(i\) 後, \(i\) 就是固定的了,在上面的方程中可以視為一個常數,而 \(j\) 則是變數。
我們將 \(f[j]\) 看作是因變數 \(y\)\(c[j]\) 看作是自變數 \(x\) ,上面的式子可以簡單地寫成:

\[y=k*x+b \]

其中 \(b=f[i]-t[i]*c[i]-s*c[n]\)\(k=(t[i]+s)\)
這個形式,正好是直線方程
我們的目標是最小化 \(f[i]\),而注意到 \(-t[i]*c[i]-s*c[n]\) 是已經確定的了,所以最小化 \(b\) (直線對應的截距)即可。

因為直線是過點 \(P_{j}(x,y)\)\((c[j],f[j])\) 的,所以下面考慮如何快速找到合適的點,讓直線的截距最小化。
\(P_{j}(x,y) ~~j \in [1,i-1]\) 畫在平面直角座標系上:

現在要求斜率一定的直線在和什麼樣的點相交時截距取到最小。

接下來的內容可能需要一點線性規劃基礎。

1、首先,直線一定和下凸殼上的點相交時截距才可能取到最小:

(上圖橙線所連的部分為下凸殼
簡證:假設直線 \(A\) 經過下凸殼上方的點,那麼經過下凸殼的點的相同斜率直線 \(B\) 截距更小:

從這一事實中,我們想到維護一個凸包(下凸殼)。

下凸殼具有從左到右斜率遞增的性質(所以考慮用單調佇列來維護)

2、直線在和從左到右第一條斜率比它大的直線所對應的點(我們記為 \(P\) )相交時截距最小。

結合影像理解:

證明提示:考慮其他點(分左右討論),發現沒有比這個點更優的了。

下面的工作是實現:(不理解時可以結合程式碼)

可能需要單調佇列基礎

先開一個單調佇列 q[] ,存點所對應的下標(注意!不是橫座標
核心是維護一個凸包,注意到在這題中隨著 \(i\) 的增加,直線的斜率是遞增的,因而在從左到右尋找 \(P\) 的過程中,不是 \(P\)\(P\) 左邊)的點出隊就行了。(出隊順序從左到右)

在找到 \(P\) 後,對應的 \(j\) 就在隊頭,從而我們可以對 \(f[i]\) 進行更新。

下標 \(i\) 所對應的點是新點,需要進行入隊,入隊的時候要保證維護下凸殼,因此如果新點和先前的點形成的斜率是小於等於原來的點形成的斜率的,就要將原來的點出隊。 (出隊順序從右到左)
如圖:

最後將新點入隊即可。

程式碼:

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N=3e5+5;

int n,s;
ll c[N],t[N];
ll f[N];
int q[N];
 
int main(){
    cin>>n>>s;
     for(int i=1;i<=n;i++){
        cin>>t[i]>>c[i];
        c[i]+=c[i-1]; t[i]+=t[i-1];
    }
     
    int tt,hh;
    tt=hh=0; //空的佇列是tt=-1, hh=0 而佇列一開始是有一個元素的
    q[0]=0;
     
    for(int i=1;i<=n;i++){
        while(tt>hh && (f[q[hh+1]]-f[q[hh]])<=(t[i]+s)*(c[q[hh+1]]-c[q[hh]]) ) hh++; //彈出左邊的點
        int j=q[hh];
        f[i]=f[j]-(t[i]+s)*c[j]+t[i]*c[i]+s*c[n]; //更新f
        while(tt>hh && (__int128)(f[i]-f[q[tt-1]])*(c[q[tt]]-c[q[tt-1]])<=(__int128)(f[q[tt]]-f[q[tt-1]])*(c[i]-c[q[tt-1]])) tt--; //彈出右邊的點
        q[++tt]=i;
    }
     
    cout<<f[n]<<endl;
     
    return 0;
}

複雜度

注意到每個點只可能入隊,出隊一次,故複雜度是 \(O(N)\)

擴充套件

上面例題的情況屬於較為簡單的情況,所對應的直線斜率隨 \(i\) 增加而增加,而且更新點(入隊的點)的橫座標也是遞增的,因此在尋找 \(P\) 的過程中將 \(P\) 左邊的點去掉即可。

但是,在性質不那麼好的題目中,是可能出現斜率非單增的情況的,這時候我們就不能在尋找 \(P\) 的過程中將 \(P\) 左邊的點去掉,凸包仍然是下凸殼(具有從左到右斜率遞增的性質),故可以用二分法來尋找 \(P\)
這樣做的複雜度是\(O(N\log N)\)
例題:https://www.acwing.com/problem/content/description/304/

程式碼
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=3e5+5;

ll t[N],c[N];
int n,s;
ll f[N];
int q[N];

int main(){
    cin>>n>>s;
    for(int i=1;i<=n;i++){
        cin>>t[i]>>c[i];
        t[i]+=t[i-1]; c[i]+=c[i-1];
    }
    
    int tt,hh;
    tt=hh=0;
    q[0]=0;
    
    for(int i=1;i<=n;i++){
        int l=hh,r=tt;
        while(l<r){
            int mid=l+r>>1;
            if( (t[i]+s)*(c[q[mid+1]]-c[q[mid]])<=f[q[mid+1]]-f[q[mid]] ) r=mid;
            else l=mid+1;
        }
        
        int j=q[l];
        f[i]=f[j]-(t[i]+s)*c[j]+t[i]*c[i]+s*c[n];
        while(tt>hh && (__int128)(f[i]-f[q[tt-1]])*(c[q[tt]]-c[q[tt-1]])<=(__int128)(f[q[tt]]-f[q[tt-1]])*(c[i]-c[q[tt-1]])) tt--;
        q[++tt]=i;
    }
    
    cout<<f[n]<<endl;

    return 0;
}

事實上,還有斜率不單調,更新點(入隊的點)的橫座標也不單調的情況,這個時候就需要用平衡樹等資料結構維護凸包了。

相關文章