算法实战笔记:空间换时间的黑魔法------单调栈全景解析(十一)

在算法刷题的过程中,有一类问题常常让人觉得十分"憋屈":寻找数组中任一元素右边(或左边)第一个比自己大(或小)的元素。
如果用最直观的暴力解法,我们需要两层嵌套循环:对于每一个元素,都往后遍历寻找符合条件的目标,时间复杂度直接飙升至 O(n2)O(n^2)O(n2)。在数据量稍大的情况下,必然会超时。
这个时候,我们就需要请出能化腐朽为神奇的黑魔法结构------单调栈(Monotonic Stack)。它能够利用"空间换时间"的极致思想,将整个寻找过程的时间复杂度硬生生压到 O(n)。
一、 单调栈的底层哲学
单调栈的本质,其实是用一个栈来记录我们遍历过的元素。
为什么需要记录?因为在遍历数组时,对于那些"目前还没找到更大(或更小)元素"的数字,我们不能就把它们忘了,我们需要一个"候车室"把它们暂存起来。单调栈就是这样一个特殊的候车室,它巧妙地利用了入栈和出栈的时机,保持了栈内元素的单调性。
在使用单调栈之前,你必须在脑海里清晰地回答两个问题:
1. 栈里到底存什么?
单调栈里存放的永远是元素的"下标(索引)",而不是具体的数值!
这是初学者最容易踩的坑。因为我们在计算诸如"接雨水的宽度"或"等待的天数"时,必须使用下标相减。如果你想获取数值,只要直接用 nums[i] 就能瞬间拿到,所以存下标是一举两得的选择。
2. 栈内是递增还是递减?
这里的递增递减,我们统一约定指从栈头(栈顶)到栈底的顺序:
- 求右边第一个更大的元素 :单调栈是递增的(栈顶最小,栈底最大)。因为只有当新来的元素比栈顶大时,它才正是栈顶元素苦苦寻找的那个"更大值",此时触发弹栈记录结果;否则就乖乖入栈等待。
- 求右边第一个更小的元素 :单调栈则是递减的(栈顶最大,栈底最小)。
二、 破冰模板:每日温度 (LeetCode 739)
这是单调栈的教科书级入门题。题目要求:根据每日气温列表,计算出还需要等待几天才能遇到一个更高的气温。
这就是典型的"求右边第一个更大元素"。我们维护一个栈顶到栈底递增的单调栈:
java
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int lens = temperatures.length;
int[] res = new int[lens];
// 单调栈:存放下标,栈顶到栈底元素对应的值递增
Deque<Integer> stack = new LinkedList<>();
for(int i = 0; i < lens; i++) {
// 如果栈不为空,且当前遍历的元素 大于 栈顶元素对应的值
// 说明找到了栈顶元素右侧的第一个更大值!
while(!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
// 记录答案:等待的天数 = 当前下标 - 栈顶下标
res[stack.peek()] = i - stack.peek();
// 栈顶元素使命完成,弹出
stack.pop();
}
// 当前元素下标入栈,等待属于它的那个"更大值"
stack.push(i);
}
return res;
}
}
三、 进阶双煞:接雨水与最大矩形
当你吃透了每日温度的模板,单调栈的真正威力才刚刚展现。在面试中,真正让你拉开差距的是接雨水和最大矩形这两道 Hard 题。
1. 接雨水 (LeetCode 42)
核心思路:找"凹槽"
要接住雨水,必须要有凹槽。凹槽是怎么形成的?就是当前元素比左边高,同时也比右边高!
因此,我们要找的是右边第一个比自己高的元素 ,单调栈从栈顶到栈底自然是递增的。
当 height[i] > height[stack.peek()] 时,说明坑的右边界找到了!此时弹出的栈顶元素就是"坑底",而弹出坑底后,新的栈顶元素就是坑的"左边界"。
高度 h = Math.min(左右边界高度) - 坑底高度
宽度 w = 右边界下标 i - 左边界下标 - 1
java
class Solution {
public int trap(int[] height) {
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
int res = 0;
for (int i = 1; i < height.length; i++) {
// 发现右侧比栈顶高,可能形成凹槽
while (!stack.isEmpty() && height[stack.peek()] < height[i]) {
int mid = stack.pop(); // 凹槽底部
if (!stack.isEmpty()) {
// 凹槽左侧边界就是新的栈顶
int h = Math.min(height[stack.peek()], height[i]) - height[mid];
int w = i - stack.peek() - 1;
res += h * w;
}
}
stack.push(i);
}
return res;
}
}
2. 柱状图中最大的矩形 (LeetCode 84)
核心思路:找"凸起"
要找到以某个柱子为高度的最大矩形,我们必须知道这根柱子能向左、向右延伸多远。延伸的条件是:左右两边的柱子不能比自己矮。
所以,我们要找的是左右两侧第一个比自己矮的柱子 。既然找更小元素,单调栈从栈顶到栈底就必须是递减的!
神仙细节:首尾加 0
如果原数组一直是单调递增的,栈里的元素将永远不会被弹出结算。为了强行触发结算,我们需要在原数组的头部和尾部分别加上一个高度为 0 的哨兵元素。
java
class Solution {
public int largestRectangleArea(int[] heights) {
// 首尾加哨兵 0,保证所有元素都能被完美弹栈结算
int[] newHeights = new int[heights.length + 2];
System.arraycopy(heights, 0, newHeights, 1, heights.length);
heights = newHeights;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
int res = 0;
for (int i = 1; i < heights.length; i++) {
// 发现右侧比栈顶矮,栈顶柱子找到了它的右边界
while (!stack.isEmpty() && heights[stack.peek()] > heights[i]) {
int mid = stack.pop(); // 以该柱子高度作为基准
int h = heights[mid];
int w = i - stack.peek() - 1; // 新栈顶就是它的左边界
res = Math.max(res, h * w);
}
stack.push(i);
}
return res;
}
}
总结
单调栈并不是什么复杂的算法,而是一种极其巧妙的数据维护策略。
- 遇到找更大 值,维护栈顶到栈底递增栈。
- 遇到找更小 值,维护栈顶到栈底递减栈。
- 永远铭记:栈内存下标,计算靠相减。
打通了这条逻辑线,无论是算温度、接雨水还是画矩形,你都能一眼看透它们寻找"临界点"的底层本质。