先说个我自己的经历。第一次刷到 LeetCode 407 接雨水 II 的时候,我在草稿纸上画了半小时的方格图,试着把一维接雨水那套"左右两边最高墙取较小值"的逻辑硬塞进去,结果怎么改都过不了。后来才想明白,这题的难点压根不在代码,而在于你能不能转过"水只能从地图边界流出去"这个弯。想通之后,代码不到三十行,写起来甚至比一维版本还顺手——这也是标题里说它"超级简单易懂"的底气,前提是你得先把脑子换过来。
这篇东西我打算把 407 从头到尾拆一遍:一维接雨水的结论为什么在二维直接失效、优先队列配合边界收缩的思路是从哪冒出来的、堆里那个 max 操作到底在防什么、Python 和 Java 以及 C++ 三份代码各自有哪些坑、面试官顺着这道题往下追会问什么。不管你是刚打开 leetcode 热门 100 题榜单的新手,还是写过三五遍每回都要重新推一遍的老手,应该都能从里面抠出点东西。整篇不玩虚的,所有推理过程我都尽量摊开写,代码给全,能直接提交。
1. 一维接雨水的结论,搬到二维为什么直接失效
1.1 一维版本的核心其实就一句话
一维接雨水那道经典题,结论浓缩起来就是:每个位置能蓄多少水,取决于它左边最高的那根柱子和右边最高的那根柱子,两者中较矮的那个决定了天花板,再减掉自己脚下这块地的高度。写成公式就是water[i] = max(0, min(leftMax[i], rightMax[i]) - height[i])。
这个式子的物理含义特别直白:水往低处流,但流到一半被两边的墙夹住了,它能蓄多高,由两边墙里矮的那一边说了算。就像你拿一个盆接水,盆沿一边高一边低,水一定从矮的那边溢出去,所以水面永远停在矮沿的高度上。理解了这个"木桶效应",一维版本无论是暴力预处理前缀最大值,还是用双指针把空间压到 O(1),都只是实现技巧上的差别,思路内核是一模一样的。
双指针的优化为什么能成立?本质上是因为我们每次只从较矮的那一侧结算:当左指针位置的柱子比右指针位置的柱子矮时,右侧一定存在一根不低于它的柱子,于是左指针这个位置的天花板就只由它左边已知的最高墙决定,可以放心结算,然后左指针右移。整个过程没有回头路,一遍扫完,O(n) 时间 O(1) 空间,干净利落。这个"从确定的一侧往里收缩"的直觉,等下会以另一种形式出现在 407 里。
1.2 二维世界里,左右两边不再是一条线
问题来了:到了二维网格,每个格子不再只有左右两个方向,而是上下左右四个方向,甚至可以说存在无穷多条通往边界的路径。你没办法用两个变量概括"左边最高"和"右边最高",因为"左边"到底是贴着同一行往左走,还是可以拐弯绕行?这个歧义直接让一维公式破产。
更要命的是,二维里的水不一定沿着你眼睛看到的最短直线流走。假设某个低洼格子的正上方是一堵高墙,但斜上方的远处有个缺口,水照样能绕过那堵墙从缺口流出去。所以你不能再盯着一个方向的墙体算账,而要考虑"这个格子里的水,最终能从哪里逃出去"。
换个角度,把问题重新描述一遍:对任意一个内部格子,从它出发向任意方向走到地图边界,会形成无数条路径;每条路径上都有一个最高的格子,可以理解成这条路线的"瓶颈高度";那么真正决定这个格子水位上限的,是所有路径中最小的那个瓶颈值。因为水流一定会走对它最有利的那条路,也就是翻越最矮的那个关口。这个值在算法圈有个正经名字,叫"最小化路径最大值的路径"问题,或者叫最小瓶颈路。
1.3 用一句话重新定义 407 的答案
有了上面的铺垫,407 的答案就可以非常精确地写出来了:对每个格子 (i, j),计算从它出发到边界的全部路径中「路径上最大高度」的最小值,记作cap;这个格子实际的蓄水量就是max(0, cap - height[i][j])。把全部格子的蓄水量加起来,就是最终答案。
这里有两个细节必须拎清楚。第一,路径是包含起点本身的,所以如果这个格子自己就很高(比如是整片区域的最高点),那它出发的每条路径的最大值都至少等于它自己,cap就等于它自己的高度,蓄水量为 0,正好符合常识。第二,边界上的格子不需要计算,它们的水直接就流走了,蓄水量天然为 0,所以它们正好可以当作整个算法的起始点。
把这句话翻译成工程实现,就引出了下一节的主角:最小堆加边界收缩。
2. 核心算法选型:为什么是优先队列配边界收缩
2.1 水桶模型给出的直觉
我特别喜欢用一个"慢慢注水的桶"来理解这题。把所有边界格子想象成围成一圈的桶壁,每块壁的高度各不相同。现在往桶里倒水,水会从最矮的那块壁溢出去,所以无论桶内多低,水面都不会超过这块最矮的壁。
于是我们先把所有边界格子丢进一个最小堆,堆顶就是当前最矮的那块"桶壁"。取出它,看看它周围的邻居格子:如果某个邻居比它矮,那水就能在这个邻居上方蓄起来,水面高度就是这个桶壁的高度;如果邻居比它高,那这个邻居自己就变成了更高的一块新桶壁,不蓄水。
接下来是最关键的一步:无论邻居是矮是高,我们都把max(桶壁高度, 邻居自身高度)这个值作为邻居的"有效高度"重新放回堆里。腾出来之后,这块新位置就变成了下一轮的外圈,可以继续向内推进。你会发现,整个过程就像一圈圈围墙不断向内收缩,而堆始终保证我们每次处理的是当前轮廓线上最矮的那一格。
2.2 堆里存 max 而不是存原高度,到底在防什么
很多人第一次写这题,会顺手写成heap.push(邻居自身高度),然后发现结果偏小。原因很简单:邻居这块地在自己不蓄水的同时,还承担了"给更内圈格子当围墙"的职责。如果只把它的原始高度塞回去,那么它就会以一个偏低的身份参与后面的比较,导致更内圈的格子被误认为天花板很低。
举个例子,外圈高度是 10,紧挨着的一格高度是 3,再往里一格高度是 1。处理到高度 3 这一格时,水面定在 10,积水 7,同时它作为一道墙,它的实际有效高度是 10 而不是 3。如果堆里存的是 3,那么当这格向外扩展碰到高度 1 的格子时,就会误判水位是 3,积水 2,而正确答案是水位 10、积水 9。差了一大截。
用max(当前水面高度, 邻居自身高度)就解决了这个问题:它能保证堆里的每一个元素,代表的都是"从这个位置往外,能挡住水的下限高度"。这个不变式一旦成立,整个算法的正确性就稳了。
2.3 普通 BFS 或者 DFS 为什么一定会翻车
有人会想,那我从边界开始做个普通 BFS 不就行了?广度优先遍历整个网格,每次记录访问路径上的最大值。问题在于,BFS 是按距离层次推进的,它先看到的路径不一定是最优路径。假设某个格子有两条通往边界的路线,一条很近但要翻过一堵高墙,一条很远但全程都很低。BFS 会优先走那条近路,把水位算高,等它发现远路时格子已经被标记访问过了,不会重新计算。结果就是答案偏大。
DFS 就更随意了,访问顺序取决于你写循环的方向,换一个方向顺序,结果可能就变了。这类问题的本质是:你需要按"瓶颈值从小到大"的顺序处理节点,而不是按距离顺序。这正好是 Dijkstra 的思路,而 Dijkstra 的标准实现载体就是优先队列(最小堆)。所以堆不是可选项,而是刚需。
2.4 复杂度算清楚,心里才有底
每个格子最多入堆一次、出堆一次,堆的规模最大是 m×n,单次操作是 O(log(mn)),所以总时间复杂度 O(mn log(mn))。空间方面,visited 数组占 O(mn),堆最多同时装着轮廓线上的格子,最坏情况也是 O(mn)。在题目给的 200×200 规模下,也就是四万个格子,四万次堆操作,任何主流语言都能轻松跑进时限。
顺便说一句,这题还有别的做法。比如二分答案:二分一个水位高度,然后做一次 BFS 判断水能不能漫过这个高度,复杂度 O(mn log(maxH)),也能过,但代码量翻倍,而且容易在边界判断上出错。还有用并查集的,把格子按高度从小到大排序后逐个合并,本质是 Kruskal 求最小生成树,写起来更绕。我个人的建议是:刷题阶段就死磕堆这一种,把它吃透,比记三种半懂不懂的解法有用得多。
| 对比维度 | 一维接雨水 | 二维接雨水 II |
|---|---|---|
| 水位决定因素 | 左右最高墙取较小 | 到边界所有路径瓶颈值的最小值 |
| 主流实现 | 双指针 / 单调栈 | 最小堆 + 边界向内收缩 |
| 时间复杂度 | O(n) | O(mn log(mn)) |
| 空间复杂度 | O(1) 或 O(n) | O(mn) |
| 核心直觉 | 木桶短板 | 逐圈注水,从最低处溢出 |
3. 三份可直接提交的代码,附带逐行拆解
3.1 Python 版本:十七行搞定
import heapq class Solution: def trapRainWater(self, heightMap): if not heightMap or not heightMap[0]: return 0 m, n = len(heightMap), len(heightMap[0]) if m < 3 or n < 3: return 0 visited = [[False] * n for _ in range(m)] heap = [] # 1. 把所有边界格子作为初始轮廓线入堆 for i in range(m): for j in range(n): if i == 0 or i == m - 1 or j == 0 or j == n - 1: heapq.heappush(heap, (heightMap[i][j], i, j)) visited[i][j] = True ans = 0 dirs = ((-1, 0), (1, 0), (0, -1), (0, 1)) # 2. 反复取出当前轮廓线上最矮的格子向内扩展 while heap: h, i, j = heapq.heappop(heap) for di, dj in dirs: ni, nj = i + di, j + dj if 0 <= ni < m and 0 <= nj < n and not visited[ni][nj]: visited[ni][nj] = True # 入堆前就标记 ans += max(0, h - heightMap[ni][nj]) heapq.heappush(heap, (max(h, heightMap[ni][nj]), ni, nj)) return ans逐行说一说。开头两行判空很多人会忽略,其实是保命代码,尤其是面试手写时不给测例的情况。m < 3 or n < 3这个提前返回也很关键:如果网格只有一行或者一列,那所有格子都在边界上,压根存不住水,直接返回 0,同时也避免了后面循环里出现没有内部格子的尴尬。
初始化的双重循环把四条边上的格子全部入堆并标记已访问。这里用"已访问"而不是"未访问",是因为边界本来就存不住水,把它当作已经处理过的外圈来处理,逻辑上更干净。
主循环里有两处细节值得反复看:一是visited[ni][nj] = True写在入堆之前而不是弹出之后。如果你习惯性地在弹出时才标记,那同一个格子可能被四个邻居分别入堆一次,答案会重复累加,堆的规模也会膨胀。二是ans += max(0, h - heightMap[ni][nj])里的max(0, ...)不能省,当邻居比当前水面还高时,差值是负数,不加保护就会把总答案往下拉。虽然那种情况下我们随后会用max修正入堆高度,但答案累加这一步必须先夹住。
3.2 Java 版本:比较器和装箱的坑
class Solution { public int trapRainWater(int[][] heightMap) { if (heightMap == null || heightMap.length == 0 || heightMap[0].length == 0) return 0; int m = heightMap.length, n = heightMap[0].length; if (m < 3 || n < 3) return 0; boolean[][] visited = new boolean[m][n]; // 小根堆:按高度升序,高度相同时任意 PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (i == 0 || i == m - 1 || j == 0 || j == n - 1) { pq.offer(new int[]{heightMap[i][j], i, j}); visited[i][j] = true; } } } int ans = 0; int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; while (!pq.isEmpty()) { int[] cur = pq.poll(); int h = cur[0], i = cur[1], j = cur[2]; for (int[] d : dirs) { int ni = i + d[0], nj = j + d[1]; if (ni < 0 || ni >= m || nj < 0 || nj >= n || visited[ni][nj]) continue; visited[ni][nj] = true; ans += Math.max(0, h - heightMap[ni][nj]); pq.offer(new int[]{Math.max(h, heightMap[ni][nj]), ni, nj}); } } return ans; } }Java 这边最大的坑是PriorityQueue的默认行为。它默认是小根堆没错,但它要求元素可比较,而int[]数组本身没有实现Comparable,你不写比较器,编译能过,运行时抛ClassCastException。比较器写成(a, b) -> a[0] - b[0]就够了,因为高度范围在 20000 以内,相减不会溢出 int。
另一个常见疑问是:能不能用(a, b) -> Integer.compare(a[0], b[0])?当然可以,风格上更稳妥,防止将来数值范围变大导致相减溢出。刷题时两种写法都行,工程代码里我建议用后一种。
还有个小细节,visited[ni][nj] = true和ans累加的顺序,跟 Python 版保持一致就好。顺序本身不影响正确性,因为这两个操作没有依赖关系,但保持统一风格能让你在两种语言之间切换时少犯错。
3.3 C++ 版本:结构化绑定的版本要求
class Solution { public: int trapRainWater(vector<vector<int>>& heightMap) { if (heightMap.empty() || heightMap[0].empty()) return 0; int m = heightMap.size(), n = heightMap[0].size(); if (m < 3 || n < 3) return 0; vector<vector<bool>> visited(m, vector<bool>(n, false)); // 小根堆:tuple 默认按字典序比较,正好等价于按高度排序 priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, greater<>> pq; for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) if (i == 0 || i == m - 1 || j == 0 || j == n - 1) { pq.emplace(heightMap[i][j], i, j); visited[i][j] = true; } long long ans = 0; int dirs[4][2] = {{-1,0}, {1,0}, {0,-1}, {0,1}}; while (!pq.empty()) { auto [h, i, j] = pq.top(); pq.pop(); for (auto& d : dirs) { int ni = i + d[0], nj = j + d[1]; if (ni < 0 || ni >= m || nj < 0 || nj >= n || visited[ni][nj]) continue; visited[ni][nj] = true; ans += max(0, h - heightMap[ni][nj]); pq.emplace(max(h, heightMap[ni][nj]), ni, nj); } } return (int)ans; } };C++ 这边用tuple配合greater<>是个偷懒又正确的写法,因为 tuple 的比较规则是先比第一个元素,相等再比第二个,以此类推,而我们正好希望按高度排序。要注意的是auto [h, i, j] = ...这种结构化绑定需要 C++17 标准,如果评测环境只支持 C++14,改成tuple<int,int,int> cur = pq.top(); int h = get<0>(cur);就行了。
这里我特意把ans声明成long long。虽然按题目约束算下来最大值不会超过 int 范围,但在工程习惯上,凡是累加类变量我都倾向于用宽类型,出问题的时候排查成本远高于多占那几个字节。最后返回时强转回 int 即可。
三份代码的逻辑完全一致,挑一门你刷题常用的语言,抄进编辑器跑一遍,再自己默写两遍,这道题基本就刻进肌肉记忆里了。
3.4 跟着堆走一遍,看看水位是怎么被抬上去的
光看代码容易晕,我们拿一个小网格手动跑一遍。假设地图是这样:
2 2 2 2 2 2 9 9 9 2 2 9 1 9 2 2 9 9 9 2 2 2 2 2 2这是 5×5 的格子,外圈全是 2,中间一圈是 9,正中心是 1。很明显,中心那个 1 想往外流,必须翻过一圈高度 9 的墙,所以水位会被顶到 9,蓄水量是 8。算法能不能自动得出这个结果?能。
第一步,把最外圈的 16 个高度为 2 的格子全部入堆。堆顶是 2,弹出来,检查它的四个邻居。最外圈格子的邻居里,有一部分还是外圈(已访问),有一部分是内圈的 9。
第二步,这些 9 被首次访问,max(0, 2 - 9)得到 0,不蓄水;但它们入堆的高度是max(2, 9) = 9。所以堆里现在混着高度 2 和高度 9 的元素,堆顶依然是 2。
第三步,继续弹出所有高度 2 的元素,它们能扩展到的内圈 9 已经全部标记过了,没有新的格子被发现。这时候堆顶就变成了 9。
第四步,弹出高度 9 的格子,它的邻居里有正中心那个高度 1 的格子,未被访问。ans += max(0, 9 - 1) = 8,然后把max(9, 1) = 9入堆。中心格子处理完,堆里剩下的元素都扩展不出新格子,循环结束。
最终答案就是 8。你注意第四步里,水位是 9 而不是 2——虽然外圈更矮,但水要先漫过内圈那堵 9 的墙才能出去,所以真正的天花板是 9。这个例子最好地说明了为什么必须用堆:如果按 BFS 的层次顺序走,很可能先从外圈的 2 开始就误判水位。
4. 常见问题与排查技巧实录
4.1 结果偏小,八成是丢了 max
前面提过一次,但值得再强调:pq.offer(new int[]{Math.max(h, heightMap[ni][nj]), ni, nj})里的Math.max是最容易被漏掉的地方。漏了它,所有新扩展出来的格子都会以自身原始高度进入堆,导致更内层的格子被低估。这个 bug 的隐蔽之处在于,简单的测例(比如外圈 3、内圈 0 那种)跑出来是对的,只有遇到"中间夹一层矮格子"的结构才会暴露。
我自己的排查办法是构造三组数据打桩。第一组是全平的网格,答案必然是 0,用来验证不误报。第二组是经典例题,答案已知是 4。第三组就是上面那个"外 2 内 9 心 1"的构造,答案必须是 8。三组都过,基本就稳了。
4.2 结果偏大,检查 visited 标记时机
如果答案莫名其妙偏大,十有八九是visited标记写在弹出的时候,而不是入堆的时候。这样一来,一个格子会被它的多个邻居分别入堆,弹出时就会被累加多次。举个直观的场景:某个低洼格子有上下左右四个邻居,如果都在它被弹出之前把它入堆,那它就会在堆里出现四次,每次弹出都会加一遍积水。
判断方法也很简单,在循环里加一句打印,看看同一个坐标有没有被处理超过一次。有,就是标记时机的问题。
4.3 数组越界和空输入的防守
题目虽然保证了输入是合法矩阵,但手写代码的时候该有的判断还得有。heightMap == null || heightMap.length == 0 || heightMap[0].length == 0这三连是标配。另外if (m < 3 || n < 3) return 0;这一句既优化了性能,又顺手规避了"内部没有可蓄水格子"的边界情况。有些实现忘了这一句,虽然主循环也不会出错,但白跑一遍 O(mn) 的入堆逻辑,属于无谓开销。
| 现象 | 最可能的原因 | 修复方式 |
|---|---|---|
| 结果偏小 | 入堆时未取 max | 写成 max(水面高度, 邻居高度) |
| 结果偏大 | visited 在弹出时才标记 | 改为入堆前立即标记 |
| 结果偶发偏大 | 累加时未做 max(0, ...) 保护 | 差值取非负 |
| 运行时异常 | Java 未给数组写比较器 | 补上按首元素升序的比较器 |
| 编译不过 | C++ 用了 C++17 的结构化绑定 | 改为 get<0> 取值或升标准 |
| 一行或一列的输入 | 未提前返回 | 加 m < 3 或 n < 3 的判断 |
4.4 三维延伸其实也难不倒你
有人在评论区问过,如果地图变成三维立方体怎么办。答案是这个算法一个字都不用改,只是方向数组从 4 个变成 6 个(上下左右前后),初始轮廓从"四条边"变成"六个面"。本质逻辑完全一样:所有表面格子入堆,每次弹出最矮的向内推一层,新格子的高度取 max。想清楚这一点,说明你对这套模型是真的理解了,而不是背下来的。
5. 把 407 抽象成一个通用模型
5.1 最小瓶颈路:这个模型的应用范围比你想的广
407 的底层数学结构叫最小瓶颈路问题。它的标准定义是:在带权图里,从起点到终点有若干条路径,每条路径的权重定义为路径上最大边的权值,求所有路径中这个最大边权的最小值。求解它的经典手段有两个,一个是优先队列版的 Dijkstra 变体,另一个是构造最小生成树——因为有一条很漂亮的定理:任意两点间的最小瓶颈路,一定落在最小生成树上。
放到 407 里,每条"边"的权重就是格子的高度,"路径权重"就是路径上最高格子的高度。求的正是中心格子到边界的最小瓶颈值。理解了这层抽象,你会发现同类题目其实是一整个家族。
5.2 同源题目一网打尽
LeetCode 778 水位上升的游泳池,问的是从左上角走到右下角,路径上最大值的最小值是多少,这就是最小瓶颈路的标准形态,可以用二分加 BFS 做,也可以直接堆 Dijkstra。1631 最小体力消耗路径,把路径权重从"最大值"换成了"相邻差值的最大值",结构一模一样,只是比较的对象从节点值变成了边的差值。
再往远一点说,网络路由里找一条"最不拥堵"的路径、电路布线里找一条"最不容易烧断"的线路,都是同一类问题的变体。所以刷这道题的时候,别只想着把它 AC 掉,多花十分钟把"什么时候该用堆"这个判断条件想明白,收益会大得多。
5.3 面试官顺着这题往下追,通常会问什么
第一问大概率是"为什么用优先队列,普通队列行不行"。这时候你要答的是顺序性:必须按瓶颈值从小到大处理,才能保证第一次访问某个格子时用的就是最优路径,这跟 Dijkstra 的贪心正确性是一回事。
第二问可能是"时间复杂度还能不能降"。答案是标准解法就是 O(mn log(mn)),除非牺牲通用性。二分的做法是 O(mn log(maxH)),理论上跟堆版本同阶,但常数更大。
第三问可能是"如果要求返回所有蓄水格子的坐标和水量,而不是总和呢"。改法很简单,把ans += ...换成往结果列表里塞一个三元组,同时把每个格子的最终水位记录下来,不需要动主干逻辑。
第四问有时候会拐到并查集上:能不能用并查集从外向内合并。可以,思路是把格子按高度排序,从小到大依次加入,用一个虚拟的"外部"节点表示边界,当某个格子与外部连通时它就存不住水了。但代码复杂度明显上升,面试里除非被点名要求,不建议主动往这条路上走。
6. 刷完这道题之后我攒下的几点真实体会
第一点,这类"网格 + 瓶颈路径"的题目,判断该不该用堆有一个很好用的信号:当你发现某个位置的答案只取决于一条路径上的最值,而不是路径长度或者路径总和时,基本上就是堆的活。反过来,如果答案跟路径长度有关,那就是普通 BFS;跟路径总和有关,那就是 Dijkstra 的最短路径版本。三种信号对应三种工具,分清楚了,看图论题就不会再靠猜。
第二点,我踩过最深的坑不是算法本身,而是代码里的顺序。visited什么时候标记、max什么时候取、ans什么时候累加,这三件事每换一种语言写都容易错位一次。后来我给自己定了个规矩:不管写哪个语言,都先把这三行按固定顺序摆好,再填内容。养成这个习惯之后,一次性通过率明显上去了。
第三点,关于验证。我强烈建议你自己手写一个暴力版本用来对拍。暴力版本的写法是:对每个内部格子做一次广度优先搜索,用小根堆维护"从它出发到边界的最小瓶颈值",把每个格子的结果算出来,最后加总。虽然它是 O((mn)^2 log(mn)),跑不了大数据,但用来验证小规模随机数据足够了。随机生成 5×5 到 8×8 的网格,跑一百组对拍,如果结果全都一致,那你这道题算是真正拿下了。
第四点,讲讲心态。407 在 leetcode 热门 100 题里算偏难的一道,第一遍做不出来太正常了。我建议的做法是先自己硬想四十分钟,想不出来再去看提示,只看到"优先队列"这个关键词就停,剩下的自己补。这样既保留了思考的价值,又不会卡死在一道题上消耗热情。毕竟刷题的节奏感,比单题的胜负重要得多。