LeetCode 42. 接雨水:从暴力到双指针的四层跃迁

接雨水(LeetCode 42)是力扣早期最著名的困难题之一,也常年霸榜各大公司面试。它受欢迎的理由很充分:题面一眼能看懂、画面感极强,而解法梯度异常完整——同一个模型,暴力、动态规划、单调栈、双指针四种视角层层递进,每一种都踩在前一种的痛点上。一道题把「优化是怎么想出来的」完整演示一遍,这在题库里并不多见。本文沿这条递进链走完全程:每一层讲清楚优化了什么、为什么是对的,最后把重心放在双指针 O(1) 空间的「短板效应」证明上——那是这道题真正的灵魂。

题目回顾

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

  • 示例 1:height = [0,1,0,2,1,0,1,3,2,1,2,1],输出 6——凹槽处横向蓄水,数一数蓝色格子正好 6 个。
  • 示例 2:height = [4,2,0,3,2,5],输出 9——中间的 0 被左右高墙夹住,是大头。
  • 数据范围:n == height.length,1 <= n <= 2 * 10^4,0 <= height[i] <= 10^5。

数据范围里藏着两条信息。其一,n 的上限 2×10^4 意味着 O(n²) 的总操作量约 4×10^8——本地实测能跑,力扣时限内属于贴边,优化空间明摆着。其二,答案的上界值得算一笔账:像 [10^5, 0, 0, …, 0, 10^5] 这种两端高墙、中间全平的形状,蓄水量接近 (n - 2) × 10^5,约 2×10^9,已经贴着 32 位有符号整数的上限。Java 与 C++ 选手惯用 long 求稳;JavaScript 的 Number 是双精度浮点,安全范围到 2^53,无此顾虑。

第一层:暴力——先把「按列求水」的模型立住

一切从一个观察开始:水是一格一格蓄在每根柱子「头顶」上的,而位置 i 处能蓄多深,由两件事决定——它自己的高度,以及两侧最高墙中较矮的那一堵。水面一旦超过矮墙就会从那边漫出去,这正是木桶短板的物理直觉。写成公式:

water_i = min(max(height[0..i]), max(height[i..n-1])) - height[i]

这个公式为什么对,值得停一下说透。把水想象成静置的液体:位置 i 的水面高度,只由左右两侧「墙体包络线」中较低的那条决定——左边再高的墙,只要右边有一堵矮墙拦着,水就会先从矮墙那边漫出去,反之亦然。左右两个最大值恰好就是这两条包络线在 i 处的高度,而且它们都把 height[i] 自身包含在内,所以 min 一定不小于 height[i],差值天然非负,不需要再 clamp 到 0——这个细节现在记下,第四层还会用到;很多错误实现恰恰栽在把最大值算成「严格不含自身」的侧向版本,峰顶位置算出负数。按列累加就得到答案。

列视角还有一个容易被忽视的好处:每格水的归属互不依赖,公式里只有位置 i 和两个最大值,没有任何跨位置的耦合。这意味着整片水可以拆成 n 个独立的小问题分别计算——第一层先老老实实逐个算,后面几层做的全部事情,就是不断升级「拿到这两个最大值」的手段。

function trapBrute(height) {
  const n = height.length
  let water = 0
  for (let i = 0; i < n; i++) {
    let maxL = 0
    let maxR = 0
    for (let j = 0; j <= i; j++) maxL = Math.max(maxL, height[j])
    for (let j = i; j < n; j++) maxR = Math.max(maxR, height[j])
    water += Math.min(maxL, maxR) - height[i]
  }
  return water
}

时间 O(n²)、空间 O(1)。以 n = 2×10^4 的上限规模实测,node 本地一次全量计算约 150 毫秒——能出正确答案,但在时限紧的评测环境里已经岌岌可危。它的价值不在提交,而在两件事:公式本身是后续所有优化的地基;同时它「显然正确」,适合当差分测试的基准。

第二层:动态规划——把「重复扫描」换成「查表」

暴力慢在哪?每个位置 i 都要向左、向右各扫一遍求最大值,而这些最大值只依赖位置、与查询时机无关——位置 100 的左最大值,在算位置 101 时几乎原样可用。这是标准的「重叠子问题」症状,解法也是标准的:预处理,查表代替扫描。

  • maxL[i] = max(height[0..i]):从左往右一遍递推,maxL[i] = max(maxL[i-1], height[i])。
  • maxR[i] = max(height[i..n-1]):从右往左对称地推。
  • 水量公式照旧,只是两次扫描换成两次查表。
function trapDP(height) {
  const n = height.length
  if (n === 0) return 0
  const maxL = new Array(n)
  const maxR = new Array(n)
  maxL[0] = height[0]
  for (let i = 1; i < n; i++) maxL[i] = Math.max(maxL[i - 1], height[i])
  maxR[n - 1] = height[n - 1]
  for (let i = n - 2; i >= 0; i--) maxR[i] = Math.max(maxR[i + 1], height[i])
  let water = 0
  for (let i = 0; i < n; i++) water += Math.min(maxL[i], maxR[i]) - height[i]
  return water
}

时间 O(n)、空间 O(n):整趟算法由三遍线性扫描组成,左最大值一遍、右最大值一遍、逐列求和一遍,互不嵌套。上限规模实测约 1.2 毫秒,比暴力快两个数量级。空间上还有个小变体可以少写一根数组:只保留 maxR,第二遍改为从左往右,用一个滚动变量 maxL 边维护、边结算、边前进——它依然是 O(n) 空间,但能看出 maxL 其实只需要「当前值」而 maxR 需要「未来全部」,这个不对称性正是下一层双指针的切入口。

这一层的通用教益值得记住:DP 并不总长着「状态转移方程」的脸,前缀最值、前缀和这类一次性预处理消除重复扫描的结构,同样是动态规划家族里最朴素的成员。识别的信号也通用——当暴力解法里某个中间量「只依赖位置、与计算顺序无关」且被反复重算时,就值得为它建一张表。同族结构还有差分数组、稀疏表(ST 表)等,套路都是拿一遍预处理的功夫,换回每次查询 O(1)。

flowchart TD
    A["水位公式:min 左最大与右最大减自身"] --> B["暴力:逐位置扫两侧,O(n^2)"]
    B --> C["前缀后缀数组:查表代替扫描,O(n) 时间 O(n) 空间"]
    C --> D["单调栈:按层横向结算"]
    C --> E["双指针:两端收拢,O(1) 空间"]

第三层:单调栈——换一个视角,从「按列竖算」到「按层横算」

前两层把水按柱子竖着切,每格水归属一列;单调栈换了一个正交的切法——按横截面一层一层铺水。数据结构是一个存下标的栈,栈内对应高度自底向上严格递减:每根新柱入栈前,把所有比它矮的栈顶弹出来结算。弹出的时候发生了什么?弹出的柱子是凹槽的槽底 bottom,它左边第一个幸存者是左墙 left,而触发弹栈的当前柱 i 正是右墙——此刻可以确定一条横向水带:宽度 i - left - 1,深度 min(height[left], height[i]) - height[bottom],两者相乘就是这一层的水,累加进答案。

function trapStack(height) {
  const stack = [] // 存下标,对应高度自底向上单调递减
  let water = 0
  for (let i = 0; i < height.length; i++) {
    while (stack.length > 0 && height[i] > height[stack[stack.length - 1]]) {
      const bottom = stack.pop() // 被弹出的是凹槽底
      if (stack.length === 0) break // 左边没有墙,接不住水
      const left = stack[stack.length - 1]
      const width = i - left - 1
      const h = Math.min(height[left], height[i]) - height[bottom]
      water += width * h
    }
    stack.push(i)
  }
  return water
}

为什么对:bottom 被弹出时,它左右两侧都存在不矮于弹栈触发者的墙,这条横带的水位恰好由两墙较矮者减去槽底决定,不多不少;所有横带不重不漏地铺满每一格水,与按列求和是同一片水的两种切法。一个等高的细节:入栈条件用的是严格大于,等高柱子会安静地叠在栈里、互不触发弹栈——等高柱之间本来就蓄不住水(min 减自身为零),等真正更高的墙到来时,横带宽度会整段跨过它们、深度由两端墙高减槽底决定,结果依然正确。复杂度方面,每根柱子至多入栈、出栈各一次,时间 O(n);栈深最坏 O(n)(单调递减的地形)。上限规模实测约 0.5 毫秒。它还顺手解开了 DP 的一个心结:DP 必须把整个数组看完整才开始算,单调栈是真正的「在线」算法,流式数据也能边读边算。

第四层:双指针——连整个数组都可以不要

现在把目光收回到 maxL 与 maxR 这两个数组的使用方式上:计算位置 i 时,我们从未同时需要两者,需要的永远只是其中较小的那个。如果能在指针移动过程中动态判定「较小者此刻是谁」,O(n) 的两根数组就可以整个省掉。这就是双指针解法,也是本题被标为困难的全部含金量。

flowchart TD
    L["left 指针,维护 maxL"] --> Q{"maxL 小于 maxR?"}
    R["right 指针,维护 maxR"] --> Q
    Q -- 是 --> P["left 处水位已确定:结算后 left 右移"]
    Q -- 否 --> S["right 处水位已确定:结算后 right 左移"]
function trapTwoPointers(height) {
  let left = 0
  let right = height.length - 1
  let maxL = 0
  let maxR = 0
  let water = 0
  while (left < right) {
    maxL = Math.max(maxL, height[left])
    maxR = Math.max(maxR, height[right])
    if (maxL < maxR) {
      water += maxL - height[left] // 右侧必有一堵不低于 maxR 的墙,左侧短板定水位
      left++
    } else {
      water += maxR - height[right]
      right--
    }
  }
  return water
}

时间 O(n),空间只有四个变量,O(1)。它为什么是对的?这就是要重点证明的短板效应,论证分三步。

第一步,写下不变量:任一时刻,maxL = max(height[0..left]),maxR = max(height[right..n-1]),两个区间都以指针位置收尾(代码里先更新再结算,天然满足)。

第二步,证明「较小侧已可结算」。设 maxL < maxR,考察 left 处。它的真实水位由 min(真实左最大, 真实右最大) 决定:真实左最大就是 maxL,因为 [0..left] 已经完整扫过,不会再变;而真实右最大是 max(height[left..n-1]),它至少覆盖了 [right..n-1] 这个子段,所以真实右最大 ≥ maxR > maxL。两个候选里左边严格更小——短板在左,水位被 maxL 封顶,真实右墙再高也漫不进来。于是 water += maxL - height[left] 就是精确值,结算后 left 前移一格,不变量保持。maxL ≥ maxR 时对 right 完全对称。

第三步,补上循环条件留下的盲区:left < right 意味着两指针相遇的位置从未被结算。相遇点必然是全局最高柱(或并列最高之一)——若它不是最高柱,两侧至少有一侧的最大值仍严格更小,对应指针就会被继续推进,矛盾。而最高柱处 min(maxL, maxR) - height 恰好为 0,本来就没有水可加,跳过它毫无损失。

三步合起来就是那句面试金句:哪一侧的最大值更小,哪一侧当前格子的水位就已经确定——知道的一半永远够用,这就是 O(1) 空间的全部秘密。上限规模实测约 0.2 毫秒,是四个解法里最快的。

解法对比

解法 时间 空间 核心思想 适用场景
暴力 O(n²) O(1) 逐位置扫两侧求最大值 基准与对拍
动态规划 O(n) O(n) 前缀后缀最值数组查表 最直观的线性解法
单调栈 O(n) O(n) 递减栈,弹栈按层横向结算 流式输入、按层思维训练
双指针 O(n) O(1) 短板效应,较小侧直接结算 面试最优解

四个解法共享同一个水位公式,分野只在「拿什么代价换什么」:暴力不花钱但要重复扫描;DP 用 O(n) 空间买掉重复;单调栈换视角换来在线能力;双指针证明了信息冗余,把空间也省了。

边界与常见错误

以下用例全部进入过本地验证,每一条都值得单独跑:

  • 结构性边界:n = 1、n = 2 恒为 0(一堵墙、两堵墙都蓄不住水);单调递增、单调递减恒为 0;全 0、平顶恒为 0。空数组按约束不会出现,但防御式返回 0 不亏。
  • 最大值是否包含自身:公式两侧的最大值区间若实现成「严格不含 i」,峰顶位置会算出负数并污染累加和;本文所有解法都用含自身的区间,天然非负。两种写法各自自洽,混着写必错。
  • 单调栈的栈空陷阱:弹出 bottom 后若栈已空,说明左边根本没有墙,必须立即 break;不 break 会把错误的「左墙」代入下一轮宽度计算。
  • 双指针的相等分支:maxL 与 maxR 相等时,两侧的水位其实都已确定,归左归右都对;真正要警惕的是「比较条件与更新顺序不自洽」——先结算后更新与先更新后结算对应的判断条件不同。
  • 溢出:答案上界约 2×10^9,Java 与 C++ 用 long;JS 的 Number 无忧,但若用 Number.parseInt 之外的位运算技巧要当心 32 位截断。

本地验证报告:四个解法全部通过 15 个固定用例(题面两示例,空、单、双元素,单调增减、全 0、平顶、深谷、高墙平台、混合形状),并以暴力为基准做了 400 轮随机差分(300 轮小值域、100 轮 10^5 大值域),零分歧;上限规模 n = 2×10^4 下,双指针 0.2 毫秒、单调栈 0.5 毫秒、DP 1.2 毫秒、暴力 150 毫秒,与复杂度分析完全吻合。

面试追问与延伸

和「盛最多水的容器」是什么关系

LeetCode 11 与本题同用双指针、长相也像,但模型完全不同:11 是选两根线夹出最大矩形面积,水量由较矮的线直接决定,移动规则是「挪矮的那根」;42 是逐格蓄水,由两侧最大值的较小者决定,移动的是「已知最大值较小的一侧」。两个「较矮者」含义不同,只背移动规则不改状态语义,换个变体就翻车。

还能更优吗,或者换个问法

被问「O(n) 时间还能 O(1) 空间吗」,答案就是双指针本身;被问「按行算怎么做」,指向单调栈。若面试官把高度图升级成二维矩阵,水会从边缘流出,那就是 LeetCode 407 接雨水 II,解法换成最小堆从边界向内灌——本文的按列思维在那里彻底失效,是检验是否真懂模型的绝佳后续题。姊妹题还有 LeetCode 84 柱状图中最大的矩形,同样用单调栈,弹栈结算的对象从「横带」换成「矩形」。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?