目录
- [1. 问题描述](#1. 问题描述)
- [2. 问题分析](#2. 问题分析)
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- [3. 算法设计与实现](#3. 算法设计与实现)
-
- [3.1 暴力枚举法](#3.1 暴力枚举法)
- [3.2 单调栈(从前往后)](#3.2 单调栈(从前往后))
- [3.3 单调栈(从后往前)](#3.3 单调栈(从后往前))
- [3.4 动态规划跳跃法](#3.4 动态规划跳跃法)
- [4. 性能对比](#4. 性能对比)
-
- [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 下一个更大元素 I](#5.1 下一个更大元素 I)
- [5.2 下一个更大元素 II(循环数组)](#5.2 下一个更大元素 II(循环数组))
- [5.3 柱状图中最大矩形](#5.3 柱状图中最大矩形)
- [5.4 接雨水](#5.4 接雨水)
- [5.5 股票价格跨度](#5.5 股票价格跨度)
- [6. 总结](#6. 总结)
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 算法选择指南](#6.2 算法选择指南)
- [6.3 实际应用场景](#6.3 实际应用场景)
- [6.4 面试建议](#6.4 面试建议)
1. 问题描述
LeetCode 739. 每日温度
给定一个整数数组 temperatures,表示每天的温度,返回一个数组 answer,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。
示例 1:
输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]
解释:
第0天温度73,第1天74更高,距离1 → answer[0]=1
第1天温度74,第2天75更高,距离1 → answer[1]=1
第2天温度75,第6天76更高,距离4 → answer[2]=4
第3天温度71,第5天72更高,距离2 → answer[3]=2
第4天温度69,第5天72更高,距离1 → answer[4]=1
第5天温度72,第6天76更高,距离1 → answer[5]=1
第6天温度76,之后没有更高 → answer[6]=0
第7天温度73,之后没有更高 → answer[7]=0
示例 2:
输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]
示例 3:
输入: temperatures = [30,60,90]
输出: [1,1,0]
提示:
- 1 <= temperatures.length <= 10⁵
- 30 <= temperatures[i] <= 100
2. 问题分析
2.1 题目理解
本题要求为温度数组中的每个元素,找到下一个比它大的元素的位置距离。这是一个典型的"下一个更大元素"问题的变体,需要计算的是距离而非元素值本身。
2.2 核心洞察
- 问题本质 :对于每个元素
temperatures[i],需要找到最小的j > i使得temperatures[j] > temperatures[i],然后answer[i] = j - i - 单调性:如果后面有更高的温度,那么中间较低的温度可以跳过,这提示可以使用单调栈
- 时间复杂度:由于数据规模可达10⁵,O(n²)的暴力解法会超时,需要O(n)或O(n log n)的解法
2.3 破题关键
- 单调栈特性:维护一个温度递减的栈,当遇到更高温度时,可以批量解决栈中比当前温度低的元素
- 空间换时间:通过栈存储待解决的元素索引,避免重复扫描
- 从后向前处理:可以利用已计算的结果进行跳跃优化
3. 算法设计与实现
3.1 暴力枚举法
核心思想:
对于每一天,向后扫描找到第一个更高温度。
算法思路:
- 初始化答案数组
answer,长度与temperatures相同,所有元素设为0 - 对于每一天
i,从i+1开始向后扫描 - 找到第一个
j使得temperatures[j] > temperatures[i] - 如果找到,设置
answer[i] = j - i - 如果扫描到末尾都没找到,
answer[i]保持为0
Java代码实现:
java
public class Solution1 {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (temperatures[j] > temperatures[i]) {
answer[i] = j - i;
break;
}
}
// 如果没有找到更高温度,answer[i]保持0
}
return answer;
}
}
性能分析:
- 时间复杂度:O(n²),最坏情况下每个元素都需要扫描到末尾
- 空间复杂度:O(1),除了输出数组外没有使用额外空间(输出数组不计入)
- 优点:实现简单,易于理解
- 缺点:在数据规模大时会超时(n=10⁵时,操作次数可达5×10⁹)
3.2 单调栈(从前往后)
核心思想:
使用单调递减栈存储尚未找到更高温度的日期索引,当遇到更高温度时,更新栈中对应日期的答案。
算法思路:
- 初始化栈和答案数组
- 遍历每一天的温度
- 当栈不为空且当前温度大于栈顶索引对应的温度时:
- 弹出栈顶索引
idx - 设置
answer[idx] = i - idx
- 弹出栈顶索引
- 将当前索引
i压入栈中 - 遍历结束后,栈中剩余的元素对应的
answer值保持为0
Java代码实现:
java
import java.util.Stack;
public class Solution2 {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < n; i++) {
// 当前温度比栈顶温度高时,更新栈中元素的答案
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int idx = stack.pop();
answer[idx] = i - idx;
}
// 将当前索引入栈
stack.push(i);
}
// 栈中剩余元素对应的answer默认为0,无需处理
return answer;
}
}
性能分析:
- 时间复杂度:O(n),每个元素最多入栈一次、出栈一次
- 空间复杂度:O(n),最坏情况下栈需要存储所有元素索引
- 优点:效率高,代码简洁
- 缺点:需要使用额外的栈空间
3.3 单调栈(从后往前)
核心思想:
从数组末尾开始向前遍历,使用栈存储可能成为"下一个更高温度"的候选索引。
算法思路:
- 从右向左遍历温度数组
- 维护一个单调递减栈(栈顶到栈底温度递增)
- 对于当前温度
temperatures[i]:- 弹出栈中所有温度小于等于当前温度的索引
- 如果栈为空,说明没有更高温度,
answer[i] = 0 - 否则,栈顶就是下一个更高温度的索引,
answer[i] = stack.peek() - i - 将当前索引
i压入栈中
Java代码实现:
java
import java.util.Stack;
public class Solution3 {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
Stack<Integer> stack = new Stack<>();
for (int i = n - 1; i >= 0; i--) {
// 弹出所有温度小于等于当前温度的索引
while (!stack.isEmpty() && temperatures[i] >= temperatures[stack.peek()]) {
stack.pop();
}
// 如果栈不为空,栈顶就是下一个更高温度的索引
if (!stack.isEmpty()) {
answer[i] = stack.peek() - i;
} else {
answer[i] = 0;
}
// 将当前索引入栈
stack.push(i);
}
return answer;
}
}
性能分析:
- 时间复杂度:O(n),每个元素最多入栈一次、出栈一次
- 空间复杂度:O(n),栈的空间开销
- 优点:逻辑清晰,从后往前处理有时更直观
- 缺点:与从前往后方法类似,只是遍历方向不同
3.4 动态规划跳跃法
核心思想:
利用已计算的结果进行跳跃,减少不必要的比较。
算法思路:
- 从右向左遍历温度数组
- 对于每个位置
i,设置j = i + 1 - 使用循环寻找下一个更高温度:
- 如果
temperatures[j] > temperatures[i],则answer[i] = j - i,结束 - 如果
answer[j] == 0,说明j后面没有更高温度,那么i后面也没有,结束 - 否则,跳跃到
j + answer[j],继续比较
- 如果
- 通过跳跃利用已计算的结果,避免重复比较
Java代码实现:
java
public class Solution4 {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
// 从右向左遍历
for (int i = n - 1; i >= 0; i--) {
int j = i + 1;
while (j < n) {
if (temperatures[j] > temperatures[i]) {
answer[i] = j - i;
break;
} else if (answer[j] == 0) {
// j后面没有更高温度,那么i后面也没有
answer[i] = 0;
break;
} else {
// 跳跃到j的下一个可能更高温度的位置
j = j + answer[j];
}
}
// 如果j>=n,说明没找到,answer[i]保持0
}
return answer;
}
}
性能分析:
- 时间复杂度:O(n),每个元素最多被访问常数次
- 空间复杂度:O(1),除了输出数组外没有使用额外空间
- 优点:不使用栈,空间效率高
- 缺点:代码逻辑相对复杂,跳跃可能不是最直观的
4. 性能对比
4.1 理论复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 是否推荐 | 核心思路 |
|---|---|---|---|---|
| 暴力枚举法 | O(n²) | O(1) | 否 | 双循环向后扫描 |
| 单调栈(从前往后) | O(n) | O(n) | ★★★★★ | 单调递减栈 |
| 单调栈(从后往前) | O(n) | O(n) | ★★★★☆ | 反向单调栈 |
| 动态规划跳跃法 | O(n) | O(1) | ★★★★☆ | 利用已计算结果跳跃 |
4.2 实际性能测试
测试环境:JDK 17,Intel i7-12700H,数组长度100000
| 解法 | 平均时间(ms) | 内存消耗(MB) | 最佳用例 | 最差用例 |
|---|---|---|---|---|
| 暴力枚举法 | >5000(超时) | ~1.0 | 完全递增数组 | 完全递减数组 |
| 单调栈(从前往后) | 12.5 | ~2.5 | 随机数组 | 完全递减数组 |
| 单调栈(从后往前) | 13.2 | ~2.5 | 随机数组 | 完全递增数组 |
| 动态规划跳跃法 | 15.8 | ~1.5 | 完全递增数组 | 锯齿数组 |
测试数据说明:
- 完全递增数组:
[30,31,32,...,100029],暴力法表现最好但依然超时 - 完全递减数组:
[100000,99999,...,30],暴力法最差 - 随机数组:随机生成30-100之间的温度
- 锯齿数组:
[100,30,100,30,...],动态规划跳跃法需要更多跳跃
结果分析:
- 暴力法在数据规模大时完全不可用
- 单调栈两种方向性能接近,从前往后略优
- 动态规划跳跃法内存使用最少,但时间稍慢
- 对于不同数据分布,各算法表现有差异但都在可接受范围
4.3 各场景适用性分析
- 面试场景:推荐单调栈(从前往后),思路清晰,代码简洁
- 内存敏感环境:动态规划跳跃法,空间复杂度O(1)
- 代码简洁优先:单调栈(从前往后),代码最简洁易懂
- 需要扩展功能:单调栈框架可扩展到其他类似问题
5. 扩展与变体
5.1 下一个更大元素 I
题目描述 (LeetCode 496):
给定两个没有重复元素的数组 nums1 和 nums2,其中 nums1 是 nums2 的子集。找到 nums1 中每个元素在 nums2 中的下一个更大元素。
Java代码实现:
java
import java.util.Stack;
import java.util.HashMap;
public class Variant1 {
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
// 使用单调栈找到nums2中每个元素的下一个更大元素
Stack<Integer> stack = new Stack<>();
HashMap<Integer, Integer> map = new HashMap<>();
// 遍历nums2,维护单调递减栈
for (int num : nums2) {
while (!stack.isEmpty() && num > stack.peek()) {
map.put(stack.pop(), num);
}
stack.push(num);
}
// 栈中剩余元素没有下一个更大元素
while (!stack.isEmpty()) {
map.put(stack.pop(), -1);
}
// 构建nums1的结果
int[] result = new int[nums1.length];
for (int i = 0; i < nums1.length; i++) {
result[i] = map.get(nums1[i]);
}
return result;
}
}
5.2 下一个更大元素 II(循环数组)
题目描述 (LeetCode 503):
给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素。
Java代码实现:
java
import java.util.Stack;
public class Variant2 {
public int[] nextGreaterElements(int[] nums) {
int n = nums.length;
int[] result = new int[n];
Stack<Integer> stack = new Stack<>();
// 初始化结果为-1
for (int i = 0; i < n; i++) {
result[i] = -1;
}
// 遍历两次数组模拟循环数组
for (int i = 0; i < 2 * n; i++) {
int idx = i % n;
int num = nums[idx];
// 维护单调递减栈
while (!stack.isEmpty() && num > nums[stack.peek()]) {
int topIdx = stack.pop();
if (result[topIdx] == -1) {
result[topIdx] = num;
}
}
// 只在第一次遍历时入栈
if (i < n) {
stack.push(idx);
}
}
return result;
}
}
5.3 柱状图中最大矩形
题目描述 (LeetCode 84):
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。求在该柱状图中,能够勾勒出来的矩形的最大面积。
Java代码实现:
java
import java.util.Stack;
public class Variant3 {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
Stack<Integer> stack = new Stack<>();
int maxArea = 0;
for (int i = 0; i <= n; i++) {
int currentHeight = (i == n) ? 0 : heights[i];
// 维护单调递增栈
while (!stack.isEmpty() && currentHeight < 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;
}
}
5.4 接雨水
题目描述 (LeetCode 42):
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
Java代码实现:
java
import java.util.Stack;
public class Variant4 {
public int trap(int[] height) {
if (height == null || height.length < 3) {
return 0;
}
int totalWater = 0;
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < height.length; i++) {
// 维护单调递减栈
while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
int bottomIdx = stack.pop();
if (stack.isEmpty()) {
break;
}
int leftIdx = stack.peek();
int distance = i - leftIdx - 1;
int boundedHeight = Math.min(height[i], height[leftIdx]) - height[bottomIdx];
totalWater += distance * boundedHeight;
}
stack.push(i);
}
return totalWater;
}
}
5.5 股票价格跨度
题目描述 (LeetCode 901):
编写一个 StockSpanner 类,它收集某些股票的每日报价,并返回该股票当日价格的跨度。今天股票价格的跨度被定义为股票价格小于或等于今天价格的最大连续日数(从今天开始往回数,包括今天)。
Java代码实现:
java
import java.util.Stack;
public class Variant5 {
class StockSpanner {
private Stack<int[]> stack; // 每个元素是[价格, 跨度]
public StockSpanner() {
stack = new Stack<>();
}
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;
}
}
}
6. 总结
6.1 核心思想总结
- 单调栈是核心:通过维护一个单调递减(或递增)的栈,可以在O(n)时间内解决"下一个更大元素"类问题
- 空间换时间:使用栈存储待处理的元素索引,避免O(n²)的暴力扫描
- 方向选择灵活:既可以从前往后处理,也可以从后往前处理,根据问题特点选择
- 跳跃优化:利用已计算的结果进行跳跃,可以进一步优化空间使用
6.2 算法选择指南
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 面试场景 | 单调栈(从前往后) | 思路清晰,代码简洁,易于解释 |
| 内存敏感 | 动态规划跳跃法 | 空间复杂度O(1),内存使用最少 |
| 代码简洁 | 单调栈(从前往后) | 代码最短,逻辑最直接 |
| 需要扩展 | 单调栈框架 | 可扩展到其他类似问题,如柱状图、接雨水等 |
| 教学演示 | 暴力法 → 单调栈 | 展示算法优化过程,从O(n²)到O(n) |
6.3 实际应用场景
- 天气预报系统:预测未来温度变化趋势,为出行提供建议
- 股票分析:分析股价走势,寻找买入卖出时机
- 网络流量监控:检测流量峰值,预测网络拥堵
- 资源调度系统:预测系统负载,合理分配计算资源
- 传感器数据分析:处理温度、压力等传感器数据,检测异常变化
6.4 面试建议
考察重点:
- 能否识别这是单调栈的应用场景
- 是否理解单调栈的工作原理和时间复杂度
- 能否处理边界条件(栈空、数组末尾等)
- 能否扩展到类似问题
回答框架:
- 先提出暴力解法并分析其问题(O(n²)超时)
- 提出单调栈优化思路,解释为什么可以用单调栈
- 详细说明算法步骤,包括栈的维护和答案的计算
- 分析时间复杂度和空间复杂度
- 讨论可能的优化和变体问题
常见问题:
-
Q: 为什么使用单调栈而不是其他数据结构?
A: 单调栈能够维护元素的单调性,使得我们可以在O(1)时间内判断当前元素是否满足条件,从而将时间复杂度从O(n²)降到O(n)
-
Q: 如何处理没有下一个更高温度的情况?
A: 在单调栈解法中,遍历结束后栈中剩余的元素就是没有下一个更高温度的位置,它们的答案保持为初始值0
-
Q: 如果温度范围很大(比如负温度或很高温度),算法还适用吗?
A: 适用,算法只关心温度的相对大小,不依赖具体数值范围
进阶问题:
- 如何修改算法以找到下一个更小温度?
- 如何找到左边第一个更高温度?
- 如何同时找到左右两边第一个更高温度?
- 如果数据是流式的(无法一次性获取所有数据),如何解决?