简述
单调栈是栈内元素满足单调性的栈。假设需要维护一个单调递增栈,新元素进栈时,为了维护栈的单调性,需要弹出大于等于新元素的栈顶元素。
- 对于数组中的元素,可以找到左侧/右侧第一个比它大/小的元素。
- 寻找某一子数组,使得子数组中的最小值乘以子数组的长度最大。
- 寻找某一子数组,使得子数组中的最小值乘以子数组所有元素和最大。
通过以下题目,来学习单调栈解题的思想。
例题目录
- 1. T42 接雨水 困难
- 2. T84 柱状图中最大的矩形 困难
- 3. T962 最大宽度坡
- 4. T1124 表现良好的最长时间段
- 5. T239 滑动窗口的最大值 | 面试题59 - I. 滑动窗口的最大值 困难
- 6. T456 132模式
- 7. T402 移掉K位数字
题目来源:leetcode
例题
1、接雨水
题目描述:
给定 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
算法
维护一个单调递减栈,当前元素大于栈顶元素时,弹出栈顶元素,计算当前元素与栈顶的前一个元素能接雨水的大小。
动画如下:
代码
class Solution:
def trap(self, height: List[int]) -> int:
stack = []
ans = 0
for cur in range(len(height)):
while stack and height[cur] > height[stack[-1]]:
top = stack.pop()
if not stack:
break
distance = cur - stack[-1] - 1
h = min(height[cur], height[stack[-1]]) - height[top]
ans += distance * h
stack.append(cur)
cur += 1
return ans
时间复杂度:O(n)
这道题还可以用双指针解答,代码如下:
class Solution {
public int trap(int[] height) {
int n = height.length, res = 0;
int left_m = 0, right_m = 0, left = 0, right = n - 1;
while (left < right) {
left_m = Math.max(left_m, height[left]);
right_m = Math.max(right_m, height[right]);
if (left_m < right_m) {
res += Math.min(left_m, right_m) - height[left];
left++;
} else {
res += Math.min(left_m, right_m) - height[right];
right--;
}
}
return res;
}
}
2、柱状图中最大的矩形
题目描述:
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
以上是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]。
算法
维护一个单调递增栈,当前元素小于等于栈顶元素时,弹出栈顶元素,并计算栈顶元素与栈顶的前一个元素之间矩形面积的大小。
高度由弹出的元素决定,宽度由两边的最矮高度决定。
动画:
代码
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
stack = [-1]
n = len(heights)
ans = 0
for i in range(n):
while stack[-1] != -1 and heights[i] <= heights[stack[-1]]:
index = stack.pop()
ans = max(ans, (i - stack[-1] - 1) * heights[index])
stack.append(i)
while stack[-1] != -1:
index = stack.pop()
ans = max(ans, (n - stack[-1] - 1) * heights[index])
return ans
每个元素最多入栈出栈两次,因此时间复杂度为O(n).
3、最大宽度坡
题目描述:
给定一个整数数组 A,坡是元组 (i, j),其中 i < j 且 A[i] <= A[j]。这样的坡的宽度为 j - i。
找出 A 中的坡的最大宽度,如果不存在,返回 0 。
算法
1 维护一个单调递减栈,其中第一个元素是A中第一个元素,最后一个元素是A的最小值。由于需要计算长度,所以栈中存储A的索引。
2 从后向前遍历A,当元素大于栈顶元素时,计算一次最大宽度坡,并弹出(因为再往前面遍历宽度肯定会减少),由于当栈顶索引等于当前遍历到的元素的索引时,肯定会被弹出,所以没有必要判断栈顶索引是否小于等于当前遍历到的索引。
代码
class Solution {
public int maxWidthRamp(int[] A) {
Stack<Integer> stack = new Stack<>();
int n = A.length;
// 得到A的单调递减栈
for (int i = 0; i < n; i++) {
if (stack.isEmpty() || A[stack.peek()] > A[i]) {
stack.add(i);
}
}
int res = 0, i = n - 1;
while (i > res) {
while (!stack.isEmpty() && A[stack.peek()] <= A[i]) {
res = Math.max(res, i - stack.pop());
}
i--;
}
return res;
}
}
4、表现良好的最长时间段
题目描述:
给你一份工作时间表 hours,上面记录着某一位员工每天的工作小时数。
我们认为当员工一天中的工作小时数大于 8 小时的时候,那么这一天就是「劳累的一天」。
所谓「表现良好的时间段」,意味在这段时间内,「劳累的天数」是严格 大于「不劳累的天数」。
请你返回「表现良好时间段」的最大长度。
示例 1:
输入:hours = [9,9,6,0,6,6,9]
输出:3
解释:最长的表现良好时间段是 [9,9,6]。
代码
class Solution {
public int longestWPI(int[] hours) {
int n = hours.length;
// 大于8编码为1,小于8编码为-1
int[] hourCode = new int[n];
for (int i = 0; i < n; i++) {
hourCode[i] = hours[i] > 8 ? 1 : -1;
}
// 求编码的前缀和
int[] preSum = new int[n+1];
int total = 0;
for (int i = 0; i < n; i++) {
preSum[i] = total;
total += hourCode[i];
}
preSum[n] = total;
// 单调递减栈
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < n + 1; i++) {
if (stack.isEmpty() || preSum[stack.peek()] > preSum[i]) {
stack.add(i);
}
}
// 求最长坡度,倒序遍历
int res = 0;
for (int i = n; i >= 0; i--) {
while (!stack.isEmpty() && preSum[stack.peek()] < preSum[i]) {
res = Math.max(res, i - stack.pop());
}
}
return res;
}
}
5、滑动窗口的最大值
题目描述:
给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。
示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
算法
- 暴力解法。对于每一个元素nums[i],寻找nums[i:i+k]范围内的最大值。时间复杂度为O(kn)
- 优先队列(大顶堆)。
首先往大顶堆中放入k个元素,此时的堆顶元素是所求的第一个滑动窗口的值。
后面继续往大顶堆放入元素,同时取此时的最大值。取最大值的时候检查堆顶是否是在此时的滑动窗口之内,如果不是,则丢弃继续寻找。因此堆中存放的数据应该包含索引信息。
时间复杂度O(nlogn),空间复杂度O(n) - 单调栈
维护一个单调递减双向队列。遍历nums,如果当前num大于队列尾的元素,则弹出所有大于num的数;同时如果当前元素等于队列首的元素,则poll队列首的元素。
代码
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0 || k == 0) return new int[0];
Deque<Integer> deque = new LinkedList<>();
int[] res = new int[n - k + 1];
for (int i = 0; i < k; i++) {
while (!deque.isEmpty() && nums[i] > deque.peekLast()) {
deque.pollLast();
}
deque.addLast(nums[i]);
}
res[0] = deque.peekFirst();
for (int i = k; i < n; i++) {
if (!deque.isEmpty() && nums[i - k] == deque.peekFirst()) {
deque.pollFirst();
}
while (!deque.isEmpty() && nums[i] > deque.peekLast()) {
deque.pollLast();
}
deque.addLast(nums[i]);
res[i - k + 1] = deque.peekFirst();
}
return res;
}
每个元素最多入队出队一次,因此时间复杂度O(n),双向队列的长度最大为k,空间复杂度为O(k).
6、132模式
题目描述:
给你一个整数数组 nums ,数组中共有 n 个整数。132 模式的子序列 由三个整数 nums[i]、nums[j] 和 nums[k] 组成,并同时满足:i < j < k 和 nums[i] < nums[k] < nums[j] 。
如果 nums 中存在 132 模式的子序列 ,返回 true ;否则,返回 false 。
进阶:很容易想到时间复杂度为 O(n^2) 的解决方案,你可以设计一个时间复杂度为 O(n logn) 或 O(n) 的解决方案吗?
算法
很容易想到两层循环来解决,但是似乎可以用单调栈来进一步减少时间复杂度。
做法:
- 维护一个 n u m s [ : i ] nums[:i] nums[:i] 的最小数组 m i n L i s t minList minList, m i n L i s t [ i ] minList[i] minList[i] 表示 n u m s [ : i ] nums[:i] nums[:i] 中的最小值;
- 从后往前遍历 nums 的同时,维护一个单调递减栈 stack;
- 如果当前元素大于栈顶元素,则弹出栈顶元素 topNum,同时比较 topNum 和 minList[i] 的大小,如果 topNum > minList[i],说明找到了“132模式”。
代码
public class Solution456 {
// 132模式
public boolean find132pattern(int[] nums) {
int n = nums.length;
int[] minList = new int[n];
minList[0] = nums[0];
for (int i = 1; i < n; i++) {
minList[i] = Math.min(minList[i - 1], nums[i]);
}
Stack<Integer> stack = new Stack<>();
for (int i = n - 1; i >= 0; i--) {
int topNum = Integer.MIN_VALUE;
while (!stack.isEmpty() && nums[i] > stack.peek()) {
topNum = stack.pop();
}
if (topNum > minList[i]) {
return true;
}
stack.add(nums[i]);
}
return false;
}
}
建立 minList 时间复杂度为 O(n),寻找132模式过程中每个元素最多进栈出栈一次,时间复杂度 O(n),所以最终时间复杂度为 O(n).
7、移掉k位数字
题目描述:
给定一个以字符串表示的非负整数 num,移除这个数中的 k 位数字,使得剩下的数字最小。
注意:
num 的长度小于 10002 且 ≥ k。
num 不会包含任何前导零。
示例 1 :
输入: num = “1432219”, k = 3
输出: “1219”
解释: 移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219。
算法
维护一个单调递增双向队列,同时记录从队列中弹出的元素个数,当弹出元素个数为k时,则得到了最终的结果。
由于最终的输出是字符串,所以还需要从队列头依次取出元素,所以不用栈,而是用双向队列。
同时还需注意删除前导0.
代码
class Solution {
public String removeKdigits(String num, int k) {
Deque<Character> deque = new LinkedList<>();
for (int i = 0; i < num.length(); i++) {
while (k > 0 && !deque.isEmpty() && num.charAt(i) < deque.peekLast()) {
deque.pollLast();
k--;
}
deque.addLast(num.charAt(i));
}
while (!deque.isEmpty() && k > 0) {
deque.pollLast();
k--;
}
while (!deque.isEmpty() && deque.peekFirst() == '0') {
deque.pollFirst();
}
if (deque.isEmpty()) {
return "0";
}
StringBuilder builder = new StringBuilder();
while (!deque.isEmpty()) {
builder.append(deque.pollFirst());
}
return builder.toString();
}
}
每个元素最多进队出队一次,所以时间复杂度为O(n).