42. 接雨水
给定 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 个单位的雨水(蓝色部分表示雨水)。
示例 2:
输入:height = [4,2,0,3,2,5]
输出:9
提示:
n == height.length1 <= n <= 2 * 1040 <= height[i] <= 105
没问题,为了让你能够从根本上吃透这道题,我将为你提供一份**"接雨水"问题的深度算法通关指南**。
我们将从最底层的数学原理出发,详细拆解三种主流解法的推导过程、核心逻辑差异以及图解分析。
一、 核心物理模型:木桶原理(短板效应)
无论使用哪种算法,接雨水的本质永远只有一个公式。
对于数组中的任意位置 i,它能接住的雨水量 Water\[i\] 取决于:
- 它左边最高的柱子 LeftMax_i
- 它右边最高的柱子 RightMax_i
- 它自己的高度 Height_i
Water\[i\] = \\max(0, \\min(LeftMax_i, RightMax_i) - Height_i)
人话翻译: 此时头顶的水位,取决于两边"墙"中较矮的那堵墙。
二、 解法深度拆解
1. 动态规划法 (Dynamic Programming)
核心思路: 空间换时间,提前记账。
为什么需要它?
在最暴力的解法中,对于每一个位置,我们都要向左跑一趟找最大值,再向右跑一趟找最大值。这导致了 O(N\^2) 的时间复杂度。
动态规划的思想是:不要每次都重新跑,把结果存下来。
算法流程详细推导:
- 从左向右扫描: 建立数组
left_max。
-
left_max[i]代表0到i这一段区间的最大高度。- 递推公式:left\\_max\[i\] = \\max(left\\_max\[i-1\], height\[i\])
- 从右向左扫描: 建立数组
right_max。
-
right_max[i]代表i到n-1这一段区间的最大高度。- 递推公式:right\\_max\[i\] = \\max(right\\_max\[i+1\], height\[i\])
- 最终合并:
-
- 遍历每个位置,直接查表计算:\\min(left\\_max\[i\], right\\_max\[i\]) - height\[i\]。
优缺点分析:
- 优点: 逻辑非常直观,容易理解,是很多人的"第一反应"解法。
- 缺点: 需要额外的两个数组空间,空间复杂度为 O(N)。
2. 双指针法 (Two Pointers) ------ 最优解
核心思路: 压缩空间,利用"相对论"确定短板。
核心难点解析(为什么它能工作?):
这是此题最难理解的部分。
在动态规划中,我们必须确切知道 left_max[i] 和 right_max[i] 的具体数值。
但在双指针中,我们利用了一个推论:
只要 left_max_current < right_max_current,那么对于左指针 left 来说,它右边必然 存在一个比 left_max_current 更高(或相等)的墙(因为 right_max 就在那边等着)。
详细推演步骤:
假设 left 在左边,right 在右边。
- 维护两个变量
L_Max和R_Max,分别记录左边走过的路和右边走过的路的最大值。 - 比较 height[left]和 **height[right]**:
-
- 情况 A:左边矮 ( height[left] < height[right]****)
-
-
- 此时,我们甚至不需要知道右边最大的墙到底是一万米还是一千米。我们只需要知道:只要右边有这根 height[right]挡着,左边这个位置的水位瓶颈就一定在 **L_Max**上。
- 所以可以直接计算左边的水,然后
left右移。
-
-
- 情况 B:右边矮 ( height[left] >= height[right]****)
-
-
- 同理,右边的瓶颈一定是
R_Max,左边不用管了。 - 计算右边的水,
right左移。
- 同理,右边的瓶颈一定是
-
这种方法是"按列求和":
它依然是把每一根柱子上能存的水算出来,然后加在一起。
3. 单调栈法 (Monotonic Stack)
核心思路: 按"层"求和,横向填充凹槽。
思维转换:
前两种方法是竖着切分雨水(一列一列算)。
单调栈方法是横着切分雨水(一层一层算,或者说一个坑一个坑算)。
详细图解逻辑:
我们需要维护一个单调递减栈 (栈底大,栈顶小)。栈里存放的是下标。
- 入栈阶段(下坡):
-
- 如果当前柱子高度小于栈顶柱子,说明在"下坡",存不住水。将下标入栈。
- 出栈阶段(上坡/填坑):
-
- 如果当前柱子高度 大于 栈顶柱子,说明遇到了"右墙",形成了一个凹槽。
- 弹出栈顶元素 ,记为
bottom(坑底)。 - 此时,新的栈顶元素是
left_idx(左墙),当前遍历到的元素是i(右墙)。 - 计算这个坑的体积:
-
-
- 宽度 W :i - left\\_idx - 1 (两堵墙中间的距离)
- 高度 H :\\min(height\[left\\_idx\], height\[i\]) - height\[bottom\] (左右墙较矮者 减去 坑底高度)
- 雨水:W \\times H
-
举例追踪:[2, 0, 2]
- i=0, h=2: 栈空,入栈。 Stack:
[0] - i=1, h=0:
0 < 2,递减,入栈。 Stack:[0, 1](栈顶是1,高度0) - i=2, h=2:
2 > 0,遇到右墙!
-
- 弹出
1做为坑底bottom(高度0)。 - 栈顶剩
0做为左墙left(高度2)。当前2是右墙。 - 计算高度:\\min(2, 2) - 0 = 2。
- 计算宽度:2 - 0 - 1 = 1。
- 体积:2 \\times 1 = 2。
- 继续...
- 弹出
三、 三种解法深度对比总结
为了方便记忆和面试,请参考下表:
|-----------|----------------|------------------------|--------------------|
| 特性 | 动态规划 (DP) | 双指针 (Two Pointers) | 单调栈 (Stack) |
| 视角方向 | 纵向 (竖着切) | 纵向 (竖着切) | 横向 (横着切/层级填充) |
| 核心逻辑 | 预先计算全图的 Max 数组 | 边走边根据局部信息推断全局瓶颈 | 找凹槽,遇到高墙就结算之前的低洼地 |
| 时间复杂度 | O(N) (3次遍历) | O(N) (1次遍历) | O(N) (每个元素进出栈1次) |
| 空间复杂度 | O(N) (存两排数组) | O(1) (仅需几个变量) | O(N) (最坏情况栈深N) |
| 代码量 | 中等,逻辑简单 | 极少,逻辑较绕 | 较多,逻辑复杂 |
| 面试评价 | 稳妥,能通过 | 卓越,最佳解 | 炫技,适合解决变种问题 |
四、 应该如何选择?
- 面试首选: 双指针法。它是本题的终极形态,代码简练且性能最优。你之前提供的代码正是这一种。
- 思维训练: 单调栈。如果你遇到了"柱状图中最大的矩形"或者"区间最大/最小值"这类题目,单调栈是通用的神技,学会这个方法能打通一类题。
- 工程直觉: 动态规划。在如果不缺空间且要求代码可读性极高的情况下,DP 是最不容易写出 Bug 的。
代码实现吗?
package demo
class Solution {
public func trap(height: Array<Int64>): Int64 {
let n = height.size
// 边界检查:如果柱子少于3根,无法形成凹槽,不可能接住雨水
if (n <= 2) {return 0}
// 初始化双指针,分别指向数组的两端
var left = 0
var right = n - 1
// 初始化左右两边的最高高度
// leftMax 记录从左往右扫描过程中的最大值
// rightMax 记录从右往左扫描过程中的最大值
var leftMax = height[left]
var rightMax = height[right]
var result = 0
// 双指针相向移动,直到相遇
while (left < right) {
// 核心逻辑:木桶原理(短板效应)
// 当前位置能接多少水,取决于左右两边最大高度中较矮的那一个。
// 如果左边的高度小于右边的高度,说明"瓶颈"在左边。
// 我们此时可以确定左指针当前位置的积水高度,因为它只受限于 leftMax,
// 而右边由于存在更高的柱子(height[right]),肯定能兜住水。
if (height[left] < height[right]) {
// 移动左指针向右一步
left += 1
// 更新左边的历史最高高度
// max 函数用于比较:如果当前柱子比之前的最高还要高,更新 leftMax;否则保持原样
leftMax = max(leftMax, height[left])
// 累加水量
// 当前位置的水量 = 当前区域的最高水位 (leftMax) - 当前柱子高度 (height[left])
// 如果 height[left] 就是最高点,leftMax - height[left] 为 0,不积水
result += leftMax - height[left]
} else {
// 这是一个对称的操作:如果右边较小(或相等),说明"瓶颈"在右边。
// 处理右指针,逻辑同上
right -= 1
// 更新右边的历史最高高度
rightMax = max(rightMax, height[right])
// 累加右侧当前位置的水量
result += rightMax - height[right]
}
}
return result
}
}
// 测试代码
main(): Unit {
let solver = Solution()
// 示例测试
// 这是一个标准的凹凸不平的测试用例,包含多个积水槽
println(solver.trap([0,1,0,2,1,0,1,3,2,1,2,1])) // 输出:6
// 这是一个包含较高边缘的测试用例
println(solver.trap([4,2,0,3,2,5])) // 输出:9
// 这是一个无法积水的凸型测试用例(中间低两边高才能积水,这里中间是平的或凸的)
println(solver.trap([1,0,1])) // 输出:1
// 只有两根柱子,无法积水
println(solver.trap([1,1])) // 输出:0
}