今天遇到一道題:
給定長度為n的陣列a,a[i]表示在第i點建立基站的開銷。
同時給出m個區間[li,ri],要滿足給定的m個區間內都至少有一個基站,求最小的開銷。
正解是單調佇列最佳化dp,那麼什麼是單調佇列?我們先看另外一道題:
顯然最小值和最大值是互相獨立的,我們可以先考慮最大值。用手模擬一下這個過程:
-
[1 3 -1 -3 5 3 6 7]
最開始我們的視窗裡什麼也沒有,我們把1加入備選答案。
現在的答案備選:[1] -
[1 3 -1 -3 5 3 6 7]
新進來一個3,發現3比1大,並且3比1還更有未來!!更有未來指的是在視窗往右滑動的過程中,1會先淘汰掉,3在1後面,3的"有效期"更久。那3不僅比1大,有效期還更久,1兩個都比不過,永遠不會對最大值有貢獻了,我們的備選答案裡就可以淘汰掉1,加入3
現在的答案備選:[3] -
[1 3 -1 -3 5 3 6 7]
新進來一個-1,-1並沒有比3大,但是我們現在還不知道後面的數會有多小,來個-100,-inf什麼的,在往右滑動的過程中3會被踢掉,此時-1就可能成為最大值。所以暫且把-1加入備選答案。
現在的答案備選:[3,-1] -
[1 3 -1 -3 5 3 6 7]
新進來一個-3,同理,-3比-1小,但是有效期是最新的,我們現在還不知道後面的數會有多小,來個-100,-inf什麼的,在往右滑動的過程中3和-1會被踢掉,此時-3就可能成為最大值。所以暫且把-3加入備選答案。
現在的答案備選:[3,-1,-3] -
[1 3 -1 -3 5 3 6 7]
新進來一個5,發現此時的5不僅比-3大,還比-1、3都大,且有效期最新,所以把3/-1/-3都淘汰掉,只留一個5.
現在的答案備選:[5] -
[1 3 -1 -3 5 3 6 7]
新進來一個3,雖然3比5小,但有效期新,加進來。
答案備選[5,3] -
[1 3 -1 -3 5 3 6 7]
新進來一個6,比現有的答案都大,且有限期更新,淘汰掉現在的答案備選。
答案備選[6]
......
在這個過程中大家也發現,如果新加進來一個數,當前答案備選裡的“隊尾”哪哪都比不過人家時(不僅有效期短,數值還更小),就永遠不會對答案有貢獻,我們就可以踢掉隊尾。但也不一定只踢掉一次,為什麼呢?
假設現在答案備選裡有[1000,666,233,-1,-7],新加進來一個7。
比較當前的隊尾-7:數值更小,“有效期”更短,用7來代替-7肯定會是更優的解,把-7踢掉。答案備選變成[1000,666,233,-1]
比較當前的隊尾-1:同理,用7代替-1,答案備選變成[1000,666,233]
比較當前的隊尾233:雖然233可能很快就失效了(視窗要滑過去,233要沒了),但是它的值更大,在失效前可能會成為最優解,先保留著。這時在隊尾加入新進來的7,答案備選就變成了[1000,666,233,7]。
也就是說,我們在維護一個單調遞減的答案備選佇列,每新加進來一個數 x,就考慮能否用 x 替代掉隊尾,如果發現可以,則一直這麼做,直到當前的隊尾比 x 大。
這部分的程式碼是:
int h = 1, t = 0,q[1000005],p[1000005]; //q儲存值,p儲存位置
for (int i = 1; i <= n; i++) {
while (h <= t && q[t] <= a[i]) t--; //如果隊尾不優,就一直淘汰掉
t++; //加入新的數
q[t] = a[i];
p[t] = i;
}
你可能會發現我們上面一直沒有考慮過k的限制,k怎麼處理?不就是要讓當前的隊頭不能過期,否則就要踢出去嘛。那就是:
while (p[h] <= i - k) h++; //如果p[h]<= i-k 說明當前的h離i超過了k,已經離開了視窗 ,就h++。
這樣我們就用單調佇列解決了這道滑動視窗。再回來看
給定長度為n的陣列a,a[i]表示在第i點建立基站的開銷。
同時給出m個區間[li,ri],要滿足給定的m個區間內都至少有一個基站,求最小的開銷。
我們能想到最暴力的dp表示式:令 dp[i] 表示強制在 i 建立基站,且前面所有線段都滿足要求的最小花費,則
dp[i]=max(dp[j]+a[i]) ,其中[j+1,i-1]之間不能有完整的區間(不然就有區間沒被覆蓋到了)
看起來這個方程是O(N^2)的,因為我們要一層for迴圈列舉i,再一層for迴圈列舉j。但事實上由於"[j+1,i-1]之間不能有完整的區間"這一限制,想象一下,對於一個i,它的j只能是往前一小段。
比如上圖,合法的區間只有綠色那段,如果再從更前面的點轉移過來,就會導致最上面的那段區間沒被覆蓋到。
因此我們可以這麼處理:
cin>>m;
while(m--){
int l,r; cin>>l>>r;
pre[r+1]=max(pre[r+1],l);
}
讀入一段區間[l,r]後,對於r+1來說最小的合法前驅必須至少是l,再往前就會導致區間[l,r]不被覆蓋。因為可能同一個r對應很多的l,所以取個最大值。
但只考慮端點還是不夠的,可能出現這種情況:
本來對於r'+1來說最小的合法前驅至少得是l',對於R+1來最小的合法前驅至少得是L,但這顯然是錯的。如果要在R+1建立基站,最小的合法前驅得是l',否則[l',r']這段無法覆蓋到。也就是說當前點的合法前驅還跟前面的點有關,取一個字首max即可。
for(int i=1;i<=n;i++) pre[i]=max(pre[i-1],pre[i]);
然後正式開始dp,和剛才的滑動視窗類似:
處理隊頭的操作:
while(hh<=tt and q[hh]<pre[i]) hh++; //如果當前隊頭超出了合法的範圍,也就是說“過期了”,把隊頭踢掉
處理隊尾的操作:
while(hh<=tt and dp[q[tt]]>=dp[i]) tt--; //如果當前的答案比隊尾還優(這裡更小是更優),且因為是新加進來的,有效期更新,更不容易超出合法的範圍。
//當前的答案怎麼看都更好,就把隊尾踢掉。
//最後佇列裡就是合法的、且答案單調遞增的備選答案
dp轉移操作:
dp[i] = dp[q[hh]] + a[i]; //因為我們的佇列裡維護的是合法的、答案單調遞增的備選答案,所以直接取隊首就是最優解
最後合起來完整程式碼就是:
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
int n,m;
void solve(){
cin>>n;
vector<int> a(n+5),pre(n+5),q(n+5);
vector<LL> dp(n+5);
for(int i=1;i<=n;i++) cin>>a[i];
n++;
cin>>m;
while(m--){
int l,r; cin>>l>>r;
pre[r+1]=max(pre[r+1],l);
}
for(int i=1;i<=n;i++) pre[i]=max(pre[i-1],pre[i]);
int hh=0,tt=0;
q[0]=0;
for(int i=1;i<=n;i++){
while(hh<=tt and q[hh]<pre[i]) hh++;
dp[i] = dp[q[hh]] + a[i];
while(hh<=tt and dp[q[tt]]>=dp[i]) tt--;
q[++tt]=i;
}
cout << dp[n] << endl;
return ;
}
int main(){
int t;cin>>t;
while(t--){
solve();
}
}