动态规划🔟:单调栈
单调栈算法(Monotonic Stack Algorithm)是一种利用栈来解决一类特定问题的算法。主要应用于需要找到数组中下一个更大元素(Next Greater Element)或者下一个更小元素(Next Smaller Element)的场景中。
单调栈的基本思想:
-
栈的特性:
- 单调递增栈:栈中元素保持单调递增的顺序,即栈顶元素最小。
- 单调递减栈:栈中元素保持单调递减的顺序,即栈顶元素最大。
-
算法流程:
- 遍历数组元素,维护一个单调递增或递减的栈。
- 对于每个元素,将其与栈顶元素进行比较:
- 如果当前元素比栈顶元素大(或小),则栈顶元素找到了下一个更大(或更小)的元素。
- 如果当前元素不满足要求,则将其压入栈中,继续遍历下一个元素。
-
应用场景:
- Next Greater Element问题:找到数组中每个元素的下一个更大元素。
- Next Smaller Element问题:找到数组中每个元素的下一个更小元素。
示例:Next Greater Element问题
假设给定数组 [2, 1, 2, 4, 3]
,要找到每个元素的下一个更大元素。
- 使用单调递减栈来解决:
- 初始化一个空栈和一个结果数组。
- 从左到右遍历数组:
- 对于当前元素,如果栈不为空且当前元素大于栈顶元素,则栈顶元素的下一个更大元素即为当前元素。
- 将当前元素压入栈中。
- 遍历完数组后,栈中剩余元素的下一个更大元素设为
-1
。
通过上述步骤,可以得到结果数组 [4, 2, 4, -1, 4]
,分别表示每个元素的下一个更大元素。
739. 每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
思路
首先想到的当然是暴力解法,两层for循环,把至少需要等待的天数就搜出来了。时间复杂度是
怎么能想到用单调栈呢? 什么时候用单调栈呢?
通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时就要想到可以用单调栈了。时间复杂度为
例如本题其实就是找找到一个元素右边第一个比自己大的元素,此时就应该想到用单调栈了。
那么单调栈的原理是什么呢?为什么时间复杂度是
单调栈的本质是空间换时间,因为在遍历的过程中需要用一个栈来记录右边第一个比当前元素高的元素,优点是整个数组只需要遍历一次。
更直白来说,就是用一个栈来记录遍历过的元素,因为遍历数组的时候,不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以需要用一个容器(这里用单调栈)来记录遍历过的元素。
在使用单调栈的时候首先要明确如下几点:
- 单调栈里存放的元素是什么?
单调栈里只需要存放元素的下标i
就可以了,如果需要使用对应的元素,直接T[i]
就可以获取。
- 单调栈里元素是递增呢? 还是递减呢?
注意以下讲解中,顺序的描述为 从栈头到栈底的顺序,因为单纯的说从左到右或者从前到后,不说栈头朝哪个方向的话,大家一定比较懵。
这里要使用递增循序(再强调一下是指从栈头到栈底的顺序),因为只有递增的时候,栈里要加入一个元素i的时候,才知道栈顶元素在数组中右面第一个比栈顶元素大的元素是i。
即:如果求一个元素右边第一个更大元素,单调栈就是递增的,如果求一个元素右边第一个更小元素,单调栈就是递减的。
文字描述理解起来有点费劲,接下来画了一系列的图,来讲解单调栈的工作过程,大家再去思考,本题为什么是递增栈。
使用单调栈主要有三个判断条件。
- 当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况
- 当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况
- 当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况
把这三种情况分析清楚了,也就理解透彻了。
接下来用temperatures = [73, 74, 75, 71, 71, 72, 76, 73]为例来逐步分析,输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
首先先将第一个遍历元素加入单调栈
加入T[1] = 74,因为T[1] > T[0](当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况)。
要保持一个递增单调栈(从栈头到栈底),所以将T[0]弹出,T[1]加入,此时result数组可以记录了,result[0] = 1,即T[0]右面第一个比T[0]大的元素是T[1]。
加入T[2],同理,T[1]弹出
加入T[3],T[3] < T[2] (当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况),加T[3]加入单调栈。
加入T[4],T[4] == T[3] (当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况),此时依然要加入栈,不用计算距离,因为要求的是右面第一个大于本元素的位置,而不是大于等于!
加入T[5],T[5] > T[4] (当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况),将T[4]弹出,同时计算距离,更新result
T[4]弹出之后, T[5] > T[3] (当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况),将T[3]继续弹出,同时计算距离,更新result
直到发现T[5]小于T[st.top()],终止弹出,将T[5]加入单调栈
加入T[6],同理,需要将栈里的T[5],T[2]弹出
同理,继续弹出
此时栈里只剩下了T[6]
加入T[7], T[7] < T[6] 直接入栈,这就是最后的情况,result数组也更新完了。
此时有同学可能就疑惑了,那result[6] , result[7]怎么没更新啊,元素也一直在栈里。
其实定义result数组的时候,就应该直接初始化为0,如果result没有更新,说明这个元素右面没有更大的了,也就是为0。
以上在图解的时候,已经把,这三种情况都做了详细的分析。
- 情况一:当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况
- 情况二:当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况
- 情况三:当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况
通过以上过程,大家可以自己再模拟一遍,就会发现:只有单调栈递增(从栈口到栈底顺序),就是求右边第一个比自己大的,单调栈递减的话,就是求右边第一个比自己小的。
C++代码如下:
1 | // 版本一 |
精简代码如下:
1 | // 版本二 |
- 时间复杂度:
- 空间复杂度:
496.下一个更大元素 I
给你两个 没有重复元素 的数组 nums1 和 nums2 ,其中nums1 是 nums2 的子集。
请你找出 nums1 中每个元素在 nums2 中的下一个比其大的值。
nums1 中数字 x 的下一个更大元素是指 x 在 nums2 中对应位置的右边的第一个比 x 大的元素。如果不存在,对应位置输出 -1 。
示例 1:
输入: nums1 = [4,1,2], nums2 = [1,3,4,2].
输出: [-1,3,-1]
解释:
对于 num1 中的数字 4 ,你无法在第二个数组中找到下一个更大的数字,因此输出 -1 。
对于 num1 中的数字 1 ,第二个数组中数字1右边的下一个较大数字是 3 。
对于 num1 中的数字 2 ,第二个数组中没有下一个更大的数字,因此输出 -1 。
示例 2:
输入: nums1 = [2,4], nums2 = [1,2,3,4].
输出: [3,-1]
解释:
对于 num1 中的数字 2 ,第二个数组中的下一个较大数字是 3 。
对于 num1 中的数字 4 ,第二个数组中没有下一个更大的数字,因此输出-1 。
提示:
- 1 <= nums1.length <= nums2.length <= 1000
- 0 <= nums1[i], nums2[i] <=
- nums1和nums2中所有整数 互不相同
- nums1 中的所有整数同样出现在 nums2 中
思路
在739. 每日温度中是求每个元素下一个比当前元素大的元素的位置。
本题则是说nums1 是 nums2的子集,找nums1中的元素在nums2中下一个比当前元素大的元素。
从题目示例中可以看出最后是要求nums1的每个元素在nums2中下一个比当前元素大的元素,那么就要定义一个和nums1一样大小的数组result来存放结果。
题目说如果不存在对应位置就输出 -1 ,所以result数组如果某位置没有被赋值,那么就应该是是-1,所以就初始化为-1。
在遍历nums2的过程中,要判断nums2[i]
是否在nums1中出现过,因为最后是要根据nums1元素的下标来更新result数组。
注意题目中说是两个没有重复元素 的数组 nums1 和 nums2。
没有重复元素,就可以用map来做映射了。根据数值快速找到下标,还可以判断nums2[i]
是否在nums1中出现过。
C++中,当要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的。在关于哈希表,你该了解这些!中也做了详细的解释。
那么预处理代码如下:
1 | unordered_map<int, int> umap; // key:下标元素,value:下标 |
使用单调栈,首先要想单调栈是从大到小还是从小到大。
本题和739. 每日温度是一样的。
栈头到栈底的顺序,要从小到大,也就是保持栈里的元素为递增顺序。只要保持递增,才能找到右边第一个比自己大的元素。
其实递减栈就是求右边第一个比自己小的元素了。
接下来就要分析如下三种情况,一定要分析清楚。
- 情况一:当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况
此时满足递增栈(栈头到栈底的顺序),所以直接入栈。
- 情况二:当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况
如果相等的话,依然直接入栈,因为要求的是右边第一个比自己大的元素,而不是大于等于!
- 情况三:当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况
此时如果入栈就不满足递增栈了,这也是找到右边第一个比自己大的元素的时候。
判断栈顶元素是否在nums1里出现过,(注意栈里的元素是nums2的元素),如果出现过,开始记录结果。
记录结果这块逻辑有一点小绕,要清楚,此时栈顶元素在nums2数组中右面第一个大的元素是nums2[i](即当前遍历元素)。
代码如下:
1 | // 版本一 |
针对版本一,进行代码精简后,代码如下:
1 | // 版本二 |
503.下一个更大元素II
给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素。数字 x 的下一个更大的元素是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1。
示例 1:
- 输入: [1,2,1]
- 输出: [2,-1,2]
- 解释: 第一个 1 的下一个更大的数是 2;数字 2 找不到下一个更大的数;第二个 1 的下一个最大的数需要循环搜索,结果也是 2。
提示:
思路
本篇侧重与说一说,如何处理循环数组。
相信不少同学看到这道题,就想那直接把两个数组拼接在一起,然后使用单调栈求下一个最大值不就行了!
确实可以!
将两个nums数组拼接在一起,使用单调栈计算出每一个元素的下一个最大值,最后再把结果集即result数组resize到原数组大小就可以了。
代码如下:
1 | // 版本一 |
这种写法确实比较直观,但做了很多无用操作,例如修改了nums数组,而且最后还要把result数组resize回去。
resize倒是不费时间,是O(1)的操作,但扩充nums数组相当于多了一个O(n)的操作。
其实也可以不扩充nums,而是在遍历的过程中模拟走了两边nums。
代码如下:
1 | // 版本二 |
可以版本二不仅代码精简了,也比版本一少做了无用功!
最后在给出 单调栈的精简版本,即三种情况都做了合并的操作。
1 | // 版本二 |
42. 接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
- 输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
- 输出:6
- 解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例 2:
- 输入:height = [4,2,0,3,2,5]
- 输出:9
思路
本文深度讲解如下三种方法:
- 暴力解法
- 双指针优化
- 单调栈
暴力解法
本题暴力解法也是也是使用双指针。
首先要明确,要按照行来计算,还是按照列来计算。
按照行来计算如图:
按照列来计算如图:
首先,如果按照列来计算的话,宽度一定是1了,再把每一列的雨水的高度求出来就可以了。
可以看出每一列雨水的高度,取决于,该列 左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度。
例如求列4的雨水高度,如图:
列4 左侧最高的柱子是列3,高度为2(以下用lHeight表示)。
列4 右侧最高的柱子是列7,高度为3(以下用rHeight表示)。
列4 柱子的高度为1(以下用height表示)
那么列4的雨水高度为 列3和列7的高度最小值减列4高度,即: min(lHeight, rHeight) - height。
列4的雨水高度求出来了,宽度为1,相乘就是列4的雨水体积了。
此时求出了列4的雨水体积。
一样的方法,只要从头遍历一遍所有的列,然后求出每一列雨水的体积,相加之后就是总雨水的体积了。
首先从头遍历所有的列,并且要注意第一个柱子和最后一个柱子不接雨水,代码如下:
1 | for (int i = 0; i < height.size(); i++) { |
在for循环中求左右两边最高柱子,代码如下:
1 | int rHeight = height[i]; // 记录右边柱子的最高高度 |
最后,计算该列的雨水高度,代码如下:
1 | int h = min(lHeight, rHeight) - height[i]; |
整体代码如下:
1 | class Solution { |
因为每次遍历列的时候,还要向两边寻找最高的列,所以时间复杂度为
双指针优化
在暴力解法中,可以看到只要记录左边柱子的最高高度 和 右边柱子的最高高度,就可以计算当前位置的雨水面积,这就是通过列来计算。
当前列雨水面积:min(左边柱子的最高高度,记录右边柱子的最高高度) - 当前柱子高度。
为了得到两边的最高高度,使用了双指针来遍历,每到一个柱子都向两边遍历一遍,这其实是有重复计算的。把每一个位置的左边最高高度记录在一个数组上(maxLeft),右边最高高度记录在一个数组上(maxRight),这样就避免了重复计算。
当前位置,左边的最高高度是前一个位置的左边最高高度和本高度的最大值。
即从左向右遍历:maxLeft[i] = max(height[i], maxLeft[i - 1]);
从右向左遍历:maxRight[i] = max(height[i], maxRight[i + 1]);
代码如下:
1 | class Solution { |
单调栈
单调栈就是保持栈内元素有序。和单调队列一样,需要自己维持顺序,没有现成的容器可以用。
通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时就要想到可以用单调栈了。
而接雨水这道题目,正需要寻找一个元素,右边最大元素以及左边最大元素,来计算雨水面积。
准备工作
那么本题使用单调栈有如下几个问题:
- 首先单调栈是按照行方向来计算雨水,如图:
- 使用单调栈内元素的顺序
从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。
因为一旦发现添加的柱子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。
如图:
关于单调栈的顺序给大家一个总结:739. 每日温度 中求 一个元素右边第一个更大元素,单调栈就是递增的,84.柱状图中最大的矩形 求一个元素右边第一个更小元素,单调栈就是递减的。
- 遇到相同高度的柱子怎么办。
遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,将新元素(新下标)加入栈中。
例如 5 5 1 3 这种情况。如果添加第二个5的时候就应该将第一个5的下标弹出,把第二个5添加到栈中。
因为要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度。
如图所示:
- 栈里要保存什么数值
使用单调栈,也是通过 长 * 宽 来计算雨水面积的。 长就是通过柱子的高度来计算,宽是通过柱子之间的下标来计算,
那么栈里有没有必要存一个pair<int, int>
类型的元素,保存柱子的高度和下标呢。
其实不用,栈里就存放下标就行,想要知道对应的高度,通过height[stack.top()]
就知道弹出的下标对应的高度了。
所以栈的定义如下:
1 | stack<int> st; // 存着下标,计算的时候用下标对应的柱子高度 |
明确了如上几点,再来看处理逻辑。
单调栈处理逻辑
以下逻辑主要就是三种情况
- 情况一:当前遍历的元素(柱子)高度小于栈顶元素的高度
height[i] < height[st.top()]
- 情况二:当前遍历的元素(柱子)高度等于栈顶元素的高度
height[i] == height[st.top()]
- 情况三:当前遍历的元素(柱子)高度大于栈顶元素的高度
height[i] > height[st.top()]
先将下标0
的柱子加入到栈中,st.push(0);
。 栈中存放遍历过的元素,所以先将下标0加进来。
然后开始从下标1开始遍历所有的柱子,for (int i = 1; i < height.size(); i++)
。
如果当前遍历的元素(柱子)高度小于栈顶元素的高度,就把这个元素加入栈中,因为栈里本来就要保持从小到大的顺序(从栈头到栈底)。
代码如下:
1 | if (height[i] < height[st.top()]) st.push(i); |
如果当前遍历的元素(柱子)高度等于栈顶元素的高度,要跟更新栈顶元素,因为遇到相相同高度的柱子,需要使用最右边的柱子来计算宽度。
代码如下:
1 | if (height[i] == height[st.top()]) { // 例如 5 5 1 7 这种情况 |
如果当前遍历的元素(柱子)高度大于栈顶元素的高度,此时就出现凹槽了,如图所示:
取栈顶元素,将栈顶元素弹出,这个就是凹槽的底部,也就是中间位置,下标记为mid,对应的高度为height[mid]
(就是图中的高度1)。
此时的栈顶元素st.top()
,就是凹槽的左边位置,下标为st.top()
,对应的高度为height[st.top()]
(就是图中的高度2)。
当前遍历的元素i,就是凹槽右边的位置,下标为i,对应的高度为height[i]
(就是图中的高度3)。
此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的元素,三个元素来接水!
那么雨水高度是 min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度,代码为:int h = min(height[st.top()], height[i]) - height[mid];
雨水的宽度是 凹槽右边的下标 - 凹槽左边的下标 - 1(因为只求中间宽度),代码为:int w = i - st.top() - 1 ;
当前凹槽雨水的体积就是:h * w
。
求当前凹槽雨水的体积代码如下:
1 | while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while,持续跟新栈顶元素 |
关键部分讲完了,整体代码如下:
1 | class Solution { |
以上代码冗余了一些,但是思路是清晰的,下面将代码精简一下,如下:
1 | class Solution { |
精简之后的代码,大家就看不出去三种情况的处理了,貌似好像只处理的情况三,其实是把情况一和情况二融合了。 这样的代码不太利于理解。
84.柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
暴力解法
1 | class Solution { |
如上代码并不能通过leetcode,超时了,因为时间复杂度是
双指针解法
难就难在本题要记录记录每个柱子 左边第一个小于该柱子的下标,而不是左边第一个小于该柱子的高度。
所以需要循环查找,也就是下面在寻找的过程中使用了while
1 | class Solution { |
单调栈(1)
本地单调栈的解法和接雨水的题目是遥相呼应的。
为什么这么说呢,42. 接雨水找每个柱子左右两边第一个大于该柱子高度的柱子,而本题是找每个柱子左右两边第一个小于该柱子的柱子。
这里就涉及到了单调栈很重要的性质,就是单调栈里的顺序,是从小到大还是从大到小。
在题解[42. 接雨水]中讲解了接雨水的单调栈从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。
因为本题是要找每个柱子左右两边第一个小于该柱子的柱子,所以从栈头(元素从栈头弹出)到栈底的顺序应该是从大到小的顺序!
来举一个例子,如图:
只有栈里从大到小的顺序,才能保证栈顶元素找到左右两边第一个小于栈顶元素的柱子。
所以本题单调栈的顺序正好与接雨水反过来。
此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的三个元素组成了要求最大面积的高度和宽度
理解这一点,对单调栈就掌握的比较到位了。
除了栈内元素顺序和接雨水不同,剩下的逻辑就都差不多了,在接雨水对单调栈的各个方面做了详细讲解,这里就不赘述了。
主要就是分析清楚如下三种情况:
- 情况一:当前遍历的元素
heights[i]
大于栈顶元素heights[st.top()]
的情况 - 情况二:当前遍历的元素
heights[i]
等于栈顶元素heights[st.top()]
的情况 - 情况三:当前遍历的元素
heights[i]
小于栈顶元素heights[st.top()]
的情况
C++代码如下:
1 | // 版本一 |
细心的录友会发现,在 height数组上后,都加了一个元素0, 为什么这么做呢?
首先来说末尾为什么要加元素0?
如果数组本身就是升序的,例如[2,4,6,8],那么入栈之后 都是单调递减,一直都没有走 情况三 计算结果的哪一步,所以最后输出的就是0了。 如图:
那么结尾加一个0,就会让栈里的所有元素,走到情况三的逻辑。
开头为什么要加元素0?
如果数组本身是降序的,例如 [8,6,4,2],在 8 入栈后,6 开始与8 进行比较,此时得到 mid(8),rigt(6),但是得不到 left。
(mid、left,right 都是对应版本一里的逻辑)
因为 将 8 弹出之后,栈里没有元素了,那么为了避免空栈取值,直接跳过了计算结果的逻辑。
之后又将6 加入栈(此时8已经弹出了),然后 就是 4 与 栈口元素 8 进行比较,周而复始,那么计算的最后结果resutl就是0。 如图所示:
所以需要在 height数组前后各加一个元素0。
版本一代码精简之后:
1 | // 版本二 |
单调栈(2)
首先,面积最大矩形的高度一定是 heights 中的元素。这可以用反证法证明:假如高度不在 heights 中,比如 4,那我们可以增加高度直到触及某根柱子的顶部,比如增加到 5,由于矩形底边长不变,高度增加,我们得到了面积更大的矩形,矛盾,所以面积最大矩形的高度一定是 heights 中的元素。
假设 h=heights[i] 是矩形的高度,那么矩形的宽度最大是多少?我们需要知道:
- 在 i 左侧的小于 h 的最近元素的下标 left,如果不存在则为 −1。这样 left+1 就是在 i 左侧的大于等于 h 的最近元素的下。
- 在 i 右侧的小于 h 的最近元素的下标 right,如果不存在则为 n。这样 right−1 就是在 i 右侧的大于等于 h 的最近元素的下标。
比如示例 1(上图),选择 i=2 这个柱子作为矩形高,那么左边小于 heights[2]=5 的最近元素的下标为 left=1,右边小于 heights[2]=5 的最近元素的下标为 right=4。
那么矩形的宽度就是 right−left−1=4−1−1=2,矩形面积为 h⋅(right−left−1)=5⋅2=10。
枚举 i,计算对应的矩形面积,更新答案的最大值。
1 | class Solution { |