参考链接:《单调栈解题模板秒杀三道算法题》--labuladong
最近买了《剑指Offer专项突破版》,做到栈这一章,栈最经典的运用就是单调栈。书里列出几道单调栈的题目:
- 小行星碰撞
- 每日温度
- 直方图最大矩形面积
- 矩阵中的最大矩形
做完前面两道刚好看到 labuladong 写的单调栈模板,感觉相见恨晚。只有你做了几道相近题之后,再回过头看人家总结的模板才比较有感觉。如果一上来就让你看模板很明显拿不符合认知的先后流程,难以理解透彻,纯属个人体会。
单调栈模板可以用一句话总结:Next Greater Number。
既然是下一个更大的元素,那从数组尾部逆序遍历,这样再结合入栈出栈,会带来很大的方便,代码统一,更接近单调栈模板方法,以不变应万变。
一、单调栈模板方法:每日温度
下面从每日温度这道题切入,是一个最简单的单调栈模板题目(弄懂之前不敢这么说,但理解后确实是很符合这个单调栈模板方法,很清晰简洁):
class Solution {
//《单调栈模板方法》:逆序遍历数组,然后判断栈是否为空来作为当前元素是否有下一个更大元素的依据。
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] res = new int[n];
Stack<Integer> stack = new Stack<>();
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && temperatures[stack.peek()] < temperatures[i]) {
stack.pop();
}
//只有这一步会变,其他步骤是相对固定的。但也是以栈是否为空来作为判断是否满足题目要求的条件
res[i] = stack.isEmpty() ? 0 : stack.peek() - i;
stack.push(i);
}
return res;
}
}
以上就是单调栈的模板方法,模板流程拆解:
- 从后到前逆序遍历目标数组
- 利用单调栈的单调递减性来决定栈顶元素是否需要出栈。
- 重点:以栈是否为空来作为是否满足题目条件的依据进行处理。不为空说明符合条件,为空说明栈里之前没有一个元素满足条件。比如,由于是逆序,说明当前元素之后没有任何元素大于它。
- 入栈。
- 何时出栈:如果新元素大于栈顶元素,那么此时如果新元素入栈,就不符合单调递减性。因此此时需要将栈顶元素循环弹出,直到符合单调递减性为止。
- 何时入栈:满足单调递减性才能入栈。
这里满足单调递减性有两种情况,栈被清空,或栈顶元素大于新元素。此时新元素入栈,满足单调递减性。
二、单调栈模板方法: 496. 下一个更大元素 I
496. 下一个更大元素 I 跟每日温度是非常相似的题目,接下来进入举一反二环节。本题比每日温度这道中等题难一些,但标签反而是easy。
区别只在于借助了额外的数据结构 HashMap,它可以存错遍历过程中所有元素的下一个更大值。最后在遍历子数组从 HashMap 里以 O(1) 时间复杂度获取目标值。
class Solution {
//《单调栈目标方法》
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
int n = nums2.length;
Stack<Integer> stack = new Stack<>();
Map<Integer, Integer> map = new HashMap<>();
for (int i = n - 1; i >= 0; i--) {
while(!stack.isEmpty() && nums2[i] > stack.peek()) {
stack.pop();
}
int nextGreater = stack.isEmpty() ? -1 : stack.peek();//因为是从数组尾部遍历入栈的,因此栈顶肯定是位于当前元素之后
map.put(nums2[i], nextGreater);
stack.push(nums2[i]);
}
int[] res = new int[nums1.length];
for (int i = 0; i < nums1.length; i++) {
res[i] = map.get(nums1[i]);//借助 Map 很方便
}
return res;
}
}
本题有更简单的版本,在 labuladong 的文章里给出里以下简单版本:
输入一个数组
nums = [2,1,2,4,3]
,你返回数组[4,2,4,-1,-1]
。
也就是说,求所有元素的 Next Greater Element,不涉及子数组。因此就不需要再借助 HashMap 来快速获取子数组对应的值,因为逆序一遍下来所有元素都遍历完,且得到了每个元素下一个更大的元素值。
class Solution {
//《单调栈模板方法》
public int[] nextGreaterElement(int[] nums) {
int[] res = new int[nums.length];
Stack<Integer> stack = new Stack<>();
for(int i = nums.length; i >= 0; i--) {
//不满足单调递减,弹出直到满足了下面才入栈
while (!stack.isEmpty() && nums[i] > stack.peek()) stack.pop();
//所有元素都会得到结果
res[i] = stack.isEmpty() ? -1 : stack.peek();
//所有元素最终都会入栈
stack.push(nums[i]);
}
return res;
}
}
下面是labuladong对时间复杂度的分析,引用如下:
“这个算法的时间复杂度不是那么直观,如果你看到 for 循环嵌套 while 循环,可能认为这个算法的复杂度也是
O(n^2)
,但是实际上这个算法的复杂度只有O(n)
。分析它的时间复杂度,要从整体来看:总共有
n
个元素,每个元素都被push
入栈了一次,而最多会被pop
一次,没有任何冗余操作。所以总的计算规模是和元素规模n
成正比的,也就是O(n)
的复杂度。”
Hard 42. 接雨水
接雨水这道题是 hard 题目,刚开始做不知道套路就别死磕了,反正我做不出来。一开始不知道单调栈的套路,直接看了官方题解的双指针往里夹逼结合贪心算法来获取雨水面积,题解就不附上了。这里要讲的是单调栈解法:
//单调递减栈法:按行计算
public int trap(int[] height) {
Deque<Integer> stack = new LinkedList<>();
int sum = 0;
for (int i = 0; i < height.length; i++) {//单调递减栈,从左往右遍历,按行计算雨水
while (!stack.isEmpty() && height[i] > height[stack.peek()]) { //出现凹槽
int low = stack.pop();
if (stack.isEmpty()) break;
//左边比 top 大,右边也比 top 大,形成了下凹,可以积水。
int L = stack.peek();
int shorter = Math.min(height[L], height[i]);//左边的边界可能比右边大,水量取决于短板
int distance = i - L - 1;//手动推算即可
//由于是单调递减栈,栈顶是最小的,越往栈底越大(下标对应的高度):cur = stack.pop() 出栈顶之后,新栈顶 left > cur
sum += distance * (shorter - height[low]);//凹槽两边的短的那个板减去凹槽
}
stack.push(i);//存入的是下标,你可以根据下标在 O(1) 时间从数组获取到对应的元素值
}
return sum;
}
套路是不是似曾相识。这里按行计算很简洁,不过一般是想不出来这种解法的,直接看官方题解的动画即可。
但是共同点就是利用单调栈的性质,这里是单调递减性:
遍历过程中,后面元素比栈顶小才能入栈(符合单调递减)。当遇到比栈顶大的元素,栈顶出栈,这点是非常固定的,都是通过一个while loop 来循环出栈,直到满足单调递减性为止才将新元素入栈。