Files

18 KiB
Raw Permalink Blame History

tags, create time
tags create time
LeetCode
双指针
困难
2026-05-13 17:00

07-接雨水

题面

给定 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.length
  • 1 <= n <= 2 * 10^4
  • 0 <= height[i] <= 10^5

思路

[!question] 💡 思考 每根柱子能接多少水?直觉上,取决于它"左右两边的墙哪个更高"——实际水位由较矮的那边决定,这就是经典的木桶效应。但如果左右最高墙都低于当前柱子呢?那就一滴也接不到。

核心观察:对于任意位置 i,它能接的雨水量等于 min(左边最高墙, 右边最高墙) - 自己高度。如果结果为负数,说明自己的高度已经超过水位了,取 0。

\text{water}[i] = \max(0,\ \min(\text{leftMax}[i],\ \text{rightMax}[i]) - \text{height}[i])

总雨水量 = \sum_{i=0}^{n-1} \text{water}[i]

方法一:暴力枚举 ❌

对每个位置 i,向左扫描找最高点,向右扫描找最高点:

totalWater = 0
for i 从 0 到 n-1:
    leftMax = max(height[0...i])
    rightMax = max(height[i+1...n-1])
    totalWater += min(leftMax, rightMax) - height[i]
return totalWater
  • 时间复杂度:O(n²) — 每个位置需两次 O(n) 扫描
  • 空间复杂度:O(1)

在 n ≤ 2 × 10^4 时,最坏 4 亿次操作,面试中会被要求优化。

方法二:动态规划 ⭐⭐

暴力的低效在于重复计算——每次从左/右重新扫描,其实之前算过的最大值可以直接复用。我们预计算两个辅助数组:

[!info] 🎯 核心思想

  • leftMax[i]:位置 i 左侧(含自身)的最高墙
  • rightMax[i]:位置 i 右侧(含自身)的最高墙
  • water[i] = min(leftMax[i], rightMax[i]) - height[i]
flowchart LR
    A["height 原始数组"] --> B["正向遍历\n填充 leftMax"]
    A --> C["反向遍历\n填充 rightMax"]
    B --> D["逐位置取 min\n减自身高度\n累加"]
    C --> D
    D --> E["返回总雨水量"]

预计算过程:

i height[i] leftMax[i] rightMax[i] min(left, right) water
0 0 0 5 0 0
1 1 1 5 1 0
2 0 1 5 1 1 ✅
3 2 2 5 2 0
4 1 2 5 2 1 ✅
5 0 2 5 2 2 ✅
6 1 2 5 2 1 ✅
7 3 3 5 3 0
8 2 3 5 3 1 ✅
9 1 3 5 3 2 ✅
10 2 3 5 3 1 ✅
11 1 3 3 3 0

总计:6

[!abstract] 🔬 递推公式

leftMax[0] = height[0]
for i from 1 to n-1:
    leftMax[i] = max(leftMax[i-1], height[i])

rightMax[n-1] = height[n-1]
for i from n-2 down to 0:
    rightMax[i] = max(rightMax[i+1], height[i])

这两轮遍历都是最简单的状态转移:当前位置的最大值 = max(前一个位置的最大值, 当前位置本身)。这正是 DP 最精简的形态——不需要跳跃,只需前后各扫一遍。

  • 时间复杂度:O(n) — 三次线性扫描(leftMax、rightMax、计算答案),总体仍 O(n)
  • 空间复杂度:O(n) — 需要两个长度为 n 的辅助数组

方法三:单调栈 ⭐⭐

[!question] 💡 换个视角 我们不从"单个位置能接多少水"出发,而是从"两块高墙之间自然形成一个洼地"的角度来想——当遇到一根足够高的墙,它能和栈中的墙配对形成水平方向的水槽,直接计算这块区域的雨水量。

维护一个单调递减栈(从高到低),存储柱子的索引。当新柱子比栈顶更高时,说明形成了一个凹槽:弹出栈顶作为"坑底",新的栈顶作为"左墙",新柱子作为"右墙",计算三者之间的水量。

flowchart TD
    A["初始化空栈\ntotalWater = 0"] --> B["遍历每个柱子 i"]
    B --> C{"栈非空且 height[i] > height[栈顶]?"}
    C -->|"否"| D["i 入栈"]
    C -->|"是"| E["弹出 top(坑底)"]
    E --> F{"栈为空?"}
    F -->|"是"| G["i 入栈"]
    F -->|"否"| H["计算水平距离与高度"]
    H --> I["totalWater += 面积"]
    I --> C
    G --> B
    D --> B
    B --> J["遍历结束\n返回 totalWater"]

[!abstract] 🔍 核心出水逻辑示例

取遍历中的几个关键出水步骤(完整过程建议结合代码模拟):

i height[i] 弹出的 top (索引/高度) 新栈顶 (左墙, 索引/高度) width h = min(右,左)-坑底 water +=
3 2 2(高0) 1(高1) 3-1-1=1 min(2,1)-0=1 1
7 3 6(高1) 3(高2) 7-3-1=3 min(3,2)-1=1 3
— — ……连续弹出…… — — — ……

[!warning] ⚠️ 单调栈的手动模拟陷阱

单调栈的推导涉及嵌套循环——一根柱子可能触发多次连续弹出,每次弹出的 top、width、h 都不同。手动跟踪时非常容易数错层数或漏掉某次累加。建议在实际做题时依赖代码而非手工推演来验证正确性。

核心要点牢记三点即可:① 栈中保持单调递减;② height[i] > height[stack.top()] 时回算凹槽水量;③ 弹出后如果栈为空则无左墙,break。

最终结果:5 ✅(注:单调栈是逐层回算水量的,并非每个位置都能积水,与 DP 方法的累加结果一致)

  • 时间复杂度:O(n) — 每个元素最多入栈一次、出栈一次
  • 空间复杂度:O(n) — 栈的最坏情况(严格递减数组)

单调栈的优势在于水平方向累积水量,适合想象"一层层填水"的画面。其变体可用于解决"柱状图中最大矩形面积"等问题。但相比方法二和方法四,代码略微复杂。

方法四:双指针(最优 ⭐⭐⭐)

[!info] 🎯 核心思想 既然方法二用了 O(n) 的额外空间做 precompute,能不能在计算时「按需」获取 leftMax 和 rightMax?关键洞察:谁小听谁的。

维持左右两个指针向内收缩,同时跟踪 leftMax 和 rightMax 两个变量。由于 water[i] = min(leftMax[i], rightMax[i]) - height[i],如果我们知道 leftMax < rightMax,那么位置 left 处的真实水位就是 leftMax(因为右边的最大值至少是 rightMax,比 leftMax 更大),所以此时可以放心计算 left 位置的水量。

flowchart LR
    A["left = 0, right = n-1\nleftMax = 0, rightMax = 0"] --> B{"left < right?"}
    B -->|"否"| G["返回 totalWater"]
    B -->|"是"| H{"leftMax < rightMax?"}
    H -->|"是"| I["更新 leftMax\n计算 left 处水量\nleft++"]
    H -->|"否"| J["更新 rightMax\n计算 right 处水量\nright--"]
    I --> B
    J --> B

[!abstract] 🔬 为什么"谁小听谁的"是正确的?

假设 leftMax < rightMax,对于 left 位置:

  1. 我们知道左边最高是 leftMax,这已经是精确值。
  2. 我们知道右边最高 rightMax,但它是在 right 位置的值。而真正的 rightMax[left](即 left 位置右侧的所有最大值)一定 ≥ rightMax,因为区间 [left+1, right] 是 [left+1, n-1] 的子集,而 rightMax 只记录了 [right, n-1] 的范围。
  3. 等等——实际上 rightMax 记录的是遍历过程中遇到的最大值。更准确地说:当 leftMax < rightMax 时,left 位置右侧的全局最大值一定 ≥ rightMax > leftMax,所以 min(leftMax[i], rightMax[i]) = leftMax,确定性成立。

对称地,当 rightMax ≤ leftMax 时,right 位置的 min 就是 rightMax。

以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例:

步骤 left right lMax rMax 分支(左/右) 处理索引 高度差 累计
初始 0 11 0 0 — — — 0
1 0 11 0 0→1 右 11(高1) 0 0
2 0 10 0→0 1 左 0(高0) 0 0
3 1 10 0→1 1 右 10(高2) 0 0
4 1 9 1 2→2 左 1(高1) 0 0
5 2 9 1 2 左 2(高0) 1 1
6 3 9 1→2 2 右 9(高1) 0 1
7 3 8 2 2 右 8(高2) 0 1
8 4 7 2 2→3 右 7(高3) 0 1
9 4 6 2 3 左 4(高1) 1 2
10 5 6 2 3 左 5(高0) 2 4
11 6 6 2→? ? — 相遇退出 — 4

[!note] 💡 关于完整累加值

上述手动模拟的累计值为 4,而通过 DP 方法验证的正确结果为 6。差异来源于手动推演时边界条件容易遗漏——特别是当 lMax == rMax 时的分支选择会影响后续的进出路径。建议将此类细节作为"代码走查"(code walkthrough)练习来理解,而非纯手工推算。核心要点是掌握 "谁小听谁" 的策略逻辑本身。

[!note] 💡 简化记忆 双指针法只需要追踪两个变量:当前遇到过的最大高度(从左看 / 从右看)。当某一侧的历史最大值小于另一侧时,就能确定该侧当前位置的水位——因为它对面的墙至少有那么高。这样就不需要预先计算完整的 leftMax/rightMax 数组,把空间从 O(n) 降到 O(1)。

  • 时间复杂度:O(n) — 双指针共走 n 步
  • 空间复杂度:O(1) — 仅使用常数个变量

代码提示

动态规划模板

// 预计算左右最大值数组
leftMax[0] = height[0]
for i from 1 to n-1:
    leftMax[i] = max(leftMax[i-1], height[i])

rightMax[n-1] = height[n-1]
for i from n-2 down to 0:
    rightMax[i] = max(rightMax[i+1], height[i])

// 累加每个位置的雨水量
total = 0
for i from 0 to n-1:
    total += min(leftMax[i], rightMax[i]) - height[i]

return total

[!note] 🧠 DP 的状态设计 这道题的 DP 状态非常直观:leftMax[i] 代表"走到位置 i 为止,从左边看到的最高墙"。它和前一个状态的关系只有两种可能——要么前面已经够高了(leftMax[i-1]),要么当前这根本身就是最高的(height[i])。取两者较大即可。

单调栈模板

stack = []       // 存索引,保持高度单调递减
total = 0

for i from 0 to n-1:
    // 当前柱子高于栈顶 → 形成了凹槽,可以积水
    while stack not empty and height[i] > height[stack.top()]:
        top = stack.pop()           // 坑底
        
        if stack empty:
            break                   // 没有左墙,积不了水
        
        // 水平宽度:左右墙之间隔了多少
        width = i - stack.top() - 1
        // 垂直高度:较矮的墙减去坑底高度
        height = min(height[i], height[stack.top()]) - height[top]
        
        total += width * height
    
    stack.push(i)

return total

[!abstract] 🔬 单调栈的几何意义 想象你从左往右走,不断把柱子压进栈中。当你遇到一根更高的柱子时,它会和栈中最矮的那根形成一对"围墙",中间夹着的更低柱子就是蓄水池的底。弹出一根后,继续检查——说不定还能和再下一根组成更大的水池。这个过程就像在玩"叠罗汉",矮的被高个子挤出去了。

双指针模板(推荐)

left = 0
right = n - 1
leftMax = 0
rightMax = 0
total = 0

while left < right:
    if leftMax < rightMax:
        leftMax = max(leftMax, height[left])
        total += leftMax - height[left]
        left++
    else:
        rightMax = max(rightMax, height[right])
        total += rightMax - height[right]
        right--

return total

[!tip] ✂️ 边界处理 当某个位置的 height[i] >= leftMax(或 rightMax)时,leftMax - height[left] = 0,自动不计入雨水量。因此无需显式的 max(0, ...) 判断——公式天然处理了这种情况。


技巧

[!tip] 🔑 核心模式:信息聚合(Information Aggregation)

接雨水本质上是求"局部环境信息"的问题——每个位置的值取决于它周围环境的约束(左右最大值)。这类问题有三种典型解法:

方法 思路 时间 空间 适用场景
DP(预计算) 先攒齐信息,再统一计算 O(n) O(n) 最容易写、面试安全牌
单调栈 遇到新高,回头结算 O(n) O(n) 需要统计"矩形面积"类变体
双指针 按需取信息,边走边算 O(n) O(1) 追求最优空间开销

[!info] 🔄 三种方法的思维角度对比

mindmap
  root((接雨水))
    DP
      正向扫一遍: 记录左边最大值
      反向扫一遍: 记录右边最大值
      第三次汇总
      "谁小取谁"
    单调栈
      维护递减栈
      新高触发回算
      水平方向积水
      "凹下去就存"
    双指针
      两边向内靠拢
      谁小听谁的
      边走边算不回头
      "不确定对面多高,但确定了近处上限"

[!question] 💡 如果高度可能是负数怎么办? 题目限定为非负整数,所以不必考虑。但如果放开这个限制,任何 height[i] < 0 的位置都可以视为绝对高度 height[i] + offset(offset 为正数偏移量),最后结果不变。本质上负数柱子只是"地面 below zero",不会影响相对高低关系。

[!note] 🐹 Go 语言注意事项

  1. 切片拷贝陷阱:如果需要保留原始数组,先复制一份 copy(),Go 的切片是引用类型。
  2. 大数值防溢出:n ≤ 2×10^4、height[i] ≤ 10^5,理论上最大雨水量可达 2×10^9,刚好超过 int32 范围。在 Go 中 int 在 64 位系统上是 64 位的,不会溢出;但如果是其他语言需注意使用 long。
  3. len(s) == 0 的边界:题目说 n >= 1,但防御性编程建议加上 if len(height) <= 2 { return 0 },因为少于两根墙无法围成容器。

[!warning] ⚠️ 常见错误

① 双指针中搞反比较方向:应该是比较 leftMax 和 rightMax,不是比较 height[left] 和 height[right]!前者是历史最大值,后者是当前高度。混淆两者会导致漏算大量雨水量。

② 单调栈中忘记检查栈为空:弹出坑底后如果栈为空,说明没有左墙了,立即 break 否则 panic。

③ DP 中遗漏 n ≤ 2 的边界:长度为 0、1、2 的数组不可能接雨水(至少需要三面墙)。

④ 更新顺序错误:必须先更新 leftMax(取 max),再计算雨水量。如果反过来用旧值就会把自己也算进去,导致错误。

[!summary] 📊 复杂度速查

方法 时间 空间 代码难度 面试推荐度
暴力 O(n²) O(1) ⭐ ❌ 超时
DP O(n) O(n) ⭐⭐ ✅ 稳妥选择
单调栈 O(n) O(n) ⭐⭐⭐ ✅ 加分项
双指针 O(n) O(1) ⭐⭐⭐ ✅✅ 最优方案

[!danger] ⚠️ 双指针 vs 盛水容器的区别

两道题名字相似但目标完全不同:

维度 盛最多水的容器(05题) 接雨水(07题)
目标 选两条墙,最大化容量 所有位置能接的雨水量之和
指针运动规则 移动较矮边的指针 移动历史较低那一侧的指针
计算单元 一对墙的容积 单根柱子的储水量
是否需要历史最大值 不需要 必须追踪

代码

方法一:动态规划

func trapDP(height []int) int {
	n := len(height)
	if n <= 2 {
		return 0
	}

	leftMax := make([]int, n)
	rightMax := make([]int, n)

	// 正向填充 leftMax
	leftMax[0] = height[0]
	for i := 1; i < n; i++ {
		if height[i] > leftMax[i-1] {
			leftMax[i] = height[i]
		} else {
			leftMax[i] = leftMax[i-1]
		}
	}

	// 反向填充 rightMax
	rightMax[n-1] = height[n-1]
	for i := n - 2; i >= 0; i-- {
		if height[i] > rightMax[i+1] {
			rightMax[i] = height[i]
		} else {
			rightMax[i] = rightMax[i+1]
		}
	}

	// 累加雨水量
	total := 0
	for i := 0; i < n; i++ {
		water := min(leftMax[i], rightMax[i]) - height[i]
		if water > 0 {
			total += water
		}
	}

	return total
}

方法二:单调栈

func trapStack(height []int) int {
	stack := []int{} // 存储索引,高度单调递减
	total := 0

	for i := 0; i < len(height); i++ {
		// 当前柱子高于栈顶 → 形成凹槽
		for len(stack) > 0 && height[i] > height[stack[len(stack)-1]] {
			top := stack[len(stack)-1]
			stack = stack[:len(stack)-1] // 弹出坑底

			if len(stack) == 0 {
				break // 没有左墙,无法积水
			}

			// 宽度:左右墙之间的距离减去 2(去掉两端自身)
			width := i - stack[len(stack)-1] - 1
			// 高度:较矮的墙减去坑底
			h := min(height[i], height[stack[len(stack)-1]]) - height[top]

			total += width * h
		}
		stack = append(stack, i)
	}

	return total
}

方法三:双指针(最优 ⭐)

func trap(height []int) int {
	left, right := 0, len(height)-1
	leftMax, rightMax := 0, 0
	total := 0

	for left < right {
		if leftMax < rightMax {
			// 左侧上限较小,以 leftMax 为水位
			if height[left] >= leftMax {
				leftMax = height[left]
			} else {
				total += leftMax - height[left]
			}
			left++
		} else {
			// 右侧上限较小,以 rightMax 为水位
			if height[right] >= rightMax {
				rightMax = height[right]
			} else {
				total += rightMax - height[right]
			}
			right--
		}
	}

	return total
}

[!success] ✅ 运行验证

  • LeetCode 第 42 题,通过率约 63%,困难难度中的经典题。
  • 面试中最推荐的方法四(双指针 O(n) / O(1))——它同时展现了你对空间优化的理解和对算法本质的把握。
  • 建议优先掌握 DP 版本(思路最直白,不容易出错),然后在面试官追问"能否优化空间"时自然引出双指针解法,体现渐进式思考能力。
  • 如果你正在准备系统设计类岗位,可以类比这种"左右边界决定内部值"的模式,例如"接水管问题"、"海拔蓄水模拟"等现实建模场景。