开头
刷算法题刷到Day48的朋友,肯定对“单调栈”这个词不陌生了。今天要聊的这三道题——739.每日温度、496.下一个更大元素I、503.下一个更大元素II——可以说是单调栈这个套路里最经典的三板斧。它们仨放在一起刷,本质上就是同一套思想在不同场景下的三次应用:暴力解法谁都会写,但数据规模一大就崩;而单调栈能在O(n)时间内解决“找右边第一个比我大/小的元素”这类问题,这才是核心价值。
如果你正准备面试,或者刷题到了单调栈这个专题节点,这三题是你绕不开的基石。把它们彻底吃透,不只是背下代码模板,而是理解“为什么用单调栈”“什么时候该从右往左遍历”“循环数组怎么处理”这些底层逻辑,以后再遇到同类型的题基本就是秒杀。
这篇博文我会把三题放在一起拆,从题目本质、算法选型、代码实现到常见坑位,一条线捋清楚,争取让你看完之后不用再翻别的资料,直接能上手写出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 三道题的本质:同一个套路,三种变体
1.1 题目到底在问什么
先说结论:这三道题说的都是同一件事——找“右边第一个比当前元素更大的元素”。
739.每日温度,给你一个温度数组,问每个位置要等几天才能等到一个更高的温度。举个例子,[73, 74, 75, 71, 69, 72, 76, 73],第0天温度73,第1天就是74,所以答案是1;第1天温度74,第2天是75,答案也是1;第2天温度75,后面找了一圈到第6天才是76,所以答案是4。注意这里要求的是“间隔天数”,也就是索引差,而不是更大的值本身。
496.下一个更大元素I,题目绕了一层。给你两个数组,nums2是完整数组,nums1是nums2的子集。对nums1里的每个元素,去nums2里找它右边第一个更大的值,找不到就返回-1。比如nums1 = [4, 1, 2],nums2 = [1, 3, 4, 2],4在nums2右边没有更大的,返回-1;1的右边第一个更大值是3,返回3;2的右边没有更大的(它自己是最后一个),返回-1。
503.下一个更大元素II,场景变成了循环数组。也就是说,数组末尾的元素“绕一圈”还能继续往后找。比如[1, 2, 1],第2个元素1,右边找不到更大的,但循环回去以后第0个元素还是1,第1个元素是2,所以答案是2;而第1个元素2,循环一圈也找不到比2更大的,返回-1。
你会发现,这三题的解题框架完全一样,区别只在于:739是求“索引差”,496是“部分元素查询 + 哈希表映射”,503是“数组循环处理 + 取模索引”。
1.2 为什么第739题不能无脑暴力
很多初学者看到739的第一反应是双重循环,外层遍历每一天,内层从i+1开始往后找第一个更大的温度,找到了就记录差值,找不到就写0。这个逻辑没错,时间复杂度是O(n²),提交上去大概率超时。力扣这道题的n范围是10^5级别,O(n²)意味着最坏情况下要跑10^10次操作,在现代计算机上也需要几十秒,显然不行。
那为什么单调栈能把复杂度压到O(n)?核心在于“每个元素最多进栈一次、出栈一次”。你在遍历过程中,每个元素被压入栈一次,弹出一次,整体操作次数就是2n级别。这比暴力解法中每个元素都要往后扫描一大段区间的做法高效得多。
所以这道题的正确思路是:用空间换时间,借助一个单调递减栈(从栈底到栈顶递减)来维护“还没找到右边更大元素”的候选位置。
注意:这里说“单调递减栈”是从栈底到栈顶递减。配合遍历方向不同,有时候我们用的是递增栈,需要先明确自己的定义,不然代码很容易写反。
1.3 单调栈的核心原理:先记住一个生活化模型
单调栈的原理听起来抽象,其实完全可以类比成“排队看身高”。
想象有一排人站成一列,每个人想知道自己右边第一个比自己高的人在哪里。你可以从右往左看,也可以从左往右维护一个“还没被更高的身高淘汰”的候选队列。用从左往右的视角:遍历到一个新元素时,如果它比栈顶元素高,那么栈顶元素右边的第一个更高者就是这个新元素,于是栈顶出栈并记录答案;然后继续比较新的栈顶,直到栈顶比当前元素高或者栈空,最后把当前元素压栈。
用更直白的话说:栈里存的都是“暂时还没找到答案的可怜人”,而且这些人的身高从栈底到栈顶是递减的。新人一来,如果自己更高,就能把栈里那些比自己矮的人全部“解救”,因为自己就是它们右边第一个更高的围墙。而那些比新人高的,留在栈里继续等。
这个模型能帮你快速判断:什么时候出栈,出栈时记录什么,栈里存的是索引还是值。搞清楚了,单调栈的代码就可以像模板一样默写。
2. 739.每日温度:最经典的单调栈入门
2.1 题目的隐含要求与数据规模
先看题目细节:温度数组长度范围是[1, 10^5],温度值在[30, 100]之间。每道题返回的是一个等长数组,answer[i]表示从第i天开始,要等多少天才能等到更高的温度,没有就填0。
注意三个容易忽略的点:
第一,要求的是“间隔天数”而不是“温度差”,所以记录索引差而不是温度差值。
第二,如果右边没有更高温度,结果是0。这意味着结果数组初始化时全部置0,只有被“解救”出来的位置才填具体差值。
第三,温度值有可能相等。题目说的是“更高温度”,所以等于当前温度不算,必须严格大于。这个边界在写比较条件时要注意,代码里容易写成>=导致答案出错。
2.2 单调栈解法:从左往右遍历
先给标准解法,从左往右遍历,维护一个单调递减栈,栈里存的是索引。
python复制def dailyTemperatures(temperatures):
n = len(temperatures)
ans = [0] * n
stack = [] # 栈里存索引,栈底到栈顶对应的温度递减
for i in range(n):
# 当前温度比栈顶索引对应的温度高,说明栈顶元素找到了右边第一个更高的温度
while stack and temperatures[i] > temperatures[stack[-1]]:
idx = stack.pop()
ans[idx] = i - idx
stack.append(i)
return ans
这个写法非常经典,核心就是while循环那三行。每次遇到一个新温度,就把栈里所有比它小的元素都弹出来,因为对栈里那些元素来说,当前这个位置就是它们的“右边第一个更高温度”。然后当前索引入栈,继续等待后面的温度来处理它。
为什么栈里存索引而不是存温度?因为题目要求输出“间隔天数”,即索引差。如果只存温度,出栈的时候算不出差了多少天,还得额外维护位置信息,得不偿失。
2.3 逆向遍历的另一种写法
739也可以从右往左遍历,逻辑会有一点不同。从右往左的话,栈维护的是一个单调递增栈(从栈底到栈顶递增),因为右边的信息已经处理过了,我们要在栈里找“第一个比当前温度高的索引”。
python复制def dailyTemperatures(temperatures):
n = len(temperatures)
ans = [0] * n
stack = [] # 从栈底到栈顶递增,存索引
for i in range(n - 1, -1, -1):
# 弹出所有比当前温度小的,因为这些元素对当前温度不构成“更高”
while stack and temperatures[i] >= temperatures[stack[-1]]:
stack.pop()
if stack:
ans[i] = stack[-1] - i
stack.append(i)
return ans
注意这里的while条件用的是temperatures[i] >= temperatures[stack[-1]],也就是把等于当前温度的也要弹出去。为什么?因为从右往左找的是“右边第一个严格大于当前温度的索引”,如果栈顶温度等于当前温度,那它不满足“更高”的条件,且因为它更靠右,后面即使存在更高的温度,也不可能是栈顶这个元素了,所以直接弹出。
这两种写法没有绝对的优劣,但从左往右的版本更容易理解,也更贴合单调栈的经典教学思路。面试时建议首选从左往右的写法,因为它的出栈时机和答案记录逻辑非常直白,不容易被追问细节时卡住。
2.4 复杂度分析与优化思路
739的单调栈解法,时间复杂度O(n),空间复杂度O(n)(栈空间)。每个元素最多入栈一次、出栈一次,所以整体是线性复杂度。
这里有一个值得注意的优化细节:如果温度范围只有[30, 100]这一小段区间,理论上可以用一个“跳跃指针”来继续优化,比如从后往前记录每个温度最新出现的位置,跳过大量无效比较。但在实际面试和力扣环境下,单调栈已经足够优秀,没必要再搞花活。
我在刷题时见过不少人在739上翻车,翻车原因大多不是思路问题,而是边界处理:栈空判断、索引差计算、等于情况处理。这三处都是细节,但任何一处写错,答案就会偏差。
3. 496.下一个更大元素 I:单调栈加哈希表组合
3.1 嵌套查询的陷阱与数据规模
496这道题的表面描述比739复杂,因为它引入了两个数组:nums1是nums2的子集,且nums1中的元素在nums2中一定出现。你需要对nums1中的每个元素,输出它在nums2中右边第一个更大的值,找不到就-1。
数据规模方面,nums1和nums2的长度都在[1, 1000]范围内。这个规模其实暴力也能过:对nums1的每个元素,先在nums2中找到它的位置,然后从右边线性扫描找更大的。时间复杂度是O(m * n),m是nums1长度,n是nums2长度。1000 * 1000 = 10^6次操作,完全能跑。
但面试时千万不要一上来就写暴力。原因有两个:第一,面试官让你做这道题,考察的显然是单调栈的思想,不会满足于暴力;第二,如果后续跟进数据规模扩大,暴力就会挂,而单调栈解法依然稳如泰山。
3.2 一次性预处理nums2再查表
496的核心思路可以拆成三步:
第一步,用单调栈对nums2做一次完整扫描,求出nums2中每个元素的下一个更大元素。
第二步,将结果存入哈希表nextGreater,键是元素值,值是该元素的下一个更大值。
第三步,遍历nums1,直接从哈希表中取值,取不到就填-1。
这么做的好处是,把两个数组的关联问题转化为一次预处理加多次查询,非常清晰。
python复制def nextGreaterElement(nums1, nums2):
next_greater = {}
stack = []
# 第一次扫描 nums2
for num in nums2:
while stack and num > stack[-1]:
next_greater[stack.pop()] = num
stack.append(num)
# 栈中剩余的元素右边没有更大的
for num in stack:
next_greater[num] = -1
# 查表
return [next_greater[num] for num in nums1]
这里有个细节和739不同:496的栈里存的是元素值而不是索引。为什么?因为最终只需要知道“下一个更大的值”是多少,不需要知道索引差。至于是否存在重复元素?题目保证了nums2中所有元素互不相同,所以直接用值作为哈希表的key完全没问题。
3.3 示例推演:手动跑一遍更直观
拿题目给的例子走一遍:nums1 = [4, 1, 2],nums2 = [1, 3, 4, 2]。
初始化栈空,遍历nums2:
- 遇到1,栈空,1入栈,栈为
[1]。 - 遇到3,3 > 1,弹出1,记录
next_greater[1] = 3。栈空,3入栈,栈为[3]。 - 遇到4,4 > 3,弹出3,记录
next_greater[3] = 4。栈空,4入栈,栈为[4]。 - 遇到2,2 < 4,不弹出,2入栈,栈为
[4, 2]。
遍历结束,栈中剩余4和2,它们的右边没有更大的元素,所以next_greater[4] = -1,next_greater[2] = -1。
最后查表:nums1中4对应-1,1对应3,2对应-1,输出[-1, 3, -1]。和题目给出的结果一致。
这个流程走完你就能发现:整个过程其实和739非常像,只是把“索引差”换成了“值”,并且额外加了一个哈希表做映射。
3.4 时间与空间复杂度分析
预处理阶段,单调栈对nums2的每个元素只操作一次,时间复杂度O(n),其中n是nums2的长度。查表阶段遍历nums1,时间复杂度O(m)。总时间复杂度O(m + n),空间复杂度O(n)(哈希表和栈)。
这个复杂度比暴力O(m * n)好太多了,而且代码逻辑很清爽。面试时如果你能写出这种解法,面试官基本会点头默认。
注意:496题目说nums1是nums2的子集,所以nums1中的所有元素一定能在哈希表中找到key。但为了代码稳健,仍然要处理好“key不存在”的情况(虽然实际上不会发生),这也是写代码的一个好习惯。
4. 503.下一个更大元素 II:循环数组的破局思路
4.1 循环数组的本质与破环为链
503在739的基础上增加了一个条件:数组是循环的。也就是说,第n-1个元素的下一个元素不是“没有了”,而是绕回第0个元素继续往后找。
打破循环数组局面的经典思路叫“破环为链”。具体做法有两种:
第一种,把原数组复制一份接在后面,形成长度2n的新数组,然后对新数组做单调栈处理。比如[1, 2, 1]变成[1, 2, 1, 1, 2, 1],然后问题退化成非循环的“下一个更大元素”。
第二种,不显式拼接数组,而是在遍历时通过索引取模来模拟循环,遍历2n次,索引用i % n。这个方法省空间,代码上也更优雅,推荐优先掌握。
4.2 取模遍历的单调栈实现
先给完整代码:
python复制def nextGreaterElements(nums):
n = len(nums)
ans = [-1] * n
stack = [] # 栈里存索引
for i in range(2 * n):
idx = i % n
# 当前元素比栈顶索引对应的元素大,弹出并记录答案
while stack and nums[idx] > nums[stack[-1]]:
ans[stack.pop()] = nums[idx]
# 只在第一轮遍历时入栈,避免重复处理
if i < n:
stack.append(idx)
return ans
逐行拆解:
ans初始化全为-1,意思是默认所有元素都找不到下一个更大元素。如果循环一圈都没有更大的,保持-1就行。- 遍历
2n次,idx = i % n保证索引始终落在合法范围内。 - 当
nums[idx]大于栈顶索引对应的元素时,说明栈顶元素找到了“右边第一个更大的值”,弹栈并记录。 - 入栈条件
if i < n是关键优化。因为前n次遍历已经把所有元素都入栈了,后n次只需要负责查找和弹出,不需要再入栈,否则会重复计算。
关于这个if i < n的写法,很多初学者会困惑:为什么第二次遍历不入栈?因为栈里的元素本质上存储的是“位置信息”,同一个位置我们只需要处理一次。如果我们第2n次还把同一个位置压进栈,就会出现重复计算甚至死循环的问题。后n次遍历的作用,本质上是让栈里剩下的元素有“绕一圈找到更大值”的机会,比如倒数第二个元素可以通过第二轮循环找到第一个元素附近更大的值。
4.3 手动推演503:验证边界情况
拿[1, 2, 1]跑一遍:
初始化ans = [-1, -1, -1],栈空。
- i=0, idx=0, nums[0]=1,栈空,0入栈,栈为
[0]。 - i=1, idx=1, nums[1]=2,2 > nums[0]=1,弹出0,
ans[0] = 2。栈空,1入栈,栈为[1]。 - i=2, idx=2, nums[2]=1,1 < nums[1]=2,不入栈?不对,i=2 < n=3,所以2入栈,栈为
[1, 2]。 - i=3, idx=0, nums[0]=1,1不大于nums[2]=1(等于不弹),i=3 >= n,不入栈。
- i=4, idx=1, nums[1]=2,2 > nums[2]=1,弹出2,
ans[2] = 2;然后2 > nums[1]=2?不,2不大于2,弹出栈顶1吗?等一下,此时栈为[1, 2],栈顶是2(索引),已经弹出,栈变[1],nums[1]=2,2 > nums[1]=2不成立,所以不弹。i=4 >= n,不入栈。 - i=5, idx=2, nums[2]=1,1 < nums[1]=2,不弹,不入栈。
最终ans = [2, -1, 2],和预期一致。注意看第4个i这一步,2并没有被弹出,因为等于不算更大。所以第一个元素2保持-1,这是正确的,因为循环数组里比2大的元素不存在。
这个推演过程看起来很啰嗦,但实际面试时如果你能在白板上把类似流程画出来,面试官会非常认可。
4.4 两种破环为链方式的对比
| 方式 | 优缺点 | 适用场景 |
|---|---|---|
| 显式拼接数组 | 思路最简单,直接转化为非循环问题,代码更直观;但额外占用O(n)内存,且结果数组需要索引映射 | 适合初学者理解,或面试时间紧张时快速给出方案 |
| 索引取模模拟循环 | 不需要额外数组空间,代码更优雅;但需要理解取模逻辑和入栈条件,对初学者有一点点门槛 | 适合熟练掌握单调栈之后使用,实际写代码推荐这种 |
显式拼接的方式,结果记录时需要对i和i - n做映射,很多人在这一步容易搞混。比如拼接后的数组长度是2n,当你遍历到n位置时,对应原数组的索引是i - n,但原数组中每个元素在拼接数组中有两个位置,如果处理不好,会重复覆盖结果。所以两种方式我建议都写一遍,感受一下差异,面试时根据情况选一种最顺手的。
5. 三道题横向对比与实战建议
5.1 数据规模、时间复杂度和代码复杂度对比
| 题目 | 输入规模 | 时间复杂度 | 空间复杂度 | 栈中存储 | 特殊技巧 |
|---|---|---|---|---|---|
| 739.每日温度 | n ≤ 10^5 | O(n) | O(n) | 索引 | 出栈时记录索引差 |
| 496.下一个更大元素I | m, n ≤ 1000 | O(m + n) | O(n) | 值 | 预处理+哈希表 |
| 503.下一个更大元素II | n ≤ 10^4 | O(n) | O(n) | 索引 | 取模遍历2n |
从表中能看出,三者的核心都是单调栈,区别在“栈里存什么”和“答案记录方式”。739最朴素,存索引算差值;496在值上做文章,引入哈希表简化查询;503加上循环数组和取模,处理“绕圈”的情况。
5.2 面试实战中的解题话术
如果你在面试中拿到这三道题中的任何一道,建议按这个顺序组织思路:
第一,先说暴力解法是什么。比如739就是双重循环,496就是双层查找,503就是每个元素绕一圈找最大值。让面试官知道你能想到最朴素的做法,同时说明复杂度太高。
第二,引出单调栈优化。重点讲清楚“每个元素最多入栈出栈一次”这个性质,以及单调栈如何维护“候选集合”。
第三,明确这道题的变体具体在考什么。739考出栈时记录索引差;496考多次查询时如何预处理;503考循环数组如何通过取模模拟。
第四,写代码时注意边界:栈空判断、等于情况的处理、索引差的计算。写完代码后,主动用一个简单例子推演一遍,展示严谨性。
这套流程走下来,你的解题过程就会显得非常完整,暴力和优化两个方案都有展示,比闷头写最优解更能体现功力。
5.3 从这三题到更广的单调栈变体
掌握了这三题,你就掌握了单调栈的基本框架。但单调栈的应用远不止“找下一个更大元素”,它还可以解决“接雨水”“柱状图中最大的矩形”“去除重复字母”等问题。
拿“接雨水”举例,本质上也是找每个位置左右两边的边界,单调栈可以在一次遍历中同时处理好左右边界的信息,思路和739很像,只是记录的内容从“索引差”变成了“可积水量”。如果你能把739彻底吃透,接雨水的单调栈解法对你来说不会太难理解。
再比如“去除重复字母”,要求字典序最小且每个字母只出现一次,这个问题用单调栈配合出现次数统计也很经典。面试中这类题出现频率不低,建议练完这三题后趁热打铁做一遍。
6. 常见问题排查与避坑指南
6.1 典型错误:栈里存索引还是存值
这是新手最容易混淆的地方。739和503存索引,因为要算索引差,或者要通过索引访问原数组的值。496存值,因为题目只要求返回“值”,不需要索引信息。
判断规则很简单:如果答案需要位置关系(比如天数、距离),存索引;如果答案只需要值本身,存值。一旦记反,代码就会变得很别扭,甚至不得不额外开一个数组存位置,那就绕远了。
6.2 典型错误:等于情况处理错误
题目要求“严格大于”,所以比较时要用>而不是>=(739从左往右)或者>=(739从右往左)。在496和503中,这个“等于”问题同样存在。如果允许大于等于,那么遇到相同元素时栈会提前弹出,导致结果把“等于”当作“更大”,答案错误。
我调bug时见过太多这种情况:代码逻辑看起来没问题,一提交答案就错几个case,结果都是比较符号写错了。建议大家写完代码后,专门构造一个含重复元素的测试用例跑一遍,比如[73, 74, 75, 75, 71],看看结果是否符合预期。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 结果数组全是0或-1,没有更新 | 栈为空判断写错,或元素从未出栈 | 检查while条件,确认比较符号是否正确 |
| 索引差算错,如739输出1但应该为2 | 入栈/出栈时用了值而不是索引 | 确认栈中存储的是索引,计算i - idx |
| 496哈希表查不到某个元素 | 预处理时只处理了部分元素,栈内剩余元素没被赋值 | 遍历结束后,把栈中剩余元素统一设为-1 |
| 503结果中后半段覆盖前半段 | 显式拼接数组时索引映射错误 | 改用索引取模方式,避免索引混乱 |
| 503死循环或索引越界 | 循环2n次时忘记取模,或i < n入栈条件缺失 |
确认idx = i % n,并只在第一轮循环中入栈 |
6.4 几个容易被忽略的细节
第一,初始化时别偷懒。739初始化ans全0,496初始化next_greater不初始化但遍历后统一补-1,503初始化ans全-1。这三者的默认值不同,搞混了会导致答案差之毫厘谬以千里。
第二,单调栈解题时,大脑里始终要有一个“状态图”。比如从左往右遍历时,栈底到栈顶是递减的;从右往左遍历时,栈底到栈顶是递增的。这个状态图能帮你快速判断while循环里的比较条件。
第三,如果面试官追问优化,可以从内存角度聊。503的取模方案已经是最优的O(n)空间,但如果题目要求返回的数组不算额外空间,那空间复杂度就是O(1)(只看栈本身)。这种细节在面试里提一句,能加分不少。
6.5 刷题建议:怎么刷才能举一反三
这三道题建议放在一个时间段内集中刷,不要隔几天刷一道。因为它们的核心套路完全一致,连续刷能帮你形成肌肉记忆,把“单调栈”这个知识点内化成条件反射。
具体步骤可以这样:
先独立做739,做完后不看题解,把代码默写一遍,再手动推演一个例子,确认每一步为什么这么写。
然后做496,尝试自己推导“预处理+哈希表”的方案,实在想不出来再看题解,然后关上题目,重新写一遍。
最后做503,先自己想怎么处理循环,可以试着先写暴力解法,再优化成单调栈。
三题做完,最好能总结出一页笔记,内容包括:单调栈的适用场景、两种遍历方向代码模板、几个常见变体的处理方式。这样后续复习时,一页纸就能唤起全部记忆。
我在实际刷题中的体会是:单调栈这个专题,最重要的不是背模板,而是搞清楚一个核心逻辑——当新元素入栈时,它弹出的每一个元素,都是因为“找到了自己的答案”。当你把这句话想透了,单调栈的几百道变体题,对你来说都只是在问“答案具体是什么”而已。
