本文概览:本文以LeetCode经典题目"接雨水"为例,从暴力解法入手,逐步优化到动态规划(左右数组)解法,再进一步优化空间复杂度到 O(1) 的双指针解法,系统讲解如何利用"木桶效应"计算每个位置的接水量
一、题目
二、题目分析
给定一个非负整数数组 height,其中 height[i] 表示第 i 根柱子的高度。柱子之间可以接住雨水,求整个数组能接住多少雨水
目标:计算所有位置能接住的雨水总量
核心原理:木桶效应
对于位置 i,它能接住的雨水量取决于它左右两边最高柱子中较矮的那个(即木桶的短板),再减去当前位置柱子本身的高度。即:
位置 i 的接水量 = min(左边最高柱子, 右边最高柱子) - height[i]
如果这个值 ≤ 0,说明当前位置无法接水(柱子本身比两边的"挡板"还高)
思路概览
Java实现代码如下
public int trap(int[] height) {
//如果只有两个格子,则无法存水
if (height.length < 3 ) {
return 0;
}
//左右指针
int left = 0 , right = height.length - 1;
//左指针左边的最大值,右指针右边的最大值
int leftMax = height[left], rightMax = height[right];
//最左和最有的格子无法存水
left ++;
right --;
int res = 0;
//向中间靠近
while (left <= right) {
leftMax = Math.max(leftMax, height[left]);
rightMax = Math.max(rightMax, height[right]);
if(leftMax < rightMax){
//如果left指针此时的max<=右指针的max
//说明右边必然有大于leftMax高度的木板
//left指针位置的水位由leftMax决定
res += leftMax - height[left];
left ++;
} else {
//此时rightMax<=leftMax
//说明左边必然有大于rightMax高度的木板
//右指针位置的水位由rightMax决定
res += rightMax - height[right];
right --;
}
}
return res;
}
思路简要说明
- 双指针从两端向中间收缩:
left从左往右,right从右往左 - 维护左右两侧的最大值:
leftMax记录左指针左侧的最高柱子,rightMax记录右指针右侧的最高柱子 - 比较左右最大值决定移动哪边:哪边的最大值更小,就先处理哪边。因为较小的一侧决定了该位置的水位上限,且另一侧必然有更高的柱子作为"挡板"
三、思路详解
暴力解法入手
最自然的想法是:对于数组中的每个位置 i,向左扫描找到左边的最高柱子,向右扫描找到右边的最高柱子,然后取两者中的较小值作为水位上限,减去当前柱子高度就是该位置的接水量
显然可以得出以下结论
- 时间复杂度:O(n²),对每个位置都要向左右各扫描一遍
- 核心瓶颈:大量重复计算,每次计算某个位置的左右最大值时,都在重复扫描已经扫过的区域
- 关键思考:能否预先计算好每个位置的左右最大值,避免重复扫描?
动态规划解法(左右数组)
思路分析
暴力解法的问题在于每次都要重新扫描左右两侧来找最大值。如果我们能提前把每个位置的左右最大值都算好,那每个位置的接水量就可以 O(1) 得出
具体做法是使用两个数组:
maxLeft[i]:位置i左侧(不含i)的最高柱子高度maxRight[i]:位置i右侧(不含i)的最高柱子高度
这两个数组可以通过递推来填充:
maxLeft[i] = max(maxLeft[i-1], height[i-1]),从左往右扫一遍maxRight[i] = max(maxRight[i+1], height[i+1]),从右往左扫一遍
有了这两个数组后,每个位置的接水量就是 min(maxLeft[i], maxRight[i]) - height[i]
Java实现代码如下
public int trap(int[] height) {
int sum = 0;
int[] maxLeft = new int[height.length];
int[] maxRight = new int[height.length];
for (int i = 1; i < height.length - 1; i++) {
maxLeft[i] = Math.max(maxLeft[i - 1], height[i - 1]);
}
for (int i = height.length - 2; i >= 0; i--) {
maxRight[i] = Math.max(maxRight[i + 1], height[i + 1]);
}
for (int i = 1; i < height.length - 1; i++) {
int min = Math.min(maxLeft[i], maxRight[i]);
if (min > height[i]) {
sum = sum + (min - height[i]);
}
}
return sum;
}
举例说明
以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例
先计算 maxLeft(从左往右递推):
| 位置 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| height | 0 | 1 | 0 | 2 | 1 | 0 | 1 | 3 | 2 | 1 | 2 | 1 |
| maxLeft | 0 | 0 | 1 | 1 | 2 | 2 | 2 | 2 | 3 | 3 | 3 | 3 |
再计算 maxRight(从右往左递推):
| 位置 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| maxRight | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 2 | 2 | 2 | 1 | 0 |
然后对每个位置计算 min(maxLeft, maxRight) - height:
- 位置 1:
min(0, 3) - 1 = -1→ 0(无法接水) - 位置 2:
min(1, 3) - 0 = 1→ 接水 1 - 位置 4:
min(2, 3) - 1 = 1→ 接水 1 - 位置 5:
min(2, 3) - 0 = 2→ 接水 2 - 位置 6:
min(2, 3) - 1 = 1→ 接水 1 - ...
总计接水量为 6
- 时间复杂度:O(n),三次遍历
- 空间复杂度:O(n),需要两个额外数组
双指针优化解法
思路分析
动态规划解法虽然时间复杂度已经是最优的 O(n),但空间复杂度为 O(n)。我们真的需要精确知道每个位置的左右最大值吗?
仔细想想,其实不需要。我们只需要在遍历过程中动态地维护实时左右最大值,然后根据哪边的最大值更小来决定处理哪边
关键观察:我们只需要知道左右最大值中较小的那个
对于位置 i,其接水量 = min(左侧最高, 右侧最高) - height[i]。如果我们知道 leftMax < rightMax,那么位置 i 的接水量就由 leftMax 决定,因为水位不会超过左右两侧中较矮的那个。此时我们甚至不需要知道右侧的确切最高值是多少,只需要知道右侧一定存在一个比 leftMax 更高的柱子就够了
为什么 leftMax < rightMax 时,右指针右侧必然有高于 leftMax 的柱子?
因为 rightMax 本身就是右指针右侧的最高柱子高度,而 rightMax > leftMax,所以右侧必然有柱子的高度 ≥ rightMax > leftMax。这意味着左指针位置的"右挡板"一定够高,水位完全由左挡板(即 leftMax)决定
同理,当 rightMax ≤ leftMax 时,左指针左侧必然有高于 rightMax 的柱子,右指针位置的水位由 rightMax 决定
具体做法
-
左右指针分别从两端出发,
leftMax和rightMax分别记录左右指针扫过区域的最大值 -
每次比较
leftMax和rightMax:- 若
leftMax < rightMax:左指针位置的水位由leftMax决定,计算接水量后left++ - 若
leftMax >= rightMax:右指针位置的水位由rightMax决定,计算接水量后right--
- 若
-
两指针相遇时结束
Java实现代码如下
public int trap(int[] height) {
//如果只有两个格子,则无法存水
if (height.length < 3 ) {
return 0;
}
//左右指针
int left = 0 , right = height.length - 1;
//左指针左边的最大值,右指针右边的最大值
int leftMax = height[left], rightMax = height[right];
//最左和最有的格子无法存水
left ++;
right --;
int res = 0;
//向中间靠近
while (left <= right) {
leftMax = Math.max(leftMax, height[left]);
rightMax = Math.max(rightMax, height[right]);
if(leftMax < rightMax){
//如果left指针此时的max<=右指针的max
//说明右边必然有大于leftMax高度的木板
//left指针位置的水位由leftMax决定
res += leftMax - height[left];
left ++;
} else {
//此时rightMax<=leftMax
//说明左边必然有大于rightMax高度的木板
//右指针位置的水位由rightMax决定
res += rightMax - height[right];
right --;
}
}
return res;
}
举例说明
以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例
初始:left = 1, right = 10, leftMax = 0, rightMax = 1
| 步骤 | left | right | leftMax | rightMax | 比较 | 处理位置 | 接水量 | 操作 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 10 | 1 | 2 | leftMax < rightMax | left=1 | 1-1=0 | left++ |
| 2 | 2 | 10 | 1 | 2 | leftMax < rightMax | left=2 | 1-0=1 | left++ |
| 3 | 3 | 10 | 2 | 2 | leftMax >= rightMax | right=10 | 2-2=0 | right-- |
| 4 | 3 | 9 | 2 | 2 | leftMax >= rightMax | right=9 | 2-1=1 | right-- |
| 5 | 3 | 8 | 2 | 2 | leftMax >= rightMax | right=8 | 2-2=0 | right-- |
| 6 | 3 | 7 | 2 | 3 | leftMax < rightMax | left=3 | 2-2=0 | left++ |
| 7 | 4 | 7 | 2 | 3 | leftMax < rightMax | left=4 | 2-1=1 | left++ |
| 8 | 5 | 7 | 2 | 3 | leftMax < rightMax | left=5 | 2-0=2 | left++ |
| 9 | 6 | 7 | 2 | 3 | leftMax < rightMax | left=6 | 2-1=1 | left++ |
| 10 | 7 | 7 | 3 | 3 | leftMax >= rightMax | right=7 | 3-3=0 | right-- |
总计接水量:0+1+0+1+0+0+1+2+1+0 = 6
- 时间复杂度:O(n),双指针各遍历一次
- 空间复杂度:O(1),只用了常数个变量