关于单调栈的一些理解

230 阅读6分钟

前沿

在不久前我写博客记录了单调队列的内容,单调栈和单调队列在实现思路上有相似的地方,在这里写下我的一点理解。

一、单调栈和单调队列的区别和联系

单调栈和单调队列里的元素都是单调的,单调递增或单调递减,时间复杂度都是O(n)。单调队列主要用于解决滑动窗口问题,单调栈则主要用于解决NGE问题(Next Greater Element),也就是,对序列中每个元素,找到下一个比它大(小)的元素。

单调队列是用来维护一个给定大小的区间的最值,这个区间就像滑动窗口。这个滑动窗口的大小是固定的,单调队列只维护这个区间里面的最值。每次能利用的值也只有队头的最值,我们并不清楚队列里面的其他元素的情况

单调栈一般是用来解决,寻找一个序列中,每个元素的左边(或者右边)第一个比它大(或小)的元素的值(或者元素下标)的。

从具体实现上来看,单调队列是用队列来实现的,单调栈用栈来实现。为了保持队列内(栈内)元素的单调性,在遇到新的元素的时候,需要不断去判断容器内部的元素是否满足要求,从尾部不断pop元素出来,直到满足单调性为止,然后再把新元素push进去。不同的是,单调队列还需要判断队列头部的元素是否还在当前滑动窗口内,如果不满足的话,需要把元素从头部pop出去。换而言之,单调队列可以从队头和队尾移除元素,而单调栈只能从栈尾移除元素。

二、单调栈的实现思路

2.1 大致思路

单调栈的本质是空间换时间,整个数组只需要遍历一次,时间复杂度是O(n)。在遍历的过程中用一个栈来记录右边第一个比当前元素高(或者低)的元素(元素值或者元素位置)。

我们维护一个栈,表示“待确定NGE的元素”,然后遍历序列。当我们碰上一个新元素,我们知道,越靠近栈顶的元素离新元素位置越近。所以不断比较新元素与栈顶,如果新元素比栈顶大,则可断定新元素就是栈顶的NGE,于是弹出栈顶并继续比较。直到新元素不比栈顶大,再将新元素压入栈。显然,这样形成的栈是单调递减的。

简单来说,就是用一个栈来记录遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,所以如果不用额外空间来记录的话,每次遇到一个新的元素,想要找到它旁边第一个比它大的元素的话,都得从头重新遍历这样时间复杂度就是n^2。

而栈就起到了一个帮助我们“记忆”的作用:记忆每个元素的一侧所遍历过的元素,用来帮助我们找到每个元素之前比它大或小的元素。

2.2 经典题目&&代码

2.2.1 下一个更大的元素

这是一道单调栈的入门级题目,题目链接:496. 下一个更大元素 I - 力扣(LeetCode)

直接看代码,思路都在注释里:

class Solution {
public:
    vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
        //核心算法就是单调栈,然后用一下map记录一下就ok了
        vector<int>vec(nums1.size(),-1);
        unordered_map<int,int>record;//key记录元素值,value记录第一个比元素大的元素的值
        stack<int>sta;//存放的是下标
        for(int i = 0;i < nums2.size();i ++)
        {
            while(!sta.empty() && nums2[i] > nums2[sta.top()])//破坏了单调性,不断弹栈更新栈内元素,直到满足单调性
            //当前元素就是栈顶元素的NGE元素,在弹栈的过程中不断记录下来
            {
                record[nums2[sta.top()]] = nums2[i];
                sta.pop();
            }
            sta.push(i);
        }
        for(int i = 0;i < nums1.size();i ++)
        {
            if(record.find(nums1[i]) != record.end())
                vec[i] = record[nums1[i]];
            else
                continue ;
        }
        return vec;
    }
};

2.2.2 每日温度

题目链接:739. 每日温度 - 力扣(LeetCode)

这道题也是很简单的单调栈的直接运用题,不需要怎么变通。题目要求就是找出下一个更高气温的天气出现在几天之后,没有就是-1。 直接附精简版代码:

class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& temperatures) {
        int size = temperatures.size();
        vector<int>res(size,0);
        stack<int>sta;//sta里面存放的是下标
        for(int i = 0;i < size;i ++)
        {
            while(!sta.empty() && temperatures[i] > temperatures[sta.top()] )
            {
                res[sta.top()] = i - sta.top();
                sta.pop();
            }
            sta.push(i);
        }
        return res;
    }
};

2.2.3 每日温度

题目链接:42. 接雨水 - 力扣(LeetCode)

题目要求:给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

image.png

这个题目就有点难度了,稍微饶了一点。核心思想是要分别找到当前元素的左边和右边第一个比它大的元素,储水量就等于(min(右边元素的高度,左边元素的高度) - 当前元素的高度)乘宽度,宽度就是右边元素下标减去左边元素下标减一。

单调栈做法的思路是按照行来计算的,如下图:

image.png 想法就是,只有凹槽才是能装得下水的。栈里面的元素从栈底到栈头是递减排列的,一旦遇到一个新的元素比栈头元素大,那么就说明凹槽要出现了。凹槽由三个部分组成:左端点右端点和底。储水量的高低肯定取决于更短的那一段,所以此时要在左端值和右端值之间选一个最小的,此时sta.top()扮演的就是底的角色,新元素就是右端的高度,sta.top()下面的那个元素就是左端的高度。计算公式就是(min(右边元素的高度,左边元素的高度) - 当前元素的高度)乘宽度,宽度就是右边元素下标减去左边元素下标减一。 下面看代码:

class Solution {
public:
    int trap(vector<int>& height) {
        //二刷,单调栈做法
        int volume = 0;
        int size = height.size();
        if(size < 3)
            return volume;
        stack<int>sta;//保存元素下标,从栈底到栈头是递减序列
        for(int i = 0;i < size;i ++)
        {
            while(!sta.empty() && height[i] >= height[sta.top()])//出现了凹槽,不断计算每一行的积水面积,然后更新栈内元素
            {
                int bottom = height[sta.top()];//此时,栈头元素就是凹槽的底
                sta.pop();
                if(!sta.empty())
                {
                    int width = i - sta.top() - 1;//计算宽度
                    int high = min(height[i],height[sta.top()]);
                    volume += (high - bottom)*width;
                }
            }
            sta.push(i);
        }
        return volume;
    }
};

这道题还可以用动态规划来解决,核心思想和单调栈做法有相似的地方,单调栈是按照行来计算储水量,动态规划是计算每一列能储水多少,是按照列来计算的。要分别找到每一列的左右两端最大的那个值,然后计算当前列的储水量。代码如下:

class Solution {
public:
    int trap(vector<int>& height) {
        //二刷,双指针做法
        int size = height.size();
        if(size < 3)
            return 0;
        //思路就是,当前柱子的储水量就是min(左边柱子最大值,右边柱子最大值) - 当前柱子的高度。分别用两个数组去记录每个位置左右侧的最大值
        //int left_max = height[0];
        //int right_max = height[size - 1];//分别用来记录每个位置左侧和右侧的最大值
        vector<int>left(size); 
        vector<int>right(size);
        left[0] = height[0];
        right[size - 1] = height[size - 1];
       
        for(int i = 1;i < size - 1;i ++)
        {
            left[i] = max(left[i - 1],height[i]);
            right[size - i - 1] = max(right[size - i],height[size - i - 1]);
        }
        int volume = 0;
        for(int i = 1;i < size - 1;i ++)
        {
            if(height[i] < min(left[i],right[i]))
            {
                volume += min(left[i],right[i]) - height[i];
            }
        }
        return volume;
    }
};

2.2.4 柱状图中最大的矩形

题目链接: 84. 柱状图中最大的矩形 - 力扣(LeetCode)

题目要求:给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。 求在该柱状图中,能够勾勒出来的矩形的最大面积。如图:

image.png 这道题比之前接雨水那道题稍微难一点,主要是要能理解矩形的面积是怎么来计算的。矩形的面积无非就是长乘宽,柱状图中每一个柱子都有当“高”的机会(至少可以自己单独成为一个矩形)。那么就可以通过,计算每个柱子分别作为“高”的时候所能形成的最大的矩形面积,来比较得出最大的矩形面积是多少。那么问题就是,一个柱子如果能当作高的话,要满足什么条件呢?答案显而易见,柱子两边的矩形高度肯定要大于等于当前柱子的高才行,因为反之如果小于当前柱子的高度的话,那么肯定就行不成一个完整的矩形了。就类似于锯木板一样。

那么问题就转换成了,如何找到每个元素两边第一个比它小的元素。此时,就可以直接用单调栈了。

单调栈从栈底到栈顶元素顺序是单调递增的,这样才能找到第一个比它小的元素。栈顶元素就是矩形的高,栈顶的下面一个元素的下标就是矩形的左边界,当前元素下标是矩形的右边界,宽度就是右下标减去左下标再减一,然后面积就求出来了。

代码如下:

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        //heights.insert(0,0);
        heights.insert(heights.begin(),0);
        heights.push_back(0);
        stack<int>sta;
        int result = 0;
        for(int i = 0;i < heights.size();i ++)
        {
            while(!sta.empty() && heights[i] < heights[sta.top()])
            {
                int mid = heights[sta.top()];
                sta.pop();
                int width = i - sta.top() - 1;
                result = max(result,mid*width);
            }
            sta.push(i);
        }
        return result;
    }
};

注意,之所以在元素的基础上要在首位插入一个0,是为了保证即便原数组是单调递增的或者是单调递减的,代码还能正常计算,不然有可能一直压栈不会出栈。

2.2.5 子数组的最小值之和

907. 子数组的最小值之和 - 力扣(LeetCode)

这是本系列的最后一道例题啦,这道题思路很难,本身价值也不大。写这道题主要是为了想记录其中核心算法部分的实现。算法核心就是用单调栈去记录序列里元素的左边和右边第一个比它小的元素的位置,在不在序列首尾插入0的情况下来实现的。直接看核心代码就好了:

int sumSubarrayMins(vector<int>& arr) {
        //arr.insert(arr.begin(),0);
        int size = arr.size();
        vector<int>left(size);
        vector<int>right(size);
        stack<int>sta;
        for(int i = 0;i < size;i ++)
        {
            while(!sta.empty() && arr[i] < arr[sta.top()])
            {
                sta.pop();
            }
            if(!sta.empty())
                left[i] = sta.top();
            else
                left[i] = -1;
            sta.push(i);
        }
       //sta.clear();
        while(!sta.empty()) sta.pop();
        for(int i = size - 1;i >= 0;i --)
        {
            while(!sta.empty() && arr[i] < arr[sta.top()])
            {
                sta.pop();
            }
            if(!sta.empty())
                right[i] = sta.top();
            else
                right[i] = size;
            sta.push(i);
        }