NO.42 接雨水 —— LeetCode热题100面试导向深度解析

0 阅读11分钟

题目:

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

示例 1:

{ACC91426-E7AC-4676-A1DE-439C82A1FC98}.png

输入: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。

这个思考过程让我明确了:每个位置的积水量,需要知道的是左边所有柱子的最大值右边所有柱子的最大值,两者取较小值,再减去当前柱子高度。

从双数组到双指针的演进

明确了原理之后,最直接的做法就是三次遍历:

  1. 从左到右,记录每个位置的 leftMax
  2. 从右到左,记录每个位置的 rightMax
  3. 从左到右,套公式累加

这个思路完全正确,但我当时有个疑问:能不能在遍历的过程中同时维护左右最大值,而不需要提前把两个数组都算出来?

答案是双指针法。核心洞察是:当我们从两端向中间移动时,对于较矮的那一侧,它的“另一侧最高值”已经不再重要了。因为水面高度由矮的那一侧决定,而我们已经知道这一侧的最高值就是当前的 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
1left=0, right=110 < 1maxLeft=max(0,0)=0, water+=0-0=0, left=100
2left=1, right=111 == 1maxRight=max(0,1)=1, water+=1-1=0, right=1000
3left=1, right=101 < 2maxLeft=max(0,1)=1, water+=1-1=0, left=200
4left=2, right=100 < 2maxLeft=max(1,0)=1, water+=1-0=1, left=311
5left=3, right=102 == 2maxRight=max(1,2)=2, water+=2-2=0, right=901
6left=3, right=92 > 1maxRight=max(2,1)=2, water+=2-1=1, right=812
7left=3, right=82 == 2maxRight=max(2,2)=2, water+=2-2=0, right=702
8left=3, right=72 < 3maxLeft=max(1,2)=2, water+=2-2=0, left=402
9left=4, right=71 < 3maxLeft=max(2,1)=2, water+=2-1=1, left=513
10left=5, right=70 < 3maxLeft=max(2,0)=2, water+=2-0=2, left=625
11left=6, right=71 < 3maxLeft=max(2,1)=2, water+=2-1=1, left=716
12left=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]=2height[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 列出:

索引01234567891011
高度010210132121
蓄水量001012100100

总和 = 0+0+1+0+1+2+1+0+0+1+0+0 = 6

推演过程中关键状态的 maxLeft 和 maxRight 变化:

阶段maxLeftmaxRight说明
初始00-
处理完前4步(左侧处理为主)11左侧最高为1,右侧最高为1
处理完第5-7步(右侧连续处理)12右侧遇到高度2,maxRight更新为2
处理完第8-11步(左侧连续处理)22左侧遇到高度2,maxLeft更新为2
最终22左右边界最大值均为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 柱状图中最大的矩形:虽然是求最大矩形面积,但核心同样是找到左右两侧第一个比当前柱子矮的位置作为边界,再计算面积。
  • 股票买卖系列问题:在计算最大利润时,同样需要维护"左侧最低买入价"和"右侧最高卖出价"的双向信息。

学完这道题,你应该掌握:

  1. 遇到"蓄水""容量""面积"类问题,优先想到"边界决定容量"。
  2. 双指针的适用条件:可以从两端向中间收缩,且每次能够确定一端的结果。
  3. 空间优化思路:从"预先存储所有数据"到"边遍历边结算",是降低空间复杂度的通用思路。

面试金句:

"接雨水的本质是找每个位置的左右最高边界,取较小值减自身。双指针法通过每次处理较矮的一侧,在 O(n) 时间 O(1) 空间内完成结算,关键在于理解'为什么较矮的那一侧可以立即确定水量'——因为另一侧已经存在一个更高的边界。"