力扣hot100---42.接雨水(java版)
1、题目描述
给定
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 * 104
0 <= height[i] <= 105
2、思路
第一步:彻底理解“接雨水”的物理模型(为什么能接水?)
目标: 把现实问题抽象成数学/编程模型。
想象你面前有一排不同高度的柱子(数组 height = [0,1,0,2,1,0,1,3,2,1,2,1]),下雨了,水会从天上落下。
关键问题: 一个位置 i 上能不能积水?能积多少?
答案: 一个位置 i 能积水,当且仅当 它的左边有比它高的柱子,并且右边也有比它高的柱子。水的高度由左边最高柱子和右边最高柱子中较矮的那个决定。
公式: 位置 i 的积水量 = Min(左边最高柱子高度, 右边最高柱子高度) - 当前柱子高度
如果这个值是负数或零,说明不能积水。
为什么是这样? 想象一个木桶,木桶能装多少水,取决于最短的那块木板。在这里,位置
i就像木桶的底部,它的“左壁”是左边最高的柱子,“右壁”是右边最高的柱子。水位只能到“较矮的那块木板”的高度。
手动验证: 以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例,看位置 i=2 (高度为0):
左边最高柱子:
max(0,1) = 1右边最高柱子:
max(2,1,0,1,3,2,1,2,1) = 3较矮的那边是
1所以积水量 =
1 - 0 = 1
完全正确!
这一步的目的是什么? 把模糊的“接水”概念,变成一个精确的、可计算的数学公式。这是编程的第一步:量化。
第二步:暴力解法——最直接的思路(先让代码跑起来)
目标: 不考虑效率,先用最笨的方法实现功能,建立信心。
既然我们知道每个位置 i 的积水量取决于它左边和右边的最大值,那最直接的想法就是:
对于数组中的每一个位置 i:
向左扫描,找到
0到i-1的最大值leftMax。向右扫描,找到
i+1到n-1的最大值rightMax。计算
water = min(leftMax, rightMax) - height[i]。如果
water > 0,就加到总水量里。
Java 代码实现 (暴力法):
public class Solution {public int trap(int[] height) {// 总水量int totalWater = 0;int n = height.length;// 遍历数组中的每一个位置for (int i = 0; i < n; i++) {// 1. 找左边最大值 (从0到i-1)int leftMax = 0;for (int j = 0; j < i; j++) {if (height[j] > leftMax) {leftMax = height[j];}}// 2. 找右边最大值 (从i+1到n-1)int rightMax = 0;for (int j = i + 1; j < n; j++) {if (height[j] > rightMax) {rightMax = height[j];}}// 3. 计算当前位置的积水量int waterAtI = Math.min(leftMax, rightMax) - height[i];// 4. 如果能积水,就加到总量里if (waterAtI > 0) {totalWater += waterAtI;}}return totalWater;}
}分析:
时间复杂度: O(n²)。因为对每个
i,我们都要遍历一次左边和右边。空间复杂度: O(1)。只用了几个变量。
为什么先写暴力法?
验证思路: 它直接对应了我们第一步的数学模型,能确保我们的核心逻辑是正确的。
建立基准: 有了一个能工作的版本,我们才能去优化它。如果优化后的代码结果不对,我们可以用暴力法的结果来对比调试。
面试加分: 在面试中,先给出一个暴力解,再优化,是标准流程,能体现你的思考过程。
这一步的目的是什么?
把数学公式翻译成最直白的代码,确保核心逻辑无误。
第三步:优化思路——动态规划(预计算,避免重复劳动)
目标: 优化暴力法中重复的扫描操作。
暴力法慢在哪里?慢在对于每个位置 i,我们都要重新扫描一遍左边和右边。这是巨大的浪费!
核心洞察: 位置 i 的左边最大值 和 位置 i+1 的左边最大值 是有关系的!
leftMax[i+1] = max(leftMax[i], height[i])
同样,从右边看:
rightMax[i-1] = max(rightMax[i], height[i])
优化方案: 我们可以预先计算好两个数组:
leftMaxArray[i]: 表示从位置0到位置i(包含i)的最大高度。rightMaxArray[i]: 表示从位置i到位置n-1(包含i)的最大高度。
这样,在计算每个位置 i 的积水量时,我们只需要 O(1) 的时间去查表,而不是 O(n) 的时间去扫描。
如何构建 leftMaxArray?
leftMaxArray[0] = height[0](第一个位置左边最大值就是它自己)leftMaxArray[i] = max(leftMaxArray[i-1], height[i])(当前位置的最大值,是前一个位置的最大值和当前高度的较大者)
如何构建 rightMaxArray?
rightMaxArray[n-1] = height[n-1](最后一个位置右边最大值就是它自己)rightMaxArray[i] = max(rightMaxArray[i+1], height[i])(从右往左遍历)
Java 代码实现 (动态规划):
 public class Solution {public int trap(int[] height) {if (height == null || height.length == 0) {return 0;}int n = height.length;int totalWater = 0;// 1. 创建并填充 leftMaxArrayint[] leftMaxArray = new int[n];leftMaxArray[0] = height[0];for (int i = 1; i < n; i++) {leftMaxArray[i] = Math.max(leftMaxArray[i - 1], height[i]);}// 2. 创建并填充 rightMaxArrayint[] rightMaxArray = new int[n];rightMaxArray[n - 1] = height[n - 1];for (int i = n - 2; i >= 0; i--) {rightMaxArray[i] = Math.max(rightMaxArray[i + 1], height[i]);}// 3. 遍历每个位置,计算积水量for (int i = 0; i < n; i++) {int waterAtI = Math.min(leftMaxArray[i], rightMaxArray[i]) - height[i];if (waterAtI > 0) {totalWater += waterAtI;}}return totalWater;}}分析:
时间复杂度: O(n)。我们遍历了3次数组(构建左数组、构建右数组、计算总水量),3n 还是 O(n)。
空间复杂度: O(n)。我们额外使用了两个长度为
n的数组。
为什么想到动态规划? 因为我发现了重叠子问题。计算
i的左边最大值和计算i+1的左边最大值,有很大一部分计算是重复的。动态规划的核心思想就是“记住已经算过的结果”,避免重复计算。
这一步的目的是什么?
通过预计算和空间换时间,将时间复杂度从 O(n²) 降低到 O(n)。
第四步:终极优化——双指针(空间优化,一次遍历)
目标: 在保持 O(n) 时间复杂度的同时,将空间复杂度从 O(n) 优化到 O(1)。
动态规划法已经很快了,但它用了额外的两个数组。我们能不能不用数组,只用几个变量就搞定?
核心洞察: 我们最终要的是 min(leftMax, rightMax)。我们不需要知道精确的左边最大值和右边最大值,我们只需要知道它们中较小的那个。
双指针策略:
我们用两个指针,
left从数组开头开始,right从数组末尾开始。同时维护两个变量:
leftMax(从左边遍历到目前为止遇到的最大值),rightMax(从右边遍历到目前为止遇到的最大值)。关键决策:比较
height[left]和height[right]。如果
height[left] < height[right]:这意味着,对于
left指针指向的位置,它的“瓶颈”一定在左边。因为右边有一个比它高的height[right],所以left位置的积水量只取决于leftMax。我们可以安全地计算
left位置的积水量:water = leftMax - height[left](如果leftMax > height[left])。然后
left++。
如果
height[left] >= height[right]:同理,对于
right指针指向的位置,它的“瓶颈”在右边。计算
right位置的积水量:water = rightMax - height[right]。然后
right--。
为什么这个逻辑成立? 假设 height[left] < height[right]。
我们知道
leftMax是left左边的最大值。我们知道
height[right]是left右边的一个值,并且height[right] > height[left]。那么,
left右边所有值的最大值rightMaxOverall一定大于等于height[right]。所以,
min(leftMax, rightMaxOverall)的结果,一定等于leftMax(因为leftMax <= height[left] < height[right] <= rightMaxOverall)。因此,我们不需要知道精确的
rightMaxOverall,只需要知道leftMax就够了!
Java 代码实现 (双指针):
public class Solution {public int trap(int[] height) {if (height == null || height.length == 0) {return 0;}int left = 0; // 左指针int right = height.length - 1; // 右指针int leftMax = 0; // 从左边遍历遇到的最大高度int rightMax = 0; // 从右边遍历遇到的最大高度int totalWater = 0; // 总积水量// 当两个指针相遇时,遍历结束while (left < right) {// 决策:哪边矮,就先处理哪边if (height[left] < height[right]) {// 左边矮,处理左边if (height[left] >= leftMax) {// 更新左边见过的最大值leftMax = height[left];} else {// 当前位置可以积水!// 水量 = 左边最大值 - 当前高度totalWater += leftMax - height[left];}left++; // 左指针右移} else {// 右边矮 (或相等),处理右边if (height[right] >= rightMax) {// 更新右边见过的最大值rightMax = height[right];} else {// 当前位置可以积水!// 水量 = 右边最大值 - 当前高度totalWater += rightMax - height[right];}right--; // 右指针左移}}return totalWater;}// 测试代码public static void main(String[] args) {Solution solution = new Solution();int[] height = {0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1};int result = solution.trap(height);System.out.println("Total water trapped: " + result); // 应该输出 6}}分析:
时间复杂度: O(n)。两个指针最多遍历整个数组一次。
空间复杂度: O(1)。只用了常数个额外变量。
为什么能想到双指针?
观察动态规划的数组: 我们发现
leftMaxArray是从左到右递增的,rightMaxArray是从右到左递增的。寻找“瓶颈”: 我们意识到决定积水量的是
min(leftMax, rightMax),而这个最小值是由“较矮”的那一侧决定的。贪心策略: 既然较矮的一侧决定了结果,那我们就优先处理它,因为它“更确定”。
这一步的目的是什么?
在保持最优时间复杂度的同时,将空间复杂度优化到极致,达到最优解。
第五步:总结涉及的 Java 数据结构与算法知识点
数据结构:
数组 (Array): 最基础的数据结构,用于存储柱子的高度。我们对它进行遍历、读取和比较。
基本数据类型 (int): 用于存储高度、最大值、指针位置、总水量等。
算法思想:
暴力枚举 (Brute Force): 最直接、最易理解的解法,作为基准。
动态规划 (Dynamic Programming): 通过“记忆化”或“预计算”来避免重复子问题,是优化暴力法的关键。核心是找到状态转移方程 (
leftMax[i] = max(leftMax[i-1], height[i]))。双指针 (Two Pointers): 一种高效的遍历技巧,特别适用于有序数组或需要从两端向中间处理的问题。在这里,它巧妙地利用了“木桶效应”和贪心思想,实现了空间优化。
贪心算法 (Greedy Algorithm): 双指针法中的决策(“哪边矮先处理哪边”)是一种贪心策略,它在每一步都做出局部最优的选择(处理确定性更高的那一侧),最终达到全局最优。
编程技巧:
边界条件处理: 代码开头检查
height == null || height.length == 0,这是良好的编程习惯。循环控制: 熟练使用
for循环和while循环。条件判断: 使用
if-else进行逻辑分支。数学函数: 使用
Math.min()和Math.max()。

