定义:
单调栈,顾名思义就是栈内元素单调按照递增(递减)顺序排列的栈。
适用问题:
要知道单调栈的适用于解决什么样的问题,我们首先需要知道单调栈的作用。单调栈分为单调递增栈和单调递减栈,通过使用单调栈我们可以访问到下一个比他大(小)的元素(或者说可以)。也就是说在队列或数组中,我们需要通过比较前后元素的大小关系来解决问题时我们通常使用单调栈。下面我们通过简单介绍单调减栈和单调增栈问题来进一步说明使用单调栈处理问题的过程。
单调栈主要解决以下问题:
寻找下一个更大元素
寻找前一个更大元素
寻找下一个更小元素
寻找前一个更小元素
qualified 的 窗口的 max/min
sliding window max/min
首先说一下单调递减栈
单调递减栈: ①在一个队列中针对每一个元素从它右边寻找第一个比它大的元素
②在一个队列中针对每一个元素从它左边寻找第一个比它大的元素(从后往前遍历)
其实 1 和 2 只是从两个不同的角度来分析问题,但是它解决问题的过程是一样的。并且在寻找的过程中我们都需要对栈内的元素进行一定的处理。我们通过介绍一个简单的例子来说明这个问题。
假如有一个地方三月三十一号要传授武林秘籍,大家要在这一天之前来这里排好队等3月31号能够学到东西。来了很多武林高手,但是这个地方的人要根据人来的先后顺序教,先来的学的武功就高深,来的越靠后学的就越差,但是能保证只要来就能学到。假如他们一开始有一个初始的排队顺序和武力水平如下所示,大家可以按照这个初始顺序从前往后学习。
但是问题来了,武力值高的肯定愿意先学习到高深一点的武功啊,那我就找到前面武力值低的人说:“你别学了,我叫你一门武功比那里学的好”,武力值低的那一个一听就答应了,从它哪里学了一门武术高高兴兴的走了,这样武功高的那个就排在了前面。下面我们来从前往后模拟一下过程:
(1)首先来的是炮灰甲,炮灰甲一看,OK,栈内没有人就可以先排在第一位
(2)然后扫地僧来了,扫地僧教给了炮灰甲自己的功夫(炮灰甲的师傅为扫地僧),然后扫地僧让炮灰甲离开自己排在第一位
(3)然后杨过过来,看到前面是扫地僧,自己打不过只能老老实实站到队里
(4)后面一直来人(如3)------ 直到张三 对里面站的是 扫地僧 杨过 慕容复 张三
(5)然后张无忌来了,他首先看到前面是张三,张无忌教给张三自己的功夫并让他离开(张三师傅为张无忌),
然后张无忌再往前看到了慕容复(过程如上所示)------------直到遇到扫地僧,OK,自己打不过,
老老实实的占到了后面 —现在队里有扫地僧,张无忌 ----杨过,慕容复,张三的师傅为张无忌
(6)柯镇恶来了,打不过,站到后面就好了
(7)然后乔峰来了把柯镇恶和张无忌打发走, —现在队里有扫地僧,乔峰 ----张无忌,柯镇恶的师傅为乔峰
(8)然后李四来了,打不过乔峰,只能站到了最后。
(9)第二天扫地僧,乔峰,李四成功学到了武林秘籍
现在看他们分别学到武功对应如下图:
这样我们就处理完了所有过程。因此单调栈的时间复杂度为O(n),在比较时对出栈的元素有一个处理(例如扫地僧让炮灰甲走的时候教给他自己的武功),另外最后留在站内的元素有一个统一的处理(扫地僧,乔峰,李四成功学到了武林秘籍)。
伪代码如下所示:
对于第i个到来的人:
每当队里面有人并且打不过自己的时候:
让这个人离开并交给他自己的武功
自己入队
下面看一道用单调递减栈解决的问题:
1019. 链表中的下一个更大节点
给出一个以头节点 head 作为第一个节点的链表。链表中的节点分别编号为:node_1, node_2, node_3, … 。
每个节点都可能有下一个更大值(next larger value):对于 node_i,如果其 next_larger(node_i) 是 node_j.val,那么就有 j > i 且 node_j.val > node_i.val,而 j 是可能的选项中最小的那个。如果不存在这样的 j,那么下一个更大值为 0 。
返回整数答案数组 answer,其中 answer[i] = next_larger(node_{i+1}) 。
注意:在下面的示例中,诸如 [2,1,5] 这样的输入(不是输出)是链表的序列化表示,其头节点的值为 2,第二个节点值为 1,第三个节点值为 5 。
示例:
输入:[2,1,5]
输出:[5,5,0]
输入:[2,7,4,3,5]
输出:[7,0,5,5,0]
输入:[1,7,5,1,9,2,5,1]
输出:[7,9,9,9,0,5,0,0]
代码:
过程如下:先用一个数组将链表内元素存储起来,然后使用单调递减栈。从前往后遍历元素,遍历到每个元素的位置的时候,判断栈是否为空,若不为空,判断栈顶元素位置的值是否小于当前元素,若小于栈顶元素位置则将他的位置直接入栈,若大于栈顶元素位置的值,则栈顶元素出栈并且将栈顶元素的位置的值设置为当前元素的值。(有点绕口,栈里面存的不是元素的大小,存的是元素的位置)
代码:
class Solution:
def nextLargerNodes(self, head: ListNode) -> List[int]:
stack = []
nums = []
i=0
temp = head
while temp != None:
nums.append(temp.val)
temp = temp.next
i += 1
res = [0]*i
temp = head
for j in range(len(nums)):
while len(stack)>0 and nums[j]>nums[stack[-1]]:
i = stack.pop()
res[i] = nums[j]
stack.append(j)
return res
下面来说一下单调递增栈
单调递增栈: ①在一个队列中针对每一个元素从它右边寻找第一个比它小的元素
②在一个队列中针对每一个元素从它左边寻找第一个比它小的元素(从后往前遍历)
单调递增栈和单调递减栈相类似,只是存入的方式不同,因此不做过多的介绍,通过一个例题来介绍:
896. 单调数列
如果数组是单调递增或单调递减的,那么它是单调的。
如果对于所有 i <= j,A[i] <= A[j],那么数组 A 是单调递增的。 如果对于所有 i <= j,A[i]> = A[j],那么数组 A 是单调递减的。
当给定的数组 A 是单调数组时返回 true,否则返回 false。
示例 1:
输入:[1,2,2,3]
输出:true
示例 2:
输入:[6,5,4,4]
输出:true
示例 3:
输入:[1,3,2]
输出:false
示例 4:
输入:[1,2,4,5]
输出:true
示例 5:
输入:[1,1,1]
输出:true
过程如下:在遍历过程中使用单调递增数列和单调递减数列,依次存入最小栈和最大栈,如果数组是单调的,那么单调递减栈或单调递增栈中必定有一个是没有弹出的过程,也就是说必定有一个栈是满的。最后判断栈的长度,如果两个有一个长度和数组A的长度相同的,则满足条件。
代码如下
class Solution:
def isMonotonic(self, A: List[int]) -> bool:
st_up = []
st_down = []
for i in range(len(A)):
while len(st_down)>0 and A[i]>A[st_down[-1]]:
st_down.pop()
while len(st_up)>0 and A[i]<A[st_up[-1]]:
st_up.pop()
st_down.append(i)
st_up.append(i)
if len(st_up)==len(A) or len(st_down)==len(A):
return True
return False
将单调栈的基础作用介绍完,我们介绍一下单调栈在实际问题中的应用
84. 柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
– - - - - -
以上是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]。
----------
图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10 个单位。
输入: [2,1,5,6,2,3]
输出: 10
看到这个题我们首先想到的是暴力法,我们首先遍历每个柱状图,针对每个柱子,我们遍历链表,分别找到左边第一个比它小的柱子位置x和右边第一个比它小的柱子y,这样在第i个柱子的最大面积为 heights[i] * (y-x-1),这样我们再遍历所有柱子的最大面积,找出最大的那个即可得到最大矩形面积。 时间复杂度O(n^2),空间复杂度O(n)。
如图所示,我们遍历到第五个柱子的时候, x=1, y=6 则 S[4] = 8
----------
但是当数据量很大的时候,暴力法显然是不可取的。我们考虑使用单调递增栈来解决问题。
过程如下:
首先我们结合暴力法来分析一下为什么使用单调栈做本题,我们计算每个柱子出的最大面积的时候是分别找到左边第一个比它小的柱子位置x和右边第一个比它小的柱子y来计算的。当我们弹出一个元素的时候,因为是单调递增栈因此栈顶元素值一定是它左边第一个小于它的元素,而**使它弹出的那个元素一定是它右边第一个小于它的值**,这样我们只需要使用弹出的高度 * 宽度就可计算出当前位置的面积,如弹出 2 的时候如下图所示:
做题之前我们来对柱状图做一个简单的处理,即在最前面和最后面分别加一个高度为零的柱形图如下图所示,因为我们求的是面积,因此添加这两个高度为零的柱形对我们的结果并不会产生影响。
-----------
我们来分析一下加这两个值的原因,首先分析最后那个0,因为在遍历的过程中只有满足一定的条件(即栈不为空且栈顶元素大于当前位置元素的时候)才会出栈并计算出栈元素索引位置的最大面积。但是遍历完所有的元素栈内一定会有残留,这样的话就不能求出所有位置的最大面积(只有弹出的时候才计算弹出位置的面积),因此在最后一位加0是为了保证所有的非零元素都能弹出栈。那我们为什么在首位加0呢,其实没有什么特殊的原因,就是为了保证栈不为空,这样计算方便一点。
代码如下:
class Solution:
def largestRectangleArea(self, heights) -> int:
heights.append(0)
S = [0] * len(heights)
stack = [[0, -1]]
for i in range(len(heights)):
while stack and stack[-1][0] > heights[i]:
last_height, last_index = stack.pop()
S[last_index] = last_height * (i - stack[-1][1]-1)
stack.append([heights[i], i])
return max(S)
单调递减栈的实际应用:
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
------------------
上面是由数组 [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
和上面用单调递增栈解决问题思路相类似。如果用暴力法解决一定是针对每个柱子,找后一个比它大的,然后计算他们两个中间能接多少雨水,最后总和就可以了。但这需要O(n^2)的时间复杂度。如果我们用单调递减栈存储。那么当一个索引位置弹出的时候,栈顶元素(如果存在)一定是左比它大的,使他弹出的遍历到的那个元素一定是右边第一个比它大的。这样我们就可以计算弹出的那个元素的前一个位置和遍历位置中间接多少雨水了。如当第三个位置弹出时如下图:
--------------
代码如下:
class Solution:
def trap(self, height):
lenh = len(height)
if lenh<3:
return 0
st = []
res = 0
for i in range(len(height)):
while st and height[i]>height[st[-1]]:
j = st.pop()
if st:
res = res + (min(height[st[-1]], height[i]) - height[j]) * (i-st[-1]-1)
st.append(i)
return res
class Solution {
public int trap(int[] height) {
if(height==null || height.length == 0) return 0;
Stack<Integer> st = new Stack<>();
int l=0, r=0, res=0;
for(int i=0; i<height.length; i++){
while(!st.isEmpty() && height[st.peek()] < height[i]){
int temp = st.pop();
if(!st.isEmpty()){
res += (Math.min(height[i], height[st.peek()])
- height[temp]) * (i - st.peek() - 1);
}
}
st.push(i);
}
return res;
}
}