1. 单调栈算法专题解析
单调栈是一种特殊的栈结构,它在解决"寻找下一个更大/更小元素"这类问题时表现出色。今天我将通过三个经典题目(每日温度、下一个更大元素Ⅰ、下一个更大元素Ⅱ)来详细讲解单调栈的应用技巧和实现细节。
提示:单调栈的核心在于维护栈内元素的单调性,通过一次遍历就能高效解决问题,时间复杂度通常为O(n)。
2. 每日温度问题(LeetCode 739)
2.1 问题分析与解法思路
给定一个温度数组temperatures,要求返回一个数组answer,其中answer[i]表示在第i天后才会出现更高温度所需等待的天数。如果气温在这之后都不会升高,则在该位置用0来代替。
核心思路:
- 使用单调递增栈(栈底到栈顶温度值递增)
- 栈中存储的是温度数组的索引而非温度值本身
- 通过比较当前温度与栈顶温度来决定压栈或弹栈操作
2.2 详细实现与代码解析
java复制import java.util.Deque;
import java.util.LinkedList;
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
// 使用双端队列实现栈,存储温度数组的索引
Deque<Integer> stack = new LinkedList<>();
int n = temperatures.length;
int[] result = new int[n];
// 初始时栈为空,直接将第一个元素索引入栈
stack.push(0);
for (int i = 1; i < n; i++) {
// 当前温度小于等于栈顶温度,直接压栈
if (temperatures[i] <= temperatures[stack.peek()]) {
stack.push(i);
} else {
// 当前温度大于栈顶温度,循环处理所有可以确定结果的元素
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int prevIndex = stack.pop();
result[prevIndex] = i - prevIndex;
}
stack.push(i);
}
}
return result;
}
}
关键点解析:
-
为什么存储索引而不是温度值?
- 因为需要计算天数差(索引差)
- 通过索引可以同时访问温度值和位置信息
-
时间复杂度分析:
- 每个元素最多入栈一次、出栈一次
- 总体时间复杂度为O(n)
2.3 实际案例演示
以temperatures = [73,74,75,71,69,72,76,73]为例:
- 初始状态:stack = [0], result = [0,0,0,0,0,0,0,0]
- i=1: 74>73 → result[0]=1, stack = [1]
- i=2: 75>74 → result[1]=1, stack = [2]
- i=3: 71<75 → stack = [2,3]
- i=4: 69<71 → stack = [2,3,4]
- i=5: 72>69 → result[4]=1, stack = [2,3]
72>71 → result[3]=2, stack = [2]
72<75 → stack = [2,5] - i=6: 76>72 → result[5]=1, stack = [2]
76>75 → result[2]=4, stack = [6] - i=7: 73<76 → stack = [6,7]
最终结果:[1,1,4,2,1,1,0,0]
3. 下一个更大元素Ⅰ(LeetCode 496)
3.1 问题理解与解法设计
给定两个没有重复元素的数组nums1和nums2,其中nums1是nums2的子集。对于nums1中的每个元素,找到它在nums2中对应位置的下一个更大元素。
解题步骤:
- 使用哈希表记录nums1中元素的索引位置
- 对nums2使用单调栈找到每个元素的下一个更大元素
- 当遇到nums1中的元素时,记录结果
3.2 完整代码实现
java复制import java.util.Arrays;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
class Solution {
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
// 初始化结果数组,默认值为-1
int[] result = new int[nums1.length];
Arrays.fill(result, -1);
// 建立nums1元素到索引的映射
HashMap<Integer, Integer> numToIndex = new HashMap<>();
for (int i = 0; i < nums1.length; i++) {
numToIndex.put(nums1[i], i);
}
// 单调栈处理nums2
Deque<Integer> stack = new LinkedList<>();
stack.push(0);
for (int i = 1; i < nums2.length; i++) {
if (nums2[i] <= nums2[stack.peek()]) {
stack.push(i);
} else {
while (!stack.isEmpty() && nums2[i] > nums2[stack.peek()]) {
// 如果栈顶元素在nums1中存在,则更新结果
if (numToIndex.containsKey(nums2[stack.peek()])) {
int index = numToIndex.get(nums2[stack.peek()]);
result[index] = nums2[i];
}
stack.pop();
}
stack.push(i);
}
}
return result;
}
}
3.3 关键技巧与注意事项
-
哈希表预处理:
- 将nums1的元素映射到其索引位置
- 使得在nums2处理过程中可以快速判断和定位
-
单调栈处理:
- 与每日温度问题类似,但需要额外判断元素是否在nums1中
- 弹栈时不仅要维护栈的单调性,还要更新结果
-
边界情况处理:
- nums1或nums2为空数组
- nums2中不存在nums1元素的更大值
4. 下一个更大元素Ⅱ(LeetCode 503)
4.1 循环数组的特殊处理
给定一个循环数组,要求找出每个元素的下一个更大元素。循环数组意味着可以循环搜索以找到下一个更大元素。
解决方案:
- 通过取模运算模拟数组的循环遍历
- 遍历范围扩展为原数组长度的两倍
- 使用单调栈的方式与之前类似
4.2 C++实现代码
cpp复制#include <vector>
#include <stack>
using namespace std;
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> result(n, -1);
if (n == 0) return result;
stack<int> st;
st.push(0);
// 遍历两倍长度以模拟循环
for (int i = 1; i < 2 * n; i++) {
int current = nums[i % n];
while (!st.empty() && current > nums[st.top()]) {
result[st.top()] = current;
st.pop();
}
// 只在第一轮遍历时压入元素
if (i < n) {
st.push(i);
}
}
return result;
}
};
4.3 算法优化与思考
-
循环数组的处理技巧:
- 不需要实际复制数组
- 通过i % n的方式模拟数组循环
-
压栈策略优化:
- 只在第一轮遍历时压入元素
- 避免重复处理相同元素
-
时间复杂度分析:
- 虽然遍历2n次,但每个元素最多处理两次
- 时间复杂度仍为O(n)
5. 单调栈的通用解题模板
通过以上三个问题,我们可以总结出单调栈的通用解题模式:
-
初始化:
- 创建结果数组并设置默认值
- 初始化空栈
-
遍历元素:
- 对于每个元素,与栈顶元素比较
- 维护栈的单调性(递增或递减)
-
更新结果:
- 在弹栈操作时更新相关结果
- 确保每个元素都能找到对应的解
-
边界处理:
- 考虑空输入的情况
- 处理循环数组等特殊结构
通用Java模板:
java复制Deque<Integer> stack = new LinkedList<>();
int[] result = new int[n];
Arrays.fill(result, defaultValue); // 根据题目设置默认值
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) {
int prevIndex = stack.pop();
result[prevIndex] = ...; // 根据题目要求更新结果
}
stack.push(i);
}
6. 常见问题与调试技巧
在实际编码过程中,可能会遇到以下问题:
-
栈空指针异常:
- 原因:在peek()或pop()前未检查栈是否为空
- 解决:确保每次操作前检查!stack.isEmpty()
-
结果计算错误:
- 原因:索引计算错误或更新条件不正确
- 解决:使用小例子手动模拟验证
-
性能问题:
- 原因:不必要的重复计算
- 解决:确保每个元素只入栈、出栈一次
调试建议:
- 使用小规模测试用例(3-5个元素)
- 打印栈状态和中间结果
- 对比手动计算结果与程序输出
7. 单调栈的变种与应用
单调栈不仅可以解决"下一个更大元素"问题,还可以应用于:
-
柱状图中最大矩形(LeetCode 84):
- 维护单调递增栈
- 计算以每个柱为高的最大矩形面积
-
接雨水问题(LeetCode 42):
- 使用单调栈计算凹槽容量
- 通过高度差和宽度计算积水量
-
滑动窗口最大值(LeetCode 239):
- 使用双端队列维护可能的最大值
- 本质上也是一种单调队列的应用
掌握单调栈的核心思想后,可以灵活应用到各种变种问题上。关键在于理解问题本质,识别出需要维护单调性的场景。