1. 单调栈的基本概念与应用场景
单调栈(Monotonic Stack)是一种特殊的栈结构,它要求栈中的元素始终保持单调递增或单调递减的顺序。这种数据结构在解决某些特定类型的问题时表现出极高的效率,尤其是那些需要找到元素左侧或右侧第一个比它大/小的元素的问题。
单调栈的核心特性在于它能够主动丢弃无用的数据。当新元素准备入栈时,我们会根据单调性要求弹出栈顶元素,直到满足单调条件为止。这个过程中弹出的元素往往不会再被后续计算所需要,因此可以安全地移除。
单调栈最经典的应用场景包括:下一个更大元素问题、柱状图中的最大矩形、每日温度等。这些问题如果使用暴力解法通常需要O(n²)的时间复杂度,而单调栈可以将其优化到O(n)。
2. 单调栈的代码实现与核心逻辑
2.1 基础单调栈实现模板
以下是一个标准的单调递增栈的实现代码(以Java为例):
java复制public int[] monotonicStack(int[] nums) {
int n = nums.length;
int[] result = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 维护单调性:弹出所有比当前元素大的栈顶元素
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
int poppedIndex = stack.pop();
result[poppedIndex] = nums[i]; // 当前元素是弹出元素的下一个更大元素
}
stack.push(i); // 将当前索引入栈
}
// 处理栈中剩余元素(没有下一个更大元素的)
while (!stack.isEmpty()) {
result[stack.pop()] = -1; // 用-1表示不存在
}
return result;
}
这段代码实现了"下一个更大元素"问题的解决方案。关键点在于:
- 使用双端队列模拟栈结构(Java中ArrayDeque比Stack性能更好)
- 存储的是元素索引而非值本身,方便进行结果记录
- 在弹出元素时确定其解,此时当前元素就是被弹出元素的下一个更大元素
2.2 单调栈的三种变体
根据问题需求,单调栈有三种常见变体形式:
-
严格递增栈:栈中元素严格递增,不允许相等
java复制while (!stack.isEmpty() && nums[stack.peek()] <= nums[i]) { stack.pop(); } -
非严格递减栈:栈中元素非严格递减,允许相邻元素相等
java复制while (!stack.isEmpty() && nums[stack.peek()] > nums[i]) { stack.pop(); } -
前向遍历与后向遍历:根据问题需求决定是从左向右还是从右向左遍历数组
java复制// 后向遍历示例 for (int i = n - 1; i >= 0; i--) { while (!stack.isEmpty() && nums[stack.peek()] <= nums[i]) { stack.pop(); } // ... }
3. 单调栈的典型问题解析
3.1 下一个更大元素问题
这是单调栈最直接的应用。给定一个数组,为每个元素找到下一个比它大的元素的位置。如果没有,则返回-1。
优化思路:
- 使用单调递减栈(栈底到栈顶递减)
- 当遇到比栈顶大的元素时,该元素就是栈顶元素的下一个更大元素
- 时间复杂度从暴力解法的O(n²)降到O(n)
java复制public int[] nextGreaterElement(int[] nums) {
int n = nums.length;
int[] res = new int[n];
Arrays.fill(res, -1);
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
res[stack.pop()] = nums[i];
}
stack.push(i);
}
return res;
}
3.2 柱状图中的最大矩形
这个问题需要找到柱状图中能勾勒出的最大矩形的面积。单调栈在这里的应用非常巧妙:
- 维护一个单调递增栈
- 当遇到比栈顶小的元素时,说明找到了栈顶元素的右边界
- 栈中前一个元素就是左边界
- 计算当前矩形面积:高度×(右边界-左边界-1)
java复制public int largestRectangleArea(int[] heights) {
int n = heights.length;
int maxArea = 0;
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i <= n; i++) {
int h = (i == n) ? 0 : heights[i];
while (!stack.isEmpty() && h < heights[stack.peek()]) {
int height = heights[stack.pop()];
int width = stack.isEmpty() ? i : i - stack.peek() - 1;
maxArea = Math.max(maxArea, height * width);
}
stack.push(i);
}
return maxArea;
}
3.3 每日温度问题
给定每日温度列表,计算需要等待多少天才能遇到更暖和的温度。这是单调栈的另一个典型应用:
java复制public int[] dailyTemperatures(int[] temps) {
int n = temps.length;
int[] res = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && temps[i] > temps[stack.peek()]) {
int idx = stack.pop();
res[idx] = i - idx; // 计算天数差
}
stack.push(i);
}
return res;
}
4. 单调栈的高级应用与优化技巧
4.1 处理循环数组
当问题扩展到循环数组时(如LeetCode 503题),我们需要一些小技巧:
- 将数组长度翻倍(虚拟连接两个相同数组)
- 使用取模运算来模拟循环访问
- 实际只需遍历一次原始数组,但比较时考虑循环特性
java复制public int[] nextGreaterElements(int[] nums) {
int n = nums.length;
int[] res = new int[n];
Arrays.fill(res, -1);
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < 2 * n; i++) {
int num = nums[i % n];
while (!stack.isEmpty() && nums[stack.peek()] < num) {
res[stack.pop()] = num;
}
if (i < n) stack.push(i);
}
return res;
}
4.2 二维矩阵中的单调栈问题
单调栈可以扩展到二维问题,如最大矩形问题(LeetCode 85题)。解决方法是将二维问题转化为一系列的一维柱状图问题:
- 从上到下逐行构建高度数组
- 对每一行应用柱状图最大矩形算法
- 最终取所有行结果的最大值
java复制public int maximalRectangle(char[][] matrix) {
if (matrix.length == 0) return 0;
int n = matrix[0].length;
int[] heights = new int[n];
int maxArea = 0;
for (char[] row : matrix) {
for (int i = 0; i < n; i++) {
heights[i] = row[i] == '1' ? heights[i] + 1 : 0;
}
maxArea = Math.max(maxArea, largestRectangleArea(heights));
}
return maxArea;
}
// 复用之前的柱状图算法
private int largestRectangleArea(int[] heights) {
// ... 同上 ...
}
4.3 单调栈的空间优化
在某些情况下,我们可以直接用结果数组作为栈,进一步优化空间:
java复制public int[] finalPrices(int[] prices) {
int n = prices.length;
int[] stack = new int[n]; // 用数组模拟栈
int top = -1;
int[] res = Arrays.copyOf(prices, n);
for (int i = 0; i < n; i++) {
while (top >= 0 && prices[stack[top]] >= prices[i]) {
res[stack[top]] -= prices[i];
top--;
}
stack[++top] = i;
}
return res;
}
这种优化在内存受限的环境中特别有用,但会牺牲一定的代码可读性。
5. 单调栈的常见陷阱与调试技巧
5.1 边界条件处理
单调栈实现中最容易出错的几个边界条件:
- 空栈情况下的peek/pop操作
- 数组遍历完成后栈中剩余元素的处理
- 相等元素的处理(严格递增还是非严格递增)
建议在编写代码时先考虑这些边界情况,特别是当输入数组包含重复元素或全相同元素时。
5.2 索引与值的混淆
初学者常犯的错误是混淆存储索引和存储值:
-
存储索引的优点:
- 可以同时访问值和位置信息
- 方便计算宽度或距离
- 适用于需要记录位置的问题
-
存储值的优点:
- 代码更简洁
- 适用于只需要比较值的问题
5.3 调试技巧
当单调栈代码出现问题时,可以:
- 打印每次操作后的栈状态
- 可视化数组和栈的变化过程
- 用小规模测试用例手动模拟执行过程
- 检查循环终止条件和单调性维护逻辑
java复制// 调试示例:打印栈状态
for (int i = 0; i < n; i++) {
System.out.println("Processing index " + i + ", value=" + nums[i]);
System.out.println("Current stack: " + stack);
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
int popped = stack.pop();
System.out.println(" Popped " + popped + ", setting result[" + popped
+ "] = " + nums[i]);
}
stack.push(i);
System.out.println("After push: " + stack);
System.out.println("-----");
}
6. 单调栈的性能分析与替代方案
6.1 时间复杂度分析
单调栈的核心优势在于其时间复杂度:
- 每个元素最多入栈一次、出栈一次:因此总体操作次数为2n次
- 平摊时间复杂度:O(n),比暴力解法的O(n²)有显著提升
- 空间复杂度:最坏情况下O(n),当输入序列本身是单调的时候
6.2 替代方案比较
虽然单调栈很高效,但某些问题也有其他解法:
-
暴力解法:
- 优点:实现简单直观
- 缺点:时间复杂度高,不适合大规模数据
-
分治法:
- 优点:思路清晰
- 缺点:实现复杂,递归有额外开销
-
线段树/RMQ:
- 优点:可以解决更通用的区间查询问题
- 缺点:实现复杂,常数因子较大
6.3 实际应用中的选择建议
- 当问题明显符合"下一个更大/小元素"模式时,优先考虑单调栈
- 当需要频繁区间查询时,考虑线段树
- 当数据规模很小时,简单的暴力解法可能更合适
7. 单调栈的扩展与变种
7.1 单调队列
单调队列是单调栈的双端版本,可以在两端操作:
java复制// 滑动窗口最大值问题
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0) return new int[0];
int[] res = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 移除超出窗口范围的元素
while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 维护单调递减性
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
// 记录窗口最大值
if (i >= k - 1) {
res[i - k + 1] = nums[deque.peekFirst()];
}
}
return res;
}
7.2 二维单调栈
对于二维矩阵问题,可以结合动态规划和单调栈:
java复制// 最大全1子矩阵问题
public int maximalRectangle(char[][] matrix) {
if (matrix.length == 0) return 0;
int m = matrix.length, n = matrix[0].length;
int[] heights = new int[n];
int maxArea = 0;
for (char[] row : matrix) {
for (int i = 0; i < n; i++) {
heights[i] = row[i] == '1' ? heights[i] + 1 : 0;
}
maxArea = Math.max(maxArea, largestRectangleArea(heights));
}
return maxArea;
}
7.3 带权重的单调栈
某些问题需要在单调栈中存储额外信息:
java复制// 股票跨度问题
class StockSpanner {
Deque<int[]> stack; // 存储[price, span]
public StockSpanner() {
stack = new ArrayDeque<>();
}
public int next(int price) {
int span = 1;
while (!stack.isEmpty() && stack.peek()[0] <= price) {
span += stack.pop()[1];
}
stack.push(new int[]{price, span});
return span;
}
}
8. 单调栈的实战经验分享
在实际编码中,使用单调栈时我有以下几点深刻体会:
-
模板化思维:虽然不同问题有不同细节,但核心模板是一致的。掌握基础模板后,大部分问题都是在这个模板上进行微调。
-
索引存储技巧:在解决涉及距离、宽度的问题时,存储索引比存储值更有优势。这让我能够轻松计算元素间的相对位置。
-
边界处理:总是特别关注循环结束后栈中剩余元素的处理。很多bug都源于忽略了这部分处理。
-
可视化调试:当遇到复杂问题时,我会在纸上画出数组和栈的变化过程。这种可视化方法能快速定位逻辑错误。
-
空间优化:在比赛或面试中,如果空间受限,我会考虑用数组模拟栈来减少对象创建开销。
-
语言特性利用:在Java中,我发现ArrayDeque比Stack性能更好;在Python中,直接使用list作为栈更简洁高效。
-
问题转化能力:最难的不是写单调栈代码,而是识别一个问题是否能够转化为单调栈问题。这需要大量的练习和经验积累。
-
测试用例设计:特别注意设计包含重复元素、全递增、全递减、单元素等边界情况的测试用例,这些往往能暴露代码中的潜在问题。
