單調佇列:從一道題說起_2023CCPC廣東省賽B.基站建設

liyishui發表於2024-05-05

今天遇到一道題:

給定長度為n的陣列a,a[i]表示在第i點建立基站的開銷。
同時給出m個區間[li,ri],要滿足給定的m個區間內都至少有一個基站,求最小的開銷。

正解是單調佇列最佳化dp,那麼什麼是單調佇列?我們先看另外一道題:

image

顯然最小值和最大值是互相獨立的,我們可以先考慮最大值。用手模擬一下這個過程:

  1. [1 3 -1 -3 5 3 6 7]
    最開始我們的視窗裡什麼也沒有,我們把1加入備選答案。
    現在的答案備選:[1]

  2. [1 3 -1 -3 5 3 6 7]
    新進來一個3,發現3比1大,並且3比1還更有未來!!更有未來指的是在視窗往右滑動的過程中,1會先淘汰掉,3在1後面,3的"有效期"更久。那3不僅比1大,有效期還更久,1兩個都比不過,永遠不會對最大值有貢獻了,我們的備選答案裡就可以淘汰掉1,加入3
    現在的答案備選:[3]

  3. [1 3 -1 -3 5 3 6 7]
    新進來一個-1,-1並沒有比3大,但是我們現在還不知道後面的數會有多小,來個-100,-inf什麼的,在往右滑動的過程中3會被踢掉,此時-1就可能成為最大值。所以暫且把-1加入備選答案。
    現在的答案備選:[3,-1]

  4. [1 3 -1 -3 5 3 6 7]
    新進來一個-3,同理,-3比-1小,但是有效期是最新的,我們現在還不知道後面的數會有多小,來個-100,-inf什麼的,在往右滑動的過程中3和-1會被踢掉,此時-3就可能成為最大值。所以暫且把-3加入備選答案。
    現在的答案備選:[3,-1,-3]

  5. [1 3 -1 -3 5 3 6 7]
    新進來一個5,發現此時的5不僅比-3大,還比-1、3都大,且有效期最新,所以把3/-1/-3都淘汰掉,只留一個5.
    現在的答案備選:[5]

  6. [1 3 -1 -3 5 3 6 7]
    新進來一個3,雖然3比5小,但有效期新,加進來。
    答案備選[5,3]

  7. [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只能是往前一小段。

image

比如上圖,合法的區間只有綠色那段,如果再從更前面的點轉移過來,就會導致最上面的那段區間沒被覆蓋到。

因此我們可以這麼處理:

cin>>m;
	while(m--){
		int l,r; cin>>l>>r;
		pre[r+1]=max(pre[r+1],l);
	}

image

讀入一段區間[l,r]後,對於r+1來說最小的合法前驅必須至少是l,再往前就會導致區間[l,r]不被覆蓋。因為可能同一個r對應很多的l,所以取個最大值。

但只考慮端點還是不夠的,可能出現這種情況:

image

本來對於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();
	}
}

相關文章