Icebound

icebound-area

单调栈全攻略

最近刷了不少单调栈,总算搞懂了

基本性质

  1. 入栈顺序为下标顺序。
  2. 出栈序列分批次为单调增/单调减。
  3. 每个元素应当入栈一次出栈一次,复杂度为O(N)

利用好这3点性质,是发挥单调栈作用的关键:

对于性质1,我们可以认为:当你使用了单调栈,那么你所解决的问题一定与数组下标顺序(或者排序后的数组下标顺序)有关系。相当于帮你固定了题目中的一组偏序。对于性质2,我们认为:想要使用单调栈,那么答案或者中间结果必然具有某种单调性。对于性质3,当我们需要O(N)算法时,可以考虑单调栈。

说的过于抽象了,看题。

类型1:分批次计算后方元素对前方元素影响

这是最经典且最简单的单调栈应用,看最经典的题:

下一个更大的元素1

给一个序列,要求找到序列中每个元素后面第一个比他大的元素。

在说单调栈正解之前,我们考虑用其他的方法解决这个问题:

所谓找下一个更大的元素,就是在【比a[i]大的所有元素中】找【数组下标大于i】的第一个元素。因此,我们按照这个思路,可以维护一个set(或平衡树):我们先对数组排序,一开始set是全空的,然后把整个数组里最大的数字下标插进去,然后这个数字的答案是-1(没有比他更大的了)。然后,我们继续遍历数组,对于每个元素a[i],在set中二分查找下标i,找到比它下标大的第一个元素,即为答案,然后再把a[i]插进去,开始处理下一个元素。

观察一下刚刚我们做的事情:排序,在set里二分,实际是先固定了元素本身大小的单调,然后再用set帮我们处理数组下标的顺序,这样本身是有点反人类思维的。而单调栈与这种做法相反,是固定了数组下标顺序,利用后方元素对前方元素的影响解题。

利用这个性质,我们很容易得到一个单调栈解法:维护一个栈,按顺序遍历数组,当a[i]要入栈时,检查栈顶,如果栈顶<a[i],那么弹出,栈顶答案为a[i],直到栈顶=a[i]或者栈空,a[i]入栈。

观察一下可以发现,这个单调栈的栈顶到栈底是单调递增的,所以出栈顺序是单调递增的。因为有刚刚的后方对前方的影响,所以每次更新的答案也是对的。

class Solution {
public:
    vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
        stack<int>sta;
        int n=nums2.size();
        int m=nums1.size();
        vector<int>ans(m,-1);
        unordered_map<int,int>mp;
        for(int i=0;i<n;i++) {
            while(!sta.empty()) {
                int now=sta.top();
                if(now<nums2[i]) {
                    mp[now]=nums2[i];
                    sta.pop();
                } else break;
            }
            sta.push(nums2[i]);
        }
        while(!sta.empty()) {
            int now=sta.top();sta.pop();
            mp[now]=-1;
        }
        for(int i=0;i<m;i++) {
            ans[i]=mp[nums1[i]];
        }
        return ans;
    }
};

有另一个类似的题是:每日温度,可以试试。

借着这个思路,看下一道题

接雨水

这也是一个经典题。给你一些竖着的墙,问你最多能接的雨水是多少:

接雨水,问蓝色部分有多少

按照刚刚的思路,后面的墙对前面的墙的影响是单调的,这个就像括号匹配一样。后面的墙是右括号,前面的墙是左括号,两边去做匹配。但是这个题不一样的是,这里面的墙除了对前面有影响外,可能还会对后面有影响!因此,我们在计算答案的时候,必须保证从栈中弹出的元素的左右侧影响都考虑过了。

还是和刚刚一样,栈顶到栈底元素单调递增,出栈顺序单调递增。只不过,策略略有不同:

  1. 如果栈顶a[j]>=a[i],那么栈顶与a[i]可能形成积水,入栈。
  2. 如果栈顶a[j]<a[i],那么:
    1. 先把a[j]出栈
    2. 新栈顶a[k]一定大于a[j],计算以a[j]为底面,a[k]和a[i]围成的水域大小。这个时候a[k]有可能等于a[j]的,大小就是0。
  3. 把a[i]入栈。

整体上没什么不同,只不过这个是要在固定右侧即将入栈的元素之后,去看栈内的两个元素,比下一个更大的数字多看一层。

class Solution {
public:
    int trap(vector<int>& height) {
        stack<int>st;
        int ans=0;
        for(int i=0;i<height.size();i++) {
            while(!st.empty()&&height[st.top()]<height[i]) {
                int s=st.top();st.pop();
                if(st.empty())break;
                int l=st.top();
                int r=i;
                ans+=(r-l-1)*(min(height[r],height[l])-height[s]);
            }
            st.push(i);
        }
        return ans;
    }
};

另一个类似的题是:柱状图中的最大矩形,可以试试

类型2:类似单调队列,维护值,弹出无用元素

移掉 K 位数字

给你一个数字,要求你移除掉里面的k个数字(可以不连续),剩下的数字组成的数最小。

这个题要先观察到一个性质:对于i<j,如果a[i]>=a[j],那么一定删a[i]而不是删a[j]。很显然的,删高位的大数总比删低位小数的强。

利用这个性质,我们维护一个从栈顶到栈底单调递减的栈(尽量保留高位小数),遍历数组,当a[i]大于栈顶,则入栈。如果a[i]小于等于栈顶,且当前还没有删够k个数字,则弹栈。最后如果还没删够k个或者有前导0,需要做下特殊处理。

class Solution {
public:
    string removeKdigits(string num, int k) {
        int n=num.length();
        stack<int>sta;
        for(int i=0;i<n;i++) {
            int now=num[i]-'0';
            while(!sta.empty()) {
                int x=sta.top();
                if(x>now&&k>0) {
                    sta.pop();
                    k--;
                } else break;
            }
            sta.push(now);
        }
        string ans;
        while(!sta.empty()&&k) {
            k--;
            sta.pop();
        }
        while(!sta.empty()) {
            ans += sta.top() + '0';
            sta.pop();
        }
        while(ans.length()>0&&ans.back()=='0') {
            ans.pop_back();
        }
        if(ans.length()==0) {
            ans+='0';
        }
        reverse(ans.begin(), ans.end());
        return ans;
    }
};

132模式

题意:给一个数组,问是否存在i<j<k且a[i]<a[k]<a[j],也就是132模式,1最小,3最大,2中间。

一个解法是从左到右遍历数组,维护左侧最小值作为a[i],当前枚举的值作为a[j],用一个set维护左侧序列,尝试二分找有没有比a[j]大的,如果能找到则返回true。我第一次做这个题也是用的这个解法。

另一个解法就是单调栈了。考虑从右到左枚举a[i],倘若能维护a[k]和a[j],就能得到结果了。我们可以用单调栈在维护a[j]的同时更新a[k]。搞一个从顶到底单调递增的栈,作为候选的a[k](越小的a[k]且越大的k(下标),越有可能被选,所以单调栈在这里很科学),然后我们再维护一个max_k,作为右侧所有可以作为a[k]的元素中最大的的那个。

当我们枚举到a[i]时,考虑三件事:

  1. a[i]是不是小于max_k,如果是,则直接返回true。因为如果有a[k]被选中,那必有a[j]在中间,所以132序列存在
  2. 考虑a[i]当a[j](最大数)。那么对于栈内元素来说,只要是比当前元素(a[j])小的,都可以作为a[k]来使用,于是弹栈,更新max_k。
  3. 考虑a[i]作为候选a[k]的可能性,a[i]必须大于max_k才有可能成为候选k,否则你这个a[k]的下标又不靠后,元素的值也不够大,不会成为k的。如果能成为候选k,则入栈。

思路清晰,代码:

class Solution {
public:
    bool find132pattern(vector<int>& nums) {
        stack<int>st;
        int n=nums.size()-1;
        int mx_k=-0x7fffffff;
        for(int i=n;i>=0;i--) {
            if(nums[i]<mx_k)return true;
            if(st.empty()) {
                st.push(nums[i]);
                continue;
            }
            while(!st.empty()&&nums[i]>st.top()) {
                mx_k=st.top();
                st.pop();
            }
            if(nums[i]>mx_k) {
                st.push(nums[i]);
            }
        }
        return false;
    }
};

类型3:栈内二分(决策单调性优化dp)

这种就比较难了,一般和DP相关,可以先略了。

TBD