题目:
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
一、读题
思路历程
拿到这道题,我的第一反应是:某个位置能不能积水,取决于它两边的柱子是不是都比它高。这个直觉是对的,但它不够精确。
真正让我把思路往前推了一步的问题是:“两边的数都比它大”这个条件里,“两边的数”具体指的是哪两个数? 是紧挨着它的左右两根柱子,还是左边所有柱子里最高的那一根和右边所有柱子里最高的那一根?
答案是后者。因为水是流动的,它会被整个区域里最高的边界拦住。比如在 [5,0,1] 这个数组中,位置 1(高度为 0)的左边最高是 5,右边最高是 1,水面高度由较矮的右边决定,是 1。但如果只看紧挨着的左右柱子(左边是 5,右边是 1),得出的结论也是 1,这个例子恰好一致。换一个例子 [5,0,3,1],位置 1 的紧挨着右边是 3,但整个右边最高是 3,结果一致。再看 [5,0,1,4],紧挨着右边是 1,整个右边最高是 4,这时候如果只看紧挨着的柱子就会算错,因为水实际上会被右边更远处的 4 拦住,水面高度应该是 min(5,4)=4 而不是 min(5,1)=1。
这个思考过程让我明确了:每个位置的积水量,需要知道的是左边所有柱子的最大值和右边所有柱子的最大值,两者取较小值,再减去当前柱子高度。
从双数组到双指针的演进
明确了原理之后,最直接的做法就是三次遍历:
- 从左到右,记录每个位置的 leftMax
- 从右到左,记录每个位置的 rightMax
- 从左到右,套公式累加
这个思路完全正确,但我当时有个疑问:能不能在遍历的过程中同时维护左右最大值,而不需要提前把两个数组都算出来?
答案是双指针法。核心洞察是:当我们从两端向中间移动时,对于较矮的那一侧,它的“另一侧最高值”已经不再重要了。因为水面高度由矮的那一侧决定,而我们已经知道这一侧的最高值就是当前的 maxLeft(或 maxRight),另一侧至少有一个比它高的柱子存在(就是当前另一端的指针指向的值),所以可以放心结算这一侧的水量。
双指针法为什么是对的?
这是读者最容易困惑的地方。我们来正面回答一个核心质疑:
“我处理左侧的时候,右侧后面可能还有更高的柱子,我现在算的水量会不会算少了?”
答案是:不会。
假设当前 height[left] < height[right],我们处理左侧。此时:
- 左侧从 0 到 left 的最大值
maxLeft已经确定,因为我们已经遍历过这些位置了。 - 右侧至少有一个柱子(就是 right 指向的位置)比当前的
height[left]高。 - 既然右侧存在一个比当前柱子高的边界,那么右侧真正的最高值
rightMax一定 >=height[right]>height[left]。 - 因此,
min(maxLeft, rightMax)在右侧边界这一项上,一定 >=maxLeft。 - 所以当前位置的水面高度就是
maxLeft,不需要等待右侧所有数据遍历完。
同理,处理右侧时,左侧至少有一个柱子比当前的 height[right] 高,所以当前位置的水面高度就是 maxRight。
一句话总结:每次处理较矮的一侧时,另一侧已经存在一个比它高的边界,所以这一侧的水量可以立即确定,不受更远处数据的影响。
代码实现
var trap = function(height) {
// 双指针:left 从左向右移动,right 从右向左移动
let left = 0;
let right = height.length - 1;
// maxLeft: 从 0 到 left 范围内所有柱子的最高值
// maxRight: 从 right 到末尾范围内所有柱子的最高值
let maxLeft = 0;
let maxRight = 0;
let water = 0;
while (left < right) {
// 左侧更矮,说明左侧的边界已经确定,可以结算 left 位置
if (height[left] < height[right]) {
// 第一步:把当前柱子纳入左侧最高值的计算
maxLeft = Math.max(maxLeft, height[left]);
// 第二步:当前柱子的水面高度 = maxLeft
// 蓄水量 = 水面高度 - 柱子自身高度
// 这行代码的物理含义:水填满到左侧最高边界的高度,减去柱子本身占用的体积
water += maxLeft - height[left];
// 第三步:结算完毕,左指针右移
left++;
} else {
// 右侧更矮或相等,处理右侧,逻辑对称
maxRight = Math.max(maxRight, height[right]);
water += maxRight - height[right];
right--;
}
}
return water;
};
关键注释解读:
water += maxLeft - height[left] 这行是核心。想象一下:水在柱子周围,水面能升到的高度就是左侧最高边界 maxLeft,但柱子本身占据了一部分体积,所以实际的水体积是水面高度减去柱子高度。这个差值就是当前位置能接住的水量。
执行演示
以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例,完整拆解 12 步推演过程:
| 步骤 | 指针状态 | 比较值 | 处理侧 | 操作 | 蓄水量 | 累计水量 |
|---|---|---|---|---|---|---|
| 初始 | left=0, right=11 | - | - | maxLeft=0, maxRight=0 | - | 0 |
| 1 | left=0, right=11 | 0 < 1 | 左 | maxLeft=max(0,0)=0, water+=0-0=0, left=1 | 0 | 0 |
| 2 | left=1, right=11 | 1 == 1 | 右 | maxRight=max(0,1)=1, water+=1-1=0, right=10 | 0 | 0 |
| 3 | left=1, right=10 | 1 < 2 | 左 | maxLeft=max(0,1)=1, water+=1-1=0, left=2 | 0 | 0 |
| 4 | left=2, right=10 | 0 < 2 | 左 | maxLeft=max(1,0)=1, water+=1-0=1, left=3 | 1 | 1 |
| 5 | left=3, right=10 | 2 == 2 | 右 | maxRight=max(1,2)=2, water+=2-2=0, right=9 | 0 | 1 |
| 6 | left=3, right=9 | 2 > 1 | 右 | maxRight=max(2,1)=2, water+=2-1=1, right=8 | 1 | 2 |
| 7 | left=3, right=8 | 2 == 2 | 右 | maxRight=max(2,2)=2, water+=2-2=0, right=7 | 0 | 2 |
| 8 | left=3, right=7 | 2 < 3 | 左 | maxLeft=max(1,2)=2, water+=2-2=0, left=4 | 0 | 2 |
| 9 | left=4, right=7 | 1 < 3 | 左 | maxLeft=max(2,1)=2, water+=2-1=1, left=5 | 1 | 3 |
| 10 | left=5, right=7 | 0 < 3 | 左 | maxLeft=max(2,0)=2, water+=2-0=2, left=6 | 2 | 5 |
| 11 | left=6, right=7 | 1 < 3 | 左 | maxLeft=max(2,1)=2, water+=2-1=1, left=7 | 1 | 6 |
| 12 | left=7, right=7 | - | - | 循环终止 (left < right 不成立) | - | 6 |
关键步骤解读:
第 2 步(height[1] == height[11]): 相等时我们选择处理右侧(其实处理哪边都可以)。这里处理右侧,maxRight 从 0 更新为 1。
第 4 步(height[2]=0): 这是第一次产生实际蓄水量。左侧最高是 1,当前位置高度是 0,水面高度为 1,蓄水量 = 1 - 0 = 1。水被左边的 height[1]=1 和右边的 height[11]=1(或者 height[10]=2)夹住。
第 5-7 步(右侧连续处理): 为什么连续处理右侧?因为 height[3]=2 比 height[10]=2 大(或相等),之后比 height[9]=1 大,再之后又等于 height[8]=2。右侧不断左移,但 maxRight 在遇到高度 2 后就不再增长,这几步蓄水量都为 0,因为右侧边界高度没有超过当前柱子高度。
第 10 步(height[5]=0): 这是单步蓄水量最大的一步(2 单位)。左侧最高已经到 2,当前位置高度为 0,水面高度为 2,蓄水量 = 2。此时左侧有 height[3]=2 作为边界,右侧有 height[7]=3 作为边界。
第 11 步(height[6]=1): 这是最后一步产生蓄水量。左侧最高仍为 2,当前位置高度为 1,蓄水量 = 1。到这一步,累计水量达到 6。
验证汇总:
将每个位置的蓄水量按索引 0~11 列出:
| 索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 高度 | 0 | 1 | 0 | 2 | 1 | 0 | 1 | 3 | 2 | 1 | 2 | 1 |
| 蓄水量 | 0 | 0 | 1 | 0 | 1 | 2 | 1 | 0 | 0 | 1 | 0 | 0 |
总和 = 0+0+1+0+1+2+1+0+0+1+0+0 = 6 ✓
推演过程中关键状态的 maxLeft 和 maxRight 变化:
| 阶段 | maxLeft | maxRight | 说明 |
|---|---|---|---|
| 初始 | 0 | 0 | - |
| 处理完前4步(左侧处理为主) | 1 | 1 | 左侧最高为1,右侧最高为1 |
| 处理完第5-7步(右侧连续处理) | 1 | 2 | 右侧遇到高度2,maxRight更新为2 |
| 处理完第8-11步(左侧连续处理) | 2 | 2 | 左侧遇到高度2,maxLeft更新为2 |
| 最终 | 2 | 2 | 左右边界最大值均为2 |
常见错误与修正
在编码过程中,我犯过一个顺序错误:
// ❌ 错误写法:先移动指针再更新最大值
if (height[left] < height[right]) {
left++;
maxLeft = Math.max(maxLeft, height[left]);
water += maxLeft - height[left];
}
这个错误的本质是:移动指针后,当前要计算的位置已经变了,但 maxLeft 还没有包含这个新位置的高度。正确顺序是:先更新最大值(把当前柱子的高度纳入考虑),再计算当前柱子的水量,最后移动指针。
另外,在 else 分支中我一开始错误地在循环开头重置了 maxRight,导致丢失了之前右侧遍历到的更高柱子的信息。正确的做法是只在分支内更新,不在循环开头重置。
复杂度分析
- 时间复杂度:O(n),每个元素最多被处理一次
- 空间复杂度:O(1),只使用了常数个变量
面试追问
Q:如果数据量极大(比如 10^9 个柱子),双指针法还能用吗?
A:双指针法时间复杂度 O(n),空间复杂度 O(1)。如果数据量极大,主要瓶颈在于输入本身的大小,任何算法都需要至少 O(n) 的时间来读取数据。但双指针法不需要额外存储,这是它相比双数组法(O(n) 空间)的核心优势。在内存受限的嵌入式系统或超大流式数据处理场景中,双指针法是更优选择。
Q:相等时为什么可以随意选择处理左侧还是右侧?
A:当 height[left] == height[right] 时,如果处理左侧,maxLeft 会更新为这个相等的值,蓄水量 = 该值 - height[left] = 0;如果处理右侧,结果一样。因为两侧高度相同,无论从哪边结算,当前柱子的高度都不低于边界,蓄水量为 0,不会影响最终结果。
Q:如果输入数组是单调递增或单调递减,双指针法表现如何?
A:单调递增(如 [0,1,2,3,4]):每次都是右侧更矮(因为右侧是递减方向),会不断处理右侧,maxRight 始终等于当前柱子高度,蓄水量始终为 0,最终返回 0。单调递减同理。时间复杂度仍是 O(n)。
Q:这道题还有其他解法吗?各有什么优劣?
A:有三种主流解法:
| 解法 | 时间复杂度 | 空间复杂度 | 优缺点 |
|---|---|---|---|
| 双数组法 | O(n) | O(n) | 最直观,适合展示基础思路 |
| 双指针法 | O(n) | O(1) | 最优,面试推荐 |
| 单调栈法 | O(n) | O(n) | 按行计算,适合理解"凹陷"结构 |
双指针法综合最优,是面试中优先选择的解法。
相似题型
- NO.11 盛最多水的容器 —— 同样使用双指针,但计算的是容器面积而非积水量
- NO.84 柱状图中最大的矩形 —— 找到左右两侧第一个比当前柱子矮的位置作为边界
- NO.407 接雨水 II —— 二维版本,需要优先队列辅助
核心思想提炼:
这道题的精髓可以浓缩为一句话:"容量由矮边界决定,矮边界一旦确定即可结算。"
这个"矮边界决定容量"的思想在算法题中反复出现:
- NO.11 盛最多水的容器:两根柱子之间的容器,容量由较矮的那根决定。双指针每次移动较矮的那一侧,因为较矮的柱子已经无法作为更高的边界了。
- NO.84 柱状图中最大的矩形:虽然是求最大矩形面积,但核心同样是找到左右两侧第一个比当前柱子矮的位置作为边界,再计算面积。
- 股票买卖系列问题:在计算最大利润时,同样需要维护"左侧最低买入价"和"右侧最高卖出价"的双向信息。
学完这道题,你应该掌握:
- 遇到"蓄水""容量""面积"类问题,优先想到"边界决定容量"。
- 双指针的适用条件:可以从两端向中间收缩,且每次能够确定一端的结果。
- 空间优化思路:从"预先存储所有数据"到"边遍历边结算",是降低空间复杂度的通用思路。
面试金句:
"接雨水的本质是找每个位置的左右最高边界,取较小值减自身。双指针法通过每次处理较矮的一侧,在 O(n) 时间 O(1) 空间内完成结算,关键在于理解'为什么较矮的那一侧可以立即确定水量'——因为另一侧已经存在一个更高的边界。"