一、题目
给定一个非负整数数组 height,其中每个元素表示某一列柱子的高度,要求计算这些柱子之间最多能接多少雨水。
例如:
height = [0,1,0,2,1,0,1,3]
可以把它理解成一排高低不同的柱子。
这道题最容易一开始不知道从哪里入手,但真正核心其实只有一句:
每一列能接的水
=
min(左边最高柱子, 右边最高柱子)
-
当前柱子高度
也就是:
water[i]
=
Math.min(leftMax[i], rightMax[i])
-
height[i];
只要这个公式理解了,整道题就已经解决了一大半。
二、为什么要看左右最高柱子?
假设当前位置:
height[i] = 1
左边最高柱子:
5
右边最高柱子:
3
那么水最多能涨到多高?
不能涨到:
5
因为右边只有:
3
超过 3 的水会从右侧流出去。
所以真正能形成的水面高度是:
min(5,3) = 3
当前柱子本身高度是:
1
所以当前位置能存:
3 - 1 = 2
单位水。
因此公式就是:
Math.min(leftMax[i], rightMax[i]) - height[i]
三、这道题真正的核心不是"水",而是边界
对于任意一个位置 i:
水面
----------------
左墙 当前柱子 右墙
能接多少水完全取决于:
左边最高墙
右边最高墙
而且只能取较矮的一侧。
所以可以形成一个固定思维:
接雨水不是看相邻柱子,而是看当前位置左右两侧的最高边界。
四、最直接的暴力思路
对于每个位置 i:
-
向左找最高柱子
-
向右找最高柱子
-
计算当前水量
可以写成:
java
for (int i = 0; i < height.length; i++) {
int leftMax = 0;
int rightMax = 0;
for (int j = 0; j <= i; j++) {
leftMax = Math.max(leftMax, height[j]);
}
for (int j = i; j < height.length; j++) {
rightMax = Math.max(rightMax, height[j]);
}
ans += Math.min(leftMax, rightMax) - height[i];
}
这个思路正确。
但是每一个位置都重新扫描左右两边。
时间复杂度:
O(n²)
明显存在大量重复计算。
五、优化思路:提前把左右最大值算出来
既然:
每个位置都要知道左边最高
每个位置都要知道右边最高
那就提前保存。
定义:
int[] leftMax = new int[n];
int[] rightMax = new int[n];
其中:
leftMax[i]
=
从 0 到 i 的最高柱子
而:
rightMax[i]
=
从 i 到 n-1 的最高柱子
这样后面每个位置就不用重新扫描。
六、最终代码
这是我目前掌握并推荐先固定下来的版本:
java
class Solution {
public int trap(int[] height) {
// 每一列水量:
// min(leftMax[i], rightMax[i]) - height[i]
int n = height.length;
int[] leftMax = new int[n];
int[] rightMax = new int[n];
int ans = 0;
// 初始化左右边界
leftMax[0] = height[0];
rightMax[n - 1] = height[n - 1];
// 计算前缀最大值
for (int i = 1; i < n; i++) {
leftMax[i] =
Math.max(leftMax[i - 1], height[i]);
}
// 计算后缀最大值
for (int i = n - 2; i >= 0; i--) {
rightMax[i] =
Math.max(rightMax[i + 1], height[i]);
}
// 累加每一列能够存的水
for (int i = 0; i < n; i++) {
ans +=
Math.min(leftMax[i], rightMax[i])
- height[i];
}
return ans;
}
}
七、为什么 leftMax0 = height0?
代码:
leftMax[0] = height[0];
因为对于最左边这个位置:
0 ~ 0
范围里只有它自己。
所以:
左边最高柱子
=
height[0]
因此:
leftMax[0] = height[0];
这是前缀最大值的初始条件。
八、为什么 leftMax 要从 1 开始?
代码:
for (int i = 1; i < n; i++) {
因为:
leftMax[0]
已经初始化好了。
对于:
i = 1
开始,我们可以使用:
leftMax[i - 1]
也就是:
leftMax[0]
所以自然从:
1
开始。
九、leftMax 的状态转移怎么理解?
核心:
leftMax[i] =
Math.max(leftMax[i - 1], height[i]);
翻译成人话:
到当前位置
i为止的最大高度,要么是前面已经出现过的最大高度,要么是当前柱子更高。
例如:
height =
[0, 1, 0, 2, 1]
计算:
leftMax[0] = 0
然后:
i = 1
max(0,1)
= 1
所以:
leftMax[1] = 1
继续:
i = 2
max(1,0)
= 1
所以:
leftMax[2] = 1
继续:
i = 3
max(1,2)
= 2
所以:
leftMax[3] = 2
最终:
height:
0 1 0 2 1
leftMax:
0 1 1 2 2
十、rightMax 完全对称
初始化:
rightMax[n - 1] = height[n - 1];
因为最后一个位置右边只有自己。
然后从右往左:
for (int i = n - 2; i >= 0; i--) {
状态转移:
rightMax[i] =
Math.max(rightMax[i + 1], height[i]);
意思:
从当前位置
i往右的最高柱子,要么是右边已经找到的最大值,要么是当前柱子。
十一、为什么 rightMax 从 n - 2 开始?
因为:
rightMax[n - 1]
已经初始化。
而:
rightMax[i]
需要依赖:
rightMax[i + 1]
所以第一个可以计算的位置就是:
n - 2
例如数组长度:
n = 5
最后一个下标:
4
已初始化。
所以从:
3
开始向左。
十二、最终公式
有了:
leftMax[i]
rightMax[i]
以后,每一列水量直接:
Math.min(leftMax[i], rightMax[i])
-
height[i]
总水量:
ans +=
Math.min(leftMax[i], rightMax[i])
- height[i];
这就是整道题最终核心。
十三、用一个小例子完整走一遍
假设:
height = [3,0,2]
先计算:
leftMax
得到:
[3,3,3]
因为:
位置0:
最大 = 3
位置1:
max(3,0) = 3
位置2:
max(3,2) = 3
再计算:
rightMax
得到:
[3,2,2]
因为:
位置2:
最大 = 2
位置1:
max(2,0) = 2
位置0:
max(2,3) = 3
所以:
height = [3,0,2]
leftMax = [3,3,3]
rightMax = [3,2,2]
逐个计算水量。
i = 0
min(3,3) - 3
= 0
i = 1
min(3,2) - 0
= 2
i = 2
min(3,2) - 2
= 0
总水量:
2
正确。
十四、为什么最左边和最右边也可以直接计算?
直觉上最左边和最右边肯定接不了水。
代码却写:
for (int i = 0; i < n; i++)
从头到尾全部算。
为什么没问题?
因为最左边:
leftMax[0] = height[0]
所以:
min(leftMax[0], rightMax[0])
<= leftMax[0]
= height[0]
实际上最终水量一定为:
0
最右边同理。
所以不需要特殊处理。
十五、为什么水量不会是负数?
公式:
Math.min(leftMax[i], rightMax[i])
-
height[i]
因为:
leftMax[i]
本身包含:
height[i]
所以一定:
leftMax[i] >= height[i]
同理:
rightMax[i] >= height[i]
因此:
min(leftMax[i], rightMax[i])
>= height[i]
所以:
水量 >= 0
不会出现负数。
十六、这是不是动态规划?
可以把它理解成:
前缀 / 后缀 DP。
因为:
leftMax[i]
依赖:
leftMax[i - 1]
而:
rightMax[i]
依赖:
rightMax[i + 1]
都有明显的状态递推关系。
但面试里说:
前后缀最大值
其实会更直观。
十七、复杂度分析
一共进行了三次遍历。
第一次:
计算 leftMax
O(n)
第二次:
计算 rightMax
O(n)
第三次:
计算答案
O(n)
总时间:
O(n) + O(n) + O(n)
=
O(n)
所以时间复杂度:
O(n)
额外创建:
leftMax
rightMax
两个长度为 n 的数组。
所以空间复杂度:
O(n)
十八、还能不能优化?
可以。
当前:
时间 O(n)
空间 O(n)
还可以进一步通过:
双指针
将空间复杂度优化到:
O(1)
但当前这版已经非常适合理解和面试保底。
因为它直接对应最核心公式:
water[i]
=
min(leftMax[i], rightMax[i])
-
height[i]
逻辑最清楚。
十九、双指针优化的思想
前后缀数组实际上是为了提前知道:
左边最高
右边最高
但如果使用两个指针:
left
right
并同时维护:
leftMax
rightMax
就不需要两个数组。
因此可以优化成:
时间 O(n)
空间 O(1)
不过学习顺序应该是:
先理解每列公式
↓
再理解前后缀最大值
↓
最后学双指针空间优化
而不是直接死背双指针。
二十、面试讲法
如果面试官让我讲,我会这样回答:
对于任意位置
i,它能够接的水量取决于它左侧最高柱子和右侧最高柱子中较矮的那个,因此当前水量为min(leftMax[i], rightMax[i]) - height[i]。为了避免对每个位置都重新向左右扫描,我分别预处理一个前缀最大值数组
leftMax和后缀最大值数组rightMax。
leftMax[i] = max(leftMax[i-1], height[i]),rightMax[i] = max(rightMax[i+1], height[i])。最后遍历数组,将每个位置的水量累加。
整体时间复杂度 O(n),额外空间复杂度 O(n)。如果进一步要求 O(1) 空间,可以使用双指针优化。
这段就已经非常完整。
二十一、最容易写错的地方
1. leftMax 初始化错误
应该:
leftMax[0] = height[0];
不能直接从:
0
默认值开始递推而忽略第一个柱子。
2. rightMax 初始化错误
应该:
rightMax[n - 1] = height[n - 1];
3. rightMax 循环方向写反
应该从右向左:
for (int i = n - 2; i >= 0; i--)
因为它依赖:
rightMax[i + 1]
4. 公式用了 max
错误:
Math.max(leftMax[i], rightMax[i])
应该:
Math.min(leftMax[i], rightMax[i])
因为水面由较矮的边界决定。
5. 忘记减当前柱子
错误:
ans += Math.min(leftMax[i], rightMax[i]);
正确:
ans +=
Math.min(leftMax[i], rightMax[i])
- height[i];
因为柱子本身占据空间,不是水。
二十二、面试前 30 秒速记
看到:
接雨水
先想公式:
每一列水量
=
min(左边最高, 右边最高)
-
当前高度
代码模板:
java
int n = height.length;
int[] leftMax = new int[n];
int[] rightMax = new int[n];
leftMax[0] = height[0];
for (int i = 1; i < n; i++) {
leftMax[i] =
Math.max(leftMax[i - 1], height[i]);
}
rightMax[n - 1] = height[n - 1];
for (int i = n - 2; i >= 0; i--) {
rightMax[i] =
Math.max(rightMax[i + 1], height[i]);
}
int ans = 0;
for (int i = 0; i < n; i++) {
ans +=
Math.min(leftMax[i], rightMax[i])
- height[i];
}
return ans;
三条公式:
leftMax[i]
=
max(leftMax[i-1], height[i])
rightMax[i]
=
max(rightMax[i+1], height[i])
water[i]
=
min(leftMax[i], rightMax[i])
-
height[i]
复杂度:
时间 O(n)
空间 O(n)
二十三、最终总结
这道题最开始容易被图形吓到,但真正拆开以后,每一个位置其实都是独立计算:
当前位置能接多少水?
答案就是:
左右两边最高墙中较矮的那个
-
当前柱子高度
也就是:
Math.min(leftMax[i], rightMax[i]) - height[i]
为了避免每个位置重复向左右扫描,通过:
前缀最大值 leftMax
后缀最大值 rightMax
提前保存边界信息。
最终思维链:
接雨水
↓
逐列计算
↓
需要左右最高墙
↓
前后缀最大值
↓
O(n) 求解
当前这版代码已经足够作为一个稳定的面试解法。
后续如果继续优化,只需要进一步把:
leftMax[]
rightMax[]
两个数组压缩成:
leftMax
rightMax
两个变量,再配合双指针,就可以做到:
O(n) 时间
O(1) 空间
但无论怎么优化,整道题最核心的公式始终不变:
water[i]
=
min(leftMax[i], rightMax[i])
-
height[i]