这是经典中的经典。柱子之间能接多少雨水,本质上是问"每个位置上方能存多少水"。这道题的演进路径非常完整------从 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) | 不算切片的话 |
暴力法的问题很清楚:对每个 i,left_max 和 right_max 都是临时算的,相邻位置之间存在大量重复扫描。比如位置 i 和 i+1 的 left_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)}")
相关题目推荐:
- LeetCode 11 · 盛最多水的容器(同样是双指针,但求的是"面积"而非"水量")
- LeetCode 407 · 接雨水 II(二维版本,优先队列 BFS)
- LeetCode 84 · 柱状图中最大的矩形(单调栈经典题,和接雨水的栈解法互为镜像)