题目描述
给定 n 个非负整数表示柱子的高度图,计算这些柱子能接住的雨水量。
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:蓝色部分表示雨水,总雨水量为 6。
示例 2:
输入:height = [4,2,0,3,2,5]
输出:9
如何计算储水量?
💧 从最简单的例子开始:三根柱子
首先,我们来看一个最最简单的情形:只有三根柱子,形成一个“U”型结构,例如高度数组为 [2, 0, 2]。
在这个例子中:
- 最左边的柱子高度是 2。
- 中间的柱子高度是 0。
- 最右边的柱子高度是 2。 可以清晰地看到,中间高度为 0 的区域会被左右两边高度为 2 的柱子“围”起来,形成一个可以储水的空间。
计算积水量
- 确定“水面高度”: 水面高度是由左右两边较矮的柱子决定的。在这个例子中,左右两边的柱子高度都是 2,所以水面高度就是 2。
- 计算“储水高度”: 对于中间的柱子,它的高度是 0。 因此,它可以储水的高度就是 水面高度 - 柱子自身高度 = 2 - 0 = 2。
- 计算总积水量: 由于只有一个宽度单位的“坑”,所以总积水量就是 储水高度 * 宽度 = 2 * 1 = 2。
因此,对于输入
[2, 0, 2],接雨水量为 2。
现在,稍微增加一点复杂度,改变中间柱子的高度,来看看积水量如何变化。 [2, 1, 2]
在这个例子中:
- 最左边的柱子高度是 2。
- 中间的柱子高度是 1。
- 最右边的柱子高度是 2。
计算积水量:
- 水面高度: 左右两边“墙”的高度仍然都是 2,所以水面高度还是 2。
- 储水高度: 中间柱子高度是 1。 储水高度 = 水面高度 - 柱子自身高度 = 2 - 1 = 1。
- 总积水量: 1 * 1 = 1。
因此,对于输入 [2, 1, 2],接雨水量为 1。当中间柱子高度升高时,积水量减少了。
继续增加复杂度,将左右两边的柱子高度变为不一致,看看积水量的变化。[3, 0, 2]
在这个例子中:
- 最左边的柱子高度是 3。
- 中间的柱子高度是 0。
- 最右边的柱子高度是 2。
计算积水量:
- 水面高度: 这次左右两边的“墙”高度不一样了,分别是 3 和 2。 水面高度由较矮的“墙”决定,所以水面高度是 2。
- 储水高度: 中间柱子高度是 0。 储水高度 = 水面高度 - 柱子自身高度 = 2 - 0 = 2。
- 总积水量: 2 * 1 = 2。
对于输入 [3, 0, 2],接雨水量为 2。虽然左边的墙更高了,但水面高度仍然被右边较矮的墙限制了。
结合上面两种,接着计算 [3, 1, 2]的情况。
在这个例子中:
- 最左边的柱子高度是 3。
- 中间的柱子高度是 1。
- 最右边的柱子高度是 2。
计算积水量:
- 水面高度: 左右“墙”高度分别是 3 和 2, 较矮的是 2,所以水面高度为 2。
- 储水高度: 中间柱子高度是 1。 储水高度 = 水面高度 - 柱子自身高度 = 2 - 1 = 1。
- 总积水量: 1 * 1 = 1。
对于输入 [3, 1, 2],接雨水量为 1。
从上面的例子,我们能够总结到:
- 水面高度由左右柱子中较矮的一方决定。
- 每个位置的积水量是水面高度与该位置柱子高度之差。
扩展到更复杂的例子
现在,来看题目描述的例子,数组为[0,1,0,2,1,0,1,3,2,1,2,1]
为了计算总的积水量,我们可以把每个“水坑”的积水量分别计算出来,然后加总。
- 第一个 “坑” (索引 2 的位置,高度为 0):
- 左边的“墙” (到索引 1 为止) 最高高度是 1。
- 右边的“墙” (从索引 3 开始) 最高高度是 3 (实际上是索引 7 的柱子)。
- 较矮的“墙”高度是 1。
- 储水高度 = 1 - 0 = 1。
- 积水量 = 1 * 1 = 1。
- 第二个 “坑” (索引 4 的位置,高度为 1):
- 左边的“墙” (到索引 3 为止) 最高高度是 2。
- 右边的“墙” (从索引 5 开始) 最高高度是 3。
- 较矮的“墙”高度是 2。
- 储水高度 = 2 - 1 = 1。 实际上,从图中看,这个位置可以储水的高度应该是 2 - 1 = 1, 因为右边墙更高。 我们稍后会修正这个思考方式。 实际上,这个位置的储水高度应该由 min(左边最高墙, 右边最高墙) - 柱子高度 决定。 所以这里应该是
min(2, 3) - 1 = 2 - 1 = 1。 图中可能视觉效果有些偏差。
- 第三个 “坑” (索引 5 的位置,高度为 0):
- 左边的“墙” (到索引 4 为止) 最高高度是 2。
- 右边的“墙” (从索引 6 开始) 最高高度是 3。
- 较矮的“墙”高度是 2。
- 储水高度 = 2 - 0 = 2。
- 积水量 = 2 * 1 = 2。
- 第四个 “坑” (索引 6 的位置,高度为 1):
- 左边的“墙” (到索引 5 为止) 最高高度是 2。
- 右边的“墙” (从索引 7 开始) 最高高度是 3。
- 较矮的“墙”高度是 2。
- 储水高度 = 2 - 1 = 1。
- 积水量 = 1 * 1 = 1。
- 第五个 “坑” (索引 9 的位置,高度为 1):
- 左边的“墙” (到索引 8 为止) 最高高度是 3。
- 右边的“墙” (从索引 10 开始) 最高高度是 2 (索引 10 的柱子)。
- 较矮的“墙”高度是 2。
- 储水高度 = 2 - 1 = 1。
- 积水量 = 1 * 1 = 1。
- 第六个 “坑” (索引 11 的位置,高度为 1):
- 注意,索引 11 是数组的最后一个元素,它的右边没有墙了,因此这个位置不能积水。 或者,如果非要找右边的墙,可以认为右边没有更高的墙,右边最高的墙就是自身高度 1。 这样
min(左边最高墙, 右边最高墙) = min(2, 1) = 1,1 - height[11] = 1 - 1 = 0, 积水量为 0。
- 注意,索引 11 是数组的最后一个元素,它的右边没有墙了,因此这个位置不能积水。 或者,如果非要找右边的墙,可以认为右边没有更高的墙,右边最高的墙就是自身高度 1。 这样
总积水量 = 1 + 1 + 2 + 1 + 1 + 0 = 6,与示例给出的答案 6 吻合。 从这个例子中,能够总结:
- 对于更复杂的情况,我们仍然可以逐个位置计算积水量。
- 关键仍然是找到每个位置左右两侧的“墙”的最大高度,并取较小值作为水面高度。
- 总积水量是每个位置积水量的总和。
算法思路推导
通过上面的分析,我们已经有了计算每个位置积水量的基本方法。 然而,当我们仔细审视“逐列求雨水”的流程时,会发现一个效率上的瓶颈:为了计算每个柱子上方的积水,我们都不得不重复扫描左右两侧的所有柱子,去寻找 max_left 和 max_right。 设想一下,如果柱子数量非常庞大,这种重复扫描的代价就会变得相当可观,算法的效率也会因此大打折扣,这时,“双指针算法” 便应运而生。双指针算法的精妙之处在于,它动态地维护了左右两边的“最高墙”,避免了重复扫描。相反,它采用了左右夹逼,动态更新的策略,巧妙地避免了重复计算,将寻找最高墙的过程融入到指针移动的过程中。
在双指针算法中,leftMax 和 rightMax 不再是针对每个柱子重新计算的局部最大值,而是全局扫描过程中的动态最大值。
leftMax:记录的是从数组最左端到当前left指针位置所遇到的最大高度,不断更新他们所见过的最高的高度记录。rightMax:记录的是从数组最右端到当前right指针位置所遇到的最大高度。 同理,右侧也在不断更新最高记录。
它们随着指针的移动而不断更新,始终反映着从两侧边界到当前位置的最高“墙”的高度信息。
if leftMax < rightMax 条件:
当 if leftMax < rightMax 条件成立时,意味着当前左侧的“水位标尺” leftMax 低于右侧的 rightMax。 这时能做出一个关键的判断: 对于当前 left 指针所指的位置,其积水高度完全可以由 leftMax 来界定。
else 分支:
else分支 (leftMax >= rightMax) 的逻辑与if分支完全对称。 当rightMax小于等于leftMax时,我们判定当前right指针位置的积水高度由rightMax决定,并移动右指针right。 原理与if分支完全一致,只不过方向相反。
核心思想
- 左右最大值:维护左右两侧的最大高度
leftMax和rightMax。 - 动态更新:根据当前左右最大值的大小关系,决定移动左指针还是右指针,并累加雨水量。
复杂度分析
- 时间复杂度:O(n),仅需一次遍历。
- 空间复杂度:O(1),仅使用常量空间。
代码实现
func trap(height []int) int {
water := 0
left := 0
right := len(height) - 1
leftMax := height[left]
rightMax := height[right]
for left < right {
if leftMax < rightMax {
left++
leftMax = max(leftMax, height[left])
water += leftMax - height[left]
} else {
right--
rightMax = max(rightMax, height[right])
water += rightMax - height[right]
}
}
return water
}