1. 程式人生 > >單調棧,單調佇列的入門

單調棧,單調佇列的入門

轉載地址:https://www.cnblogs.com/tham/p/8038828.html

我自己的話單調棧是也用陣列模擬出來的,我是根據這個部落格學的,原理挺簡單的,但是做題思想的轉變有點麻煩,最後陣列模擬的單調佇列其實是雙向佇列。

下邊是大佬的部落格:

單調佇列是什麼呢?可以直接從問題開始來展開。
Poj 2823
給定一個數列,從左至右輸出每個長度為m的數列段內的最小數和最大數。
數列長度:N<=106,m<=NN<=106,m<=N

解法①

很直觀的一種解法,那就是從數列的開頭,將窗放上去,然後找到這最開始的k個數的最大值,然後窗最後移一個單元,繼續找到k個數中的最大值。
Alt text


這種方法每求一個f(i),都要進行k-1次的比較,複雜度為O(Nk)O(Nk)。
顯然,如果暴力時間複雜度為 O(Nm)O(Nm) 不超時就怪了。

解法②

還有一種想法是維護一個BST,然後for迴圈從左到右,依次加入到BST裡面,如果某個數超出了k的範圍,就從BST中刪除。
因為每個數只insert一次,最多erase一次,所以複雜度是O(NlogN)O(NlogN)的,已經很不錯了。
但是106106級別的極限資料,這種做法會被卡掉的,況且維護一個BST的程式碼也比較麻煩。

void getans() {
    BST tree;

    for(int i=1,j=1;i<=N;++i) {
        tree.insert(a[i]);
        while(j<=i-k) {
            tree.erase(a[j]);
            --j;
        }
        cout<<tree.max()<<endl;
    }
}

解法③

我們知道,解法①在暴力列舉的過程中,有一個地方是重複比較了,就是在找當前的f(i)的時候,i的前面其它m-1個數在算f(i-1)的時候我們就比較過了。
當你一個個往下找時,每一次都是少一個然後多一個,如果少的不是最大值,然後再問新加進來的,看起來很省時間對吧,那麼如果少了的是最大值呢?第二個最大值是什麼??
那麼我們能不能儲存上一次的結果呢?當然主要是i的前k-1個數中的最大值了。答案是可以,這就要用到單調佇列。
對於單調佇列,我們這樣子來定義:

  • 1、維護區間最值
  • 2、去除冗雜狀態 如上題,區間中的兩個元素a[i],a[j](假設現在再求最大值)
    若 j>i且a[j]>=a[i] ,a[j]比a[i]還大而且還在後面(目前a[j]留在佇列肯定比a[i]有用,因為你是往後推, 核心思想 !!!)
  • 3、保持佇列單調,最大值是單調遞減序列,最小值反之
  • 4、最優選擇在隊首

單調佇列實現的大致過程: 
1、維護隊首(對於上題就是如果隊首已經是當前元素的m個之前,則隊首就應該被刪了,head++)
2、在隊尾插入(每插入一個就要從隊尾開始往前去除冗雜狀態,保持單調性)

簡單舉例應用
數列為:6 4 10 10 8 6 4 2 12 14
N=10,K=3;
那麼我們構造一個長度為3的單調遞減佇列:
首先,那6和它的位置0放入佇列中,我們用(6,0)表示,每一步插入元素時佇列中的元素如下
插入6:(6,0);
插入4:(6,0),(4,1);
插入10:(10,2);
插入第二個10,保留後面那個:(10,3);
插入8:(10,3),(8,4);
插入6:(10,3),(8,4),(6,5);
插入4,之前的10已經超出範圍所以排掉:(8,4),(6,5),(4,6);
插入2,同理:(6,5),(4,6),(2,7);
插入12:(12,8);
插入14:(14,9);
那麼f(i)就是第i步時隊列當中的首元素:6,6,10,10,10,10,8,6,12,14
同理,最小值也可以用單調佇列來做。

單調佇列的時間複雜度是O(N),因為每個數只會進隊和出隊一次,所以這個演算法的效率還是很高的。
注意:建議直接用陣列模擬單調佇列,因為系統自帶容器不方便而且不易除錯,同時,每個數只會進去一次,所以,陣列絕對不會爆,空間也是S(N),優於堆或線段樹等資料結構。

更重要的:單調是一種思想,當我們解決問題的時候發現有許多冗雜無用的狀態時,我們可以採用單調思想,用單調佇列或類似於單調佇列的方法去除冗雜狀態,儲存我們想要的狀態,

#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<algorithm>
using namespace std;
struct node
{
    int x,y;
}v[1010000]; //x表示值,y表示位置 可以理解為下標
int a[1010000],n,m,mx[1010000],mn[1010000];
void getmin()
{
    int i,head=1,tail=0;// 預設起始位置為1 因為插入是v[++tail]故初始化為0
    for(i=1;i<m;i++)
    {
        while(head<=tail && v[tail].x>=a[i]) tail--;
        v[++tail].x=a[i],v[tail].y=i;
                // 根據題目 前m-1個先直接進入佇列
    }
    for(;i<=n;i++)
    {
        while(head<=tail && v[tail].x>=a[i]) tail--;
        v[++tail].x=a[i],v[tail].y=i;
        while(v[head].y<i-m+1) head++;
        mn[i-m+1]=v[head].x;
               // 道理同上,當然了 要把已經超出範圍的從head開始排出
               //  然後每個隊首則是目前m個數的最小值
    }
}
void getmax() //最大值同最小值的道理,只不過是維護的是遞減佇列
{
    int i,head=1,tail=0;
    for(i=1;i<m;i++)
    {
        while(head<=tail && v[tail].x<=a[i]) tail--;
        v[++tail].x=a[i],v[tail].y=i;
    }
    for(;i<=n;i++)
    {
        while(head<=tail && v[tail].x<=a[i]) tail--;
        v[++tail].x=a[i],v[tail].y=i;
        while(v[head].y<i-m+1) head++;
        mx[i-m+1]=v[head].x;
    }
}
int main()
{
    int i,j;
    scanf("%d%d",&n,&m);
    for(i=1;i<=n;i++)scanf("%d",&a[i]);
    getmin();
    getmax();
    for(i=1;i<=n-m+1;i++)
    {
        if(i==1)printf("%d",mn[i]);
        else printf(" %d",mn[i]);
    }
    printf("\n");
    for(i=1;i<=n-m+1;i++)
    {
        if(i==1)printf("%d",mx[i]);
        else printf(" %d",mx[i]);
    }
    printf("\n");
    return 0;
}

這就是單調佇列,單調棧和單調佇列區別不大,都是每次push的時候在棧頂要維護單調性。

關於單調棧的一道題目

問題描述
地上從左到右豎立著 n 塊木板,從 1 到 n 依次編號,如下圖所示。我們知道每塊木板的高度,在第 n 塊木板右側豎立著一塊高度無限大的木板,現對每塊木板依次做如下的操作:對於第 i 塊木板,我們從其右側開始倒水,直到水的高度等於第 i 塊木板的高度,倒入的水會淹沒 ai 塊木板(如果木板左右兩側水的高度大於等於木板高度即視為木板被淹沒),求 n 次操作後,所有 ai 的和是多少。如圖上所示,在第 4 塊木板右側倒水,可以淹沒第 5 塊和第 6 塊一共 2 塊木板,a4 = 2。

解法①

暴力求解,複雜度是O(n²)
例如現在存在5塊木板
每塊木板從左至右高分別為
10,5,8,12,6
從第一塊木板(高度為10)右側開始倒水,當水到達第四塊木板(高度為12)時,可以淹沒第一塊木板
即第一塊木板至第四塊木板之間的木板數量,即4-1-1 = 2,a1 = 2;
也就是說:尋找在第 i 個木板右邊第一個比它大的木板j,ai 就等於木板 i 和木板 j 之間的木板數
同理得到
a2=0
a3=0
a4=1
a5=0
sum = a1 + a2 +a3 +a4 +a5 = 3
於是,問題就變成了尋找在第 i 個數右邊第一個比它大的數。可以暴力求解,從 1 迴圈到 n,對每塊木板再往右迴圈一遍,這樣的時間複雜度是O(n²)O(n²) 。

解法②

單調棧來求解的話,複雜度是O(n)
結合單調棧的性質:使用單調棧可以找到元素向左遍歷第一個比他小的元素,也可以找到元素向左遍歷第一個比他大的元素。
顧名思義,單調棧就是棧內元素單調遞增或者單調遞減的棧,這一點和單調佇列很相似,但是單調棧只能在棧頂操作。
單調棧有以下兩個性質:
1、若是單調遞增棧,則從棧頂到棧底的元素是嚴格遞增的。若是單調遞減棧,則從棧頂到棧底的元素是嚴格遞減的。
2、越靠近棧頂的元素越後進棧。
單調棧與單調佇列不同的地方在於棧只能在棧頂操作,因此一般在應用單調棧的地方不限定棧的大小,否則可能會造成元素無法進棧。
元素進棧過程:對於單調遞增棧,若當前進棧元素為e,從棧頂開始遍歷元素,把小於e或者等於e的元素彈出棧,直接遇到一個大於e的元素或者棧為空為止,然後再把e壓入棧中。對於單調遞減棧,則每次彈出的是大於e或者等於e的元素。

資料模擬木板倒水單調棧的入棧計算過程
思路:尋找比棧頂高的木板i,找到就出棧,不是就把木板i入棧,給出迴圈計數樣例 10,5,8,12,6
從左往右掃描
棧為空,10入棧 棧:10 此時棧頂是10,也就是說要尋找比10大的木板
5比10小,5入棧 棧:5,10 此時棧頂是5,也就是說要尋找比5大的木板
8比5大,5出棧 棧:10
這個時候,第二個高度為5的木板右邊比它高的木板已經找到了,是第三個木板8,所以5出棧,計算a2 = 3-2-1 = 0
8比10小,8入棧 棧:8,10 此時棧頂是8,也就是說要尋找比8大的木板
12比8大,8出棧 棧:10
第三個高度為8的木板右邊比它高的木板已經找到了,是第四個木板12,8出棧,計算a3 = 4-3-1 = 0
12比10大,10出棧 棧:空
第一個高度為10的木板右邊比它高的木板已經找到了,是第四個木板12,所以10出棧,計算a1 = 4-1-1 = 2
棧為空,12入棧 棧:12 此時棧頂是12,也就是說要尋找比12大的木板
6比12小,6入棧 棧:6,12 此時棧頂是6,也就是說要尋找比6大的木板
掃描完成結束
最後棧的結構是:6,12 棧頂為6
由於最右端豎立著一塊高度無限大的木板,即存在第六塊木板高度為無窮,所以剩餘兩塊木板的演算法如下 a5 = 6-5-1 =0
a4 = 6-4-1 = 1
sum = a1 + a2 +a3 +a4 +a5 = 3
因此本題可以在O(n)O(n)的時間內迎刃而解了。
從左往右將木板節點壓棧,遇到比棧頂木板高的木板就將當前棧頂木板出棧並計算淹沒的木板數,如此迴圈直到棧頂木板高度比當前木板高或者棧為空,然後將此木板壓棧。木板全都壓棧完成後,棧內剩餘的木板都是右側沒有比它們更高的木板的,所以一個個出棧並計算ai=n+1-temp_id-1(用最右邊無限高的木板減)

//從左往右解木板倒水
int main() {
    int n,ans=0;
    cin>>n;
    Stack<Node> stack(n);
    Node temp;
    for(int i=1;i<=n;i++){
        cin>>temp.height;
        temp.id=i;
        //遇到了右側第一個比棧頂元素大的元素,計算並出棧
        while(!stack.empty()&&stack.top().height<=temp.height){
            ans=ans+i-stack.top().id-1;
            stack.pop();
        }
        stack.push(temp);
    }
    //現在棧中的木板右側沒有比它高的木板,用最右側無限高的木板減
    while(!stack.empty()){
        ans=ans+n+1-stack.top().id-1;
        stack.pop();
    }
    cout<<ans<<endl;
    return 0;
}