1. 问题背景与核心挑战
这道LeetCode经典题目"接雨水"看似简单,却蕴含着精妙的算法思想。题目给定n个非负整数表示的高度图,每个柱子的宽度为1,计算下雨后这个排列能接多少雨水。举个生活化的例子,就像在凹凸不平的屋顶摆放一排宽度相同的花盆,雨后计算这些花盆之间能存住多少水。
新手最容易陷入的误区是只关注局部凹陷。比如看到三个柱子[3,1,2]就认为1的位置能存1单位水,实际上右边2比1高但比3矮,最终只能存(3-2)=1单位水。全局视角的缺失会导致计算错误,这正是问题的精妙之处。
2. 暴力解法与优化方向
最直观的暴力解法是对每个柱子,找到其左右两侧的最高柱子,取较小值减去当前柱子高度。时间复杂度O(n²),空间复杂度O(1)。这在LeetCode上会超时,但能帮助我们理解问题本质:
java复制int trap(int[] height) {
int res = 0;
for (int i = 1; i < height.length - 1; i++) {
int leftMax = 0, rightMax = 0;
// 向左找最高
for (int j = i; j >= 0; j--)
leftMax = Math.max(leftMax, height[j]);
// 向右找最高
for (int j = i; j < height.length; j++)
rightMax = Math.max(rightMax, height[j]);
res += Math.min(leftMax, rightMax) - height[i];
}
return res;
}
观察发现暴力解法中有大量重复计算——每个位置都在反复查询左右最大值。很自然想到可以用预处理数组存储这些值,这正是双指针解法的基础。
3. 预处理数组解法详解
预处理解法通过两次遍历,提前计算好每个位置的左右最大值:
java复制int trap(int[] height) {
if (height == null || height.length == 0) return 0;
int n = height.length;
int[] leftMax = new int[n];
int[] rightMax = new int[n];
// 从左到右计算leftMax
leftMax[0] = height[0];
for (int i = 1; i < n; i++)
leftMax[i] = Math.max(leftMax[i-1], height[i]);
// 从右到左计算rightMax
rightMax[n-1] = height[n-1];
for (int i = n-2; i >= 0; i--)
rightMax[i] = Math.max(rightMax[i+1], height[i]);
// 计算每个位置的积水量
int res = 0;
for (int i = 0; i < n; i++)
res += Math.min(leftMax[i], rightMax[i]) - height[i];
return res;
}
3.1 预处理过程解析
预处理阶段的关键在于:
leftMax[i]表示位置i左侧(包括i)的最高柱子rightMax[i]表示位置i右侧(包括i)的最高柱子- 这两个数组的填充都采用动态规划思想:
leftMax[i] = max(leftMax[i-1], height[i])rightMax[i] = max(rightMax[i+1], height[i])
注意边界条件处理:当i=0时leftMax[0]=height[0],当i=n-1时rightMax[n-1]=height[n-1]
3.2 空间复杂度优化
虽然预处理解法时间复杂度优化到O(n),但使用了O(n)额外空间。实际上可以通过双指针进一步优化空间:
java复制int trap(int[] height) {
int left = 0, right = height.length - 1;
int leftMax = 0, rightMax = 0;
int res = 0;
while (left < right) {
if (height[left] < height[right]) {
if (height[left] >= leftMax)
leftMax = height[left];
else
res += leftMax - height[left];
left++;
} else {
if (height[right] >= rightMax)
rightMax = height[right];
else
res += rightMax - height[right];
right--;
}
}
return res;
}
4. 双指针解法核心原理
双指针解法的精妙之处在于:
-
指针移动策略:总是移动较矮一侧的指针。因为积水高度由较矮的一侧决定,这与木桶原理一致。
-
实时更新最大值:在移动过程中,维护当前左右两侧遇到的最大值:
- 当左指针的值小于右指针的值时:
- 如果当前值≥leftMax,更新leftMax
- 否则计算积水量(leftMax - height[left])
- 右指针同理
- 当左指针的值小于右指针的值时:
-
正确性证明:由于每次移动较矮一侧的指针,可以确保另一侧一定有不低于当前最大值的柱子。例如当height[left] < height[right]时,无论右边中间有什么柱子,rightMax都能保证≥height[right]。
5. 常见错误与调试技巧
5.1 边界条件处理
- 空数组检查:输入可能为空
- 单元素数组:无法积水
- 单调递增/递减序列:积水量为0
5.2 典型错误案例
java复制// 错误示例:未考虑相等情况
if (height[left] < height[right]) { ... }
else { ... } // 当height[left]==height[right]时会漏算
// 正确写法应包含等于的情况
if (height[left] <= height[right]) { ... }
5.3 调试建议
- 打印指针移动过程:
java复制System.out.println("left="+left+" right="+right+
" leftMax="+leftMax+" rightMax="+rightMax);
- 对特殊测试用例手动模拟:
- [0,1,0,2,1,0,1,3,2,1,2,1]
- [4,2,0,3,2,5]
6. 算法复杂度分析
| 解法类型 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力解法 | O(n²) | O(1) | 仅用于理解 |
| 预处理 | O(n) | O(n) | 面试推荐 |
| 双指针 | O(n) | O(1) | 最优解 |
预处理解法在面试中更容易解释清楚,而双指针解法需要更深入的理解。建议先掌握预处理解法,再进阶学习双指针优化。
7. 实际应用场景延伸
这种"接雨水"问题的思想可以应用于:
- 地理信息系统中的洼地分析
- 建筑设计中排水系统容量计算
- 图像处理中的形态学操作
- 金融领域的支撑/阻力位分析
例如在建筑设计中,给定屋顶的剖面高度,可以计算最大储水量来设计排水系统。算法只需要将高度数据输入,就能快速得到积水总量。
8. 不同语言实现要点
虽然本文以Java为例,但算法思想通用。其他语言实现时注意:
Python:
python复制def trap(height):
left, right = 0, len(height)-1
left_max = right_max = res = 0
while left < right:
if height[left] < height[right]:
left_max = max(left_max, height[left])
res += left_max - height[left]
left += 1
else:
right_max = max(right_max, height[right])
res += right_max - height[right]
right -= 1
return res
C++:
cpp复制int trap(vector<int>& height) {
int left = 0, right = height.size()-1;
int leftMax = 0, rightMax = 0;
int res = 0;
while (left < right) {
if (height[left] < height[right]) {
height[left] >= leftMax ? (leftMax = height[left]) : res += leftMax - height[left];
++left;
} else {
height[right] >= rightMax ? (rightMax = height[right]) : res += rightMax - height[right];
--right;
}
}
return res;
}
9. 进阶挑战与变种问题
掌握基础解法后,可以尝试以下变种:
- 3D接雨水问题(LeetCode 407)
- 使用单调栈解法
- 允许柱子本身有宽度的情况
- 实时计算滑动窗口内的积水量
以单调栈解法为例,其核心思想是维护一个递减栈,当遇到较高柱子时计算积水:
java复制int trap(int[] height) {
Stack<Integer> stack = new Stack<>();
int res = 0;
for (int i = 0; i < height.length; i++) {
while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
int top = stack.pop();
if (stack.isEmpty()) break;
int distance = i - stack.peek() - 1;
int boundedHeight = Math.min(height[i], height[stack.peek()]) - height[top];
res += distance * boundedHeight;
}
stack.push(i);
}
return res;
}
这种解法更适合处理某些特定模式的高度分布,时间复杂度同样是O(n)。