決策單調性最佳化DP

HarlemBlog發表於2024-10-31
更新日誌 update 2024/10/31: 更新分治最佳化與單調佇列最佳化 update 2024/11/01: 更正單調佇列最佳化部分表述,更新斜率最佳化,附上例題程式碼

簡介

決策單調性,通常用於“最優類”DP(每個狀態均由一個最優決策點轉移而來),指的是最優決策點具有單調性,如單調右移等。

分治最佳化

概念

考慮每次解決一個問題區間,而每次都解決區間中點,並且根據中點資訊分別解決左右兩半區間,也就是分治的思想。

思路

因為決策單調性的存在,所以我們可以同時儲存“當前問題區間”與“可能存在的最優決策點區間”。

每次我們都計算出當前區間的中點的值,並且記錄它的最優決策點。由於決策單調性,以單調右移為例,我們可以肯定,它左半邊區間內的最優決策點必然在中點最優決策點左側或相等,右半邊同理,必然在右側或相等,那麼就縮小了尋找最優決策點的範圍,同時減少了時間複雜度。

這只是一個框架,我們可以加入更多的最佳化。舉個例子,假如狀態的轉移與區間資訊和有關,那麼每次轉移時,我們可以使用莫隊的思想,如例題所示,等等。

例題

CF868F

程式碼
#include<bits/stdc++.h>
using namespace std;

typedef long long ll;

const ll inf=1e18;
const int N=2e5+5,K=25;

int n,k;
int a[N];
ll f[N][K];

int nl,nr;
int v[N];
ll cans;
ll calc(int l,int r){
    while(nl>l)cans+=v[a[--nl]]++;
    while(nl<l)cans-=--v[a[nl++]];
    while(nr<r)cans+=v[a[++nr]]++;
    while(nr>r)cans-=--v[a[nr--]];
    return cans;
}

void solve(int d,int l,int r,int bl,int br){
    int mid=l+r>>1,bml=bl,bmr=min(br,mid);
    ll mans=inf;int mfrm;
    for(int j=bml;j<=bmr;j++){
        ll res=f[j-1][d-1]+calc(j,mid);
        if(res<mans){
            mans=res;
            mfrm=j;
        }
    }
    f[mid][d]=mans;
    if(l==r)return;
    solve(d,l,mid,bl,mfrm);
    solve(d,mid+1,r,mfrm,br);
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cin>>n>>k;
    for(int i=1;i<=n;i++){
        cin>>a[i];
        f[i][0]=inf;
    }
    f[0][0]=0;
    nl=nr=n+1>>1;v[a[nl]]++;
    for(int d=1;d<=k;d++){
        solve(d,1,n,1,n);
    }
    cout<<f[n][k];
    return 0;
}

單調佇列最佳化

概念

介於單調性,我們可以很輕鬆地想到維護一個單調佇列,儲存最優決策點。

思路

單調佇列中的單調性,是指決策最優性的單調。也就是說,靠近隊頭的必然比靠近隊尾的更優,那麼每次都只需要由隊頭元素轉移即可。

下面考慮如何維護這個單調佇列。

首先考慮有效性,如果決策點存在一個作用範圍,那麼每次計算新的狀態時,都要現檢查是否超出了隊頭的作用範圍,如果是,由於決策單調性,隊頭就永遠不會被再次用到了,所以彈出。

其次考慮單調性,這個需要在往單調佇列中插入已經完成了的新狀態(供下一次轉移)來維護。我們考慮維護隊尾,假如隊尾元素不比當前元素更優,那麼我們就應該彈出它,因為新元素必然比其更優(前提是存在單調性)。

具體考慮“優劣”定義,就在於實際實現了。

擴充(動態作用範圍)

其實這部分就是二分佇列,但我感覺這個名字有些不明所以、偏離重點。

有時候,作用範圍是在動態更新的,也就是說,新來的狀態,並沒有單獨的作用範圍,而是在一定範圍內比之前的狀態更優,從而“搶佔”了之前狀態的作用範圍。

換句話說,出現這個決策點後,原來的一些元素的最優決策點變成了它,就可以視作一次作用範圍的更新。

我們藉助斜率最佳化(這裡的講解會比較抽象,如果看不懂可以考慮先看看斜率最佳化)的思路,將這些決策點轉移為一些直線,x座標表示由這些決策轉移去的點,y座標表示價值。毫無疑問,每一個點(需要更新的),都只需要取它所在的x座標對應的最大/最小y值(考慮是最大化問題還是最小化問題)。

這些直線必然會產生交點,交點的作用就是標識著最優決策的變化。比如,交點前 \(A\) 更優,經過了與 \(B\) 的交點,\(B\) 就更優了。

那麼,這個所謂的交點,實際上,就是作用範圍的右邊界。這個問題就可以使用單調佇列維護。

具體地,我們還是以思路部分的思路進行討論。

  • 有效性:我們肯定,一個點的作用範圍右邊界不會右移,那麼,如果當前點已經超出了它的作用範圍,彈出即可。
  • 單調性:這是重點部分。我們在這裡就要考慮到動態更新區間了。具體的,我們每次都找出隊尾的直線與隊尾前一條直線、即將加入的直線的交點(可以二分查詢),假如後者在前者之前——我們在這裡可以想象三根直線,更有利於理解——就能說明即將加入的直線作用範圍覆蓋了隊尾的作用範圍。或者這麼說,新加入的直線與倒數第二條直線的交點在與隊尾直線的交點之前,又因為新加入直線的 \(k\) 要大於隊尾的 \(k\) ,所以新加入的直線必然是絕對優於隊尾直線、並且覆蓋了隊尾直線的作用範圍的。那麼,隊尾直線就失去了價值,可以彈出了。

例題

LG1912詩人小G
(注:用到了擴充部分的思路)

程式碼
#include<bits/stdc++.h>
using namespace std;

typedef long double ld;

void print(ld num){
    cout<<num;
}

const int N=1e5+5;

int n,l,p;
string s[N];
int len[N],sum[N];

int hd=1,tl=0;
int q[N];
int lst[N];
ld f[N];

ld qpow(ld a,int b){
    ld res=1;
    while(b){
        if(b&1)res*=a;
        a*=a;
        b>>=1;
    }
    return res;
}

ld calc(int j,int i){
    return f[j]+qpow(abs(sum[i]-sum[j]-1-l),p);
}

int find(int a,int b){
    int l=b,r=n+1;
    while(l<r){
        int m=l+r>>1;
        if(calc(a,m)>=calc(b,m))r=m;
        else l=m+1;
    }
    return l-1;
}

void solve(){
    cin>>n>>l>>p;
    for(int i=1;i<=n;i++){
        cin>>s[i];
        len[i]=s[i].size();
        sum[i]=sum[i-1]+len[i]+1;
    }
    hd=0;tl=0;
    for(int i=1;i<=n;i++){
        while(hd<tl&&find(q[hd],q[hd+1])<i)hd++;
        f[i]=calc(q[hd],i);
        lst[i]=q[hd];
        while(hd<tl&&find(q[tl-1],q[tl])>=find(q[tl],i))tl--;
        q[++tl]=i;
    }
    if(f[n]>1e18)cout<<"Too hard to arrange\n";
    else{
        cout<<f[n]<<"\n";
        stack<int> hh;
        for(int i=n;i!=0;i=lst[i])hh.push(i);
        for(int i=1;i<=n;i++){
            cout<<s[i];
            if(hh.top()==i){
                cout<<"\n";
                hh.pop();
            }else cout<<" ";
        }
    }
    cout<<"--------------------\n";
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cout<<fixed<<setprecision(0);
    int t;cin>>t;
    while(t--)solve();
    return 0;
}

斜率最佳化

概念

構建一個二維座標系,通常橫座標為決策點、縱座標為某個公式值,維護一個凸包。

思路

這個最佳化的重點就在於座標系中相鄰兩點連線線的斜率,我們會細緻講解。

通常情況下,我們首先需要證明這個問題是具有單調性的。這個先記著,具體維護什麼的單調性後面再講。

通常情況下,我們需要推出一個公式,這個公式的作用是判斷兩個已有決策點對於一個待更新點來說誰更優的。(其實,它的另一個作用是確認直線的斜率,這個我們後面再講,這裡為了清晰理解,只需要記住括號外的概念即可。)

假設待更新點是 \(i\) ,兩個決策點分別為 \(j,k\)。這個公式通常長這個樣子:

\[G(j,k)>F(i) \]

中間的不等符號不一定,要看實際情況。這個格式的意思,就是左邊全是 \(j,k\) 有關式子,右邊全是 \(i\) 有關式子。

這個公式如何得來呢?不固定,但通常情況下,有一種較為簡單的方式:

我們考慮原始的狀態轉移方程,比如說:

\[f_i = \max_j(f_j+\rm{calc}(j,i)) \]

當然,這裡 \(\max\) 只是一個象徵,表示最最佳化,具體是最大還是最小需要看題目要求。\(\rm{calc}\) 只是一個格式,其內容就是你的具體轉移方程。
好,現在我們我們就考慮如何根據這個式子判斷兩個決策點誰更優。毫無疑問,假設我們是要取最大值,且 \(j\) 優於 \(k\),必有:

\[f_j+\rm{calc}(j,i) > f_k+\rm{calc}(k,i) \]

我們對這個公式進行變形,變形成上述形式,就得到了一個我們需要的公式了。完畢。

以上內容與斜率基本無關,下面我們就要考慮斜率最佳化了。

先給定義吧,用 \(G(j,k)\) 代表兩個決策點之間的斜率,\(F(i)\) 代表一條代表待更新點的直線的斜率。

下述斜率最佳化所有內容都基於公式為 \(G(j,k)>F(i)\) 的情況,具體不等符號請靈活變通。

根據上述定義,只要兩個決策點之間的斜率大於待更新直線的斜率,那麼直線右邊的決策點就優於左邊的決策點。反之同理。

不難想到,如果我們維護所有可能的決策點,那麼從左到右(右側為正方向,越向右決策點越“新”),斜率應該是單調遞減的。

簡要證明,假如有一段斜率比前一段大,那麼這段直線右端點絕對比左端點更優,左端點就沒有存在的必要的,直接刪除即可。事實上,這就是後面要說的維護單調性的方式。

好,那麼,對於每一個待更新點,我們都去尋找第一條斜率小於 \(F(i)\) 的連線,它的左端點就是這個待更新點的最優決策點。

簡要證明,我們首先保證了斜率單調遞減,那麼第一條斜率小於 \(F(i)\) 的連線,它後面的所有斜率都是小於 \(F(i)\) 的,意思就是它左邊的節點要更優。而這條連線左端點就是最優的決策點。

這裡提一下兩個若智的特殊情況:

  1. 目前沒有點,不存在,必然有一個初始化,不然無法動態規劃。
  2. 目前只有一個點,那麼只有它能當轉移點不是嗎?擴充一下,假如說最右側的直線斜率都大於 \(F(i)\),那麼根據定義,最右邊的端點就是當前的最優決策點。

具體實現

單調佇列

使用單調佇列有一個前提: \(F(i)\) 單調遞減。也就是說,新的待更新直線斜率必然大於先前的待更新直線斜率。

這樣的話,我們就可以使用單調佇列維護了。更具體的,每次單調佇列的隊頭必然是最優決策點。

如何維護這個性質呢?我們只需要保證第一條直線就是第一條斜率小於當前的 \(F(i)\) 的直線即可。

也就是說,我們每次要找的 第一條斜率小於 \(F(i)\) 的連線,保證是第一條連線。

維護方法非常簡單,因為 \(F(i)\) 單調遞減,假如說當前的第一條連線斜率大於了 \(F(i)\),那麼它就永遠大於後續的所有 \(F(i)\) 了。意義是,它左側的節點,永遠都不會再成為最優決策點,就可以出隊了。

接下來考慮維護單調性,我們每次都從隊尾插入節點,每次對比當前的最後一條直線與即將加入的連線斜率(這裡的連線不是 \(F(i)\) 了,而是 \(G(t,i)\)\(t\) 是實時變化的當前隊尾決策點),如果不滿足單調遞減的性質,就彈出當前隊尾節點(這時候新節點還沒插入),直到佇列中只剩下一個決策點或者滿足了單調遞減的性質為止。

如果你不太明白邊界情況,那麼就請看一下思路部分結尾的兩個(事實上三個)若智特殊情況,應該就能解答你的疑惑了。

二分

如果插入的 \(F(i)\) 沒有單調性,那麼每次都二分查詢第一條斜率小於當前的 \(F(i)\) 的直線即可。單調性的維護和單調佇列方法是一致的,不再細說。

例題

LG3195-玩具裝箱

程式碼
#include<bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef long double ld;

const int N=5e4+5;

int n;ll l;
ll c[N];
ll dp[N];

ld f(int i){
    return 2.0*c[i];
}

ld g(int j,int k){
    return 1.0*(dp[j]+(c[j]+l)*(c[j]+l)-dp[k]-(c[k]+l)*(c[k]+l))/(c[j]-c[k]);
}

ll calc(int j,int i){
    return (c[i]-c[j]-l)*(c[i]-c[j]-l);
}

int q[N];
int hd,tl;

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cin>>n>>l;l++;
    for(int i=1;i<=n;i++){
        cin>>c[i];
        c[i]+=c[i-1]+1;
    }
    q[hd=tl=1]=0;
    for(int i=1;i<=n;i++){
        while(hd<tl&&g(q[hd],q[hd+1])<=f(i))hd++;
        dp[i]=dp[q[hd]]+calc(q[hd],i);
        while(hd<tl&&g(q[tl-1],q[tl])>=g(q[tl],i))tl--;
        q[++tl]=i;
    }
    cout<<dp[n];
    return 0;
}

WQS二分

概念

通常用於最佳化二維DP到一維DP,縮減複雜度。

具體的,它通常用於最佳化形似“固定了特定內容個數的最最佳化問題”的DP最佳化。

思路

WQS二分通常是搭配著斜率最佳化和單調佇列使用了,建議先學一下這兩個。

具體的,我們考慮一個東西,暫且稱之為“偏差量”(可能不標準)。,用 \(\Delta x\) 表示

WQS二分的重點其實就是去除“固定特定內容個數”這個限制,來降維,具體的實現就要靠 \(\Delta x\)

如何使用 \(\Delta x\)?一般來說,我們在進行轉移時,額外的把 \(f_i\) 的值加上/減去 \(\Delta x\)

這是個很令人迷惑的操作,下面詳細地解釋它的作用。

首先,我們設 \(g_i\) 表示 \(i\) 的最優決策共選取了多少節點。
那麼,毫無疑問,\(g_i\) 越大,\(i\) 得到/失去的 \(\Delta x\) 就越多。
為了方便講述,我們假設這是個最小化問題,且得到 \(\Delta x\)。那麼,一些 \(g\) 大的狀態,其代價就會變大,就會影響後續的選擇。
更明白地,\(\Delta x\) 越大,最優決策點就越傾向於 \(g\) 小的決策點。
那麼,我們就可以透過控制 \(\Delta x\) 的大小,來控制最優狀態的 \(g\) 大小。只要不存在客觀無解的狀況,我們都可以透過找到一個恰好的 \(\Delta x\) 來使得最終的答案恰好選取了要求的個數。

那麼,我們只需要二分 \(\Delta x\) 就可以解決這種問題了。

假設原複雜度是 \(O(n^2)\),最佳化之後的複雜度就變成了 \(O(n\log{k})\),令 \(k\) 表示 \(\Delta x\) 的取值範圍。

例題

LG2619-Tree I

程式碼
#include<bits/stdc++.h>
using namespace std;

const int N=5e4+5,M=1e5+5;

struct edge{
    int s,t,v;
    int col;
}es[M*2];

int n,m,need;

vector<int> blk,wht;
bool cmp(edge a,edge b){
    return a.v==b.v?a.col<b.col:a.v<b.v;
}

struct DSU{
    int fa[N*2];
    void init(int n){
        for(int i=0;i<=n;i++){
            fa[i]=i;
        }
    }
    int find(int x){
        if(fa[x]!=x)fa[x]=find(fa[x]);
        return fa[x];
    }
    void merge(int a,int b){
        a=find(a);b=find(b);
        fa[a]=b;
    }
    bool same(int a,int b){
        return find(a)==find(b);
    }
}dsu;

int sum,num;
bool check(int ad){
    dsu.init(n);
    sum=num=0;
    for(int i=1;i<=m;i++){
        if(es[i].col==0)es[i].v+=ad;
    }
    sort(es+1,es+1+m,cmp);
    int now=1;
    for(int t=1;t<n;t++){
        while(dsu.same(es[now].s,es[now].t)){
            now++;
        }
        dsu.merge(es[now].s,es[now].t);
        sum+=es[now].v;
        if(es[now].col==0)num++;
    }
    for(int i=1;i<=m;i++){
        if(es[i].col==0)es[i].v-=ad;
    }
    return num>=need;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cin>>n>>m>>need;
    for(int i=1;i<=m;i++){
        cin>>es[i].s>>es[i].t>>es[i].v>>es[i].col;
    }
    int l=-105,r=105;
    int ans;
    while(l<=r){
        int m=l+r>>1;
        if(check(m)){
            l=m+1;
            ans=m;
        }
        else r=m-1;
    }
    check(ans);
    cout<<sum-need*ans;
    return 0;
}

相關文章