1. 先搞清楚“下一个更大元素”到底在考什么
先直说,这三道题在LeetCode上属于“单调栈”这一类的入门和进阶题。很多刷题的朋友一看到“每日温度”“下一个更大元素”这种名字,觉得是两套东西,实际上它们在考同一个核心模型:给定一个序列,对每个位置,找右边第一个比它大的元素。区别只在于题目的包装和边界条件。
我当年刷到这三题的时候,正好是准备面试的阶段,一开始也是硬用暴力解,两层循环去扫描,代码写出来当然能过样例,但一到LeetCode的测试用例就超时。后来才意识到,这类题想让你练的根本不是“能不能找到答案”,而是如何把时间复杂度从O(n²)压到O(n)。面试官在考算法题的时候,最看重的往往就是这个优化过程——你能不能识别出“重复计算在哪里”,然后用什么数据结构把它消掉。
先给没接触过的朋友扫个盲。所谓“下一个更大元素”,意思是:在一个数组里,对某个位置i,找到右边离它最近的那个位置j,使得arr[j] > arr[i],这个arr[j]就是“下一个更大元素”。如果右边没有比它大的,就返回-1(或者0,看题目怎么要求)。比如数组[2, 1, 4, 3],下标0的值是2,右边第一个比2大的是下标2的值4,所以答案是4。这种问题如果两层循环暴力解,每个元素都要往右扫描,最坏情况下就是O(n²),数据量一大就完蛋。
那单调栈为什么能优化?思路其实很朴素:你在从左往右遍历的时候,如果当前元素比栈顶元素大,说明当前元素就是栈顶那个元素的“下一个更大元素”,这时候就可以把栈顶元素弹出,记录答案,然后继续比较新的栈顶。这个过程中,每个元素最多入栈一次、出栈一次,所以总的时间复杂度是O(n)。这个思想理解透了,三道题基本就是套模板的事情。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 739. 每日温度:单调栈最经典的入门场景
2.1 题目到底让你算什么
题目是给一个数组temperatures,表示未来每天的气温,要求返回一个等长的数组answer,其中answer[i]表示:对于第i天,需要等多少天才能等到一个比当天更高的温度。如果后面都没有更高的,answer[i]就等于0。
举个例子,temperatures = [73, 74, 75, 71, 69, 72, 76, 73],输出应该是[1, 1, 4, 2, 1, 1, 0, 0]。
这个题和“下一个更大元素”的本质关系非常直接:我们要找的不是“下一个更大元素的值”,而是“下一个更大元素离当前多远”。换句话说,单调栈里存的不能只是值,还要能算出来“距离”。这里有个关键点:单调栈里存的是下标,而不是值。为什么要存下标?因为只有拿到下标,才能算出两个位置之间的距离。这是很多新手第一次写这道题最容易卡住的地方——想存值,结果发现求不出天数。
从另一个角度看,这道题非常适合作为单调栈的“第一课”,因为它的场景非常生活化。气温的爬升和下降是随机的,我们想知道“下一次升温还要等多久”,本质上就是在问“右边第一个比当前大的位置在哪里”。一旦你理解了气温这个场景,后面再看到“下一个更大元素”这类抽象题,就很容易联想到同一个模型。
2.2 入栈出栈的具体逻辑与代码实现
先说栈的维护原则:我们从左往右遍历,维护一个栈底到栈顶递减的栈(也就是栈顶元素最小)。为什么是递减?因为我们希望在遍历的过程中,一旦遇到一个更大的元素,就把它和栈里所有比它小的元素配对。配对的顺序是从栈顶开始,所以栈顶必须是最“近”的那个还没配对的元素。
具体流程是这样的:
- 遍历temperatures的每个下标i。
- 如果栈不为空,并且当前温度temperatures[i]大于栈顶下标对应的温度temperatures[stack.peek()],就说明当前这个i就是栈顶那个下标的“下一个更高温度”,把栈顶弹出,记录距离为i - popIndex。
- 重复第2步,直到栈为空或者当前温度不大于栈顶温度。
- 把当前下标i入栈。
原因其实很清晰:栈里存的是“还在等待匹配”的下标。只要当前元素比栈顶元素大,栈顶元素就“等到”了它的结果,可以出栈了。如果当前元素不比栈顶大,那当前元素可能比栈里更下面的元素大吗?不可能,因为栈是往栈底递增的,所以当前元素也没法匹配更下面的元素,只能自己先入栈,等着后面更大的元素来解救它。
代码我直接贴出来,用Java写,因为面试里用Java的比较多:
java复制class Solution {
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++) {
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int idx = stack.pop();
answer[idx] = i - idx;
}
stack.push(i);
}
return answer;
}
}
核心就一个while循环。每轮循环里的判断是temperatures[i] > temperatures[stack.peek()],注意这里比较的是两个温度值,但栈里存的是下标,所以要用stack.peek()去取下标,然后再用下标去取温度。这个“下标换值”的操作,写多了自然熟,但第一次写的时候很容易把temperatures[stack.peek()]写成stack.peek(),那就成了拿下标和温度比,逻辑完全错了。
2.3 为什么存下标而不是存值
很多人会问,既然只关心“比当前大的值”,为什么不直接在栈里存温度值?这样栈顶就是温度,比较的时候直接比就行。
原因很简单:只存值会丢掉位置信息。这道题要求输出天数,也就是距离,你不知道原下标就没办法算差值。就算你把值和一个下标绑成一个对象存进去,其实也等价于存了下标,但写起来更啰嗦。还有一点,如果以后遇到类似题,要求算的可能是两个下标之间的区间信息,那时候必须用下标才能做。所以从一开始就养成“单调栈存下标”的习惯,后面很多题都能直接套。
这里再分享一个踩过的坑:Deque<Integer> stack = new ArrayDeque<>()还是Stack<Integer>的问题。老版本的Java里很多人用Stack类,但Stack继承自Vector,是线程安全的,性能差一些,而且它的方法名push和pop用起来也够,但笔试和面试中更推荐用ArrayDeque来实现栈操作,性能更好,而且push、pop、peek这些方法也都齐全。如果环境允许,也可以直接用数组模拟栈,那是最快的,但可读性差一些,我一般只在比赛的时候这么干。
3. 496. 下一个更大元素 I:单调栈和哈希表的组合拳
3.1 题目变了个形状,但内核没变
这道题看起来复杂了,给了两个数组:nums1是nums2的子集,要求返回一个数组,对应nums1中的每个元素,在nums2中找“下一个更大元素”。找不到就返回-1。比如nums1 = [4, 1, 2],nums2 = [1, 3, 4, 2],那么结果就是[-1, 3, -1]。
这题和工作场景里经常遇到的情况很像:有一张“完整的大表”,还有一张“关心的子集表”,我们只想知道子集里那些元素在大表里的状态。如果你傻傻地对nums1的每个元素都去nums2里重新找一遍,那复杂度大概率是O(n*m),面试官肯定不满意。
这道题和739的区别在于,它把问题拆成了两步:“计算所有元素的下一个更大元素”和“按需查表”。前一步还是用单调栈,后一步则要借助哈希表。为什么需要哈希表?因为nums2的每个元素我们都已经算出了结果,但题目要求输出顺序按nums1来,nums1的顺序和nums2的顺序根本不一致。如果每次都去nums2里找这个元素在哪,那就又变成暴力了。所以我们把nums2每个元素和它的“下一个更大元素”配对存进HashMap,然后遍历nums1去查表,这样每个元素的查询都是O(1)。
3.2 先算完整映射表,再按需查询
这里的实现有一个关键顺序问题:是先遍历nums2构建映射,再遍历nums1查答案,两个步骤是独立的。不要想着在nums1里对每个元素再跑一次单调栈,那样就重复计算了。
单调栈计算映射表的过程,跟739几乎一模一样,只是记录的不是距离,而是值:
java复制class Solution {
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
Deque<Integer> stack = new ArrayDeque<>();
Map<Integer, Integer> map = new HashMap<>();
for (int num : nums2) {
while (!stack.isEmpty() && num > stack.peek()) {
map.put(stack.pop(), num);
}
stack.push(num);
}
// 栈里剩下的元素,右边没有比它们大的,结果记-1
while (!stack.isEmpty()) {
map.put(stack.pop(), -1);
}
int[] res = new int[nums1.length];
for (int i = 0; i < nums1.length; i++) {
res[i] = map.get(nums1[i]);
}
return res;
}
}
注意一个细节:这个版本里栈里直接存值,不需要存下标,因为题目只要“下一个更大元素的值”,不要求位置和距离。这正好呼应了前面说的——存值还是存下标,取决于题目要什么。739要距离所以存下标,496要值所以可以直接存值。灵活切换才是关键。
还有一个容易被忽略的细节:第二次遍历时,栈里剩下的元素是没有“下一个更大元素”的,必须统一填-1。这个步骤如果忘了,那map里就缺键,后面map.get(nums1[i])会返回null,程序直接报错。我见过不少同学在这里翻车,明明单调栈写对了,最后因为没处理剩余栈导致空指针。
3.3 哈希表在这里的真正价值
哈希表不是花架子,它的作用是解决“两个数组顺序不一致”的问题。如果你不建表,而是模拟“在nums2中找到nums1[i]的位置,再往右找第一个更大”,那每次查找都是O(n),整体又回到了O(n*m)。用哈希表把单调栈的结果记下来,nums2只遍历一遍、每个元素只处理一次,后面查询全部O(1),最后总复杂度就是O(n+m)。这个思路在真实开发里也很有用:先把复杂计算的结果缓存起来,再按需读取。比如后端接口里对一批数据做预处理,建个索引,后续请求直接查索引,就是一个套路。
4. 503. 下一个更大元素 II:循环数组的破局点
4.1 循环数组到底难在哪
第三题是循环数组版本:nums是一个循环数组,也就是说最后一个元素的下一个元素是第一个元素。题目要求数组中每个元素的下一个更大元素,如果不存在就返回-1。比如nums = [1, 2, 1],输出是[2, -1, 2]。
为什么循环数组难倒很多人?因为“下一个更大元素”本来是在线性序列里定义的,现在变成了环形,相当于每个元素都可能要到数组后半段甚至“绕回开头”才能找到答案。最直接的想法是把数组复制一份拼接,形成长度为2n的新数组,然后对新数组跑单调栈,最后只取前n个位置的结果。这个办法能行,因为循环数组从头到尾最多绕一圈,拼成两倍长度后,任何元素在“后面”都能找到它原本可以到达的位置。但拼接本身要开辟新数组,多花一份内存。
更优雅的做法是用下标取模i % n来模拟循环数组,不需要真的拼接。这样空间是O(1)(除栈以外),代码也更紧凑。为什么取模能实现循环?因为遍历时下标从0走到2n-1,对n取模后,就自动构成了一个“虚拟的环形数组”。比如n=3,下标0到5对应的真实位置就是0、1、2、0、1、2,正好绕了两圈。这个技巧在很多循环数组中都能用,比如循环队列、环形的KMP匹配问题里也常见。
4.2 通过虚拟长度“绕圈”遍历
还有一个关键点是:为什么要遍历到2n-1而不是n-1?因为是循环数组,某个元素的下一个更大元素可能出现在它“右边”任何位置,甚至绕回到它前面。如果只遍历一次,下标靠后的元素可能永远等不到“回绕”的那一次匹配。比如nums = [5, 4, 3],下标2的值3,下一个更大元素5其实在下标0,如果只遍历一次到下标2就停止,3就找不到答案。遍历两倍长度,虚拟数组里的“3”后面就能碰到第二圈的5,问题就解决了。
代码实现如下:
java复制class Solution {
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 idx = i % n;
while (!stack.isEmpty() && nums[idx] > nums[stack.peek()]) {
res[stack.pop()] = nums[idx];
}
stack.push(idx);
}
return res;
}
}
这段代码有两个关键点。第一,res数组统一初始化为-1,这样那些“找不到更大元素”的位置就不需要额外处理。第二,栈里存的是真实的原始下标(也就是取模后的idx),不是i本身,否则出栈后计算时用到的栈顶值就和实际位置对不上了。
这里还有一个进阶细节值得注意:遍历2n次,栈里最多会有多少个元素?极端情况下,比如数组严格递减,那么前n个元素入栈后都不出栈,之后遍历第二圈时依然不出栈,所以栈最深就是n。不会因为遍历两倍长度就栈溢出,因为栈里存的是去重后的真实下标,最多n个。理解了这一点,就不会担心“虚拟数组”导致内存翻倍的问题。
4.3 三种写法的对比与取舍
三种实现这一题的方法,我都在实际中试过,简单对比一下:
| 实现方式 | 空间复杂度 | 实现难度 | 适用场景 |
|---|---|---|---|
| 拼接数组(新数组长度2n) | O(n)额外数组 | 最低,好理解 | 新手学习、笔试求稳 |
| 取模模拟循环 | O(1)额外数组(不含栈) | 中等,需要理解取模 | 面试展示优化能力 |
| 分段处理(先正序一遍再处理回绕) | O(n)栈 | 较高,易错 | 追求极致性能时 |
我个人建议,如果是在面试中,优先写取模的方案,因为它空间省、代码也不长,而且能体现你对循环结构的理解。如果实在紧张,拼接数组也不丢人,毕竟面试官主要看的是思路清晰与否,不是看谁代码写得更短。不过有一个坑要注意:拼接法在LeetCode上能过,但内存占用比取模法高,如果面试官追问怎么优化,你再说出取模方案,效果反而更好。
5. 三道题放在一起,我提炼出的单调栈解题模板
5.1 统一的核心流程
做完这三道题,我自己总结了一套几乎能套用到所有“下一个更大元素”变种题的模板,分四步:
- 确定遍历方向。通常是从左往右遍历,因为要找的是“右边第一个更大的”,从左往右处理时,当前元素天然是栈顶元素的“右边元素”。
- 确定出栈条件。一般是当前元素大于栈顶元素时,栈顶元素出栈,并记录答案。如果题目要求严格大于,就是
>;如果要求大于等于,就是>=。 - 确定入栈内容。存值还是存下标,取决于题目要输出的是值还是距离/位置。
- 确定边界。如果题目有循环,就用
i % n扩展遍历长度;如果题目是子集查询,就加哈希表做映射;如果找不到答案要返回什么,是0还是-1,提前初始化。
这个模板的价值在于,它帮我解决的不只是这三道题。后来我还刷到过“下一个更大元素III”“子数组最小乘积”等题目,虽然包装各不相同,但核心都是单调栈,只是入栈出栈的条件和记录的答案形式变了。一旦你形成了自己的模板,碰到新题就能快速找到切入点。
5.2 从暴力解到单调栈,性能提升到底有多大
很多初学者会问:我就用暴力解,难道不行吗?答案分情况。如果数组长度只有几十,暴力完全没问题;但LeetCode上面这三题,nums长度上限都是一万左右(739是10^5级别),暴力解O(n²)在最坏情况下就是10^10次运算,超时是必然的。
单调栈把时间复杂度降到O(n),这中间的性能差异有多大?我拿自己电脑实测过,n = 100000时,暴力法经常跑几百毫秒甚至直接卡死,单调栈几乎感觉不到延迟,基本在个位数毫秒级。这个差距不是靠编译器优化能追回来的,而是算法本质上的差异。所以在面试场景下,如果你能主动说清楚“暴力是怎么重复计算的,单调栈为什么能消除重复计算”,这一题基本就稳了。
从空间角度看,单调栈最多用O(n)的栈空间,哈希表解法也是O(n),都非常可控。对比暴力的O(1)空间,换取的是时间上的质变,这笔买卖完全划算。
6. 实战中绕不开的细节问题与易错点排查
6.1 出栈、入栈顺序的常见误区
我发现很多朋友在写单调栈的时候,最容易错的地方不是while循环的条件,而是把入栈写在了while循环里面,导致每个元素重复入栈、死循环或者漏处理。正确顺序一定是:先while出栈结算,再把当前元素入栈。这个顺序如果反了,栈顶永远是当前元素,while永远不会触发,答案全错。
另外一个高频问题:比较的时候到底用哪个元素。比如739题里,你要拿temperatures[i]去和temperatures[stack.peek()]比较,而不是stack.peek()本身。因为栈里存的是下标,不转成值就直接比较,拿下标和温度比,结果肯定是错的。这类错误在编译器层面不会报错,Java里下标和整数类型一致,所以非常隐蔽,只有跑测试用例才会发现结果全不对。
6.2 边界条件:数组为空、只有一个元素、全部相等
边界条件堪称刷题翻车的重灾区。我列几个这三题最常见的边界场景:
nums长度为0:结果应该是空数组。很多直接new int[nums.length]的写法没问题,但如果你手动去访问nums[0]就崩了。nums只有一个元素:739的结果应该是[0],496和503要看具体数组,503里如果只有一个元素,它找不到比自己大的数,结果就是[-1]。- 全部元素相等:比如
[3, 3, 3],因为要求是“大于”而不是“大于等于”,所有位置都找不到更大的,结果全为-1或0。这时候栈会一直增长到n,不会有任何出栈。 - 严格递减的数组,比如
[5, 4, 3, 2, 1]:每个元素右边都没有更大的,结果全为-1或0。这种极端情况下单调栈退化成“全部入栈”,出栈操作一次都不会发生,时间复杂度是O(n),依然不会超时。
我的习惯是,写完题解后立刻用这几组极端用例自己测一遍,比反复看官方示例有效得多。很多看起来很复杂的题,边界测完基本就稳了。
6.3 一道题三种语言的适配差异
这三道题在Java、Python、C++里写起来,思路一模一样,但语法细节略有不同,我顺带整理了常见版本的关键点。Java推荐ArrayDeque,Python用list模拟栈,C++直接用stack<int>。Python里要注意的是,while stack and nums[i] > nums[stack[-1]]这种写法,如果你在循环里频繁用stack[-1]取栈顶,可读性其实一般,建议先用变量存一下。C++里stack的top()返回的是引用,如果你在里面修改栈顶元素,可能有意想不到的副作用,但一般不会这么干。
如果追求极致性能,不管是Java还是C++,都可以直接用数组模拟栈。用数组模拟最大的好处是内存连续、随机访问快,省去了栈封装的开销。我在刷题时一般用int[] stack = new int[n]; int top = -1;来模拟,逻辑完全等价,但速度明显快一些。笔试的时候,用数组模拟栈还能避免Stack类同步锁带来的额外开销。
7. 从这三道题延伸到更多变种题
7.1 下一个更大元素 III 与字符串处理
LeetCode上还有一个“下一个更大元素 III”(556题),表面看着像数字排列题,其实就是求一个整数各位数字的下一个更大的排列。它和单调栈的关系不大,但你要理解“下一个更大元素”这个家族其实是可以延伸到排列、栈、贪心等很多方向的。如果只会单调栈模板,遇到这种变种可能会懵,但如果你理解了“右侧更大”的本质,再结合排列的性质,思路就容易打开。
7.2 柱状图中最大的矩形:单调栈的逆向思维
如果说739是单调栈的典型正向应用,那“柱状图中最大的矩形”(84题)就是经典的逆向应用。它要找的不是“右边第一个更大的”,而是“左右两边的比它小的边界”。有意思的是,解法依然是单调栈,但栈的顺序会反过来——栈底到栈顶递增。为什么?因为我们要找到当前柱子左右两边第一个比它矮的柱子,从而确定以当前柱子为高的最大矩形能扩多远。这正好是单调栈“快速找到左右边界”能力的体现。
7.3 堆叠起来看:这是一个“消除重复扫描”的通用思路
单调栈解决的核心问题,可以概括为:在一个序列中,快速找到每个元素左右两边第一个满足某个大小关系的元素。这个“第一个”非常关键,因为暴力扫描的重复工作就发生在反复比较上。单调栈通过“当前元素和栈顶比较,谁大谁出栈/入栈”这种机制,把那些已经确定答案的元素及时“结算”掉,避免它们再参与后续比较。
想通这一点以后,你就不会觉得单调栈是“背模板”的产物,而是一种非常自然的优化思路。我在给朋友讲这三道题的时候,最常用的比喻是:单调栈就像一列正在排队等待叫号的人,新来的人如果比队伍末尾的人“更强”,末尾的人就可以离开了,因为他的答案已经确定了。这个“强者可以淘汰弱者”的机制,就是单调栈的精髓。
8. 三个月后回看,我在这三题上沉淀的几点心得
第一,不要只背代码,要背“为什么”。我见过很多人能把739的代码默写出来,但问他“为什么存下标而不存值”,完全答不上来。这种状态面试很容易被追问击穿。反过来,如果你能把每一步的动机讲清楚,哪怕代码有细节小错,面试官也会认可你的思路。
第二,这几题的变种特别多,但单调栈的模板是固定的。我建议你亲手整理一份自己的“单调栈笔记”,把入栈条件、出栈条件、答案更新方式这三件事写清楚,然后拿10道左右的经典题去套着验证。不要试图背几十道题,而是吃透一个模板,用它去覆盖一大片题目。
第三,实际笔试的时候,环境可能没有IDE的自动补全,Stack的完整类名和方法要能直接手写出来。我吃过这个亏,有次线上笔试因为java.util.Deque没import全,编译报错浪费了好几分钟。现在我的习惯是写完代码后,用最简单的测试数据在脑内模拟一遍流程,确认栈的变化和答案数组的更新都对得上,再提交。这个小习惯,帮我避免了很多因为粗心导致的无效提交。
第四,也是我最想说的一点:单调栈这个知识点,理解起来并不难,难的是遇到具体问题时能想到用它。要做到这一点,除了刷题,更重要的是在刷完每一道题之后问自己一句:“这题暴力解法的重复计算在哪里?单调栈是怎么消除它的?”带着这个问题去复盘,比多做十道新题都管用。
这三道题我自己在准备面试阶段反复刷了至少四遍,每一遍都有新体会。从最开始暴力解超时,到背下模板能默写,再到能够跟别人讲清楚单调栈的原理,最后到遇到变种题也能灵活变形——这个过程其实就是一个典型的“由不懂到懂、由懂到会用”的成长路径。希望这篇以实际刷题经验为主线的拆解,能帮你少走一些弯路,更快跨过单调栈这道坎。
