跳转到正文
AstroPaper
返回

单调栈入门:从“右边第一个更大的数”讲起

编辑页面
Note

这篇是 LeetCode 42. 接雨水 的前置知识。文中所有 Python 代码都通过了 ruff 检查,并和暴力解法做了 3000 组随机对拍。

Table of contents

Open Table of contents

先认识“栈”

栈(stack)是一种只能从一头放、从同一头拿的容器。最好的类比是一摞盘子:

所以最后放上去的,最先被拿走,这叫“后进先出”。最上面那个位置叫“栈顶”,最下面叫“栈底”。

在 Python 里,一个普通的 list 就能当栈用,只需要三个操作:

stack: list[int] = []

stack.append(3)  # 放一个到栈顶:[3]
stack.append(1)  # 再放一个:[3, 1],栈顶是 1
top = stack[-1]  # 看一眼栈顶,不拿走:1
stack.pop()  # 拿走栈顶:[3]

写成文字时,本文统一把栈画成从左到右的一排,左边是栈底,右边是栈顶,比如 [3, 1] 表示 3 在下面、1 在上面。

“单调栈”是什么

单调栈就是栈里的元素从栈底到栈顶始终保持单调(一直变小,或者一直变大)的栈。

光看定义很难理解它有什么用。单调栈不是凭空设计出来的,而是在解决一类问题时自然出现的。下面就从这个问题讲起。

从一个问题开始:右边第一个更大的数

给一个整数数组,对每个元素,找出它右边第一个比它大的元素。找不到就记为 -1。

例子:nums = [3, 1, 2, 5, 4]

下标元素往右找第一个更大的答案
031 不行,2 不行,5 可以5
112 可以2
225 可以5
354 不行,到头了-1
44右边没有元素-1

答案是 [5, 2, 5, -1, -1]。

暴力做法

就照上面表格的方式:对每个元素,往右一个一个找。

def next_greater_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for later in nums[i + 1 :]:
            if later > num:
                answer[i] = later
                break
    return answer

最坏情况下(比如数组一直递减,谁都找不到答案),每个元素都要扫到底,时间复杂度 O(n²)。

慢的原因是:同一段元素被反复扫描。处理下标 0 时已经看过了 1、2、5,处理下标 1 时又从头看一遍。

换个方向:让元素“进场”,看谁能拿到答案

暴力做法是“每个元素主动往右找答案”。换个角度:让元素从左到右一个一个进场,每进来一个,看它能成为谁的答案。

把每个数想象成一个人,数值就是他的身高。每个人都想知道:“我右边第一个比我高的人是谁?”

一步一步走一遍

还是 nums = [3, 1, 2, 5, 4]。等待区按进来的先后从左往右排。

第 1 步:3 进场

第 2 步:1 进场

第 3 步:2 进场

第 4 步:5 进场

第 5 步:4 进场

全部进场后:等待区里剩下 5 和 4,它们的答案都是 -1。

汇总成一张表:

步骤进场拿到答案的进场后的等待区
13无[3]
21无[3, 1]
321 → 2[3, 2]
452 → 5,3 → 5[5]
54无[5, 4]
结束5 → -1,4 → -1

答案:3→5、1→2、2→5、5→-1、4→-1,也就是 [5, 2, 5, -1, -1],和暴力做法一样。

关键发现:等待区一定是从左到右递减的

看最后一列:[3]、[3, 1]、[3, 2]、[5]、[5, 4],每一个都是从左到右越来越矮。这不是巧合,而是必然:

假设等待区里有一个矮个子 A 站在高个子 B 的左边(A 先进来,B 后进来,A 比 B 矮)。那么 B 进场的那一刻,A 就应该拿到答案离开了。所以这种情况不可能存在。

既然等待区一定是递减的,就能得到两个很实用的结论:

  1. 新人进场时,从最后进来的人开始往前检查就行。 遇到第一个不比新人矮的人,就可以停下了,因为再往前的人只会更高,同样拿不到答案。
  2. 每次离开的、新加入的,都在等待区的同一头(最右边)。 这正是栈的“后进先出”。

这个永远递减的等待区,就是单调递减栈。 它不是被刻意设计成递减的,而是“没找到答案的人留下来”这条规则自然导致的。

写成代码

实际写代码时,栈里存的通常是下标而不是值。因为拿到答案时,我们要知道是哪个位置的答案,存下标既能找回值(nums[i]),又能填对位置。

def next_greater(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    waiting: list[int] = []  # 还没找到答案的下标,对应的值从栈底到栈顶递减

    for i, num in enumerate(nums):
        # 新元素比栈顶大:栈顶等到了答案
        while waiting and num > nums[waiting[-1]]:
            answer[waiting.pop()] = num
        waiting.append(i)

    return answer

这里特意把栈命名为 waiting,强调它的含义:还在等答案的元素。循环结束后还在栈里的元素不用处理,因为 answer 一开始就填好了 -1。

对照上面的步骤看代码:

为什么是 O(n)

代码里有两层循环,看起来像 O(n²),其实不是。换个角度数:每个下标最多进栈一次、出栈一次。while 循环每执行一次就有一个元素出栈,整个过程加起来最多出栈 n 次。所以总的时间复杂度是 O(n),空间复杂度也是 O(n)(最坏情况下所有元素都在栈里,比如数组一直递减)。

一个变化:要的是“距离”而不是“值”

很多题目问的不是“右边第一个更大的数是多少”,而是“要往右走几步才能遇到它”。比如:给你每天的气温,问每一天要再等几天才会变暖。

这时栈里存下标的好处就体现出来了:拿到答案时,用当前下标减去栈顶下标,就是距离。

def days_until_warmer(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    waiting: list[int] = []  # 还没等到更暖一天的日期

    for today, temperature in enumerate(temperatures):
        while waiting and temperature > temperatures[waiting[-1]]:
            day = waiting.pop()
            answer[day] = today - day
        waiting.append(today)

    return answer

days_until_warmer([3, 1, 2, 5, 4]) 的结果是 [3, 1, 1, 0, 0]:第 0 天要等 3 天(到第 3 天的 5 度),第 1 天等 1 天,以此类推;等不到的记为 0。

这正是 Hot 100 里的 739. 每日温度。

一个附赠:入栈时的栈顶,是“左边第一个不比它小的”

单调栈还有一个容易被忽略的性质。新元素在弹完比它小的元素、准备入栈时,此刻的栈顶,就是它左边第一个大于或等于它的元素。

还是看上面的步骤:

原因和前面一样:比它小的都被弹走了,剩下的栈顶就是离它最近的、不比它小的那个。

def previous_greater_or_equal(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    stack: list[int] = []

    for i, num in enumerate(nums):
        while stack and num > nums[stack[-1]]:
            stack.pop()
        if stack:
            answer[i] = nums[stack[-1]]
        stack.append(i)

    return answer

previous_greater_or_equal([3, 1, 2, 5, 4]) 的结果是 [-1, 3, 3, -1, 5]。

也就是说,一次扫描能同时得到两样东西:一个元素出栈时,让它出栈的新元素是它“右边第一个更大的”,出栈后的新栈顶是它“左边第一个不比它小的”。接雨水的单调栈解法用的正是这一点:左右两个“更高的”元素,就是一个坑的左墙和右墙。

模板和变体

把上面的代码抽象一下,单调栈的骨架只有几行(这是伪代码,应该弹出、处理 要按题目替换):

stack = []
for i, num in enumerate(nums):
    while stack 不空 and 应该弹出(nums[stack[-1]], num):
        top = stack.pop()
        处理 top:num 是 top 右边第一个满足条件的元素
    处理 i:如果 stack 不空,stack[-1] 是 i 左边第一个不满足弹出条件的元素
    stack.append(i)

改一改弹出条件,就能找不同的东西:

想找什么栈的单调性(栈底 → 栈顶)弹出条件
右边第一个更大的递减num > nums[stack[-1]]
右边第一个更小的递增num < nums[stack[-1]]
右边第一个大于或等于的严格递减num >= nums[stack[-1]]
右边第一个小于或等于的严格递增num <= nums[stack[-1]]

记忆方法:要找更大的,就让小的被弹走,栈里自然留下递减的序列;要找更小的,反过来。

什么时候该想到单调栈

题目里出现这些说法时,就可以考虑单调栈:

Hot 100 里用到单调栈的题:

题目用法
739. 每日温度右边第一个更大的元素,求距离,几乎就是本文的例子
42. 接雨水递减栈,出栈时找到坑底、左墙、右墙,按层算水
84. 柱状图中最大的矩形递增栈,找每根柱子左右两边第一个更矮的柱子

容易踩的坑

  1. 栈里存值还是存下标:大多数题目要存下标。存下标能找回值,也能算距离、填答案;只存值的话,这些信息就丢了。
  2. 严格比较还是非严格比较:> 和 >= 决定了相等的元素会不会互相弹出,结果可能不同。写之前想清楚“相等算不算更大”。
  3. 忘了处理循环结束后栈里剩下的元素:它们是“到最后都没等到答案”的元素。本文的写法是一开始就把答案初始化成 -1 或 0,所以不用额外处理;如果题目对它们有特殊要求,记得在循环后单独处理。
  4. 担心复杂度是 O(n²):别被两层循环吓到,按“每个元素最多进出栈各一次”来数,是 O(n)。

一句话总结

单调栈就是一个“还在等答案的人”的等待区。 新元素进来时,把比它小(或大)的都结算掉,剩下的自然保持单调。它能在 O(n) 时间内,为每个元素找到左边或右边第一个更大(或更小)的元素。

附:完整测试代码

把文中的三个函数和对应的暴力解法放在一起,做 3000 组随机对拍:

"""对拍测试:以暴力解法为基准,验证单调栈的几种写法。"""

import random
from collections.abc import Callable


def next_greater_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for later in nums[i + 1 :]:
            if later > num:
                answer[i] = later
                break
    return answer


def next_greater(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    waiting: list[int] = []

    for i, num in enumerate(nums):
        while waiting and num > nums[waiting[-1]]:
            answer[waiting.pop()] = num
        waiting.append(i)

    return answer


def days_until_warmer(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    waiting: list[int] = []

    for today, temperature in enumerate(temperatures):
        while waiting and temperature > temperatures[waiting[-1]]:
            day = waiting.pop()
            answer[day] = today - day
        waiting.append(today)

    return answer


def previous_greater_or_equal(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    stack: list[int] = []

    for i, num in enumerate(nums):
        while stack and num > nums[stack[-1]]:
            stack.pop()
        if stack:
            answer[i] = nums[stack[-1]]
        stack.append(i)

    return answer


def days_until_warmer_brute_force(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    for day, temperature in enumerate(temperatures):
        for later in range(day + 1, len(temperatures)):
            if temperatures[later] > temperature:
                answer[day] = later - day
                break
    return answer


def previous_greater_or_equal_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for earlier in reversed(nums[:i]):
            if earlier >= num:
                answer[i] = earlier
                break
    return answer


Scan = Callable[[list[int]], list[int]]

# (单调栈写法, 对应的暴力写法)
PAIRS: list[tuple[Scan, Scan]] = [
    (next_greater, next_greater_brute_force),
    (days_until_warmer, days_until_warmer_brute_force),
    (previous_greater_or_equal, previous_greater_or_equal_brute_force),
]


def check_fixed_cases() -> None:
    assert next_greater([3, 1, 2, 5, 4]) == [5, 2, 5, -1, -1]
    assert next_greater([]) == []
    assert days_until_warmer([3, 1, 2, 5, 4]) == [3, 1, 1, 0, 0]
    assert previous_greater_or_equal([3, 1, 2, 5, 4]) == [-1, 3, 3, -1, 5]


def check_random_cases(rounds: int = 3000, seed: int = 0) -> None:
    rng = random.Random(seed)
    for _ in range(rounds):
        nums = [rng.randint(0, 6) for _ in range(rng.randint(0, 20))]
        for fast, slow in PAIRS:
            assert fast(nums) == slow(nums), f"{fast.__name__}({nums})"


def main() -> None:
    check_fixed_cases()
    check_random_cases()
    print("all passed")


if __name__ == "__main__":
    main()

编辑页面
分享这篇文章:

上一篇
LeetCode 42. Trapping Rain Water: Four Approaches, from Brute Force to Two Pointers