LeetCode 42:接雨水|前后最大值DP

一、题目

给定一个非负整数数组 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

  1. 向左找最高柱子

  2. 向右找最高柱子

  3. 计算当前水量

可以写成:

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]
相关推荐
北域码匠9 小时前
高通滤波算法深度解析(High-Pass Filter)
stm32·算法·c#·数字信号处理·嵌入式开发·滤波算法·高通滤波
指掀涛澜天下惊10 小时前
强化学习进阶篇八 策略梯度算法
深度学习·学习·算法·强化学习
乌萨奇也要立志学C++10 小时前
【洛谷】kmp算法
开发语言·算法
禹凕10 小时前
Dijkstra算法详解与应用
python·算法
YaraMemo11 小时前
元启发式算法框架
人工智能·算法·5g·信息与通信·启发式算法·信号处理
weixin_3077791311 小时前
一维无粘 Burgers 方程的激波形成问题:MacCormack 格式求解
c++·算法·matlab
HugoStudio_SWAN11 小时前
洛谷 B4500 / B4449 / B3843 凯撒密码、密码强度与密码合规——加密与安全的三道门
c++·学习·程序人生·算法·安全
枫叶林FYL12 小时前
【群体智能集群控制工程实践】第3章 一致性协同算法
算法
禹凕13 小时前
滑动窗口算法实战指南
python·算法