Hot 100 --- 接雨水

0 阅读7分钟

本文概览:本文以LeetCode经典题目"接雨水"为例,从暴力解法入手,逐步优化到动态规划(左右数组)解法,再进一步优化空间复杂度到 O(1) 的双指针解法,系统讲解如何利用"木桶效应"计算每个位置的接水量


一、题目

接雨水题目.png

二、题目分析

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

思路简要说明

  1. 双指针从两端向中间收缩left 从左往右,right 从右往左
  2. 维护左右两侧的最大值leftMax 记录左指针左侧的最高柱子,rightMax 记录右指针右侧的最高柱子
  3. 比较左右最大值决定移动哪边:哪边的最大值更小,就先处理哪边。因为较小的一侧决定了该位置的水位上限,且另一侧必然有更高的柱子作为"挡板"

三、思路详解

暴力解法入手

最自然的想法是:对于数组中的每个位置 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(从左往右递推):

位置 i01234567891011
height010210132121
maxLeft001122223333

再计算 maxRight(从右往左递推):

位置 i01234567891011
maxRight333333322210

然后对每个位置计算 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 决定

具体做法

  1. 左右指针分别从两端出发,leftMaxrightMax 分别记录左右指针扫过区域的最大值

  2. 每次比较 leftMaxrightMax

    • leftMax < rightMax:左指针位置的水位由 leftMax 决定,计算接水量后 left++
    • leftMax >= rightMax:右指针位置的水位由 rightMax 决定,计算接水量后 right--
  3. 两指针相遇时结束

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

步骤leftrightleftMaxrightMax比较处理位置接水量操作
111012leftMax < rightMaxleft=11-1=0left++
221012leftMax < rightMaxleft=21-0=1left++
331022leftMax >= rightMaxright=102-2=0right--
43922leftMax >= rightMaxright=92-1=1right--
53822leftMax >= rightMaxright=82-2=0right--
63723leftMax < rightMaxleft=32-2=0left++
74723leftMax < rightMaxleft=42-1=1left++
85723leftMax < rightMaxleft=52-0=2left++
96723leftMax < rightMaxleft=62-1=1left++
107733leftMax >= rightMaxright=73-3=0right--

总计接水量:0+1+0+1+0+0+1+2+1+0 = 6

  • 时间复杂度:O(n),双指针各遍历一次
  • 空间复杂度:O(1),只用了常数个变量