接雨水:每列的水,由两侧最高的墙决定
一格能存的水 = min(左最高, 右最高) − 自身。但真正让 O(1) 空间成立的是另一个事实:较矮一侧的水量,此刻就能确定。
对下标 i,水量 = min(leftMax, rightMax) − height[i](负值取 0)。双指针从两端向中间走,维护左右各自见过的最高墙。因为较矮一侧的墙已经「锁死」了它那一侧的水位,所以总能安全地从矮侧结算并推进。时间 O(n),空间 O(1)。
给定 n 个非负整数表示柱子的高度,每根柱宽为 1,计算下雨后能接多少雨水。
水只能存在「被左右更高的柱子夹住」的凹槽里。
这篇文章从一个单列的公式出发,再把公式「安全结算」的条件翻译成双指针——你会看到,O(n) 时间和 O(1) 空间不是魔法,而是「矮侧先死」这条事实的直接推论。
单列公式:水从哪来
先只盯着一根柱子 i。它能存多少水,取决于它的左右两边分别有多高的墙挡着——注意,是「分别最高」的那堵,而不是紧挨着的那堵。
水要留下来,左右都必须比 height[i] 高。水位线被较低的一侧决定(水会从矮的一侧溢出),所以:water[i] = min(leftMax[i], rightMax[i]) − height[i]。
若结果为负(某侧没有比它高的墙),则 water[i] = 0。总水量是每一列的 water[i] 之和。
以 [3, 0, 2, 0, 4, 1, 3] 为例,下标 1 的 0 被左最高 3、右最高 4 夹住:min(3,4) − 0 = 3,这是整道题水的最大来源。
单列公式水位线 = min(左最高, 右最高)。它不是墙本身,而是两侧墙能兜住水的最高水位。
朴素做法:每个位置都求两侧最高
公式给了,剩下的问题是怎么快速拿到每个位置的 leftMax 和 rightMax。
最直接的想法:先从左到右扫描一遍,记下每个位置左侧的最高墙 leftMax[i];再从右到左扫一遍,记下 rightMax[i]。预处理两张表后,一遍求和。时间 O(n),空间 O(n)。
这个解法完全正确,但它多用了 O(n) 的空间。接雨水这道题真正的考眼力之处在于:能不能去掉这两张表,把空间压到 O(1)?
答案是能——秘密藏在一个看似平凡的观察里:双指针从两端往中间走时,较矮一侧的水量此刻就能确定,不需要知道另一侧的全部信息。
关键洞察:矮侧先死
把两个指针 l 和 r 分别放在数组两端,让它们向中间收拢。维护两个变量:leftMax = 下标 0..l 中的最高墙,rightMax = 下标 r..n−1 中的最高墙。
现在比较 height[l] 和 height[r]。如果 height[l] < height[r],那么下标 l 这一列的「右侧最高」已经不可能超过 rightMax(因为 rightMax 已经包含了 r 及右侧所有墙,而 rightMax ≥ height[r] > height[l])。
更重要的结论:对于下标 l 这列,它的左最高就是 leftMax,它的右最高至少是 rightMax——但水位线取的是 min(左, 右)。既然 rightMax > height[l],而水位线又 ≥ height[l](否则 water 为 0),所以水位线实际由 leftMax 决定:water[l] = leftMax − height[l](若为正)。
换句话说:较矮的一侧,它的最高墙已经被我们走过的这一侧完全掌握,可以直接结算,不需要再知道另一侧的细节。这就是「矮侧先死」——先结算矮的一侧,再把它推进。
矮侧先死height[l] < height[r] 时,l 列的水位由左侧完全确定;反之由右侧确定。这是 O(1) 空间的全部依据。
双指针完整跑一遍
用 [3, 0, 2, 0, 4, 1, 3] 全程验证。初始 l=0, r=6, leftMax=0, rightMax=0, total=0。
第 1 步:height[0]=3 < height[6]=3 不成立(相等走 else 分支),先更新 rightMax=max(0,3)=3,再 r−−。
第 2 步:l=0, r=5, height[0]=3 < height[5]=1 不成立,更新 rightMax=max(3,1)=3,r−−。
第 3 步:l=0, r=4, height[0]=3 < height[4]=4,走左分支:leftMax=max(0,3)=3,leftMax−height[l]=0,total+=0,l++。
第 4 步:l=1, r=4, height[1]=0 < 4:leftMax 不变 3,total += 3−0 = 3,l++。
第 5 步:l=2, r=4, height[2]=2 < 4:total += 3−2 = 1 → 4,l++。
第 6 步:l=3, r=4, height[3]=0 < 4:total += 3−0 = 3 → 7,l++。
第 7 步:l=4, r=4, height[4]=4 < 4 不成立,更新 rightMax=4,r−−。l 与 r 相遇,结束。
等一下——第 6 步时右侧最高还只是 3,为什么下标 3 的水敢按 3 结算?因为 height[3]=0 非常矮,min(leftMax, rightMax) 一定 ≥ 0,且 leftMax=3 已经确定是更矮的边界,所以 3 格水是安全的。这正是「矮侧先死」。
另一种实现:单调栈(思路)
双指针是这道题在 LeetCode 上最省空间的写法,但面试常被追问的还有一种「单调递减栈」的实现,它的思考角度完全不同:
栈内存的是「高度递减」的下标。遇到一个比栈顶高的墙时,说明栈顶那个位置是一个凹槽的底部——弹出它,凹槽的两侧就是「新的栈顶」和「当前墙」。
结算公式:宽 = 当前下标 − 新栈顶下标 − 1,高 = min(新栈顶高度, 当前墙高度) − 凹槽底高度。每弹出一个底部就结算一段。
单调栈的好处是每段凹槽精确结算、代码可读;双指针的好处是空间 O(1)。两者都基于同一个事实:水被「两侧更高的墙」围住。
双解法双指针和单调栈是同一道题的两面:前者按列结算,后者按凹槽整段结算。LC84 柱状图最大矩形用的是同款单调栈。
易错点与复杂度
第一个坑:双指针更新 leftMax/rightMax 的顺序。必须先「用旧的最高结算」,再「用新墙更新最高」——如果先更新再结算,会把墙自己当成挡板,多算水。
第二个坑:height[l] == height[r] 时走哪边。两边都可以,代码习惯走右分支(else),效果等价,因为相等时任何一侧结算都是 0。
第三个坑:边界列永远 0。下标 0 和 n−1 左侧或右侧没有墙,leftMax 或 rightMax 只能是它们自己,公式给 0。
时间 O(n):每个下标至多被推进一次。空间 O(1):双指针只用了四个变量。
面试表达先写公式、再讲「矮侧先死」、最后落双指针。一句「较矮的一侧,其最高墙已被掌握」足以证明你不是背答案。
Go:双指针 O(1) 空间
func trap(height []int) int {l, r := 0, len(height)-1leftMax, rightMax, water := 0, 0, 0for l < r {if height[l] < height[r] {if height[l] >= leftMax {leftMax = height[l]} else {water += leftMax - height[l]}l++} else {if height[r] >= rightMax {rightMax = height[r]} else {water += rightMax - height[r]}r--}}return water}
1左右指针从两端出发。
2leftMax/rightMax 各自维护走过区域最高墙。
3较矮一侧先结算:左矮走左分支。
4当前墙更新最高,或结算差值水。
5结算完推进指针。
6右矮走右分支,对称处理。
7相遇即结束,返回累计水量。
总结
接雨水 = 每列取 min(左最高, 右最高) − 自身;矮侧先死,O(1) 空间。
- 单列公式是起点:water[i] = min(leftMax, rightMax) − height[i]。
- 「矮侧先死」:较矮一侧的水位已被这一侧完全确定,可安全结算。
- 双指针 O(1) 空间,时间 O(n);单调栈按凹槽结算也是常见答案。
- 边界列必为 0,先结算再更新最高,是两道最常踩的坑。