【演算法筆記】單調佇列和優化DP
「冥界の土地も有限よ、餘計な霊魂は全て斬る!」
單調佇列
單調佇列是一種神奇的資料結構。
一般我們用來優化這一類決策有單調性的問題:
比如:一個元素很明顯不可能進入決策的時候,我們可以用單調佇列來做到 \(\text{O}(1)\) 的決策。
這裡來一道題(來自WC2016 by 宋新波)
這裡相當於是在保質期之內,求每一天最大能吃到的值。
這裡考慮維護一個佇列,隊列當中單調不上升。
那麼隊頭就是我們所想要的結果(最優)。
- Day1:
--------------
80
--------------
這裡隊頭是80.
- Day2:
------------- 80 75 -------------
這裡隊頭仍然是80.
- Day3
這裡開始就要注意了,我們放入78,但是放進去之後就不滿足條件了。
為什麼?
不是因為後面小了就是前面大了(這裡是單調不上升所以是這樣,其他同理)。
不過我們一般考慮後面小了(從後面進隊出隊)。
那麼在插入78 之前,我們就要彈出75 (從後方)
-------------
80 75(pop) --->
-------------
-------------
80 <------ 78(push)
-------------
-------------
80 78
-------------
此時隊頭仍然是 80。
這裡我們不難看出,如果存在 \(a_i <a_j\)
那麼 \(a_i\) 一定會被 \(a_j\) 替換掉。
因為 \(a_i\) 不僅時間靠前(更快過期),價值也不大。
所以 \(a_i\) 就是 冗雜的多餘決策。
那麼單調佇列在維護的時候的意義就是去除這些冗雜的元素。
因為每個元素只會入隊一次(所以要小心某些題的條件),所以 複雜度是 \(\text{O}(n)\) 的。
- Day 4
-------------
80 78 73
-------------
- Day 5
這裡根據 Day3,我們需要把78,73彈出來讓更好的 79 入隊。
所以佇列就變成了這樣:
------------- 80 79 ------------- poped:78 73
但是注意!這裡80過期了,所以要把80也彈出(從隊頭彈出)
那麼就是這樣的:
-------------
<---80 79
-------------
所以第五天就是 79 為最值。
那麼我們就可以知道,在這道題使用單調佇列時,要注意一下幾點:
-
佇列中始終單調
-
隊首為最優情況
-
及時去除冗雜情況
那麼我們每一次迴圈的時候,都檢查一下隊首是否過期還有新入隊元素會不會造成更新就好了。
總結一下:也就是這樣的
這裡給出維護一個長度為 \(k\) 的滑動視窗中的最值的程式碼。
#include<bits/stdc++.h>
using namespace std;
const int si=1e6+10;
int n,k;
int a[si];
int q1[si],head1=1,tail1=0; //這樣子賦值是為了讓第一個元素能入隊。
int q2[si],head2=1,tail2=0;
int ans[si][2]; //0->min 1->max
int main(){
scanf("%d%d",&n,&k);
for(register int i=1;i<=n;++i){
scanf("%d",&a[i]);
}
for(register int i=1;i<=n;++i){
while(head1<=tail1 && q1[head1]+k<=i) head1++;//排除隊頭過期
while(head1<=tail1 && a[i]<=a[q1[tail1]]) tail1--;//排除隊尾冗雜。
q1[++tail1]=i;
while(head2<=tail2 && q2[head2]+k<=i) head2++;
while(head2<=tail2 && a[i]>=a[q2[tail2]]) tail2--;
q2[++tail2]=i;
ans[i][0]=a[q1[head1]],ans[i][1]=a[q2[head2]];//注意這裡q存的是下標,所以要將其作為下標。
}
for(register int i=k;i<=n;++i){//k是因為它的區間長度已經固定,所以前面的那些長度不滿足,要去掉。
printf("%d ",ans[i][0]);
}puts("");
for(register int i=k;i<=n;++i){
printf("%d ",ans[i][1]);
}
return 0;
}
在每道題使用單調佇列之前需要看清題目的端點取捨和條件。
比如 求m區間內的最小值 那道題就是求 \([i-k-1,i)\) 的最小值.
所以說最好在用之前手玩一下資料。
單調佇列優化DP
Example
題目描述
在幻想鄉,琪露諾是以笨蛋聞名的冰之妖精。
某一天,琪露諾又在玩速凍青蛙,就是用冰把青蛙瞬間凍起來。但是這隻青蛙比以往的要聰明許多,在琪露諾來之前就已經跑到了河的對岸。於是琪露諾決定到河岸去追青蛙。
小河可以看作一列格子依次編號為0到N,琪露諾只能從編號小的格子移動到編號大的格子。而且琪露諾按照一種特殊的方式進行移動,當她在格子i時,她只移動到區間[i+l,i+r]中的任意一格。你問為什麼她這麼移動,這還不簡單,因為她是笨蛋啊。
每一個格子都有一個冰凍指數A[i],編號為0的格子冰凍指數為0。當琪露諾停留在那一格時就可以得到那一格的冰凍指數A[i]。琪露諾希望能夠在到達對岸時,獲取最大的冰凍指數,這樣她才能狠狠地教訓那隻青蛙。
但是由於她實在是太笨了,所以她決定拜託你幫它決定怎樣前進。
開始時,琪露諾在編號0的格子上,只要她下一步的位置編號大於N就算到達對岸。
輸入格式
第1行:3個正整數N, L, R
第2行:N+1個整數,第i個數表示編號為i-1的格子的冰凍指數A[i-1]
輸出格式
一個整數,表示最大冰凍指數。保證不超過2^31-1
輸入輸出樣例
輸入
5 2 3
0 12 3 11 7 -2
輸出
11
說明/提示
對於60%的資料:N <= 10,000
對於100%的資料:N <= 200,000
對於所有資料 -1,000 <= A[i] <= 1,000且1 <= L <= R <= N
這題明顯的是一個線性DP。
首先我們考慮 60pts 的資料怎麼做。
設 \(f[i]\) 表示Cirno走到第 \(i\) 個格子的時候可以取得的最大值。
那麼很明顯方程式這樣子的:
\[f[i]=\max \{ f[j]+ice[i] \} ,(i \in [l,n],j \in [i-r,i-l]) \]轉移應該是 \(\text{O}(n^2)\) 的。 60% 資料勉強過。
現在考慮對它進行優化。
仔細想想,我們每次更新都是這樣子的。
你會發現 \([i-r,i-l]\) 這個區間的上下界是隨 \(i\) 不斷後移的(區間平移,具有單調性)
而且對於每一個決策它只會進入這個區間一次並且只出去一次(區間不會平移回來)。
所以我們想到了“滑動視窗” 這個東西。
那麼就可以使用單調佇列進行決策的優化。
我們首先維護佇列裡的單調性,保證隊頭一定是當前可以轉移過來的狀態的最優狀態。
另外,當隊頭無法到達當前決策點 \(i\) 的時候,我們把隊頭出隊。
這樣子我們就做到了 \(\text{O}(1)\) 讓每一次轉移時都取最優值(並且這個狀態是合法的)。
說白了就是使用單調佇列代替 for
來決策。
注意,按照我的這種寫法,單調佇列維護的是 \([head,tail]\) 這個閉區間的元素。
Code:
#include<bits/stdc++.h>
using namespace std;
const int si=2e5+10;
int n,l,r;
int a[si];
int f[si];
int q[si],head=1,tail=0;
int main(){
scanf("%d%d%d",&n,&l,&r);
if(l>r) swap(l,r);
for(register int i=0;i<=n;++i){
scanf("%d",&a[i]);
}
memset(f,-0x3f,sizeof f);
int ans=-INT_MAX;//attention here
f[0]=0;//init
for(register int i=l;i<=n;++i){ start from 0,so the first point we can arrive is l.
while(head<=tail && q[head]<=(i-r-1)) head++;
while(head<=tail && f[q[tail]]<f[i-l]) tail--;
q[++tail]=i-l;
f[i]=f[q[head]]+a[i];
if(i+r>n) ans=max(f[i],ans); // reach the destination
}
printf("%d",ans);
return 0;
}
這時候就有人要問了,二維的怎麼做?
也是同理。
這樣子的轉移(\([l,r]\) 隨 \(j\) 平移) 都是可以的。
只是你需要根據題目要求判斷一下什麼時候出隊,什麼時候入隊,什麼時候決策。