跳转到正文
AstroPaper
返回

LeetCode 42. 接雨水:从暴力到双指针,四种思路一次讲透

编辑页面
Note

题目链接:42. 接雨水(LeetCode Hot 100,难度:困难)。 文中的四种 Python 解法都通过了 ruff 格式与静态检查,以及固定用例加 3000 组随机数据的对拍测试。

Table of contents

Open Table of contents

题目

力扣上的题目描述只有一句话:

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

示例 1

输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6

(原题这里配了一张高度图,用蓝色标出了这 6 个单位的雨水。)

示例 2

输入:height = [4,2,0,3,2,5]
输出:9

提示

题目在说什么

换成大白话:给你一个非负整数数组 height,每个数代表一根宽度为 1 的柱子的高度,柱子从左到右紧挨着排开。下一场雨之后,柱子之间的凹槽会存住水。问:一共能存多少格水?

拿示例 2 的 height = [4, 2, 0, 3, 2, 5] 来说,画出来是这样(█ 是柱子,~ 是存住的水):

5 |           █
4 | █ ~ ~ ~ ~ █
3 | █ ~ ~ █ ~ █
2 | █ █ ~ █ █ █
1 | █ █ ~ █ █ █
  +------------
    0 1 2 3 4 5

数一数 ~:下标 1 有 2 格,下标 2 有 4 格,下标 3 有 1 格,下标 4 有 2 格,一共 9 格。

示例 1 也可以照这样画出来数,结果是 6 格,你可以自己试一试。

这道题难就难在:一眼看过去,水是“一块一块”的,形状不规则,不知道从哪下手。下面先找到一个关键的切入点,后面四种解法都建立在它上面。

关键洞察:一根柱子头顶能存多少水?

别去想整块水的形状,只盯着一根柱子看:它头顶上能积多少水?

想象你站在下标 i 的柱子上。水要存得住,左边和右边都得有“墙”挡着:

水面能到多高,取决于两堵墙里矮的那一堵(木桶效应:水会从矮的那边溢出去)。所以:

下标 i 的积水量 = min(left_max, right_max) - height[i]

这里的 left_max 和 right_max 包含柱子 i 自己。这样当 i 本身就是最高点时,min(...) 等于 height[i],积水量是 0,不会出现负数。

用这个公式验证一下上面的例子:

下标 i012345
height420325
left_max444445
right_max555555
min444445
积水024120

加起来是 9,和画图数出来的一样。

到这里,题目就变成了:怎么快速求出每个位置的 left_max 和 right_max? 下面四种解法,就是对这个问题的四种回答。

解法一:暴力(先保证对,再想快)

最直接的做法:对每个位置,现场往左扫一遍找最大值,再往右扫一遍找最大值。

class Solution:
    def trap(self, height: list[int]) -> int:
        water = 0
        for i, bar in enumerate(height):
            # 两侧最高值都包含当前柱子,保证积水量不会是负数
            left_max = max(height[: i + 1])
            right_max = max(height[i:])
            water += min(left_max, right_max) - bar
        return water

这个解法在 LeetCode 上会超时,但它非常有价值:它直接对应关键洞察里的公式,后面的优化都是在它的基础上减少重复计算。写其他解法时,也可以拿它当“标准答案”来对拍。

解法二:预处理数组(动态规划)

暴力解法慢在哪?每个位置都重新扫一遍,可相邻位置的 left_max 其实只差一点:

left_max[i] = max(left_max[i - 1], height[i])

也就是说,从左往右走一遍,就能把所有的 left_max 都算出来。right_max 同理,从右往左走一遍。

这种“逐个累积”的计算,Python 标准库里正好有现成的工具:itertools.accumulate(height, max) 会依次产出 height[0]、max(height[0], height[1])、max(前面的结果, height[2])……也就是每个位置的前缀最大值。把数组反转后做同样的事,再反转回来,就得到了后缀最大值。

from itertools import accumulate


class Solution:
    def trap(self, height: list[int]) -> int:
        # left_max[i]:height[0..i] 中的最高柱子
        left_max = list(accumulate(height, max))
        # right_max[i]:height[i..n-1] 中的最高柱子
        right_max = list(accumulate(reversed(height), max))[::-1]

        return sum(
            min(left, right) - bar
            for left, right, bar in zip(left_max, right_max, height)
        )

这是最推荐先掌握的解法:思路直白,和公式一一对应,不容易写错。另外它对空数组也是安全的:accumulate 和 zip 遇到空输入都会产出空序列,sum 的结果自然是 0,不需要单独判断。面试时先写出它,再优化到双指针,是很自然的节奏。

解法三:双指针(把空间省到 O(1))

解法二要存两个数组,能不能不存?

再看一眼公式:积水量只取决于 min(left_max, right_max),也就是两边较矮的那一个。我们其实不需要同时精确知道两个值,只要能确定“哪一边更矮”就够了。

做法是:用 left、right 两个指针从两端往中间走,一路维护:

每一步比较 left_max 和 right_max:

一句话:哪边的最高值更矮,哪边的积水量就已经确定了,先结算那一边。

class Solution:
    def trap(self, height: list[int]) -> int:
        left, right = 0, len(height) - 1
        left_max = right_max = 0
        water = 0

        while left < right:
            left_max = max(left_max, height[left])
            right_max = max(right_max, height[right])

            if left_max < right_max:
                # 左侧最高值更矮:left 处的水位已经确定
                water += left_max - height[left]
                left += 1
            else:
                # 右侧最高值更矮(或相等):right 处的水位已经确定
                water += right_max - height[right]
                right -= 1

        return water

用 [4, 2, 0, 3, 2, 5] 走一遍:

步骤leftrightleft_maxright_max结算本步积水累计
10545左边,下标 04 - 4 = 00
21545左边,下标 14 - 2 = 22
32545左边,下标 24 - 0 = 46
43545左边,下标 34 - 3 = 17
54545左边,下标 44 - 2 = 29

left 走到 5 和 right 相遇,循环结束,答案 9。

为什么循环条件是 left < right,相遇的那个位置不用算吗? 两个指针相遇的地方,一定是全局最高的柱子之一(较矮的一侧总是先移动,最高的柱子会一直“守”到最后),最高柱子头顶不会积水,所以不用算。

这是这道题的最优解。它的难点在于想清楚“为什么可以只看较矮的一边”,上面加粗的那句话就是全部的道理。

解法四:单调栈(换个角度:按层算水)

Tip

这个解法要用到单调栈。如果你还不熟悉它,建议先读 单调栈入门:从“右边第一个更大的数”讲起,读完再回来,下面的内容会轻松很多。

换一种切法:把水横着切成一条一条

前三种解法都是竖着切:一根柱子一根柱子地算,每根头顶有几格水。单调栈换成横着切:把水切成一条一条的“水条”,一条一条地算。

还是 [4, 2, 0, 3, 2, 5],把 9 格水横着分成 4 条,分别用字母 A、B、C、D 标出来:

5 |           █
4 | █ D D D D █
3 | █ B B █ C █
2 | █ █ A █ █ █
1 | █ █ A █ █ █
  +------------
    0 1 2 3 4 5
水条位置格数
A下标 2,第 1、2 层2
B下标 1、2,第 3 层2
C下标 4,第 3 层1
D下标 1 到 4,第 4 层4

加起来是 9,和前面的答案一样。

每一条水,都被三根柱子围住

仔细看每一条水,它都被三根柱子围住:下面是坑底,左边是左墙,右边是右墙。

以 A 为例:坑底是下标 2(高 0),左墙是下标 1(高 2),右墙是下标 3(高 3)。

只要知道这三根柱子,这条水就能算出来:

宽度 = 右墙下标 - 左墙下标 - 1
深度 = min(左墙高度, 右墙高度) - 坑底高度
格数 = 宽度 × 深度

四条水全部算一遍:

水条坑底左墙右墙宽度深度格数
A下标 2(高 0)下标 1(高 2)下标 3(高 3)3 - 1 - 1 = 1min(2, 3) - 0 = 22
B下标 1(高 2)下标 0(高 4)下标 3(高 3)3 - 0 - 1 = 2min(4, 3) - 2 = 12
C下标 4(高 2)下标 3(高 3)下标 5(高 5)5 - 3 - 1 = 1min(3, 5) - 2 = 11
D下标 3(高 3)下标 0(高 4)下标 5(高 5)5 - 0 - 1 = 4min(4, 5) - 3 = 14

注意 B 的坑底是下标 1(高 2),而不是更低的下标 2。因为 B 下面那部分已经算进 A 了,B 只负责高度 2 到 3 之间的这一层。所以这里的“坑底”,指的是这一条水的底面落在哪根柱子上。

三根柱子怎么找?正好是单调栈

再看一遍每条水的三根柱子,会发现它们有固定的关系:

比如 A 的坑底是下标 2(高 0):往右看,第一根比 0 高的是下标 3;往左看,第一根不比 0 矮的是下标 1。

这两个“第一根”,正是单调栈入门最后讲到的:一个元素出栈时,让它出栈的新元素是它右边第一个更大的;出栈后的新栈顶,是它左边第一个不比它小的。

所以做法就是:从左到右,一根一根地把柱子放进一个单调递减栈。每当新来的柱子比栈顶高:

  1. 弹出栈顶,它就是坑底;
  2. 弹出之后的新栈顶,就是左墙;
  3. 新来的这根柱子,就是右墙;
  4. 三根柱子都有了,算出这一条水。

为什么栈是递减的?和单调栈入门里的“等待区”一样:栈里放的是还没等到右墙的柱子。如果一根矮柱子左边站着一根更矮的,那根更矮的早就等到右墙、被弹走了。

一步一步走一遍

栈写成从左到右一排,左边是栈底,右边是栈顶,每个元素写成 下标(高度)。

第 1 步:下标 0(高 4)进来

第 2 步:下标 1(高 2)进来

第 3 步:下标 2(高 0)进来

到这里,柱子一路在变矮,谁都还没有右墙,所以一格水都算不出来。它们都在栈里等着。

第 4 步:下标 3(高 3)进来

此刻已经算出的水(. 表示还没算到的水):

5 |           █
4 | █ . . . . █
3 | █ B B █ . █
2 | █ █ A █ █ █
1 | █ █ A █ █ █
  +------------
    0 1 2 3 4 5

第 5 步:下标 4(高 2)进来

第 6 步:下标 5(高 5)进来

全部柱子都处理完,四条水都算出来了:

5 |           █
4 | █ D D D D █
3 | █ B B █ C █
2 | █ █ A █ █ █
1 | █ █ A █ █ █
  +------------
    0 1 2 3 4 5

答案是 9。

写成代码

class Solution:
    def trap(self, height: list[int]) -> int:
        stack: list[int] = []  # 存下标,对应高度从栈底到栈顶单调递减
        water = 0

        for right, bar in enumerate(height):
            # 当前柱子比栈顶高:栈顶成了坑底,结算这一层水
            while stack and bar > height[stack[-1]]:
                bottom = stack.pop()
                if not stack:
                    break  # 坑底左边没有墙,水存不住

                left = stack[-1]
                width = right - left - 1
                depth = min(height[left], bar) - height[bottom]
                water += width * depth

            stack.append(right)

        return water

把代码和上面的步骤对上:

代码对应步骤里的
for right, bar in enumerate(height)下标 right(高 bar)进来,它是潜在的右墙
while stack and bar > height[stack[-1]]新柱子比栈顶高,可以结算
bottom = stack.pop()弹出栈顶,它是坑底
if not stack: break弹出后栈空,没有左墙,停下(第 6 步的最后一次弹出)
left = stack[-1]新栈顶是左墙
width、depth、water += ...算出这一条水
stack.append(right)新柱子放进栈里,等它自己的右墙

单调栈在这道题上不比双指针更优,但它的价值在于思路可以迁移:“找左右两边第一个更大(或更小)的元素”是单调栈的标准用法,739. 每日温度 和 84. 柱状图中最大的矩形 都是同一个套路,后者也在 Hot 100 里。

关于代码写法的几点约定

四段代码遵循同一套约定,读起来可以互相对照:

四种解法对比

解法时间空间思路什么时候用
暴力O(n²)O(1)按列,现场找两边最高理解题意、对拍验证
预处理数组O(n)O(n)按列,提前算好两边最高最推荐先掌握,不易写错
双指针O(n)O(1)按列,只结算较矮的一边最优解,面试加分
单调栈O(n)O(n)按层,找到凹槽就结算一层练单调栈,迁移到其他题

容易踩的坑

  1. 空数组:如果用下标手动初始化前缀数组(比如 left_max[0] = height[0]),空数组会越界,需要先判断。本文的四种写法都天然支持空数组:暴力和单调栈的循环不会执行,双指针的 0 < -1 不成立,accumulate 产出空序列。
  2. 积水量算成负数:left_max 和 right_max 要包含当前柱子自己,这样 min(...) - height[i] 最小是 0。
  3. 单调栈弹出后栈空:说明坑底左边没有墙,水会流走,要直接 break,不能继续算。
  4. 单调栈里的比较用 > 还是 >=:这里用 >。遇到等高的柱子时直接入栈,它和前一根等高柱子之间算出的水高度为 0,不影响结果。

一句话总结

一个位置能存多少水,取决于它左右两边最高的柱子中较矮的那一个。 暴力、预处理数组、双指针都是在用不同的方式求这两个最高值;单调栈则换了个方向,按层把水一条条算出来。

附:完整测试代码

把四种解法改写成普通函数放在一起,先跑固定用例,再用暴力解法作为基准做 3000 组随机对拍。随机数用独立的 random.Random(seed) 实例,结果可以复现,也不会影响全局的随机状态:

"""对拍测试:以暴力解法为基准,验证另外三种解法。"""

import random
from collections.abc import Callable
from itertools import accumulate


def trap_brute_force(height: list[int]) -> int:
    water = 0
    for i, bar in enumerate(height):
        left_max = max(height[: i + 1])
        right_max = max(height[i:])
        water += min(left_max, right_max) - bar
    return water


def trap_prefix_max(height: list[int]) -> int:
    left_max = list(accumulate(height, max))
    right_max = list(accumulate(reversed(height), max))[::-1]
    return sum(
        min(left, right) - bar for left, right, bar in zip(left_max, right_max, height)
    )


def trap_two_pointers(height: list[int]) -> int:
    left, right = 0, len(height) - 1
    left_max = right_max = 0
    water = 0

    while left < right:
        left_max = max(left_max, height[left])
        right_max = max(right_max, height[right])

        if left_max < right_max:
            water += left_max - height[left]
            left += 1
        else:
            water += right_max - height[right]
            right -= 1

    return water


def trap_monotonic_stack(height: list[int]) -> int:
    stack: list[int] = []
    water = 0

    for right, bar in enumerate(height):
        while stack and bar > height[stack[-1]]:
            bottom = stack.pop()
            if not stack:
                break

            left = stack[-1]
            width = right - left - 1
            depth = min(height[left], bar) - height[bottom]
            water += width * depth

        stack.append(right)

    return water


SOLUTIONS: list[Callable[[list[int]], int]] = [
    trap_brute_force,
    trap_prefix_max,
    trap_two_pointers,
    trap_monotonic_stack,
]

FIXED_CASES: list[tuple[list[int], int]] = [
    ([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1], 6),
    ([4, 2, 0, 3, 2, 5], 9),
    ([], 0),
    ([5], 0),
    ([1, 2, 3], 0),
    ([3, 2, 1], 0),
    ([3, 0, 3], 3),
    ([5, 1, 1, 1, 5], 12),
]


def check(solve: Callable[[list[int]], int], height: list[int], expected: int) -> None:
    actual = solve(height)
    assert actual == expected, (
        f"{solve.__name__}({height}) returned {actual}, expected {expected}"
    )


def check_fixed_cases() -> None:
    for height, expected in FIXED_CASES:
        for solve in SOLUTIONS:
            check(solve, height, expected)


def check_random_cases(rounds: int = 3000, seed: int = 0) -> None:
    rng = random.Random(seed)
    for _ in range(rounds):
        height = [rng.randint(0, 8) for _ in range(rng.randint(0, 25))]
        expected = trap_brute_force(height)
        for solve in SOLUTIONS[1:]:
            check(solve, height, expected)


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


if __name__ == "__main__":
    main()

编辑页面
分享这篇文章:

上一篇
Reading Notes: Skills Improvements in Deep Agents
下一篇
Monotonic Stacks from Scratch: Starting with the Next Greater Element