单调栈可以理解为用栈来存储一个单调的序列,通过特殊的入栈和弹栈时机,来保证栈内元素的单调性。
本文参考:
[数据结构]——单调栈
Carl的单调栈题解
单调栈、单调队列(超详细)
从名字上就听的出来,单调栈中存放的数据应该是有序的,所以单调栈也分为单调递增栈和单调递减栈,我根据自己理解进行了分类,因此下面的定义可能和别人的定义相反:
单调栈常用在找某个元素左边或者右边第一个最值的场景,可以 O ( N ) O(N) O(N)找到所有数组元素的左边或者右边的第一个最值。如果单纯地针对每个元素都对其左右进行遍历的话,复杂度会达到 O ( N 2 ) O(N^2) O(N2).
单调栈是通过一系列进栈和出栈规则,将栈中元素维护成了单调有序的。操作焦点集中在栈顶,其中出栈元素和进栈元素搭配使用,可以成为最终的结果。
心得体会:
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]); // 其余都进栈
}
单调递减栈作用:
单调递增栈:
如果栈空或进栈元素大于等于栈顶元素则直接入栈;如果进栈元素小于栈顶元素(找到小值拐点),则出栈,直至进栈元素大于等于栈顶元素。栈中可以存储下标或者元素值。
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)
找出每个数左边或者右边的一个最大值或者最小值。
找右边第一个大的值,单调递减栈应用,存储的是数组下标。
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;
}
}
一眼是双循环,但是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;
}
}
单调递减栈+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;
}
}
单调递减栈,存储的是数组下标。循环搜索下一个更大的数字,这里的循环考虑的是遍历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;
}
}
找接雨水的区域,其实碰到凹槽的时机,就是遇到待入栈元素大于栈顶元素,可以考虑用「单调递减栈」,由于单调递减栈的性质,栈顶的倒数第二个元素必然大于等于即将弹栈的栈顶元素,这样以栈顶倒数第二个元素、栈顶元素(待出栈)、待入栈元素,这三个元素形成了一个凹槽。
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;
}
}
类似于接雨水,不过这里是要找到每个元素左右两边第一个小于该元素的元素,因此采用「单调递增栈」,由于单调递增栈的性质,栈顶的倒数第二个元素必然大于等于即将弹栈的栈顶元素,这样以栈顶倒数第二个元素、栈顶元素(待出栈)、待入栈元素形成了一个凸起,参考图解很清晰。
每次弹栈都是以该弹栈元素为高度,待入栈元素索引与弹栈以后新的栈顶元素下标索引(原先的栈顶倒数第二个元素)的差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;
}
}
不同于用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;
}
}
这也能用到单调栈我是没想到的,参考宫水三叶的题解,从后向前遍历,维护一个单调递减栈,当前元素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;
}
}
单调递增栈+乘法贡献原理。乘法原理是为了找到,对于每个元素来说,其在所有连续子数组中,贡献了多少次。递增栈是为了找到每个元素的较小值左右边界下标,从而可以帮助计算贡献。因此两次单调递增栈,l记录元素左边最小元素,全部初始化为-1,r记录元素右边最小元素,全部初始化为数组长度,通过下面的公式计算其会出现在多少个连续的子数组中。
其中容易出错的点是,arr 可能有重复元素,我们需要考虑取左右端点时,是取成「小于等于」还是「严格小于」:这里叫我们求min(b)
,那么其实在找到和当前元素相等的元素时,将该下标也当作当前元素里的贡献,因此弹栈条件写作arr[i]
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
单调递减栈。这题提供了一个新的视角,如何在从左往右遍历的过程中,找到左边第一个比当前元素大的数字;原数组是没有直接给出来的,而是一个元素一个元素动态加入的。之前根据出栈元素的下标,进行答案填充,现在是在当前下标进行答案填充,这里需要一个工作指针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)