作为一名在算法领域摸爬滚打多年的开发者,我经常遇到需要高效解决"下一个更大元素"这类问题的场景。今天要详细拆解的LeetCode第739题《每日温度》,正是单调栈最经典的入门案例。这个题目看似简单,但其中蕴含的算法思想却非常精妙,值得每个Java开发者深入掌握。
题目要求我们根据每日温度列表,计算每一天需要等待多少天才能遇到更高的温度。这在实际开发中有着广泛的应用场景:
这类问题的共同特点是:需要为序列中的每个元素,快速找到其后第一个满足某种条件的元素。在算法领域,这被称为NGE(Next Greater Element)问题。
最直观的解法是双重循环:对每一天i,向后遍历直到找到第一个温度更高的天数j,然后计算j-i。这种方法虽然简单直接,但时间复杂度高达O(n²),当n=10⁵时(LeetCode的测试用例规模),必然会导致超时。
java复制// 暴力解法示例(仅作对比,实际不可用)
public int[] dailyTemperaturesBruteForce(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (temperatures[j] > temperatures[i]) {
ans[i] = j - i;
break;
}
}
}
return ans;
}
这个解法的主要问题在于:对于每个元素都进行了独立的向后扫描,没有利用已经处理过的信息,造成了大量重复计算。
单调栈是一种特殊的栈结构,它始终保持栈内元素的单调性(递增或递减)。对于本题,我们需要的是单调递减栈:
这种结构的精妙之处在于:它能够将O(n²)的暴力解法优化到O(n)的时间复杂度,因为每个元素最多入栈和出栈各一次。
下面是使用Deque实现的单调栈解法:
java复制import java.util.Deque;
import java.util.LinkedList;
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
// 当前温度比栈顶温度高时,进行结算
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int prevIndex = stack.pop();
ans[prevIndex] = i - prevIndex;
}
stack.push(i); // 当前索引入栈
}
return ans;
}
}
以输入temperatures = [73,74,75,71,69,72,76,73]为例,我们逐步分析栈的变化:
| 步骤 | 当前温度 | 栈状态(下标→温度) | 操作说明 | ans数组状态 |
|---|---|---|---|---|
| 0 | 73 | [] → [0(73)] | 初始入栈 | [0,0,0,0,0,0,0,0] |
| 1 | 74 | [0(73)] → [1(74)] | 74>73,结算ans[0]=1 | [1,0,0,0,0,0,0,0] |
| 2 | 75 | [1(74)] → [2(75)] | 75>74,结算ans[1]=1 | [1,1,0,0,0,0,0,0] |
| 3 | 71 | [2(75),3(71)] | 71<75,直接入栈 | [1,1,0,0,0,0,0,0] |
| 4 | 69 | [2(75),3(71),4(69)] | 69<71,直接入栈 | [1,1,0,0,0,0,0,0] |
| 5 | 72 | [2(75),5(72)] | 72>69→ans[4]=1; 72>71→ans[3]=2 | [1,1,0,2,1,0,0,0] |
| 6 | 76 | [6(76)] | 76>72→ans[5]=1; 76>75→ans[2]=4 | [1,1,4,2,1,1,0,0] |
| 7 | 73 | [6(76),7(73)] | 73<76,直接入栈 | [1,1,4,2,1,1,0,0] |
对于性能敏感的场景,我们可以用数组代替Deque来模拟栈,减少对象创建开销:
java复制public int[] dailyTemperaturesOptimized(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
int[] stack = new int[n]; // 模拟栈的数组
int top = -1; // 栈顶指针
for (int i = 0; i < n; i++) {
while (top >= 0 && temperatures[i] > temperatures[stack[top]]) {
int prevIndex = stack[top--];
ans[prevIndex] = i - prevIndex;
}
stack[++top] = i;
}
return ans;
}
这种实现方式在LeetCode上运行时可以减少约20%的时间消耗,适合对性能要求极高的场景。
除了单调栈,我们还可以利用温度范围有限(30-100)的特点,采用反向遍历的解法:
java复制public int[] dailyTemperaturesReverse(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
int[] next = new int[101]; // 记录每个温度最近出现的下标
Arrays.fill(next, Integer.MAX_VALUE);
for (int i = n - 1; i >= 0; i--) {
int current = temperatures[i];
int minIndex = Integer.MAX_VALUE;
// 检查所有比current高的温度中最近的下标
for (int t = current + 1; t <= 100; t++) {
if (next[t] < minIndex) {
minIndex = next[t];
}
}
if (minIndex != Integer.MAX_VALUE) {
ans[i] = minIndex - i;
}
next[current] = i; // 更新当前温度的最新下标
}
return ans;
}
这种解法的时间复杂度是O(n*71)(因为温度范围是71个可能值),虽然也是线性时间,但通用性不如单调栈,仅适用于温度范围有限的特殊场景。
在实现单调栈时,最容易出现的错误是:
调试时可以打印栈的状态和ans数组的变化:
java复制System.out.println("i=" + i + ", temp=" + temperatures[i] +
", stack=" + stack + ", ans=" + Arrays.toString(ans));
必须测试的边界情况包括:
面试中可能会被问到:
Q:为什么栈中要存储下标而不是温度值?
A:因为我们需要计算天数差(j-i),只存储温度值无法知道原始位置信息。
Q:如果温度范围很大(比如0到10^9),哪种方法更好?
A:单调栈方法不受温度范围限制,始终是最佳选择。
Q:如何修改代码来返回下一个更高温度的值而非天数?
A:只需将ans[prevIndex] = i - prevIndex改为ans[prevIndex] = temperatures[i]。
在量化交易中,我们可以用类似的算法检测突破信号:
java复制public int[] nextBreakthrough(double[] prices, double threshold) {
int n = prices.length;
int[] signals = new int[n];
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() &&
prices[i] > prices[stack.peek()] * (1 + threshold)) {
int idx = stack.pop();
signals[idx] = i - idx;
}
stack.push(i);
}
return signals;
}
监控服务器CPU使用率,预测何时会超过当前负载:
java复制public int[] nextOverloadAlert(int[] cpuUsage, int threshold) {
int n = cpuUsage.length;
int[] alerts = new int[n];
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && cpuUsage[i] > cpuUsage[stack.peek()] + threshold) {
int idx = stack.pop();
alerts[idx] = i - idx;
}
stack.push(i);
}
return alerts;
}
预测商品销量何时会超过当前水平的20%:
java复制public int[] nextSalesPeak(int[] sales) {
int n = sales.length;
int[] peaks = new int[n];
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && sales[i] > sales[stack.peek()] * 1.2) {
int idx = stack.pop();
peaks[idx] = i - idx;
}
stack.push(i);
}
return peaks;
}
掌握了每日温度问题后,可以尝试以下LeetCode变种题目:
特别推荐练习503题(循环数组版本),需要在原有基础上增加一些处理:
java复制public int[] nextGreaterElements(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
Arrays.fill(ans, -1);
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n * 2; i++) {
int idx = i % n;
while (!stack.isEmpty() && nums[idx] > nums[stack.peek()]) {
ans[stack.pop()] = nums[idx];
}
if (i < n) stack.push(idx);
}
return ans;
}
为了快速解决类似问题,可以记忆以下单调栈模板:
java复制// 单调递减栈模板(找下一个更大元素)
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) {
int prev = stack.pop();
// 处理prev和i的关系
}
stack.push(i);
}
// 单调递增栈模板(找下一个更小元素)
Deque<Integer> stack = new LinkedList<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[i] < nums[stack.peek()]) {
int prev = stack.pop();
// 处理prev和i的关系
}
stack.push(i);
}
记忆要点:
不同解法的性能特点:
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 暴力解法 | O(n²) | O(1) | 小数据量 | 实现简单 | 无法处理大数据 |
| 单调栈 | O(n) | O(n) | 通用场景 | 高效可靠 | 需要额外空间 |
| 反向遍历 | O(n*K) | O(K) | 值域有限 | 空间优化 | K大时效率低 |
选择建议:
要彻底掌握单调栈及相关问题,建议按照以下路径学习:
基础阶段:
进阶阶段:
综合应用:
练习时要注意:
在实际项目中使用单调栈算法时,需要注意:
输入验证:
资源管理:
日志记录:
测试覆盖:
文档注释:
java复制/**
* 使用单调栈解决每日温度问题
* @param temperatures 温度数组,范围[30,100],长度[1,10^5]
* @return 等待天数数组,0表示没有更高温度
* @throws IllegalArgumentException 如果输入为null或包含非法值
*/
public int[] dailyTemperatures(int[] temperatures) {
// 参数校验
if (temperatures == null) {
throw new IllegalArgumentException("输入不能为null");
}
// 主算法逻辑
// ...
}
为了更好地理解单调栈的工作原理,可以采用可视化方法:
横向图表法:
纵向堆栈图:
双指针标记法:
这些可视化技巧不仅有助于自己理解算法,在面试中向面试官展示时也能留下深刻印象。
单调栈与一些相似算法的关系:
与滑动窗口的区别:
与动态规划的关系:
与双指针的异同:
理解这些区别有助于在遇到新问题时快速选择合适的算法。
在实现单调栈时,开发者常犯的错误包括:
错误1:栈中存储温度值而非下标
错误2:比较方向弄反
错误3:未处理栈中剩余元素
错误4:天数计算错误
错误5:边界条件处理不当
针对Java语言的特定优化技巧:
java复制Deque<Integer> stack = new ArrayDeque<>(temperatures.length);
基本类型优化:
并行流尝试:
JVM调优:
对于已经掌握基础解法的同学,可以思考以下进阶问题:
如果不仅要找下一个更高温度,还要记录温度升高的幅度怎么办?
如何实时处理温度数据流?
如果温度是浮点数怎么办?
如何找出所有更高温度而不仅仅是第一个?
这些思考可以帮助深化对算法本质的理解,培养解决变种问题的能力。