1. 从一道题看单调栈的价值
如果你是正在刷力扣准备面试的开发者,应该对hot100这套题单不陌生。它在力扣上的全称是“热题HOT 100”,基本覆盖了面试中最高频的算法考点,而“每日温度”这道题排在第75位,是栈与单调栈这一知识板块里绕不开的经典。很多人刷到这道题时,第一反应是“这不就是双重循环嘛”,确实,暴力解法三分钟就能写出来,但这道题能被选进hot100,核心目的不是让你写暴力解法,而是帮你建立一种优化思维:如何把 O(n^2) 的重复扫描,转换成 O(n) 的一次遍历。
我的建议是,这道题适合所有刷题阶段的人去认真做一遍。新手可以用它入门“单调栈”这个概念,面试冲刺的人可以用它复习“下一个更大元素”这类题型的基本套路。我自己带过不少实习生,每当他们卡在“接雨水”“柱状图中最大的矩形”这类题上时,我都会先让他们把“每日温度”吃透。原因很简单,这道题是单调栈应用里最干净、最没有干扰条件的一个模版,把它的原理搞清楚,后面那些复杂题目都是在它基础上加条件、加变形。
本题的核心任务可以这样概括:给你一个每日温度的数组,让你求出每一天需要等待几天才会出现更高的温度,如果之后都没有更高温度,则填 0。比如 [73, 74, 75, 71, 69, 72, 76, 73],答案是 [1, 1, 4, 2, 1, 1, 0, 0]。数据规模方面,力扣给的是温度数组长度在 [1, 100000] 之间,这直接决定了暴力解法在极端情况下必然超时,所以单调栈几乎是唯一合理的正解思路。接下来,我会从暴力解法说起,一步步拆解单调栈为什么能优化、怎么实现、有哪些坑,以及它如何扩展到其他同类题目。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法的问题到底出在哪
2.1 最直观的双层循环
不夸张地说,90%的人第一次看到这道题,写出来的都是下面这个暴力版本。外层循环固定“某一天”,内层循环从它的下一天开始往后找,直到碰到第一个比它大的温度,记录距离并跳出。
java复制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;
}
}
}
return answer;
}
这段代码逻辑上完全正确,力扣上较小的测试用例也能通过。但问题在于,当输入规模到 100000 时,它的时间复杂度是 O(n^2)。你可以想象一下极端场景:温度数组是严格递减的,比如 [100, 99, 98, ... , 1],那么对每一个 i,内层循环都要从头扫到尾,每一轮都要扫完整个剩余数组,总操作次数大约在 50 亿量级。现代CPU每秒大概能执行几亿次简单操作,这个量级在不加优化的情况下已经逼近甚至超过题目的时间限制,提交后大概率会收到超时。
2.2 暴力解法浪费了什么信息
很多讲这道题的文章只会简单说“暴力会超时”,但我更想让你理解它浪费了什么,这才是优化的真正出发点。暴力解法的关键在于:对每个位置 i,它往后扫描时,完全没有把“已经扫描过的信息”保存下来,导致同一个元素被反复比较了无数次。
举个具体的例子,看 [73, 74, 75, 71, 69] 这一段。当 i=0(温度73)往后找时,它需要比较 74、75、71、69,最终在74处停下,得到距离1。当 i=1(温度74)往后找时,它又从75开始比较。这里75被比较了2次,后面更大的数组里,同一个元素可能被比较更多次。如果有一种机制,在从前向后扫描的过程中,把那些“暂时还没找到更高温度的日期”记录下来,并且一旦发现当前温度比它们高,就立刻给它们结算答案,这样每个元素就只会入栈一次、出栈一次。这就是单调栈的核心思想。
换句话说,暴力解法是“每个位置各自向后看”,而单调栈是“用栈统一管理那些悬而未决的位置”。这两种视角的差异,就是 O(n^2) 与 O(n) 的分水岭。理解这一点,你就知道单调栈不是凭空冒出来的技巧,而是对暴力解法中重复计算的合理消除。
3. 单调栈的核心原理与正向遍历实现
3.1 单调栈的本质:延迟结算的“待处理列表”
单调栈,说穿了就是一个栈,但它内部维护的元素是单调有序的。这道题里,我们维护的是一个从栈底到栈顶温度值递减的栈,也就是说,越靠近栈顶的元素,温度越低。为什么是递减而不是递增?因为在从前向后扫描时,栈里存的是“还没找到比它更暖的日期的下标”,越晚出现的温度如果越低,它自然越可能被后面的更高温“覆盖”,放在栈顶先被处理,符合栈“后进先出”的特性。
用生活化的类比来解释:你去排队买限量球鞋,队伍里每个人都在等一个比自己更“高”的人来叫号。新来的人如果身高足够高,会把前面所有比他矮的人全部叫走,处理完这些人的“等待”,然后自己站到队尾接着等。整个过程里,叫号发生的次数恰好等于每个人被叫到的次数,绝对不多一次。
这类题还有一个更通用的叫法:“下一个更大元素”问题。你现在需要找的是“下一个温度更高的日子”,本质上就是找一个元素右侧第一个比它大的元素,并计算下标距离。LeetCode 496、503 都是同一个套路的直接变形,把“每日温度”吃透,就等于掌握了一个可以复用的解题模板。
3.2 单调栈正向扫描的完整实现
先说结论,这道题的推荐写法是正向遍历数组,栈内存储的是下标,而不是温度值本身。因为我们需要计算“天数差”,存储下标才能直接 j - i 得到答案,存储值的话还得额外去查数组下标,多此一举。
java复制public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
int current = temperatures[i];
// 栈不为空,且栈顶下标对应的温度小于当前温度
while (!stack.isEmpty() && temperatures[stack.peek()] < current) {
int prevIndex = stack.pop();
answer[prevIndex] = i - prevIndex;
}
// 当前下标入栈,等待未来更高的温度来结算它
stack.push(i);
}
return answer;
}
这段代码的逻辑是:每遍历到一个新的温度 current,就检查栈顶记录的温度是否比 current 小。如果小,说明栈顶那天的“第一个更高温度日”就是今天,于是弹出它并计算天数差。重复这个过程,直到栈顶温度不再小于 current,或者栈为空。最后,把当前下标 i 压入栈中,因为它也还没找到“下一个更高温度”。
3.3 为什么要频繁弹出与入栈
很多人第一次看这段代码会有个疑问:为什么栈内元素要一直弹出,直接把每个下标都存下来不行吗?答案是不行,因为一旦某下标 prevIndex 被当前的 current 结算,它就不再需要被未来任何温度考虑了,它的答案已经定死。一个元素若等到更后面的温度才被结算,那“第一个更高温度”就不是当前这个了。
用一个手工推演来看整个过程。假设输入 [73, 74, 75, 71, 69, 72, 76, 73]:
i=0,当前73,栈空,直接推入0。栈:[0]i=1,当前74,栈顶是0,73 < 74,弹出0,结算answer[0]=1-0=1;推入1。栈:[1]i=2,当前75,栈顶是1,74 < 75,弹出1,结算answer[1]=1;推入2。栈:[2]i=3,当前71,栈顶是2,75 >= 71,不弹出,推入3。栈:[2, 3]i=4,当前69,栈顶是3,71 >= 69,不弹出,推入4。栈:[2, 3, 4]i=5,当前72,栈顶是4,69 < 72,弹出4,结算answer[4]=1;再查栈顶是3,71 < 72,弹出3,结算answer[3]=2;再查栈顶是2,75 >= 72,停止,推入5。栈:[2, 5]i=6,当前76,栈顶是5,72 < 76,弹出5,结算answer[5]=1;再查栈顶是2,75 < 76,弹出2,结算answer[2]=4;栈空,推入6。栈:[6]i=7,当前73,栈顶是6,76 >= 73,推入7。栈:[6, 7]
最后栈中剩下的 [6, 7],即第6天和第7天,它们在后续没有任何更高的温度,所以 answer[6] 和 answer[7] 保持默认值0。最终得到 [1, 1, 4, 2, 1, 1, 0, 0],与题目预期完全一致。
注意一个细节:第3天(71)和第4天(69)是递减排进去的,它们并没有立刻被弹出,因为之后要先遇到72才会把71和69一并结算。这说明单调栈栈内温度是递减的。如果某天温度比较低,它会“安全”地压在栈里,等待一个较大的温度把它和其他更小的温度一起弹出去。
4. 反向遍历的另一种思路与多语言实现对比
4.1 从右往左的思路,代码更短
前面讲的是正向遍历,这是大多数人最容易理解的版本。但其实这题还有一种反向遍历的写法,代码更短,思路却需要绕一个弯。它从数组末尾向前扫描,栈内同样记录“尚未结算”的下标,但含义略有不同:反向遍历时,栈里保存的是“当前位置右侧的温度”,且从栈底到栈顶是递增的。
核心逻辑是:当扫描到位置 i 时,如果栈顶温度小于等于 temperatures[i],说明栈顶记录的温度不够高,不可能成为位置 i 的“更高温度”,直接弹出。反复弹出后,如果栈不为空,栈顶下标就是位置 i 右侧第一个比它大的元素;如果栈为空,说明右侧没有更高的温度,答案为0。最后把 i 推入栈。
java复制public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] answer = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && temperatures[stack.peek()] <= temperatures[i]) {
stack.pop();
}
answer[i] = stack.isEmpty() ? 0 : stack.peek() - i;
stack.push(i);
}
return answer;
}
这里“弹出小于等于当前温度的元素”是关键。因为我们在找的是“严格大于”当前温度的第一个元素,所以等于当前温度的元素不能作为答案,直接弹出,留着只会挡路。
4.2 为什么反向遍历时栈内保持递增
反向遍历的栈内顺序,严格来说是从栈底到栈顶温度递增。你可以这样理解:当扫描到位置 i 时,栈里保存的是位置 i 右侧的、按“距离由近到远”顺序排列的下标。而这些下标对应的温度是严格递增的。假设栈顶的热量不够高,被弹出的元素又是“矮个子”,矮个子在之后向左扫描时也一定是障碍,因为如果当前温度都高于它,那左侧更早的、更低的温度也不需要通过它来寻找答案。所以弹出它是安全的。
这种思路实现起来更短,因为它不需要在返回答案时计算 i - prevIndex,而是直接取栈顶下标与当前位置的距离。但反向遍历的理解成本比正向高一点,因为它把“未来信息”放在了栈里,扫描方向与计算方向相反。新手建议先掌握正向版本,等到熟练了再尝试反向,这样可以多一份对单调栈的感知。
4.3 Python与C++版本对比
力扣刷题有语言偏好的话,Python 和 C++ 也是高频选择。实现逻辑完全一致,差别只在语法细节上。Python 的列表可以直接当栈用,while stack and temperatures[stack[-1]] < current 判断即可。
python复制class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
answer = [0] * n
stack = []
for i in range(n):
while stack and temperatures[stack[-1]] < temperatures[i]:
prev = stack.pop()
answer[prev] = i - prev
stack.append(i)
return answer
这里的 stack[-1] 与 Java 的 peek() 对应,pop() 无需传参默认弹出最后一个元素。注意 Python 中 while stack 直接判断列表非空,非常简洁。
C++ 的 stack 容器使用时需要引入 <stack> 头文件,top() 用于查看栈顶,pop() 用于弹出,push() 用于压入。栈类型声明为 stack<int> st;,存下标即可。
cpp复制class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> answer(n, 0);
stack<int> st;
for (int i = 0; i < n; i++) {
while (!st.empty() && temperatures[st.top()] < temperatures[i]) {
int prev = st.top();
st.pop();
answer[prev] = i - prev;
}
st.push(i);
}
return answer;
}
};
三种语言的实现思路完全同构,没有哪个语言实现起来有本质差异。我个人的建议是:如果你是专门为面试刷题,熟悉一种语言就足够了,但要把单调栈的框架记到滚瓜烂熟,因为面试手写代码时,单调栈属于“高频且固定套路”的题目,熟练度很大程度上决定了你能否在十分钟内写得又快又稳。实测下来,Java 里用 ArrayDeque 比 Stack 性能更好,因为 Stack 继承自 Vector,有锁开销,而 ArrayDeque 是无锁的、纯双端队列实现,LeetCode 上的耗时差距在小数据量下不明显,但在 100000 规模下能感受到约百分之十几的提升。C++ 里优先用 vector 模拟栈也行,但 std::stack 更直观。
5. 复杂度分析与关键知识点总结
5.1 时间与空间复杂度为什么是 O(n)
无论正向还是反向遍历,每个下标都只会被压入栈一次、弹出栈一次,所以总操作次数是 O(2n),即 O(n)。这就是单调栈最大的优势。暴力解法的 O(n^2) 在 n=100000 时会炸,而 O(n) 处理 100000 个数据毫无压力,LeetCode 上提交后耗时通常在 5ms 左右(Java)。
空间复杂度方面,栈的最大深度不会超过数组长度,所以最坏情况下是 O(n)。严格递减的数组会触发这种情况,例如 [5, 4, 3, 2, 1],所有元素都会被压入栈而没有任何弹出,栈里会一直保存全部下标,直到遍历结束。此时额外空间是 O(n),但这是数据本身导致的,无法避免。
说到复杂度,顺便提一下很多人在面试时容易犯的一个错误:只背结论“单调栈 O(n)”,但被追问为什么是 O(n) 时答不上来。核心就是“每个元素入栈一次、出栈一次”,要把这句话说到位,面试官通常就会满意。
5.2 这道题考核的三大知识点
第一,栈的基础操作。这道题考察你能不能合理地把“待处理的下标”放进栈、在合适的时机取出并结算,涉及 push、pop、peek 这三个最基础的操作。
第二,单调性维护。栈内元素保持递减是为了更方便地找到“第一个更大的值”。这里需要理解的是,我们只关心栈顶元素,因为栈顶是最接近当前元素、同时也是“最小”的待处理元素,一旦当前温度高于它,就立刻结算。这个局部判断保证了全局正确性。
第三,下标与值的转换。很多题目里直接存值就够了,但本题必须存下标,因为我们要计算天数差。这是一个很容易踩的坑,我见过不少人一开始存值,结果算距离时还得回头遍历数组找下标,白白增加复杂度。养成“先想清楚需要什么信息,再决定栈里存什么”的习惯,能省去很多麻烦。
6. 常见bug、调试技巧与同类题举一反三
6.1 我自己踩过的坑和力扣评论区常见问题
先说一个我写这道题时真实踩过的坑:初始化栈时,我一开始用了 int[] 配合一个变量模拟栈顶指针,结果在判断栈非空时忘记检查栈顶指针是否小于0,导致数组越界。虽然用 Deque 可以避免这种问题,但如果你在面试时被要求用数组模拟栈,就一定要先判断“栈是否为空”再访问栈顶。这类边界条件是栈类题目最常见的bug来源。
第二个坑是等于号的边界处理。正向遍历时,判断条件是 temperatures[stack.peek()] < current,注意没有等号。如果换成 <=,遇到温度相等的情况会把栈顶元素错误地结算,导致答案偏大。比如 [73, 73, 74],如果使用 <=,第一个73会被第二个73弹出并结算为1,但正确答案应该是2,因为题目要求“更高温度”,相等不算更高。反向遍历时,弹出条件则必须写成 <=,因为栈顶温度等于当前温度时,栈顶下标距离更近,但它不是更高温度,不弹出去就挡了后面真正的更高温度。这两种写法方向相反,容易混淆,我建议你在刷题时特地把这个细节记住:正向用 <,反向用 <=。
第三个问题是“最后几个答案怎么保证是0”。这道题的答案数组默认初始化为0,所以不需要在栈清理阶段额外赋值。如果你手动初始化为其他值,比如 -1,那就必须在遍历结束后把栈中剩余元素的下标对应位置改成0,多一步操作,容易遗漏。默认0就是最省事的设计。
6.2 力扣评论区的高频疑问:为什么栈底元素永远无法被更早的元素访问
有读者会问:“栈里存的不是下标吗?为什么弹出某个下标后,它之前的下标还在栈里,不会受影响吗?”答案是:每个下标结算后,它就被丢掉了,而它之前的那些下标与它的关系是“右侧第一个更高温度”的关系,不受它的出栈影响。它们要等一个更高温度来触发弹出,而那个更高温度可能远在后面的某天,也有可能永远不会出现。你只需要维护一个待处理序列,不需要关心已经处理完的元素。
在实际调试时,如果你想直观看到单调栈的工作过程,我建议你在循环里加一行 System.out.println(stack) 观察每个时刻栈内的下标。你会发现栈内元素对应的温度始终是递减的,这个过程跑几遍,对单调栈的认识会深刻很多,远比反复看题解更有效。
6.3 同类题目与进阶刷题策略
“每日温度”是单调栈这一类题的门面题。把它刷透之后,紧接着建议按顺序刷下面这几道,难度从易到难递进:
-
力扣496:下一个更大元素 I。这题是“每日温度”的直系变形,给定两个数组,找 nums1 中每个元素在 nums2 中右侧第一个更大的值。可以直接用单调栈处理 nums2,再用哈希表记录答案,本质就是“每日温度”去掉下标距离、换成值映射。
-
力扣503:下一个更大元素 II。数组变成循环数组,解法是遍历两遍长度,用
i % n处理循环下标,核心还是同一个单调栈框架。只要你会写“每日温度”,改成循环数组只是加一个取模操作。 -
力扣42:接雨水。这题同样是栈的经典应用,也是hot100里的高频题。接雨水需要同时维护左右边界,用单调递减栈记录柱子的边界,每次遇到比栈顶高的柱子,就算一次“凹槽”可以接多少水。它比“每日温度”多了一步面积计算,但核心的“遇到更高的值就把栈顶结算掉”的思路完全相同。
-
力扣84:柱状图中最大的矩形。这题是单调栈的另一个经典应用,需要在高度递增时持续计算矩形面积,代码难度更高,适合在“每日温度”掌握得很熟之后再去挑战。
我的建议是,不要贪多,一天内把 496、503 这两道变体刷完即可,因为它们只是“每日温度”的加壳版本,可以帮你巩固单调栈的“下一更大元素”模板。隔天再攻 42 和 84,因为这两道题的思维跳跃更大,需要重新理解“面积计算”与“边界维护”的关系。
如果你想要一个通用的模板,可以把正向遍历的“每日温度”存为一个函数骨架:
java复制int[] solve(int[] nums) {
int n = nums.length;
int[] res = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
int idx = stack.pop();
res[idx] = i - idx; // 根据题意修改结算逻辑
}
stack.push(i);
}
return res;
}
下次遇到“找右边第一个更大的元素”相关题型,直接把模板套进去,再根据题目要求改结算逻辑和存储结构,这样能节省大量思考时间。
7. 实操建议:一道题如何刷出三倍效果
7.1 第一遍:先写暴力解法
我建议你第一遍拿到题目时,先写暴力解法,哪怕你知道它会超时。原因很简单:暴力解法能帮你确认题目含义和答案结构,也能让你真实感受到性能瓶颈在哪里。刷题不是背题,而是建立“从直觉到最优”的思维链路。我在带人刷题时一直强调,不要一上来就看题解,先写一遍暴力版本,哪怕代码丑一点,也一定要自己完成。
7.2 第二遍:理解单调栈后闭卷重写
写完成功提交后,立刻去看题解,重点看单调栈思路,看完后关掉题解,自己闭卷重写。这一遍的目标是检验你是否真正理解了“栈里存什么、什么时候弹出、什么时候结算”。如果写错,再对照题解找出是哪里理解偏差。这一遍是至关重要的,因为很多人看过题解后只是“感觉自己懂了”,一动手就卡壳。闭卷重写能戳穿这种错觉。
7.3 第三遍:用测试用例手动推演
第三遍配合测试用例手动推演一遍栈的变化过程。这是我最推荐的做法。拿题目自带的示例,拿几个极端情况,比如全部递增、全部递减、包含相同温度,一步一步在纸上画出栈里存了什么、答案数组变成了什么。这个过程非常痛苦,但做一次之后,你会从“背模板”升级为“理解机制”,面试时即使紧张也不会写错。
我的一个个人体会是:刷算法题最忌讳的是“看题五分钟,题解两小时”,看完以为自己会了,实际上手写不出来。单调栈这个知识点,真正常见的面试考察方式,就是让你现场推演一遍栈的变化过程,或者写一段完整代码,而不是只让你背一个结论。所以动手推演,比多看十道题有用得多。
最后再分享一个小技巧:做这类栈相关题目时,在代码里把栈的内容打印出来调试,能极大提升你对每一步的判断的确定性。先跑一遍小规模数据,观察栈的变化是否符合预期,再提交到力扣。这样做的成功率远高于直接写完就提交,尤其在你刚开始接触单调栈的时候。
