一、什么是单调栈?
单调栈,顾名思义就是栈中元素按递增或递减顺序排列。单调栈的最大好处就是时间复杂度是线性的,每个元素遍历一次。
二、单调栈有哪些性质?
单调递增栈可以找到左起第一个比当前数字小的元素。
比如数组:[3,5,4,1],我们如何要找每一个元素下一个最小数,例如 3,下一个最小的数为1,5下一个是4。我们先构造栈,把3压入栈中,5入栈时,发现栈顶元素3比它小,就直接把5压入栈中,4入栈时,栈顶元素5大于4,于是得到,5左起第一个小的元素为4,将5弹出,压入4,接下来元素为1,也比栈顶元素小,于是得到4第一个小的元素为1,弹出4,以此类推,3也是1...栈中的元素一直保持单调递增的状态。
当然,单调递增栈可以找到右起第一个比当前数字小的元素。(只需要遍历数组时从右往左即可)
单调递减栈可以找到左起第一个比当前数字大的元素。
个人理解总结
单调栈简单来说也还是普通的栈,更确切的说,我觉得这是一个思想,我们在入栈出栈的时候,有意维护一个有顺序的栈结构,元素递增就是递增栈,元素递减就是递减栈。我们在维护单调栈的过程中,就可以发现上面提到的单调栈的特性,这也是我们利用单调栈的原因所在。
还是多看看例题感受下单调栈的使用吧。
三、单调栈例题
84. 柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。求在该柱状图中,能够勾勒出来的矩形的最大面积。
以上是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]
。
图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10
个单位。
示例:
输入: [2,1,5,6,2,3]
输出: 10
分析
这题目看到这里我还是蒙蔽的,只能看看题解再说,还好题解中说的倒是比较好。https://leetcode-cn.com/problems/largest-rectangle-in-histogram/solution/zhu-zhuang-tu-zhong-zui-da-de-ju-xing-by-leetcode-
我们需要在柱状图中找出最大的矩形,因此我们可以考虑枚举矩形的宽和高,其中「宽」表示矩形贴着柱状图底边的宽度,「高」表示矩形在柱状图上的高度。如果我们枚举「宽」,我们可以使用两重循环枚举矩形的左右边界以固定宽度 w,此时矩形的高度 h,就是所有包含在内的柱子的「最小高度」,对应的面积为 w * h。
如果我们枚举「高」,我们可以使用一重循环枚举某一根柱子,将其固定为矩形的高度 h。随后我们从这跟柱子开始向两侧延伸,直到遇到高度小于 h 的柱子,就确定了矩形的左右边界。如果左右边界之间的宽度为 w,那么对应的面积为 w∗h。
可以发现,上面两种思路暴力方法的时间复杂度都是 ,会超时,所以要进行优化,枚举宽的时候用了两重循环,本身就已经需要
的时间复杂度,不容易优化,所以我们考虑优化枚举高的方法二。
方法一:
思路
我们归纳一下枚举「高」的方法:
首先我们枚举某一根柱子 i 作为高
随后我们需要进行向左右两边扩展,使得扩展到的柱子的高度均不小于 h。换句话说,我们需要找到左右两侧最近的高度小于 h 的柱子,这样这两根柱子之间(不包括其本身)的所有柱子高度均不小于 h,并且就是 i 能够扩展到的最远范围。
那么我们先来看看如何求出一根柱子的左侧且最近的小于其高度的柱子。除了根据「前言」部分暴力地进行枚举之外,我们可以通过如下的一个结论来深入地进行思考:
这里说了太多,我就省略了,详细介绍可以去网页中看。
大致意思就是,找到一根柱子的左侧且最近的小于其高度的柱子,我们可以维护一个单调递增的栈,在维护过程中,遍历数组可以得出每个数(柱子)的左侧且最近的小于其高度的柱子。
例子
我们用一个具体的例子 [6, 7, 5, 2, 4, 5, 9, 3] 来帮助读者理解单调栈。我们需要求出每一根柱子的左侧且最近的小于其高度的柱子。初始时的栈为空。
我们枚举 6,因为栈为空,所以 6 左侧的柱子是「哨兵」,位置为 -1。随后我们将 6 入栈。
-
栈:[6(0)]。(这里括号内的数字表示柱子在原数组中的位置)
我们枚举 7,由于 6<7,因此不会移除栈顶元素,所以 7 左侧的柱子是 6,位置为 0。随后我们将 7 入栈。
-
栈:[6(0), 7(1)]
我们枚举 5,由于 7≥5,因此移除栈顶元素 7。同样地,6≥5,再移除栈顶元素 6。此时栈为空,所以 5 左侧的柱子是「哨兵」,位置为 -1。随后我们将 5 入栈。
-
栈:[5(2)]
接下来的枚举过程也大同小异。我们枚举 2,移除栈顶元素 5,得到 2 左侧的柱子是「哨兵」,位置为 −1。将 2 入栈。
-
栈:[2(3)]
我们枚举 4,5 和 9,都不会移除任何栈顶元素,得到它们左侧的柱子分别是 2,4 和 5,位置分别为 3,4 和 5。将它们入栈。
-
栈:[2(3), 4(4), 5(5), 9(6)]
我们枚举 3,依次移除栈顶元素 9,5 和 4,得到 3 左侧的柱子是 2,位置为 3。将 3 入栈。
-
栈:[2(3), 3(7)]
这样一来,我们得到它们左侧的柱子编号分别为[−1,0,−1,−1,3,4,5,3]。用相同的方法,我们从右向左进行遍历,也可以得到它们右侧的柱子编号分别为 [2, 2, 3, 8, 7, 7, 7, 8],这里我们将位置 8 看作「哨兵」。
在得到了左右两侧的柱子之后,我们就可以计算出每根柱子对应的左右边界,并求出答案了。
分析
单调栈的时间复杂度是多少?直接计算十分困难,但是我们可以发现:
每一个位置只会入栈一次(在枚举到它时),并且最多出栈一次。
因此当我们从左向右/从右向左遍历数组时,对栈的操作的次数就为 O(N)。所以单调栈的总时间复杂度为O(N)。
from typing import Listclass Solution: def largestRectangleArea(self, heights: List[int]) -> int: n = len(heights) if n <= 0: return 0 left, right = [0] * n, [0] * n mono_stack = list() for i in range(n): while mono_stack and heights[mono_stack[-1]] >= heights[i]: mono_stack.pop() left[i] = mono_stack[-1] if mono_stack else -1 mono_stack.append(i) mono_stack = list() for i in range(n-1, -1, -1): while mono_stack and heights[mono_stack[-1]] >= heights[i]: mono_stack.pop() right[i] = mono_stack[-1] if mono_stack else n mono_stack.append(i) ans = 0 for i in range(n): ans = max(ans, (right[i] - left[i] -1) * heights[i]) return ans
这道题感觉会了,但是我感觉自己还是没有真正的搞懂。这个单调栈的定义倒是不难,就是一个栈中元素递增或者递减的栈,也就是一个普通的栈,但是它之所以单调,是因为在被我们维护着的,这一类题目用到单调栈的数据结构,其实是解题过程中,我们在维护栈单调的过程中得到了重要信息。比如上面题目中找到当前遍历到的柱子左侧最近比它低的柱子的位置。这个位置是我们在维护过程中得到的结果,还是得理解题意才能正确使用单调栈。
再来看一道题目。
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
提示:
n == height.length
0 <= n <= 3 * 104
0 <= height[i] <= 105
在看了上面那道柱状图中的最大矩形题目之后,我们再来看这道题,肯定不说能做出来,最起码思路会好很多了。
如果从高度上去看的话,每根柱子所能存储的水量,需要根据其左右(好像没有最近的要求了)比它高的柱子中最高的柱子来决定啊。
我们可以维护一个单调递减栈,虽然说单调递减栈的特性是找到当前元素左侧最近的比他大的元素,但是我们也可以不适用这个特性,找到当前元素左侧最大的元素啊。也就是栈底元素。
# 在做了84题,再来看这道题就感觉难度降了一点了。如果从每根柱子的高度角度考虑,那么每根柱子的蓄水量需要根据# 其左右比它高的柱子中最高的柱子决定。那么如何分别找出该柱子左侧的最高柱子呢,递减栈是否可行,但是递减栈的# 性质貌似是可以找到左起第一个比当前数字大的元素,那这也不符合我们的想法啊。但是好像也是可以的。我们自己先试试。from typing import Listclass Solution: def trap(self, height: List[int]) -> int: stack = list() n = len(height) if n <= 0: return 0 left, right = [0] * n, [0] * n for i in range(n): # stack里面得存储height数值的索引 while stack and height[stack[-1]] <= height[i]: stack.pop() left[i] = stack[0] if stack else i stack.append(i) stack = list() for i in range(n-1, -1, -1): while stack and height[stack[-1]] <= height[i]: stack.pop() right[i] = stack[0] if stack else i stack.append(i) ans = 0 for i in range(n): water = min(height[left[i]], height[right[i]]) - height[i] ans += water return ans def trap2(self, height: List[int]) -> int: # 看了题解,感觉大可不必用上面的单调栈什么的,说到底其实思路还是一个,如果按列计算的话,我们要求出这一列左边最大和右边最大的列的高度。 # 但是为了遍历到一列时,我们不必每次都要重新遍历一遍它左边右边的最高值,我们可以一次遍历存储起来。其实也就是上面解法中的left和right数组 # 这其中可以使用动态规划的思想来求的这两个数组。 n = len(height) left = [0] * n right = [0] * n for i in range(1, n): left[i] = max(left[i-1], height[i-1]) for i in range(n-2, -1, -1): right[i] = max(right[i+1], height[i+1]) ans = 0 for i in range(1, n): water = min(left[i], right[i]) - height[i] ans += water if water > 0 else 0 return ans # 思路和解法一其实基本相同,因为他们最初的出发点都还是一样的,按行求或者按列求。solu = Solution()height = [4,2,0,3,2,5]ans = solu.trap2(height)print(ans)
总结
上面这两个题目都还是比较直观的柱状图的,没有联系到实际环境中,算简单的,我们对于这类题,可以考虑的是从行的角度或者列的角度去考虑它。然后为了优化能想到的暴力法,也就是每遇到一个柱子就去遍历一遍它的左侧右侧最高的柱子,我们可以进行动态编程,就是遍历一遍,先把我们需要的这些信息保存下来。用left和right数组保存起来,用空间换时间。
好了,接下来可以看看第217次周赛这道题是否能够做出来吧
5614. 找出最具竞争力的子序列
给你一个整数数组 nums
和一个正整数 k
,返回长度为 k
且最具 竞争力 的 nums
子序列。
数组的子序列是从数组中删除一些元素(可能不删除元素)得到的序列。
在子序列 a
和子序列 b
第一个不相同的位置上,如果 a
中的数字小于 b
中对应的数字,那么我们称子序列 a
比子序列 b
(相同长度下)更具 竞争力 。例如,[1,3,4]
比 [1,3,5]
更具竞争力,在第一个不相同的位置,也就是最后一个位置上, 4
小于 5
。
示例 1:
输入:nums = [3,5,2,6], k = 2
输出:[2,6]
解释:在所有可能的子序列集合 {[3,5], [3,2], [3,6], [5,2], [5,6], [2,6]} 中,[2,6] 最具竞争力。
示例 2:
输入:nums = [2,4,3,3,5,4,9,6], k = 4
输出:[2,3,3,4]
提示:
1 <= nums.length <= 105
0 <= nums[i] <= 109
1 <= k <= nums.length
from typing import Listclass Solution: def mostCompetitive(self, nums: List[int], k: int) -> List[int]: # 单调递增栈 n = len(nums) stack = list() stack_len = 0 for i in range(n): while stack and stack[-1] > nums[i] and stack_len + n - i > k: stack.pop() stack_len -= 1 stack.append(nums[i]) stack_len += 1 return stack[:k]solu = Solution()nums = [18,42,66,8,80,2]k = 3ans = solu.mostCompetitive(nums, k)print(ans)
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 。
提示:
nums1
和nums2
中所有元素是唯一的。nums1
和nums2
的数组大小都不超过1000。
from typing import Listclass Solution: # 方法一:题目定位为简单题,我们直接用暴力法就可以解决。 def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]: n1 = len(nums1) n2 = len(nums2) ans = [-1] * n1 for i in range(n1): idx = nums2.index(nums1[i]) for j in range(idx, n2): if nums2[j] > nums1[i]: ans[i] = nums2[j] break return ans # 方法二:使用单调栈的思想去解决它 def nextGreaterElement2(self, nums1: List[int], nums2: List[int]) -> List[int]: # 因为单调栈的作用主要就是找到该元素前一个或者后一个比它大或者比它小的值,所以我们尝试用单调栈来解决该问题。 # 首先,因为题目是要找到数组1中的每个元素在数组2中对应位置的右边比它大的第一个元素。 # 分析应该是从右向左遍历的一个递减栈 n1 = len(nums1) n2 = len(nums2) stack = list() ans = {} for i in range(n2-1, -1, -1): while stack and stack[-1] < nums2[i]: stack.pop() ans[nums2[i]] = stack[-1] if stack else -1 stack.append(nums2[i]) ret = [] for i in range(n1): ret.append(ans[nums1[i]]) return retsolu = Solution()nums1 = [4, 1, 2]nums2 = [1, 3, 4, 2]ans = solu.nextGreaterElement2(nums1, nums2)print(ans)
503. 下一个更大元素 II
给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素。数字 x 的下一个更大的元素是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1。
示例 1:
输入: [1,2,1]
输出: [2,-1,2]
解释: 第一个 1 的下一个更大的数是 2;
数字 2 找不到下一个更大的数;
第二个 1 的下一个最大的数需要循环搜索,结果也是 2。
分析
暴力法是可以通过的,但是这里还是得仔细思考单调栈的使用:
我们首先根据题意先判断好以下两个东西:
-
是递增栈还是递减栈,这里我们要找比自己大的元素,所以是递减栈;
是从左往右遍历还是从右往左遍历,这里是寻找右侧比自己大的元素,所以我们是从右往左遍历。
此外,这道题因为加了一个循环数组,所以我的做法就是加了一个完整的nums数组再后面,转换成一维的线性数组啦。
from typing import Listclass Solution: def nextGreaterElements(self, nums: List[int]) -> List[int]: # 首先肯定可以使用暴力法来解决问题,但是是否会超时就不知道了,也敲一下吧 n = len(nums) nums += nums ans = [-1] * n for i in range(n): cur = i+1 while cur < 2 * n: if nums[cur] > nums[i]: ans[i] = nums[cur] break else: cur += 1 return ans def nextGreaterElements2(self, nums: List[int]) -> List[int]: # 上面的暴力法通过了,但是时间复杂度较高,这里还是用单调栈的思路来解决。 n = len(nums) nums += nums # nums拼凑起来之后,后半部分的结果不能全部取到,前半部分的所有结果都可以取到 ans = [-1] * n stack = list() for i in range(2*n-1, -1, -1): while stack and stack[-1] <= nums[i]: stack.pop() # 注意这里,我们取前半部分的结果 if i < n: ans[i] = stack[-1] if stack else -1 stack.append(nums[i]) return ansif __name__ == '__main__': solu = Solution() nums = [1, 2, 1] ans = solu.nextGreaterElements2(nums) print(ans)
739. 每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。
如果气温在这之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
from typing import Listclass Solution: def dailyTemperatures(self, T: List[int]) -> List[int]: # 现在这道题能做出来的话,也是因为我今天做了三四道单调栈的题目,然后在已知这道题用单调栈的方法的前提下可以做出来,那么以后呢 # 所以,我们还是得多总结啊,什么题目我们可以想到单调栈呢:题目中明显的字眼就是某元素左边或者右边第一个比它大或者比它小的元素的位置或者值 n = len(T) ans = [0] * n stack = list() # 找右侧第一个比该元素大的值,那么我们就要从右向左遍历,维护递减栈 # 要注意stack中存储的是值还是索引值,这里理解之后应该是索引值更加适合 for i in range(n-1, -1, -1): while stack and T[stack[-1]] <= T[i]: stack.pop() ans[i] = stack[-1] - i if stack else 0 stack.append(i) return ansif __name__ == '__main__': solu = Solution() T = [73, 74, 75, 71, 69, 72, 76, 73] ans = solu.dailyTemperatures(T) print(ans)