算法学习-单调栈,接雨水经典题目

单调栈可以理解为用栈来存储一个单调的序列,通过特殊的入栈和弹栈时机,来保证栈内元素的单调性。

本文参考:

[数据结构]——单调栈
Carl的单调栈题解
单调栈、单调队列(超详细)

基础知识

从名字上就听的出来,单调栈中存放的数据应该是有序的,所以单调栈也分为单调递增栈和单调递减栈,我根据自己理解进行了分类,因此下面的定义可能和别人的定义相反:

  • 单调递增栈:单调递增栈就是从栈底到栈顶数据是从小到大
  • 单调递减栈:单调递减栈就是从栈底到栈顶数据是从大到小

单调栈常用在找某个元素左边或者右边第一个最值的场景,可以 O ( N ) O(N) O(N)找到所有数组元素的左边或者右边的第一个最值。如果单纯地针对每个元素都对其左右进行遍历的话,复杂度会达到 O ( N 2 ) O(N^2) O(N2).
在这里插入图片描述

单调栈是通过一系列进栈和出栈规则,将栈中元素维护成了单调有序的。操作焦点集中在栈顶,其中出栈元素和进栈元素搭配使用,可以成为最终的结果。

心得体会:

  1. 单调栈许多题目本身不需要维护结果有序,反而是我们在栈中维护有序序列的过程,能够帮助我们找到「拐点」,这是许多题目找到某一个元素左右最值的隐含信息。
  2. 单调栈弹出的元素,经常就是最终记录答案的下标,因此应该在重视「拐点」的同时,不忽略弹出元素的重要性。while不是无限弹出,要时刻比较栈顶和待加入元素的大小关系。

算法模板

在选择模板的时候,只需要注意「出栈时机」,是碰到小的还是大的出栈,然后维护对应的单调栈就可以了。要找到右边界较大值还是左边界较大值,只需要「调换遍历顺序」,按正序或者逆序遍历数组就可以了。

单调递减栈:
如果栈空或进栈元素小于等于栈顶元素则直接入栈;如果进栈元素大于栈顶元素(找到大值拐点),则出栈,直至待进栈元素小于等于栈顶元素,才进栈。栈中可以存储下标或者元素值。

int nums[maxn];
stack <Integer> q=new Stack<Integer>();
for (int i = 0; i < nums.length; i++) {
	if(q.isEmpty()||nums[i]<=q.peek()){
		q.push(nums[i]);
	}
    else{
		while (!q.empty() && nums[i] > q.peek()) {
        	q.pop();
        	处理结果,通常是找到了出栈元素的属性
    	}
    	q.push(nums[i]);
	}
}

简化后的写法如下:

int nums[maxn];
stack <Integer> q=new Stack<Integer>();
for (int i = 0; i < nums.length; i++) {
    while (!q.empty() && nums[i] > q.peek()) { // 大于栈顶先出栈
        q.pop();
        处理结果,通常是找到了出栈元素的属性
    }
    q.push(nums[i]); // 其余都进栈
}

单调递减栈作用:

  1. 求第i个数「右边第一个比它大」的数字,从左到右遍历数组,如果小于等于栈顶元素,说明还没找到右边比其大的数字,入栈存着;如果碰到即将入栈元素大于栈顶元素,则栈顶元素出栈,出栈元素右边第一大就是即将入栈的元素。

单调递增栈:
如果栈空或进栈元素大于等于栈顶元素则直接入栈;如果进栈元素小于栈顶元素(找到小值拐点),则出栈,直至进栈元素大于等于栈顶元素。栈中可以存储下标或者元素值。

int nums[maxn];
stack <Integer> q=new Stack<Integer>();
for (int i = 0; i < nums.length; i++) {
	if(q.isEmpty()||nums[i]>=q.peek()){
		q.push(nums[i]);
	}
    else{
		while (!q.empty() && nums[i] < q.peek()) {
        	q.pop();
        	处理结果,通常是找到了出栈元素的属性
    	}
    	q.push(nums[i]);
	}
}

简化后的写法如下:

int nums[maxn];
stack <Integer> q=new Stack<Integer>();
for (int i = 0; i < nums.length; i++) {
    while (!q.empty() && nums[i] < q.peek()) {
        q.pop();
        处理结果,通常是找到了出栈元素的属性
    }
    //空,nums[i]>=q.peek(),上面弹栈完以后都可以进栈
    q.push(nums[i]);
}

时间复杂度:O(N)找出每个数左边或者右边的一个最大值或者最小值。

相关题目

38.每日温度

找右边第一个大的值,单调递减栈应用,存储的是数组下标。

class Solution {
    public int[] dailyTemperatures(int[] temperatures) {
        int len=temperatures.length;
        int[]res=new int[len];
        Stack<Integer> st=new Stack<>();
        for(int i=0;i<len;i++){
            while(!st.isEmpty()&&temperatures[i]>temperatures[st.peek()]){
                int top=st.pop();
                res[top]=i-top;
            }
            st.push(i);
        }
        return res;
    }
}
1475.商品折扣后的最终价格

一眼是双循环,但是O(N^2)复杂度,然后想到单调栈可以找右边第一个小的元素,降到O(N)时间复杂度。为了找到小的拐点,因此采用单调递增栈,栈中记录下标。

class Solution {
    public int[] finalPrices(int[] prices) {
        int len=prices.length;
        int[] res=new int[len];
        for(int i=0;i<len;i++){
            res[i]=prices[i];
        }
        Stack<Integer> st=new Stack<>();
        for(int i=0;i<len;i++){
            while(!st.isEmpty()&&prices[i]<=prices[st.peek()]){
                int top=st.pop();
                res[top]=prices[top]-prices[i];
            }
            st.push(i);
        }
        return res;
    }
}
496.下一个更大元素I

单调递减栈+Map映射,栈中存储的是值,用Map映射。采用单调递减栈模板,遍历nums2,然后相同的值在nums1中找值映射,由于所有的值互不相同,因此可以用Map构建映射关系,key为数字值,value为数字在nums1中的下标。

class Solution {
    public int[] nextGreaterElement(int[] nums1, int[] nums2) {
        int len1=nums1.length;
        int[]res=new int[len1];
        HashMap<Integer,Integer> nums1ToIndex=new HashMap<>();
        for(int i=0;i<len1;i++){
            nums1ToIndex.put(nums1[i],i);
        }
        //不存在下一个更大元素的话就-1
        Arrays.fill(res,-1);
        Stack<Integer> st=new Stack<>();
        for(int i:nums2){
            //如果当前准备进栈元素大于栈顶元素,则开始不断出栈
            while(!st.isEmpty()&&i>st.peek()){
                int top=st.pop();
                if(nums1ToIndex.containsKey(top)){
                    res[nums1ToIndex.get(top)]=i;
                }
            }
            st.push(i);
        }
        return res;
    }
}
503.下一个更大的元素II

单调递减栈,存储的是数组下标。循环搜索下一个更大的数字,这里的循环考虑的是遍历nums数字两遍,在存数的时候记得取余就行。

class Solution {
    public int[] nextGreaterElements(int[] nums) {
        int len=nums.length;
        int[]res=new int[len];
        Arrays.fill(res,-1);
        Stack<Integer> st=new Stack<>();
        for(int i=0;i<2*len;i++){
            while(!st.isEmpty()&&nums[i%len]>nums[st.peek()]){
                int top=st.pop();
                res[top]=nums[i%len];
            }
            st.push(i%len);
        }
        return res;
    }
}
42.接雨水

找接雨水的区域,其实碰到凹槽的时机,就是遇到待入栈元素大于栈顶元素,可以考虑用「单调递减栈」,由于单调递减栈的性质,栈顶的倒数第二个元素必然大于等于即将弹栈的栈顶元素,这样以栈顶倒数第二个元素、栈顶元素(待出栈)、待入栈元素,这三个元素形成了一个凹槽。

height[i]>height[st.peek()]时不断弹栈,每次栈顶元素一弹出,就将其作为底部高度,算出其与左右两边最小高度的高度差,以及待入栈元素索引与弹栈以后新的栈顶元素下标索引(原先的栈顶倒数第二个元素)的差int w=i-st.peek()-1作为宽度,计算一次雨水面积。从上可知,我们在栈中存下标方便计算长度。

图像中计算感性理解来看,是横向的面积计算,int w=i-st.peek()-1作为宽度,其中有些下标元素已经弹栈。
在这里插入图片描述

class Solution {
    public int trap(int[] height) {
        int len=height.length;
        Stack<Integer> st=new Stack<>();
        int sum=0;
        for(int i=0;i<len;i++){
            while(!st.isEmpty()&&height[i]>height[st.peek()]){
                int top=st.pop();
                //判断是否存在左边的高度可以构成凹槽
                if(!st.isEmpty()){
                    int h=Math.min(height[st.peek()],height[i])-height[top];
                    int w=i-st.peek()-1;
                    sum+=h*w;
                }
            }
            st.push(i);
        }
        return sum;
    }
}
84.柱状图中最大的矩形

类似于接雨水,不过这里是要找到每个元素左右两边第一个小于该元素的元素,因此采用「单调递增栈」,由于单调递增栈的性质,栈顶的倒数第二个元素必然大于等于即将弹栈的栈顶元素,这样以栈顶倒数第二个元素、栈顶元素(待出栈)、待入栈元素形成了一个凸起,参考图解很清晰。

每次弹栈都是以该弹栈元素为高度,待入栈元素索引与弹栈以后新的栈顶元素下标索引(原先的栈顶倒数第二个元素)的差int w=i-st.peek()-1作为宽度,计算一次面积。从上可知,我们在栈中存下标方便计算长度。

这里不同的是,为了计算包含下标为0和下标最末的两块面积,左右两端都加上0这个最小高度,这样子既满足前几个元素下标长度的计算,同时在弹栈的时候,也不会出现st.isEmpty()的情况,因为没有比0更小的高度。

图像中计算感性理解来看,当矩形长度大于1时,以当前要弹栈的元素为高度,以待入栈元素为宽度右边界,到弹栈以后的栈顶元素的左边界的左边界,中间已经弹出很多元素了,比方说下图红色面积计算,1、2夹住的5后,6已经弹出,绿色面积0、0夹住1后,2、5、6、2、3都已经弹出。
在这里插入图片描述

class Solution {
    public int largestRectangleArea(int[] heights) {
        int len=heights.length;
        int[] newheights=new int[len+2];
        int res=0;
        for(int i=0;i<len;i++){
            newheights[i+1]=heights[i];
        }
        Stack<Integer> st=new Stack<>();
        for(int i=0;i<len+2;i++){
            while(!st.isEmpty()&&newheights[i]<newheights[st.peek()]){
                int top=st.pop();
                int h=newheights[top];
                int w=i-st.peek()-1;
                res=Math.max(res,h*w);
            }
            st.push(i);
        }
        return res;
    }
}
85.最大矩形

不同于用DFS求最大的1的面积,这里需要保证它是矩形。转换为,针对每一行,计算以该行为底部向上的柱状图中连续的最大的矩形面积,整体复杂度为O(mn)

class Solution {
    public int maximalRectangle(char[][] matrix) {
        int m=matrix.length;
        int n=matrix[0].length;
        int res=0;
        int[]heights=new int[n];
        for(int i=0;i<m;i++){
            for(int j=0;j<n;j++){
                if(matrix[i][j]=='0') heights[j]=0;
                else heights[j]+=1;
            }
            res=Math.max(res,largestRectangleArea(heights));
        }
        return res;
    }


    //柱状图中的最大面积
    public int largestRectangleArea(int[] heights) {
        int len=heights.length;
        int[] newHeight=new int[len+2];
        for(int i=0;i<len;i++){
            newHeight[i+1]=heights[i];
        }
        long res=0;
        Stack<Integer> st=new Stack<>();
        for(int i=0;i<len+2;i++){
            while(!st.isEmpty()&&newHeight[i]<newHeight[st.peek()]){
                int top=st.pop();
                int h=newHeight[top];
                int w=i-st.peek()-1;
                res=Math.max(res,1L*h*w);
            }
            st.push(i);
        }
        return (int)res;
    }
}
456.132模式

这也能用到单调栈我是没想到的,参考宫水三叶的题解,从后向前遍历,维护一个单调递减栈,当前元素j大于栈顶元素i时,则弹栈,从而找到了32,但是12是在遍历1的过程中不断查找的。由于「单调递减」的性质,我们至少能找到「遍历过程中」所有符合条件的 ijk 中 k 最大的那个组合。

参考评论大神的原话“单调栈维护的是3,k维护的是2,枚举的是1, k来源于单调栈,所以其索引一定大于栈顶的元素,但其值一定小于栈顶元素,故栈顶元素就是3,即找到了对“32”。 当出现nums[i] < k时,即找到了"12",这个时候一定会有3个元素的,而栈顶3必定大于2,1也必小于2,即满足“132””。

class Solution {
    public boolean find132pattern(int[] nums) {
        int len=nums.length;
        //k初始化为0是为了状态判断,最后时
        int k=Integer.MIN_VALUE;
        Stack<Integer> st=new Stack<>();
        //从后向前遍历
        for(int i=len-1;i>=0;i--){
        	//循环第一次的时候直接不成立
            if(nums[i]<k) return true;
            while(!st.isEmpty()&&nums[i]>st.peek()){
                int top=st.pop();
                k=top;
            }   
            st.push(nums[i]);
        }
        return false;
    }
}
907.子数组的最小值之和

单调递增栈+乘法贡献原理。乘法原理是为了找到,对于每个元素来说,其在所有连续子数组中,贡献了多少次。递增栈是为了找到每个元素的较小值左右边界下标,从而可以帮助计算贡献。因此两次单调递增栈,l记录元素左边最小元素,全部初始化为-1,r记录元素右边最小元素,全部初始化为数组长度,通过下面的公式计算其会出现在多少个连续的子数组中。
在这里插入图片描述
其中容易出错的点是,arr 可能有重复元素,我们需要考虑取左右端点时,是取成「小于等于」还是「严格小于」:这里叫我们求min(b),那么其实在找到和当前元素相等的元素时,将该下标也当作当前元素里的贡献,因此弹栈条件写作arr[i]<arr[st.peek()],即不弹栈,但是这也会导致分别从这两个相同各自元素出发,到另一个相同元素时,同样的最小元素被重复计算两次贡献,比方说55,82,55;但是也不能在弹栈条件上都写作arr[i]<=arr[st.peek()],这样子会让相同元素出现在同一区间的情况子数组漏掉。因此我们可以一个加一个不加互补。

class Solution {
    public int sumSubarrayMins(int[] arr) {
        int len=arr.length;
        int[]l=new int[len];
        int[]r=new int[len];
        Arrays.fill(l,-1);
        Arrays.fill(r,len);
        int mod= (int)1e9+7;
        Stack<Integer> st=new Stack<>();
        
        for(int i=0;i<len;i++){
            while(!st.isEmpty()&&arr[i]<=arr[st.peek()]){
                r[st.pop()]=i;
            }
            st.push(i);
        }
        st.clear();
        for(int i=len-1;i>=0;i--){
            while(!st.isEmpty()&&arr[i]<arr[st.peek()]){
                l[st.pop()]=i;
            }
            st.push(i);
        }
        long ans=0;
        for(int i=0;i<len;i++){
            System.out.println("l= "+l[i]+"r= "+r[i]);
            ans+= arr[i]*1L*(i-l[i])*(r[i]-i);
        }
        return (int)(ans%mod);
    }
}
class Solution:
    def sumSubarrayMins(self, arr: List[int]) -> int:
        n=len(arr)
        l=[-1]*n
        r=[n]*n
        st=[]
        for i,c in enumerate(arr):
            while st and c<=arr[st[-1]]:
                r[st.pop()]=i
            st.append(i)
        st.clear()
        for i in range(len(arr)-1,-1,-1):
            while st and arr[i]<arr[st[-1]]:
                l[st.pop()]=i
            st.append(i)
        res=0
        mod=10**9+7
        for i in range(n):
            res += arr[i]*(i-l[i])*(r[i]-i)
        return res%mod
901.股票价格跨度

单调递减栈。这题提供了一个新的视角,如何在从左往右遍历的过程中,找到左边第一个比当前元素大的数字;原数组是没有直接给出来的,而是一个元素一个元素动态加入的。之前根据出栈元素的下标,进行答案填充,现在是在当前下标进行答案填充,这里需要一个工作指针p。由于是动态加入,我们以元组(index,value)形式入栈存储。

class StockSpanner:

    def __init__(self):
            # 加个哨兵,方便第一个判断
            self.st=[(-1,inf)]
            self.p=-1

    def next(self, price: int) -> int:
        self.p +=1
        #   price>=self.st[-1][1]才开始出栈
        while price>=self.st[-1][1] :
            self.st.pop()
        self.st.append((self.p,price))
        # 用倒数第二个的序号做差
        return self.p-self.st[-2][0]
# Your StockSpanner object will be instantiated and called as such:
# obj = StockSpanner()
# param_1 = obj.next(price)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

互联网民工蒋大钊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值