LeetCode 42 · 接雨水:从暴力到双指针的三步优化

这是经典中的经典。柱子之间能接多少雨水,本质上是问"每个位置上方能存多少水"。这道题的演进路径非常完整------从 O(n²) 暴力,到 O(n) 空间的动态规划,再到 O(1) 空间的双指针。把这条路径走一遍,等于把"用空间换时间,再用逻辑换空间"的优化思想完整演练了一次。


题目长什么样

给定 n 个非负整数,表示每个宽度为 1 的柱子的高度,柱子按顺序排成一排。下雨后,柱子之间能接多少单位雨水。

输入height = [0,1,0,2,1,0,1,3,2,1,2,1]

输出6

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

输出9

说人话:每个位置 i 上方能存的水量 = min(它左边最高柱子, 它右边最高柱子) - 它自身高度,所有位置水量求和就是答案。负值按 0 算。


第一反应:每个位置枚举左右最大值

最直接的想法------按上面的定义直接实现。对每个位置 i,分别向左扫一遍找最大值,向右扫一遍找最大值,再用公式算该位置能存多少水。

python 复制代码
class SolutionBrute:
    def trap(self, height: List[int]) -> int:
        n = len(height)
        water = 0
        for i in range(n):
            left_max = max(height[:i + 1]) if height[:i + 1] else 0
            right_max = max(height[i:]) if height[i:] else 0
            water += min(left_max, right_max) - height[i]
        return water
维度 说明
时间 O(n²) 每个位置都重新扫描左右两侧
空间 O(1) 不算切片的话

暴力法的问题很清楚:对每个 ileft_maxright_max 都是临时算的,相邻位置之间存在大量重复扫描。比如位置 ii+1left_max 几乎是同一份数据,只是多了一个元素。


第二步:动态规划预处理左右最大值

既然每个位置的 left_max/right_max 反复被重算,那就预处理好存起来。

text 复制代码
left_max[i]  = max(height[0..i])     # 从左往右扫一遍
right_max[i] = max(height[i..n-1])   # 从右往左扫一遍
water[i]     = min(left_max[i], right_max[i]) - height[i]
python 复制代码
class SolutionDP:
    def trap(self, height: List[int]) -> int:
        n = len(height)
        if n == 0:
            return 0

        left_max = [0] * n
        right_max = [0] * n

        left_max[0] = height[0]
        for i in range(1, n):
            left_max[i] = max(left_max[i - 1], height[i])

        right_max[n - 1] = height[n - 1]
        for i in range(n - 2, -1, -1):
            right_max[i] = max(right_max[i + 1], height[i])

        water = 0
        for i in range(n):
            water += min(left_max[i], right_max[i]) - height[i]
        return water
维度 说明
时间 O(n) 三次线性扫描
空间 O(n) 两个长度为 n 的数组

到这里时间已经是最优了,但空间还能优化------因为我们其实并不需要同时持有整个 left_max[]right_max[]


最优解:双指针,O(1) 空间

关键观察:水的高度总是由"较短的那一侧"决定。如果我们能提前判断某个位置的左墙一定比右墙矮,那它真正能存多少水就只取决于左边的最大值,右边那个具体的最大值是多少根本不重要------反正它不会成为瓶颈。

用两个指针从两端向中间走,各自维护一侧已扫过的最大值。哪边最大值更小,就处理哪边。

python 复制代码
class Solution:
    def trap(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        left_max, right_max = 0, 0
        water = 0
        while left < right:
            if height[left] < height[right]:
                if height[left] >= left_max:
                    left_max = height[left]
                else:
                    water += left_max - height[left]
                left += 1
            else:
                if height[right] >= right_max:
                    right_max = height[right]
                else:
                    water += right_max - height[right]
                right -= 1
        return water

为什么这样做是对的?

这是这道题最容易被卡住的地方。展开讲一下。

height[left] < height[right] 时,右侧一定存在一根不低于 height[right] 的柱子作为右墙 (就是 right 这个位置本身)。所以对位置 left 来说,它的"右墙"至少有 height[right] 这么高,而它真正的瓶颈只会是左侧 ------也就是 left_max

因此:

  • 如果 height[left] >= left_max,意味着这根柱子是当前左边最高的,它自己挡水,存不住水,更新 left_max
  • 如果 height[left] < left_max,那这根柱子上方就能存 left_max - height[left] 这么多水,因为右墙肯定不矮,瓶颈在左。

右半边对称,不再赘述。

这个推理的核心是:我们不需要同时知道左右两侧的精确最大值,只要知道"哪边更矮",更矮的那一侧的最大值就是当前真正起作用的瓶颈。这是把 O(n) 空间压到 O(1) 的关键。

跑一遍示例 1

text 复制代码
height = [0,1,0,2,1,0,1,3,2,1,2,1]
        left=0                          right=11

left_max=0, right_max=0, water=0

step 1: height[0]=0 < height[11]=1 → 处理左
  0 >= 0 → left_max=0, left=1
step 2: height[1]=1, height[11]=1 → 处理右(else 分支)
  1 >= 0 → right_max=1, right=10
step 3: height[1]=1 < height[10]=2 → 处理左
  1 >= 0 → left_max=1, left=2
step 4: height[2]=0 < height[10]=2 → 处理左
  0 < 1 → water += 1-0 = 1, water=1, left=3
step 5: height[3]=2, height[10]=2 → 处理右
  2 >= 1 → right_max=2, right=9
step 6: height[3]=2, height[9]=1 → 处理右
  1 < 2 → water += 2-1 = 1, water=2, right=8
step 7: height[3]=2, height[8]=2 → 处理右
  2 >= 2 → right_max=2, right=7
step 8: height[3]=2 < height[7]=3 → 处理左
  2 >= 1 → left_max=2, left=4
step 9: height[4]=1 < height[7]=3 → 处理左
  1 < 2 → water += 2-1 = 1, water=3, left=5
step 10: height[5]=0 < height[7]=3 → 处理左
  0 < 2 → water += 2-0 = 2, water=5, left=6
step 11: height[6]=1 < height[7]=3 → 处理左
  1 < 2 → water += 2-1 = 1, water=6, left=7

left=7, right=7 → 退出
最终: water = 6

跑一遍示例 2

text 复制代码
height = [4,2,0,3,2,5]
        left=0           right=5

step 1: height[0]=4 < height[5]=5 → 处理左
  4 >= 0 → left_max=4, left=1
step 2: height[1]=2 < height[5]=5 → 处理左
  2 < 4 → water += 4-2 = 2, water=2, left=2
step 3: height[2]=0 < height[5]=5 → 处理左
  0 < 4 → water += 4-0 = 4, water=6, left=3
step 4: height[3]=3 < height[5]=5 → 处理左
  3 < 4 → water += 4-3 = 1, water=7, left=4
step 5: height[4]=2 < height[5]=5 → 处理左
  2 < 4 → water += 4-2 = 2, water=9, left=5

left=5, right=5 → 退出
最终: water = 9

示例 2 是一个很好的对照------右墙始终是最右侧的 5,全程 right 没动过,所有水量都由 left_max=4 决定,逻辑非常干净。

维度 说明
时间 O(n) 一次遍历,每个元素最多访问一次
空间 O(1) 只用了四个变量

三种解法放在一起看

解法 时间 空间 核心思想
暴力枚举 O(n²) O(1) 每个位置独立计算左右最大值
动态规划 O(n) O(n) 预处理 left_max[]right_max[],消除重复扫描
双指针 O(n) O(1) 只维护两侧各自的最大值,谁矮处理谁

从暴力到 DP 是"空间换时间"------用 O(n) 空间消掉了重复扫描。从 DP 到双指针是"逻辑换空间"------通过判断"哪边更矮",跳过了对另一侧精确值的依赖,把两个数组压成两个变量。

附:单调栈解法(可选)

还有一种按"层"思考的解法:用单调递减栈维护可能的"左墙",遇到比栈顶高的柱子就弹出栈顶当作"坑底",计算这个坑能存多少水。时间 O(n),空间 O(n)。它和双指针的视角不同------双指针是"按列算",单调栈是"按层算"。两种视角都值得掌握,但实战中双指针代码最短、常数最小。


这道题教会我什么

"瓶颈是较短的一侧"是一个通用直觉

接雨水的核心抽象是 min(左, 右) - 自身。这种"取两侧较小值"的结构在很多地方都出现:盛最多水的容器(LeetCode 11)、二维雨水 trapping-rain-water II(LeetCode 407)。识别出"较短的一侧决定上限",是处理这类问题的通用钥匙。

把空间从 O(n) 压到 O(1) 的两个套路

  • 滑动窗口/双指针:当答案只依赖"两侧的某种最值"时,常常能用两端向中间收拢的双指针把数组省掉。本题就是范例。
  • 滚动数组/状态压缩:当 DP 转移只依赖前 k 个状态时,可以用 k 个变量代替整个数组(爬楼梯、打家劫舍)。

遇到 O(n) 空间的解法时,先问一句"我真正需要的是哪几个状态",往往能省掉一整个数组。

边界:单调递增 / 单调递减

  • 全程递增(如 [1,2,3,4]):left 一直推进,left_max 一直被更新,water 始终为 0。
  • 全程递减(如 [4,3,2,1]):right 一直推进,right_max 一直被更新,water 始终为 0。
  • 单调场景天然存不住水,代码无需任何特殊处理。

完整测试代码

python 复制代码
from typing import List


class Solution:
    def trap(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        left_max, right_max = 0, 0
        water = 0
        while left < right:
            if height[left] < height[right]:
                if height[left] >= left_max:
                    left_max = height[left]
                else:
                    water += left_max - height[left]
                left += 1
            else:
                if height[right] >= right_max:
                    right_max = height[right]
                else:
                    water += right_max - height[right]
                right -= 1
        return water


if __name__ == "__main__":
    s = Solution()

    height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
    print(f"输入: {height}, 输出: {s.trap(height)}")

    height = [4, 2, 0, 3, 2, 5]
    print(f"输入: {height}, 输出: {s.trap(height)}")

    height = [1, 2, 3, 4]
    print(f"输入: {height} (单调递增), 输出: {s.trap(height)}")

    height = [4, 3, 2, 1]
    print(f"输入: {height} (单调递减), 输出: {s.trap(height)}")

    height = [5]
    print(f"输入: {height} (单根柱子), 输出: {s.trap(height)}")

相关题目推荐

相关推荐
旖-旎1 小时前
《LeetCode 695 岛屿的最大面积 FloodFill DFS 解法》
c++·算法·力扣·深度优先遍历·floodfill
syagain_zsx2 小时前
STL 之 vector 讲练结合
c++·算法
MartinYeung53 小时前
[论文学习]DP2Unlearning:高效且具保证的大型语言模型遗忘框架(基于差分隐私的 LLM Unlearning 方法)
学习·算法·语言模型
Tian_Hang3 小时前
C++原型模式(Protype)
开发语言·c++·算法
bIo7lyA8v3 小时前
算法复杂度的渐进分析与实际运行时间的差异的技术8
算法
yuan199974 小时前
欧拉梁静力与屈曲计算的 MATLAB 实现(有限差分法 + 解析解)
开发语言·算法·matlab
汉克老师5 小时前
GESP7级C++考试语法知识(二、指数函数(3、综合练习)
c++·算法·数学建模·指数函数·gesp7级·复利
Seraphina_Lily5 小时前
深入C语言底层:隐式类型转换、整数提升与截断的“致命”陷阱
c语言·开发语言·算法