一次LeeCode刷题记录:接雨水

42. 接雨水

给定 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

没问题,为了让你能够从根本上吃透这道题,我将为你提供一份**"接雨水"问题的深度算法通关指南**。

我们将从最底层的数学原理出发,详细拆解三种主流解法的推导过程、核心逻辑差异以及图解分析。


一、 核心物理模型:木桶原理(短板效应)

无论使用哪种算法,接雨水的本质永远只有一个公式。

对于数组中的任意位置 i,它能接住的雨水量 Water\[i\] 取决于:

  1. 它左边最高的柱子 LeftMax_i
  2. 它右边最高的柱子 RightMax_i
  3. 它自己的高度 Height_i

Water\[i\] = \\max(0, \\min(LeftMax_i, RightMax_i) - Height_i)

人话翻译: 此时头顶的水位,取决于两边"墙"中较矮的那堵墙。


二、 解法深度拆解

1. 动态规划法 (Dynamic Programming)

核心思路: 空间换时间,提前记账。

为什么需要它?

在最暴力的解法中,对于每一个位置,我们都要向左跑一趟找最大值,再向右跑一趟找最大值。这导致了 O(N\^2) 的时间复杂度。

动态规划的思想是:不要每次都重新跑,把结果存下来。

算法流程详细推导:
  1. 从左向右扫描: 建立数组 left_max
    • left_max[i] 代表 0i 这一段区间的最大高度。
    • 递推公式:left\\_max\[i\] = \\max(left\\_max\[i-1\], height\[i\])
  1. 从右向左扫描: 建立数组 right_max
    • right_max[i] 代表 in-1 这一段区间的最大高度。
    • 递推公式:right\\_max\[i\] = \\max(right\\_max\[i+1\], height\[i\])
  1. 最终合并:
    • 遍历每个位置,直接查表计算:\\min(left\\_max\[i\], right\\_max\[i\]) - height\[i\]
优缺点分析:
  • 优点: 逻辑非常直观,容易理解,是很多人的"第一反应"解法。
  • 缺点: 需要额外的两个数组空间,空间复杂度为 O(N)

2. 双指针法 (Two Pointers) ------ 最优解

核心思路: 压缩空间,利用"相对论"确定短板。

核心难点解析(为什么它能工作?):

这是此题最难理解的部分。

在动态规划中,我们必须确切知道 left_max[i] 和 right_max[i] 的具体数值。

但在双指针中,我们利用了一个推论:

只要 left_max_current < right_max_current,那么对于左指针 left 来说,它右边必然 存在一个比 left_max_current 更高(或相等)的墙(因为 right_max 就在那边等着)。

详细推演步骤:

假设 left 在左边,right 在右边。

  1. 维护两个变量 L_MaxR_Max,分别记录左边走过的路和右边走过的路的最大值。
  2. 比较 height[left] **height[right]**:
    • 情况 A:左边矮 ( height[left] < height[right]****)
      • 此时,我们甚至不需要知道右边最大的墙到底是一万米还是一千米。我们只需要知道:只要右边有这根 height[right]挡着,左边这个位置的水位瓶颈就一定在 **L_Max**上。
      • 所以可以直接计算左边的水,然后 left 右移。
    • 情况 B:右边矮 ( height[left] >= height[right]****)
      • 同理,右边的瓶颈一定是 R_Max,左边不用管了。
      • 计算右边的水,right 左移。
这种方法是"按列求和":

它依然是把每一根柱子上能存的水算出来,然后加在一起。


3. 单调栈法 (Monotonic Stack)

核心思路: 按"层"求和,横向填充凹槽。

思维转换:

前两种方法是竖着切分雨水(一列一列算)。

单调栈方法是横着切分雨水(一层一层算,或者说一个坑一个坑算)。

详细图解逻辑:

我们需要维护一个单调递减栈 (栈底大,栈顶小)。栈里存放的是下标

  1. 入栈阶段(下坡):
    • 如果当前柱子高度小于栈顶柱子,说明在"下坡",存不住水。将下标入栈。
  1. 出栈阶段(上坡/填坑):
    • 如果当前柱子高度 大于 栈顶柱子,说明遇到了"右墙",形成了一个凹槽。
    • 弹出栈顶元素 ,记为 bottom(坑底)。
    • 此时,新的栈顶元素是 left_idx(左墙),当前遍历到的元素是 i(右墙)。
    • 计算这个坑的体积:
      • 宽度 W i - left\\_idx - 1 (两堵墙中间的距离)
      • 高度 H \\min(height\[left\\_idx\], height\[i\]) - height\[bottom\] (左右墙较矮者 减去 坑底高度)
      • 雨水:W \\times H
举例追踪:[2, 0, 2]
  1. i=0, h=2: 栈空,入栈。 Stack: [0]
  2. i=1, h=0: 0 < 2,递减,入栈。 Stack: [0, 1] (栈顶是1,高度0)
  3. i=2, h=2: 2 > 0,遇到右墙!
    • 弹出 1 做为坑底 bottom(高度0)。
    • 栈顶剩 0 做为左墙 left(高度2)。当前 2 是右墙。
    • 计算高度:\\min(2, 2) - 0 = 2
    • 计算宽度:2 - 0 - 1 = 1
    • 体积:2 \\times 1 = 2
    • 继续...

三、 三种解法深度对比总结

为了方便记忆和面试,请参考下表:

|-----------|----------------|------------------------|--------------------|
| 特性 | 动态规划 (DP) | 双指针 (Two Pointers) | 单调栈 (Stack) |
| 视角方向 | 纵向 (竖着切) | 纵向 (竖着切) | 横向 (横着切/层级填充) |
| 核心逻辑 | 预先计算全图的 Max 数组 | 边走边根据局部信息推断全局瓶颈 | 找凹槽,遇到高墙就结算之前的低洼地 |
| 时间复杂度 | O(N) (3次遍历) | O(N) (1次遍历) | O(N) (每个元素进出栈1次) |
| 空间复杂度 | O(N) (存两排数组) | O(1) (仅需几个变量) | O(N) (最坏情况栈深N) |
| 代码量 | 中等,逻辑简单 | 极少,逻辑较绕 | 较多,逻辑复杂 |
| 面试评价 | 稳妥,能通过 | 卓越,最佳解 | 炫技,适合解决变种问题 |

四、 应该如何选择?

  1. 面试首选: 双指针法。它是本题的终极形态,代码简练且性能最优。你之前提供的代码正是这一种。
  2. 思维训练: 单调栈。如果你遇到了"柱状图中最大的矩形"或者"区间最大/最小值"这类题目,单调栈是通用的神技,学会这个方法能打通一类题。
  3. 工程直觉: 动态规划。在如果不缺空间且要求代码可读性极高的情况下,DP 是最不容易写出 Bug 的。

代码实现吗?

复制代码
package demo

class Solution {
    public func trap(height: Array<Int64>): Int64 {
        let n = height.size
        
        // 边界检查:如果柱子少于3根,无法形成凹槽,不可能接住雨水
        if (n <= 2) {return 0}

        // 初始化双指针,分别指向数组的两端
        var left = 0
        var right = n - 1
        
        // 初始化左右两边的最高高度
        // leftMax 记录从左往右扫描过程中的最大值
        // rightMax 记录从右往左扫描过程中的最大值
        var leftMax = height[left]
        var rightMax = height[right]
        
        var result = 0
        
        // 双指针相向移动,直到相遇
        while (left < right) {
            // 核心逻辑:木桶原理(短板效应)
            // 当前位置能接多少水,取决于左右两边最大高度中较矮的那一个。
            
            // 如果左边的高度小于右边的高度,说明"瓶颈"在左边。
            // 我们此时可以确定左指针当前位置的积水高度,因为它只受限于 leftMax,
            // 而右边由于存在更高的柱子(height[right]),肯定能兜住水。
            if (height[left] < height[right]) {
                // 移动左指针向右一步
                left += 1
                
                // 更新左边的历史最高高度
                // max 函数用于比较:如果当前柱子比之前的最高还要高,更新 leftMax;否则保持原样
                leftMax = max(leftMax, height[left])
                
                // 累加水量
                // 当前位置的水量 = 当前区域的最高水位 (leftMax) - 当前柱子高度 (height[left])
                // 如果 height[left] 就是最高点,leftMax - height[left] 为 0,不积水
                result += leftMax - height[left]
            } else {
                // 这是一个对称的操作:如果右边较小(或相等),说明"瓶颈"在右边。
                // 处理右指针,逻辑同上
                right -= 1
                
                // 更新右边的历史最高高度
                rightMax = max(rightMax, height[right])
                
                // 累加右侧当前位置的水量
                result += rightMax - height[right]
            }
        }
        
        return result
    }
}

// 测试代码
main(): Unit {
    let solver = Solution()
    
    // 示例测试
    // 这是一个标准的凹凸不平的测试用例,包含多个积水槽
    println(solver.trap([0,1,0,2,1,0,1,3,2,1,2,1]))  // 输出:6
    
    // 这是一个包含较高边缘的测试用例
    println(solver.trap([4,2,0,3,2,5]))              // 输出:9
    
    // 这是一个无法积水的凸型测试用例(中间低两边高才能积水,这里中间是平的或凸的)
    println(solver.trap([1,0,1]))                    // 输出:1
    
    // 只有两根柱子,无法积水
    println(solver.trap([1,1]))                      // 输出:0
}

相关推荐
Blossom.1182 小时前
RLHF的“炼狱“突围:从PPO到DPO的工业级对齐实战
大数据·人工智能·分布式·python·算法·机器学习·边缘计算
MobotStone3 小时前
从问答到决策:Agentic AI如何重新定义AI智能体的未来
人工智能·算法
Shemol3 小时前
二叉树的三种迭代遍历(无栈版本)-- 我在马克思主义课上的一些巧思
算法
胖咕噜的稞达鸭4 小时前
进程状态,孤儿进程僵尸进程,Linux真实调度算法,进程切换
linux·运维·算法
RTC老炮4 小时前
webrtc降噪-WienerFilter源码分析与算法原理
算法·webrtc
hweiyu004 小时前
数据结构:数组
数据结构·算法
无限进步_5 小时前
C语言单向链表实现详解:从基础操作到完整测试
c语言·开发语言·数据结构·c++·算法·链表·visual studio
初夏睡觉5 小时前
循环比赛日程表 题解
数据结构·c++·算法
派大星爱吃鱼5 小时前
素数检验方法
算法