1. 程式人生 > 實用技巧 >單調佇列詳解

單調佇列詳解

單調佇列

概念

顧名思義,單調佇列就是在佇列的基礎上,維護一個單調的序列。

性質

  1. 佇列中的元素其對應在原來的序列中的順序必須是單調遞增的。
  2. 佇列中元素的大小必須是單調遞(增/減/自定義)。

先來一道模板題來感受一下單調佇列的應用:

模板題:滑動視窗

題目

【題目描述】

有一個長為n的序列a,以及一個大小為k的視窗。現在這個從左邊開始向右滑動,每次滑動一個單位,求出每次滑動後窗口中的最大值和最小值。

例如:

【輸入格式】

輸入一共有兩行,第一行有兩個正整數n,k。 第二行n個整數,表示序列a

【輸出格式】

輸出共兩行,第一行為每次視窗滑動的最小值
第二行為每次視窗滑動的最大值

【輸入樣例】

8
3 1 3 -1 -3 5 3 6 7

【輸出樣例】

-1 -3 -3 -3 3 3
3 3 5 5 6 7

【資料規模】

對於50%的資料,1n105
對於100%的資料,1kn106ai[231,231)。

解析

單調佇列模板題。

對於最小值來說,我們維護一個單調遞增佇列,

這是因為我們要讓佇列的頭為該區間的最小值,那麼後一個數要比頭大,

因為是單調的,所以每一個進來的數,都應該比佇列中的數大,所以是單調遞增佇列。

題目中還有一個限制條件,那便是視窗大小為k,所以我們要時刻維護佇列中的數的下標大於當前下標減去k,

如果不滿足該條件,就從佇列頭刪去該數,可見單調佇列是個雙端佇列,這也便是為什麼不用棧的原因。

具體實現時,我們令head表示佇列頭+1,tail表示佇列尾,

那麼問題來了,為什麼head要+1呢?

試想一下,如果head不+1,那麼當head=tail時,佇列中到底是沒有數還是有1個數呢?顯然無法判斷。

所以我們令head的值+1,當head<=tail時,佇列中便是有值的,如果head>tail,佇列便為空。

我們用樣例來模擬一下單調佇列,以求最小值為例:

  • i=1,佇列為空,1進隊,[1]
  • i=2,3比1大,滿足單調性,3進隊,[1,3]
  • i=3,-1比3小,破壞單調性,3出隊,-1比1小,1出隊,佇列為空,-1進隊[-1],此時i>=k,輸出隊頭,即-1
  • i=4,-3比-1小,-1出隊,佇列為空,-3進隊[-3],輸出-3
  • i=5,5比-3大,5進隊,[-3,5],輸出-3
  • i=6,3比5小,5出隊,3比-3大,3進隊,[-3,3],輸出-3
  • i=7,-3下標為4,i-4=3,大於等於k,-3已不在區間中,-3出隊,6比3大,6進隊,[3,6],輸出3
  • i=8,7比6大,7進隊,[3,6,7],輸出3

這樣最小值便求完了,最大值同理,只需在判斷時改變符號即可。

Code

#include <algorithm>
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
#include <cmath>
using namespace std;
const int N=1e6+500;
int n,k,a[N],q[N],head=1,tail;//head要+1 
int main()
{
    scanf("%d %d",&n,&k);
    for(int i=1;i<=n;i++)
    {
        //求最小值 
        scanf("%d",&a[i]);
        while(head<=tail&&q[head]<=i-k) head++;//隊頭顯然是最早進入的,如果隊頭的下標大於i-k,該數便不在區間內了,從隊頭刪除 
        while(head<=tail&&a[q[tail]]>=a[i]) tail--;//當前數破壞了單調性,從隊尾刪除,直至隊中數小於當前數
        q[++tail]=i;//當前元素進隊 
        if(i>=k) printf("%d ",a[q[head]]);//輸出每個區間最小值 
    }
    printf("\n");
    head=1,tail=0;
    for(int i=1;i<=n;i++)
    {    //求最大值 
        while(head<=tail&&q[head]<=i-k) head++;
        while(head<=tail&&a[q[tail]]<=a[i]) tail--;
        q[++tail]=i;//當前元素進隊 
        if(i>=k) printf("%d ",a[q[head]]);
    }
    return 0;
}
View Code

學廢單調隊列了嗎?

再來幾道例題練練手吧!

例題一:切蛋糕

題目

【題目描述】

今天是小Z的生日,同學們為他帶來了一塊蛋糕。這塊蛋糕是一個長方體,被用不同色彩分成了N個相同的小塊,每小塊都有對應的幸運值。

小Z作為壽星,自然希望吃到的第一塊蛋糕的幸運值總和最大,但小Z最多又只能吃M小塊(M≤N)的蛋糕。

吃東西自然就不想思考了,於是小Z把這個任務扔給了學OI的你,請你幫他從這N小塊中找出連續的k塊蛋糕(k≤M),使得其上的幸運值最大。

【輸入格式】

輸入檔案cake.in的第一行是兩個整數N,M。分別代表共有N小塊蛋糕,小Z最多隻能吃M小塊。

第二行用空格隔開的N個整數,第i個整數Pi代表第i小塊蛋糕的幸運值。

【輸出格式】

輸出檔案cake.out只有一行,一個整數,為小Z能夠得到的最大幸運值。

【輸入樣例】

6 3
1 -2 3 -4 5 -6

【輸出樣例】

5 

【資料規模】

對20%的資料,N≤100。

對100%的資料,N≤500000,|Pi|≤500。 答案保證在2^31-1之內。

解析

因為蛋糕是連續的,所以不難聯想到字首和,令sum[i]表示從第1塊到第i塊蛋糕的幸運值之和。

於是很自然的想到了暴力:從1到n列舉i,從i-M+1到i列舉j,那麼最大幸運值maxn=max(maxn,sum[i]-sum[j-1])

但是這樣顯然會超時,考慮優化。

對於每一個i來說,實際上我們只需要找到最小的sum[j-1]即可,所以我們可以用單調遞增佇列來維護最小的sum[j-1]的值,

那麼這不就是一個滑動視窗麼?數列為sum[1]~sum[n],區間長度為1~M,求每個區間的最小值,

唯一不同的就是區間長度不是一個定值,而是1~M,但這也不難辦,依舊只需保證佇列長度不超過M即可。

Code

#include <algorithm>
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
#include <cmath>
#include <queue>
using namespace std;
inline int read()//快讀
{
    int num=0,w=1;
    char ch=getchar();
    while(ch<'0'||ch>'9')
    {
        if(ch=='-') w=-1;
        ch=getchar();
    }
    while(ch>='0'&&ch<='9')
    {
        num=(num<<1)+(num<<3)+ch-'0';
        ch=getchar();
    }
    return num*w;
}
const int N=500100;
int n,m,p[N],sum[N],q[N],head=1,tail,maxn;
int main()
{
    n=read(),m=read();
    for(int i=1;i<=n;i++)
    {
        p[i]=read(),sum[i]=sum[i-1]+p[i],maxn=max(maxn,p[i]);//可能只有一個 
        /*for(int j=i-m+1;j<=i;j++)        40分 
            maxn=max(maxn,sum[i]-sum[j-1]);*/
        while(head<=tail&&i-q[head]>m) head++;
        maxn=max(maxn,sum[i]-sum[q[head]]);
        while(head<=tail&&sum[q[tail]]>=sum[i]) tail--;
        q[++tail]=i;
    }
    printf("%d",maxn);
    return 0;
}
View Code

例題二:Flowerpot S

題目

【題目描述】

老闆需要你幫忙澆花。給出N滴水的座標,y表示水滴的高度,x表示它下落到x軸的位置。

每滴水以每秒1個單位長度的速度下落。你需要把花盆放在x軸上的某個位置,使得從被花盆接著的第1滴水開始,到被花盆接著的最後1滴水結束,之間的時間差至少為D。

我們認為,只要水滴落到x軸上,與花盆的邊沿對齊,就認為被接住。給出N滴水的座標和D的大小,請算出最小的花盆的寬度W。

【輸入格式】

第一行2個整數 N 和 D。

第2.. N+1行每行2個整數,表示水滴的座標(x,y)。

【輸出格式】

僅一行1個整數,表示最小的花盆的寬度。如果無法構造出足夠寬的花盆,使得在D單位的時間接住滿足要求的水滴,則輸出-1。

【輸入樣例】

4 5
6 3
2 4
4 10
12 15

【輸出樣例】

2 

【樣例解釋】

有4滴水, (6,3), (2,4), (4,10), (12,15).水滴必須用至少5秒時間落入花盆。花盆的寬度為2是必須且足夠的。把花盆放在x=4..6的位置,它可以接到1和3水滴, 之間的時間差為10-3 = 7滿足條件。

【資料規模】

40%的資料:1 ≤ N ≤ 1000,1 ≤ D ≤ 2000;

100%的資料:1 ≤ N ≤ 100000,1 ≤ D ≤ 1000000,0≤x,y≤10^6。

解析

先設想一下,如果我們已經知道了W,那麼怎麼求時間差是否大於等於D?

顯然,我們只需判斷每個長為W的區間,最早落下的和最晚落下的水滴的時間差是否大於等於D即可。

也就只需要知道最小的y與最大的y即可,這不就是滑動視窗嗎?

將水滴按照x從小到大排序,水滴序列便滿足單調佇列的性質1:序列為順序

於是問題就成了:給定一個序列(y值),區間長度為W,求每個區間的最大值和最小值。

分別用維護最大值的單調遞減佇列和維護最小值的單調遞增佇列即可。

時間差的問題解決了,那麼W怎麼知道?

二分!

令l=0,因為這題寬度為實際寬度-1,例如樣例中的4~6實際寬度為3,輸出卻是2,而對於3~3這樣實際寬度為1的,應輸出0,故l從0開始

r=最大的x+1,如果連W=最大的x時都無法滿足題意時,l便會等於r,即l=最大的x+1,這樣我們只需在二分完後判斷l是否等於最大的x+1,就可以知道有無答案了。

Code

#include <algorithm>
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
#include <cmath>
#include <queue>
using namespace std;
inline int read()//快讀 
{
    int num=0;
    char ch=getchar();
    while(ch<'0'||ch>'9') ch=getchar();
    while(ch>='0'&&ch<='9')
    {
        num=(num<<1)+(num<<3)+ch-'0';
        ch=getchar();
    }
    return num;
}
const int N=100100;
struct rec{
    int x,y;
}a[N];
int n,d,minq[N],maxq[N];
bool cmp(rec p,rec q)
{
    return p.x<q.x;
}
bool check(int w)
{
    int minh=1,mint=0,maxh=1,maxt=0;
    for(int i=1;i<=n;i++)
    {
        while(minh<=mint&&a[i].x-a[minq[minh]].x>w) minh++;//最小值 
        while(maxh<=maxt&&a[i].x-a[maxq[maxh]].x>w) maxh++;//最大值 
        while(minh<=mint&&a[minq[mint]].y>=a[i].y) mint--;
        while(maxh<=maxt&&a[maxq[maxt]].y<=a[i].y) maxt--;
        minq[++mint]=i,maxq[++maxt]=i;
        if(a[maxq[maxh]].y-a[minq[minh]].y>=d) return true;//符合題意 
    }
    return false;
}
int main()
{
    n=read(),d=read();
    for(int i=1;i<=n;i++) a[i].x=read(),a[i].y=read();
    sort(a+1,a+n+1,cmp);//按x從小到大排序 
    int l=0,r=a[n].x+1;//+1是為了讓l能夠比a[n].x大,令下面判斷是否應輸出-1 
    while(l<r)//二分查詢最小的W 
    {
        int mid=(l+r)>>1;
        if(check(mid)) r=mid;
        else l=mid+1;
    }
    if(l==a[n].x+1) printf("-1");
    else printf("%d",l);
    return 0;
}
View Code

例題三:圍欄

題目

【題目描述】

有N塊木板從左到右排成一行,有M個工匠對這些木板進行粉刷,每塊木板至多被粉刷一次。

第 i 個木匠要麼不粉刷,要麼粉刷包含木板Si的,長度不超過Li的連續的一段木板,每粉刷一塊可以得到Pi的報酬。

不同工匠的Si不同。

請問如何安排能使工匠們獲得的總報酬最多。

【輸入格式】

第一行包含兩個整數N和M。

接下來M行,每行包含三個整數Li,Pi,Si。

【輸出格式】

輸出一個整數,表示結果。

【輸入樣例】

8 4
3 2 2
3 2 3
3 3 5
1 1 7 

【輸出樣例】

17

【資料規模】

1≤N≤16000,
1≤M≤100,
1Pi10000

解析

顯然,這是一道動態規劃題。

令f[i][j]表示前i個工匠粉刷前j塊木板(可以有空著不刷的木板),工匠們能獲得的最多報酬。

狀態轉移方程:f[i][j]=

  1. f[i-1][j]   即第i個工匠不刷
  2. f[i][j-1] 即第j塊木板不刷
  3. f[i-1][k]+Pi*(j-k) 其中 j-Li ≤k ≤Si-1&&j ≥Si 即第i個工匠刷第k+1到第j塊木板

事實上,對於第3種轉移方式可以改寫成f[i][j]=Pi*j+max(f[i-1][k]-Pi*k)

當j增大時,對於k1與k2(k1<k2<Si-1)來說,顯然k1會更早離開[j-Li,Si-1]這個區間,

與此同時,如果f[i-1][k1]-Pi*k1≤f[i-1][k2]-Pi*k2,那麼k2顯然比k1更優,即k1是無用的決策,應排除出候選集合。

所以我們可以維護一個k單調遞增,f[i-1][k]-Pi*k單調遞減的佇列,該佇列維護方式如下:

  • 當j變大時,從隊頭將小於j-Li的決策出隊
  • 新的決策入隊時,在隊尾將破壞f[i-1][k]-Pi*k的單調性的決策出隊,然後將新的決策入隊

那麼對於每種情況,最優決策即為隊頭。

需要注意的是,如果Si-Li小於0,那麼k應該從0開始,畢竟k不能為負數。

Code

#include <algorithm>
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
#include <cmath>
#include <queue>
using namespace std;
inline int read()//快讀 
{
    int num=0;
    char ch=getchar();
    while(ch<'0'||ch>'9') ch=getchar();
    while(ch>='0'&&ch<='9')
    {
        num=(num<<1)+(num<<3)+ch-'0';
        ch=getchar();
    }
    return num;
}
const int N=16500,M=150;
struct rec{
    int l,p,s;
}a[M];
int n,m,f[M][N],q[N],head,tail;
/*f[i][j]表示前i個工匠粉刷前j塊木板(可以有空著不刷的木板),工匠們能獲得的最多報酬。
f[i][j]=max(f[i][j],以下三種情況) 
1、f[i-1][j]    第i個工匠不刷 
2、f[i][j-1]    第j塊木板不刷 
3、f[i-1][k]+Pi*(j-k) 其中 j-Li ≤k ≤Si-1,j ≥Si     第i個工匠刷第k+1到第j塊木板 
   3可以改為f[i][j]=Pi*j+max(f[i-1][k]-Pi*k)
*/
bool cmp(rec x,rec y)
{
    return x.s<y.s;
}
int calc(int i,int k)
{
    return f[i-1][k]-a[i].p*k;
}
int main()
{
    n=read(),m=read();
    for(int i=1;i<=m;i++) a[i].l=read(),a[i].p=read(),a[i].s=read();
    sort(a+1,a+m+1,cmp);//按Si從小到大排序,令序列為順序 
    for(int i=1;i<=n;i++)
    {
        head=1,tail=0;
        for(int k=max(0,a[i].s-a[i].l);k<=a[i].s-1;k++)//Si-Li<0時,k從0開始 
        {
            while(head<=tail&&calc(i,q[tail])<=calc(i,k)) tail--;
            q[++tail]=k;
        }
        for(int j=1;j<=n;j++)
        {
            f[i][j]=max(f[i-1][j],f[i][j-1]);//第一種轉移方式和第二種轉移方式 
            if(j>=a[i].s)
            {
                while(head<=tail&&q[head]<j-a[i].l) head++;
                if(head<=tail) f[i][j]=max(f[i][j],calc(i,q[head])+a[i].p*j);//第三種轉移方式 
            }
        }
    }
    printf("%d",f[m][n]);
    return 0;
}
View Code

例題四:理想的正方形

題目

【題目描述】

有一個a*b的整陣列成的矩陣,現請你從中找出一個n*n的正方形區域,使得該區域所有數中的最大值和最小值的差最小。

【輸入格式】

第一行為3個整數,分別表示a,b,n的值

第二行至第a+1行每行為b個非負整數,表示矩陣中相應位置上的數。每行相鄰兩數之間用一空格分隔。

【輸出格式】

僅一個整數,為a*b矩陣中所有“n*n正方形區域中的最大整數和最小整數的差值”的最小值。

【輸入樣例】

5 4 2
1 2 5 6
0 17 16 0
16 17 2 1
2 10 2 1
1 2 2 2

【輸出樣例】

1 

【資料規模】

(1)矩陣中的所有數都不超過1,000,000,000

(2)20%的資料2<=a,b<=100,n<=a,n<=b,n<=10

(3)100%的資料2<=a,b<=1000,n<=a,n<=b,n<=100

解析

二維的單調佇列題目。

考慮如何求每個正方形的最大值:

列舉每一行,求出每個長度為n的區間的最大值,這不正是滑動視窗嗎?

將求出來的maxx[][]作為一個新的矩形,然後從1到b-n+1列舉列,

依舊是滑動視窗求出每列的maxx[][]最大值,記錄為maxy[][],

又形成了一個新的矩形,這便是每個正方形的最大值。

不理解的可以看下面的圖,以樣例為例。

初始矩形:

maxx[][]矩形:

maxy[][]矩形:

最小值同理。

最後只需從1到a-n+1列舉i,從1到b-n+1列舉j,求出最小的maxy[i][j]-miny[i][j]即可。

Code

#include <algorithm>
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
#include <cmath>
#include <queue>
using namespace std;
inline int read()//快讀 
{
    int num=0;
    char ch=getchar();
    while(ch<'0'||ch>'9') ch=getchar();
    while(ch>='0'&&ch<='9')
    {
        num=(num<<1)+(num<<3)+ch-'0';
        ch=getchar();
    }
    return num;
}
const int N=1010;
int a,b,n,s[N][N],head,tail,q[N],maxx[N][N],maxy[N][N],minx[N][N],miny[N][N],minn=0x7f7f7f7f;
int main()
{
    a=read(),b=read(),n=read();
    for(int i=1;i<=a;i++)
        for(int j=1;j<=b;j++) s[i][j]=read();
    for(int i=1;i<=a;i++)//每一行的每個區間的最大值 
    {
        head=1,tail=0;
        for(int j=1;j<=b;j++)
        {
            while(head<=tail&&q[head]<=j-n) head++;
            while(head<=tail&&s[i][q[tail]]<=s[i][j]) tail--;
            q[++tail]=j;
            if(j>=n) maxx[i][j-n+1]=s[i][q[head]];
        }
    }
    for(int i=1;i<=b-n+1;i++)//每個正方形的最大值 
    {
        head=1,tail=0;
        for(int j=1;j<=a;j++)
        {
            while(head<=tail&&q[head]<=j-n) head++;
            while(head<=tail&&maxx[q[tail]][i]<=maxx[j][i]) tail--;
            q[++tail]=j;
            if(j>=n) maxy[j-n+1][i]=maxx[q[head]][i];
        }
    }
    for(int i=1;i<=a;i++)//每一行每個區間的最小值 
    {
        head=1,tail=0;
        for(int j=1;j<=b;j++)
        {
            while(head<=tail&&q[head]<=j-n) head++;
            while(head<=tail&&s[i][q[tail]]>=s[i][j]) tail--;
            q[++tail]=j;
            if(j>=n) minx[i][j-n+1]=s[i][q[head]];
        }
    }
    for(int i=1;i<=b-n+1;i++)//每個正方形的最小值 
    {
        head=1,tail=0;
        for(int j=1;j<=a;j++)
        {
            while(head<=tail&&q[head]<=j-n) head++;
            while(head<=tail&&minx[q[tail]][i]>=minx[j][i]) tail--;
            q[++tail]=j;
            if(j>=n) miny[j-n+1][i]=minx[q[head]][i];
        }
    }
    for(int i=1;i<=a-n+1;i++)
        for(int j=1;j<=b-n+1;j++) minn=min(minn,maxy[i][j]-miny[i][j]);//每個正方形的最大值減去最小值 
    printf("%d",minn);
    return 0;
}
View Code