LeetCode 42接雨水全解:暴力超时→DP降维打击→双指针极限压缩空间→单调栈栈式凹槽定位,全景式解析算法优化路径

文章目录

本篇摘要

本篇围绕LeetCode 42"接雨水"展开,剖析四种解法:暴力法通过嵌套循环统计每柱接水量,易超时;动态规划预先记录左右最大值,将复杂度降至O(n);双指针边遍历边更新极值,空间优化至O(1);单调栈利用栈维护"凹槽",高效定位存水区间。各方法层层递进,展现算法优化的核心思路。

LeetCode 42 接雨水 详解

① 暴力解法(多循环嵌套,卡超时,因此后续使用了两种基于暴力优化的方法)

拿到这道题,我们首先看图:

  • 这里我们不难发现当前位置装水的高度其实和两边高度有关,但是并不是相邻的,就拿索引为5的位置来说,这里最多装2个,其实和对应的3号索引以及对应大的7号索引位置有关。
  • 多观察几个就能得到一个结论:i位置处能装多少水取决于它左侧比它大的最大值与右侧比它大的最大值取个min减去i位置高度即可。

因此我们能得到:

cpp 复制代码
  int water = min(leftMax, rightMax) - height[i];

下面可以通过时间复杂度为O(N^2)走一遍,详细思路:

超时!!!!!

暴力法: 我们不难得出,只要我们遍历一遍数组,然后当前位置能接的水的高度其实,就是它左边比它大的,最大值与它右边比它大的最大值求个min

然后再减去当前位置高度即水的高度;然后我们暴力法是一个个位置走的,也就是求出每单位宽度接入的雨水进行累加,故此时的面积就是它的高度!

代码如下(详细见注释):

cpp 复制代码
class Solution {
public:
    int trap(vector<int>& height) {
        int n = height.size();
        int totalWater = 0;

        for (int i = 1; i < n - 1; i++) {
            int leftMax = 0, rightMax = 0;

            // 找左边最大值,如果不存在比它大的,就找最大的
            for (int j = 0; j < i; j++) {
                leftMax = max(leftMax, height[j]);
            }

            // 找右边最大值,如果不存在比它大的,就找最大的
            for (int j = i + 1; j < n; j++) {
                rightMax = max(rightMax, height[j]);
            }
            // 这里如果对应的左右的最大值有一个小于当前位置的高度,则说明当前位置不能接雨水
            if ((leftMax < height[i]) || (rightMax < height[i]))
                continue;

            // 计算当前位置能接的水量
            int water = min(leftMax, rightMax) - height[i];
            if (water > 0) {//这一步就可以取消了(如果前面判断了即continue那里)
                totalWater += water;
            }
        }

        return totalWater;
    }
};

复杂度分析:

  • 时间复杂度:O(n²) - 平方时间复杂度
  • 空间复杂度:O(1) - 常数空间复杂度

② 动态规划解法

核心思想

对于每个位置 i,它能存多少水,取决于:

左边最高柱子右边最高柱子 中的较小者,再减去当前高度。

公式:

text 复制代码
water[i] = max(0, min(leftMax[i], rightMax[i]) - height[i])
  • leftMax[i]:从 0i 的最大高度(包含 i)
  • rightMax[i]:从 in-1 的最大高度(包含 i)

步骤(三步走)
  1. 从左到右 扫描,记录每个位置左边(含自己)的最大高度 → leftMax
  2. 从右到左 扫描,记录每个位置右边(含自己)的最大高度 → rightMax
  3. 遍历每个位置,用上述公式累加雨水量

举例说明
text 复制代码
height = [4, 2, 0, 3, 2, 5]
i height[i] leftMax[i] rightMax[i] min(L,R) water[i]
0 4 4 5 4 0
1 2 4 5 4 2
2 0 4 5 4 4
3 3 4 5 4 1
4 2 4 5 4 2
5 5 5 5 5 0

总和: 0 + 2 + 4 + 1 + 2 + 0 = 9


代码实现思路
  • 从暴力优化:按照暴力肯定会超时,因此我们不妨提前保存对应位置,也就是左边或者右边的最大值,用的时候直接取即可。

  • 先设置状态转移方程: dp_left[i]表示i位置及i位置以左比大的最大高度(如果不存在比它大的就是它自己) dp_right[i]表示i位置及i位置以右比大的最大高度(如果不存在比它大的就是它自己)

代码实现(详细见注释):

cpp 复制代码
class Solution
 {
 public:

    int trap(vector<int> &height)
    {

      vector<int> dp_left(height.size(),
      0);//当前位置左边的最大高度(包含当前位置) vector<int>
      dp_right(height.size(), 0);//当前位置右边的最大高度(包含当前位置)

      dp_left[0] = height[0];//初始化防止越界,保证后面填表的正确性
      for(int i=1;i<height.size();i++){
        dp_left[i] = max(dp_left[i-1], height[i]);
      }

      int n=height.size();

      dp_right[n-1] = height[n-1];//初始化防止越界,保证后面填表的正确性
      for(int i=n-2;i>=0;i--){
        dp_right[i] = max(dp_right[i+1], height[i]);
      }

      //再遍历一遍保存结果:

       int ans=0;//保存结果

      for(int i=1;i<n-1;i++){
        int h = min(dp_left[i], dp_right[i]) - height[i];
        int w=1;
        //这里也是如果当前位置dp_left或者dp_right可能为height[i],此时故自然h就为0了:
        if(h>0) ans += h*w;
      }
       return ans;
    }
    };

复杂度分析

  • 时间复杂度:O(n) ------ 三次遍历
  • 空间复杂度:O(n) ------ 两个辅助数组

虽然空间不是最优(双指针可做到 O(1)),但这是最容易理解的动态规划解法


③ 双指针解法(优化对应的dp的空间复杂度变成O(1))

下面这个有点难理解,虽然代码写起来比较简单,做到了时间复杂度 O(N),空间复杂度O(1)。

双指针优化思路

首先我们使用dp的时候需要先把所有的左最大值以及右最大值找出保存起来,那么是不是可以边记录这俩种最大值,边进行雨水统计呢

首先要想理解下面这句话👇,我们首先直到这个原理:

双指针开始走,left和right哪个位置对应高度小,就统计哪个,然后决定++还是- -,所以不会出现比如left左边有比right大的,甚至比rightmax都大的(也就是这个位置被统计完了)这种情况!

下面再理解下:

因此我们就想到两个指针一个从左往右走,一个从右往左走,直到相遇,然后如果左边比右边大,说明右边的话,肯定左边比它高(也就是leftmax一定大于它),而右边👉没有比leftmax大的,然后我们直接拿着rightmax去right位置进行比较获取新的rightmax即可,然后再与right位置处作差即可(此时可能rightmax与right位置高度相同,此时right位置也就是不能接水);如果右边比左边大同理即可!!!

如果还不理解那就直接上图:

下面我们就以这模式为例:

下面我们采用反证法证明下:

  • 如果这种情况发生了,也就是对应的走到了图中的位置,那么此时我们就需要更新leftmax,然后就是8-6=2;难道最后真的是2吗,肯定是错的。
  • 因为我们要知道left只有比right小才会进行统计更新等,然后右移动,如果left能走到6处,说明8这个位置已经被统计更新完了,然而这样就违背我们代码的判断逻辑了,故不可能。
  • 也就是上面的情况不可能发生,即最后到达中间6位置处的一定是right,此时就是7-6=1了。

对应代码:

cpp 复制代码
///双指针解法:优化对应的dp的空间复杂度变成O(1)

//两个指针从首尾边走边统计接雨水量以及维护左侧和右侧对应的最大值(包括当前位置)
//这里采取两个指针都向中间走的策略,然后走过的位置就要完成对应的雨水统计+跟新leftmax或者rightmax
    int trap(vector<int>& height) {
    int left = 0, right = height.size() - 1;
    int leftMax = 0, rightMax = 0;
    int ans = 0;

    while (left <= right) {
        //这里如果left能走到这里,那么必然left左边没有比rightmax大的高度,因为要是有比右边最大还大的那么此时来到当前left位置的必然是right了,故不可能
        if (height[left] < height[right]) {
            leftMax = max(leftMax, height[left]);
            ans += leftMax - height[left];
            left++;
        } 
          //这里如果right能走到这里,那么必然right右边没有比leftmax大的高度,因为要是有比左边最大还大的那么此时来到当前right位置的必然是left了,故不可能
        else {
            rightMax = max(rightMax, height[right]);
            ans += rightMax - height[right];
            right--;
        }
    }
    return ans;
}
};

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

④单调栈解法

单调栈简介

首先简单认识下单调栈 :

单调栈 是一种特殊的栈结构,栈内元素始终保持单调递增单调递减的顺序。

核心特点
  • 单调性:栈内元素要么一直增大(单调递增栈),要么一直减小(单调递减栈)。
  • 出栈规则:新元素入栈时,若破坏单调性,则弹出栈顶元素,直到满足单调性再入栈。
常见用途

主要用于解决 "下一个更大/更小元素" 类问题,例如:

  • 找数组中每个元素右边第一个比它大的数(单调递增栈,从右往左遍历);
  • 找数组中每个元素左边第一个比它小的数(单调递增栈,从左往右遍历);
  • 计算柱状图中最大矩形面积、接雨水等问题。
左边最近比当前数大的数(用单调栈)
步骤:
  1. 初始化一个空栈和一个结果数组(初始值设为-1)。
  2. 遍历数组中的每个元素:
    • 当栈不为空且当前元素 ≥ 栈顶元素时,弹出栈顶元素(因为栈顶元素不可能是后续元素的"左边更大的数"了),然后依次循环比较。
    • 如果栈不为空,说明栈顶元素就是当前元素左边最近的比它大的数,将其存入结果数组;否则存入-1。
    • 将当前元素的索引入栈。
示例:

数组:[7, 5, 3, 2, 4, 1, 6, 9, 8]

详细步骤:

步骤 当前元素 栈状态 操作说明 结果数组
1 7 [] 栈空,结果=-1;入栈7 [-1, _, _, _, _]
2 5 [7] 5 < 7,结果=7;入栈5 [-1, 7, _, _, _]
3 3 [7, 5] 3 < 5,结果=5;入栈3 [-1, 7, 5, _, _]
4 2 [7, 5, 3] 2 < 3,结果=3;入栈2 [-1, 7, 5, 3, _]
5 4 [7, 5, 3, 2] 4 > 2 → 弹出2; 4 > 3 → 弹出3; 4 > 5?否,结果=5;入栈4 [-1, 7, 5, 3, 5]
6 1 [7, 5, 4] 1 < 4,结果=4;入栈1 [-1, 7, 5, 3, 5, 4]
7 6 [7, 5, 4, 1] 6 > 1 → 弹出1; 6 > 4 → 弹出4; 6 > 5 → 弹出5; 6 < 7,结果=7;入栈6 [-1, 7, 5, 3, 5, 4, 7]
8 9 [7, 6] 9 > 6 → 弹出6; 9 > 7 → 弹出7; 栈空,结果=-1;入栈9 [-1, 7, 5, 3, 5, 4, 7, -1]
9 8 [9] 8 < 9,结果=9;入栈8 [-1, 7, 5, 3, 5, 4, 7, -1, 9]
最终结果:

[-1, 7, 5, 3, 5, 4, 7, -1, 9]

单调栈一般模版
关键点
  • 单调栈性质:栈中元素从栈底到栈顶递减(大→小),保证每次弹出的都是"不可能成为后续元素左边更大数"的元素。
  • 时间复杂度:O(n),每个元素最多入栈、出栈一次。
注意点
  • 单调栈是一种数据结构,用于维护可能成为答案的候选集合。
  • 当保持栈内的元素单调递增时,可以通过该结构找到左边离自己最近且比自己小的数。
  • 若从右向左遍历数组,则可以利用单调栈来查找右边离自己最近且比自己更大或更小的数。
  • 我们一般题目实际应用并不是直接按照这个模版求的,而是有些附加的条件,此时一般情况就是我们在进行出栈逻辑的时候也就是对应while循环里面,根据对应条件完成对应统计 计算等操作(结合题目要求),但是思想上都是一样的。
单调栈不同选型需求
  • 求某个位置左边第一个比它大的就是维护单调递减栈(即当前元素大于栈顶就弹出,小于就入栈)
  • 求某个位置左边第一个比它小的就是维护单调递增栈(即当前元素小于栈顶就弹出,大于就入栈)
  • 求某个位置右边第一个比它大的就是维护单调递增栈(即当前元素大于栈顶就弹出,小于就入栈)
  • 求某个位置左边第一个比它小的就是维护单调递减栈(即当前元素小于栈顶就弹出,大于就入栈)
优势
  • 时间复杂度 O(n):每个元素最多入栈、出栈一次;
  • 空间复杂度 O(n):最坏情况栈存储所有元素。

单调栈通过维护单调性,将暴力枚举的 O(n²) 问题优化为线性时间,是处理"相邻元素大小关系"类问题的利器。

引入单调栈

根据暴力的思路(它左边比它大的,最大值与它右边比它大的最大值求个min然后再减去当前位置高度即水的高度),进行优化-->是否能转化成左边离它最近比它大的以及右边离它最近比它大的---->引入单调栈(这里有两种选择):

  • 1·单调递增栈(即右边最近且比他大)
  • 2·单调递减栈(即左边最近比他大)

下面我们选择第一种进行解答。

虽然这种方法理解起来比较抽象,但是博主尽量讲明白:

只要小于top位置的值就一直入栈 ,直到大于的时候的时候进行出栈处理(也就是找到第一个凹槽处,下面就开始从右往左进行求接入雨水的量了),直到栈出完了,最后退出;下面重复对应操作来找凹槽...

下面画图说明下:

  • 此时我们只需要在出栈逻辑(即while循环时刻)进行对应统计相关操作即可。
  • 因此可以看出,它的处理逻辑是出现一个凹槽,然后从这个凹槽的右边位置开始处理(也就是从右到左的处理逻辑),之前位置处理过之后就默认填平了,因此它的左边在进行处理的时候就可以认为右边对应空位已经填平了就行。

详细代码(注释详解):

cpp 复制代码
class Solution
{
public:
    int trap(vector<int> &height)
    {
        int ans = 0;
        stack<pair<int, int>> s; //保存对应的索引以及高度
        s.push(make_pair(0, height[0]));
        int Min,w,h;
        for (int i = 1; i < height.size(); i++)
        {

            // 进行维护的单调递增栈的检查
            while (!s.empty() && height[i] > s.top().second)
            {
                int mid = s.top().second;//为了求top--mid--i位置能接的最大雨水
                s.pop();
                //如果当前i位置位置比我们要求接雨水位置小,必然top--mid--i范围内无法接入雨水,而栈是单调递增的,后面的也肯定如此,直接
                //break即可!
                if (height[i] < mid)
                    break;
                //可能pop后为空了即,此次出栈任务完成,直接break
                if(s.empty()) break;
                 Min = min(s.top().second, height[i]);
                w = abs(i - s.top().first) - 1;
                h = Min - mid;
                //因为我们把可能与栈顶元素高度相同的也给入栈了,故此时可能mid和top相同因此面积及为0了,需要特判一下!
                if (w * h > 0)
                    ans += w * h;
            }
            s.push(make_pair(i, height[i]));
        }
        return ans;
    }
};

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

本篇小结

从暴力枚举到动态规划,再到双指针与单调栈,四种解法覆盖时间与空间复杂度的权衡。暴力法直观但低效,动态规划预处理打破嵌套循环,双指针进一步压缩空间,单调栈则以栈结构精准捕捉存水条件。掌握这些技巧,可灵活应对"区间极值"类问题。

相关推荐
苦藤新鸡13 小时前
6.三数之和
c语言·c++·算法·力扣
s090713613 小时前
连通域标记:从原理到数学公式全解析
图像处理·算法·fpga开发·连通域标记
西伯利亚狼_J202013 小时前
资料260105EN初级
职场和发展
@小码农13 小时前
6547网:202512 GESP认证 C++编程 一级真题题库(附答案)
java·c++·算法
自然语13 小时前
人工智能之数字生命-特征类升级20260106
人工智能·算法
菜鸟233号13 小时前
力扣343 整数拆分 java实现
java·数据结构·算法·leetcode
赫凯13 小时前
【强化学习】第五章 时序差分算法
算法
leiming613 小时前
c++ find_if 算法
开发语言·c++·算法
yuanmenghao14 小时前
自动驾驶中间件iceoryx - 内存与 Chunk 管理(三)
数据结构·c++·算法·链表·中间件·自动驾驶
_OP_CHEN14 小时前
【算法基础篇】(四十三)数论之费马小定理深度解析:从同余性质到乘法逆元
c++·算法·蓝桥杯·数论·acm/icpc