LeetCode 739. 每日温度:单调栈的第二课与「等谁」的变体

每日温度(LeetCode 739,Medium)是单调栈的经典应用:给每天的气温,问等几天会遇到更暖的日子(没有则记 0)。本库《柱状图中最大的矩形》讲过单调栈的直觉与证明(维护单调性、新元素弹栈结算),这一篇把单调栈模板化——因为「下一个更大元素」家族(每日温度、下一个更大元素 I/II、循环数组版)共享同一套骨架,变体只改「结算的内容」。

模板化写法:栈存下标、新元素弹栈结算

单调栈骨架的三个固定件:栈存下标(不是值——结算需要位置信息来算距离或写回答案);栈内维护单调性(本题为从栈底到栈顶递减——温度还没找到更暖日的日子压栈);新元素到来时弹栈结算(当前温度比栈顶温度高,栈顶的「等待」结束——答案 = 当前下标 − 栈顶下标):

def daily_temperatures(temps: list[int]) -> list[int]:
    n = len(temps)
    answer = [0] * n
    stack = []                          # 存下标,对应温度从栈底到栈顶递减
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:   # 当前温度比栈顶高:结算栈顶
            j = stack.pop()
            answer[j] = i - j             # 等了 i - j 天
        stack.append(i)                   # 自己入栈,继续等
    return answer                         # 栈里剩下的:永远等不到,保持 0

每个下标最多入栈一次、出栈一次,总时间 O(n)——虽然有两层循环,均摊仍是线性(本库《柱状图》讲过的均摊分析在此复用)。为什么单调递减是对的:栈里的日子都在「等更暖」,如果新来的日子比栈里某天暖,那天的答案就确定了;如果比栈顶还冷,它也要等——压栈后栈保持递减,未来任何更暖日会按「从近到远」依次结算栈里所有等待者。

变体家族:骨架不变,只改「结算的内容」

单调栈家族的变体,识别方法是看「结算什么」。变体一:下一个更大元素 I(LeetCode 496)——结算时记下「answer[j] = 当前值」(答案是值而不是距离);变体二:下一个更大元素 II(循环数组)(LeetCode 503)——数组首尾相接,遍历两遍(i 范围 2n-1,下标取模),第二遍只结算不重复入栈(本库语境里的循环展开技巧);变体三:每日温度的「等更冷」——单调性翻转(栈维护递增,找下一个更小)。变体四(进阶):前面而不是后面——「前一个更大元素」:从右往左遍历,或结算时 answer[i] = 栈顶下标。四个变体共用 90% 的代码——这就是「模板化」的价值:识别出题属于单调栈家族后,写代码是填空题。

识别特征:什么问题该想到单调栈

单调栈的适用问题有一个可识别的特征:「对每个元素,找左/右方向上第一个满足某单调条件(更大/更小)的元素」——这个问题形态出现时,单调栈的 O(n) 解法就该出场。对照的反例:如果找的是「全局最大」(不需要方向性)——一次 max 遍历即可;如果找的是「任意满足」(不要求第一个)——集合或二分可能更合适。与其它结构的对比:同样的问题用「从右往左 + 有序集合」也能做(O(n log n)——对每个位置查右侧第一个大于它的,用二分),但单调栈的 O(n) 与 O(1) 均摊栈操作更优;单调栈 vs 滑动窗口(本库《滑动窗口最大值》):滑动窗口是「区间约束下的最值」(窗口滑动,栈外有边界),单调栈是「方向约束下的下一个」(无窗口,只有方向)——两者长得像,适用问题的形态不同。识别口诀:问题在问「下一个/前一个 更 X」→ 单调栈;问题在问「窗口内最 X」→ 单调队列;问题在问「历史上最 X」→ 一次遍历维护——三句话覆盖了 80% 的栈类题。

单调栈的「结算时机」:入栈、出栈与最后清栈

单调栈模板里藏着三个「结算时机」,分清它们是进阶的关键。时机一:出栈结算(最常用)——新元素触发弹栈时结算(每日温度:弹栈时算等待天数;柱状图:弹栈时算以弹出柱为高的矩形面积)——结算发生在「元素的答案被确定」的时刻。时机二:入栈结算(较少)——入栈前与栈顶比较时结算(求「前一个更大元素」时,栈顶就是答案——入栈时的栈顶即「左侧第一个更大」)。时机三:最后清栈(易漏)——遍历结束后,栈里还剩「永远没有等到触发」的元素——它们的答案要么是默认值(0、n——本库每日温度的栈底残留),要么需要一次「虚拟清算」(柱状图的经典技巧:遍历结束后在数组末尾追加一个高度为 0 的虚拟柱,强制清算所有残留——虚拟清算把「收尾逻辑」消灭在主管道里,代码更短更不易错)。三个时机的选择决定代码形态:出栈结算的代码最常见(本题),「需要收尾」的场景记住虚拟清算的技巧——单调栈的 bug 清单里,「忘了最后清栈」排在第一位,虚拟柱是它的疫苗。

单调栈的栈顶重复处理:等号时弹不弹

单调栈模板有一个微妙的分支值得单独讲:当前元素等于栈顶时,弹还是不弹。以每日温度为例(找「严格更暖」):相等时不弹(相等不构成「更暖」,栈顶还在等)——这个分支的正确答案依赖题意的「严格」与「非严格」。对比「下一个更大元素」:相等时弹不弹都行——弹(严格更大才结算)与不弹(相等也结算,答案是「大于等于」)——两种语义对应两道不同的题(LeetCode 503 要求严格更大——不弹相等;某些变体要「大于等于」——弹相等)。弹与不弹的正确性影响:弹相等——栈内严格递减、相同值只保留最后一个(下标最大的那个——它的答案覆盖前面的同值者);不弹相等——栈内非严格递减、相同值都在栈里(每个同值者都等到自己的答案——但答案相同)。两种处理对答案的最终分布等价,对「中间过程」不同——选哪种的标准是题意(严格还是非严格)与后续的用法(栈内还要不要参与其他结算)。这个细节的元教训:单调栈的「单调性」定义(严格/非严格)是设计决策不是默认值——每道题显式地问自己一遍「相等怎么办」,栈的专题就没有暗礁了。

单调栈的「均摊分析」:为什么双层循环还是 O(n)

每日温度的代码有两层循环(外层遍历每个元素,内层 while 弹栈),直觉上像 O(n²)——但实际上是 O(n)。证明的关键是均摊分析(amortized analysis):每个元素最多入栈一次、最多出栈一次——入栈的总次数是 n,出栈的总次数也不可能超过 n(栈里只有 n 个元素可弹)——总操作数 ≤ 2n,时间 O(n)。均摊分析的一般原则:当「内层循环的总执行次数」被「外层变量的总量」限制时(内层的每次执行都消耗一个有限的资源——本题的栈元素),总代价是外层次数的常数倍——不是「每次外层平均花常数」(那是平均),是「全体加起来不超过常数 × 外层」——这是比平均更强的保证。均摊分析的思想在本库多篇出现(Kahn 算法的节点出队、并查集的路径压缩)——均摊分析是「两层循环但不 O(n²)」的最常见解释——学会了它,双层循环的复杂度判断就有了理论工具。

单调栈的结算时机:入栈、出栈与最后清栈

单调栈模板里藏着三个结算时机,分清它们是进阶的关键。时机一:出栈结算(最常用)——新元素触发弹栈时结算(每日温度:弹栈时算等待天数)。时机二:入栈结算(较少)——入栈前与栈顶比较时结算。时机三:最后清栈(易漏)——遍历结束后栈里还剩永远没有等到触发的元素——它们的答案要么是默认值,要么需要一次虚拟清算(柱状图的经典技巧:遍历结束后在数组末尾追加一个高度为 0 的虚拟柱,强制清算所有残留——虚拟清算把收尾逻辑消灭在主管道里)。三个时机的选择决定代码形态:出栈结算的代码最常见,需要收尾的场景记住虚拟清算的技巧——单调栈的 bug 清单里忘了最后清栈排在第一位,虚拟柱是它的疫苗。

单调栈的「双向变体」:前一个更大与后一个更大

单调栈有一个双向的变体值得认识:同时求每个元素的「前一个更大」和「后一个更大」。一次遍历可以同时得到两个方向的信息:从左到右遍历时维护单调递减栈,弹出栈顶时,当前元素就是栈顶的「下一个更大」,栈顶下方就是栈顶的「前一个更大」——一次遍历同时确定两个方向的答案。这个双向信息的组合可以解决更复杂的问题:每个元素的「跨度」(它作为最大值的范围 = 后一个更大 − 前一个更大 − 1)——这正是《柱状图中最大的矩形》的核心计算(本库前篇的单调栈应用)。双向变体的教学价值:它演示了单调栈的「一次遍历双向结算」的能力——不是只能找单向的下一个更大——而是同时确定两侧的边界——这种双向结算的思路在区间问题和直方图问题中广泛使用。

小结

这篇收成四条:单调栈模板三固定件——栈存下标、栈内单调(递减等更暖)、新元素弹栈结算(答案 = 下标差),总时间 O(n) 均摊;变体家族只改「结算的内容」——值、距离、循环取模、方向反转,骨架 90% 复用;识别特征是「对每个元素找方向上第一个满足单调条件的元素」——与滑动窗口(区间最值)、一次遍历(历史最值)的边界要分清;「下一个更大元素 II」的循环数组用遍历两遍 + 下标取模解决——循环结构的展开是通用技巧。单调栈两课(柱状图 + 本篇)之后,栈的专题只剩设计题(最小栈)——栈家族的知识地图接近完整。

参考资料

  • LeetCode 739. Daily Temperatures —— 题目与官方题解
  • 本库前篇:《LeetCode 84. 柱状图中最大的矩形:单调栈的直觉与证明》《LeetCode 155. 最小栈》《滑动窗口最大值:单调队列》
  • LeetCode 496/503. 下一个更大元素 I & II —— 变体家族的练习题
← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?