LeetCode42 接雨水(单调栈 | 联通块 | 动态规划预处理 | 双指针 | 容斥)

题目链接:leetcode42

题目大意

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
在这里插入图片描述
上面是由数组 [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

解题思路

首先要先明白一个通识,即木桶原理。木桶的容量取决于最低的挡板高度。

暴力枚举

对于每一个柱子,我们都可以单独计算它对整体容量的贡献,这个贡献取决于它左右两侧分别最高的柱子,其中较小的一个与本位置的柱子的差值,大致的处理式子为 ∣ h [ i ] − m i n [ m a x ( 1 , 2 , . . . , i − 1 ) , m a x ( i + 1 , i + 2 , . . . , n ) ] ∣ |h[i] - min[max(1,2,...,i-1), max(i+1,i+2,...,n)]| h[i]min[max(1,2,...,i1),max(i+1,i+2,...,n)]。时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)

预处理

对于暴力枚举的算法,我们尝试优化它。由于需要每次扫描一个位置左右两侧的最大值,我们可以开一个数组 lmax[n] ,对于 lmax[i] 表示:从 1 ~ i-1 的结点中最大的节点;同理,可以开一个 rmax[n] 预处理每一个节点右侧的最大值。对于预处理可以在 O ( n ) O(n) O(n) 的时间复杂度完成,思想类似于一维的动态规划,只不过进行状态转移的方向是相反的,其状态转移方程分别为:

  • l m a x [ i ] = m a x ( l m a x [ i − 1 ] , h [ i ] ) , ( i > 1 , l m a x [ 1 ] = h [ 1 ] ) lmax[i] = max(lmax[i-1], h[i]),(i>1, lmax[1]=h[1]) lmax[i]=max(lmax[i1],h[i])(i>1,lmax[1]=h[1])
  • r m a x [ i ] = m a x ( r m a x [ i + 1 ] , h [ i ] ) , ( i < n , r m a x [ n ] = h [ n ] ) rmax[i] = max(rmax[i+1],h[i]),(i<n,rmax[n]=h[n]) rmax[i]=max(rmax[i+1],h[i])(i<n,rmax[n]=h[n])

剩余的部分进行一次线性扫描,计算每个柱子的贡献即可。

时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( n ) O(n) O(n)

单调栈1(水平分块)

根据盛水的分布特点,对于每一块独立的盛水区域(即水的联通块),我们可以把水的区域按行区分,如果不同行但起点和终点相同的区域,那么我们将他们划分为同一块(实际就是左挡板和右挡板相同的盛水区域),于是,盛水区域就被划分为多个矩形。如何计算这些矩形呢?显然 要找到各个矩形的宽和高

我们可以维护一个各个柱子高度的非递增栈,当然,为了便于计算宽度,栈的内容是每个柱子的位置。每当一个元素(记为id)因后续元素(我们记这个新来的后续元素为nd)破坏了单调性而被弹出栈时,计算其左边的挡板(因为左边的挡板一定不小于 id )和 nd 中较小的挡板高度,和 id 的高度作差,该矩形的高度就为该值;而对于矩形的宽度则是 n d − s t a c k [ t o p ] − 1 nd - stack[top] - 1 ndstack[top]1,即左右挡板的中间部分的长度。把矩形的面积加到答案中,迭代所有柱子。

时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)

单调栈2(联通块/单调队列)

对于单调栈1的解法是把水的区域按行划分,而本做法是把区域按列划分。但严格地讲这种做法不能称之为单调栈,因为栈中的柱子高度不是单调的。实际上栈中存的是独立盛水区域的联通块。具体做法:栈底存的是整块区域的左挡板,当且仅当找到一块比这个左挡板大的挡板,我们将他标记为右挡板。与此同时,将栈中的元素弹出计算每个列对于总盛水量的贡献,即它与左挡板的差值。结束后,这个右挡板又作为一个新的联通块的左挡板插入栈中。迭代所有柱子即可。

但这个算法发现可能存在一个情况,遍历完所有柱子后栈中仍存在柱子,也就是说从某个柱子开始,其后续的柱子都比它低,但可能这些柱子并不是递减的,也就是可能存在部分盛水区域,这些盛水区域我们可以将他们分成不同的联通块,把栈中的元素一一弹出,对于每个列,找到他们的右挡板 hmax ,即该列右侧的最高的挡板。计算高度差即这个柱子对于总盛水容量的贡献。

时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)

双指针1

根据暴力枚举的算法,我们还可以用双指针对它进行优化。但此时我们不是优先考虑每个列的左右侧的挡板,而是考虑先找到最高的挡板,然后再从这个点的两侧往中间探测,每发现柱子呈递减趋势(相对于之前找到的挡板)就可以计算该柱子的贡献。

为什么可以马上计算?因为根据最大的挡板已经确定了其中一侧的不影响盛水容量的挡板,而每次发现递减时说明之前挡板正是影响该列容量的挡板,计算差值后即该列的贡献。但要注意的是,对于一个列的 容量影响挡板 可能不是相邻的,所以这个递减的判定不是相对于前一个的,而是相对于本遍历方向之前找到的最大挡板,和 单调栈2 的剩余挡板处理方法类似,当最大挡板被更新时才不用计算,否则都要。

实际上,如果按 单调栈2 所定义的联通块的概念,这种双指针也是对于栈做法的优化。因为当挡板更新时实际上就是切换到下一个联通块。

时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)

双指针2

对于前面这个做法,我们发现似乎最高的挡板没有被特别地利用到,反而两侧的指针最后都会到达这个位置,那么可以不去寻找这个 maxidx 吗?答案是肯定的。我们回头想想这个『maxidx』的作用到底是什么?显然,它只是用来保证某一方向的遍历,在计算一个列的容量时,另一侧有一个不低于本遍历方向之前已经找到的最高的挡板。通俗地讲,就是保证本列的贡献不会因另一侧都低于之前的挡板而影响。

根据这个『maxidx』的作用,我们比如在两个指针的移动时,当前未移动的指针就可以充当这个挡板,显然,每次只要移动那个较小的一侧。

举个例子,比如:h[l]=2,h[r]=5,lmax=3,rmax=7,(首先,我们一定要保证h[l]<=lmax,h[r]<=rmax),此时肯定要更新h[l],因为对于h[l]的右侧至少有一个h[r]的挡板,而h[r]当前暂时还没有左侧的挡板,等到h[l]更新到足以成为h[r]的挡板时,显然此时lmax = h[l] > h[r],那么则移动r。于是,我们不需要确定『idxmax』也可以处理每一个列的贡献。

时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)

容斥定理1

该思路来源:容斥,下图来源于LeetCode官方题解图。

  • 我们记从左到右遍历最高挡板的投影总面积为S1:在这里插入图片描述
  • 记从右到左最高挡板的投影总面积为S2:在这里插入图片描述
  • 记S1和S2面积叠加后的并集为U,其中线条的阴影为S1和S2的交集Inc:在这里插入图片描述
  • 记以最高挡板为高,挡板的数量为宽的矩形面积为S,显然S==U;记挡板的总面积为T

根据容斥公式, I n c = S 1 + S 2 − S − T Inc = S1 + S2 - S - T Inc=S1+S2ST。分别计算这四个的值即可。

时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)

容斥定理2/双指针3

这个做法是基于上面的容斥思想改进的,但计算面积的时候使用到了双指针。我们发现,上述的容斥公式中, S 1 + S 2 − S S1+S2-S S1+S2S 恰好为所有挡板的『凸轮廓图』,即补全了盛水面积的图的面积。而T又比较容易求得,所以问题的难点转向如何求这个『凸轮廓图』。

根据前面做法的启发,我们可以把这个面积按行或者按列分片,用双指针维护即可。对于按列分块,可以和 双指针2 的逻辑进行判定移动指针并计算面积;对于按行分块,我们可以分别从两侧进行指针探测,按从1到第k层进行计算,因为有个显而易见的结论是:底层的轮廓面积一定不小于高层的轮廓面积,即 底层的长度>=高层的长度,两个指针分别找出每层的边界即可。在这里插入图片描述
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)

代码实现

单调栈1(水平分块)

class Solution {
public:
    int trap(vector<int>& h) {
        int s[h.size()], top = -1; //单调栈 存下标
        int id, dis, fh, all = 0;
        for (int i = 0; i < h.size(); i++) {
            while (top >= 0 && h[s[top]] < h[i]) { //遇到极值更新之前的单调部分,计算贡献
                id = s[top--];
                if (top == -1) break;
                dis = i - s[top] - 1; //计算两个边界中间部分的长度
                fh = min(h[s[top]], h[i]) - h[id]; //计算要被出栈的元素的低行贡献,高行部分会被它前面的元素计算到,故不在这里计算
                all += dis * fh; //小矩形
            }
            s[++top] = i;
        }
        return all;
    }
};

单调栈2(联通块/单调队列)

class Solution {
public:
    int trap(vector<int>& h) {
        int s[h.size()], top = -1; //栈
        int all = 0;
        for (int i = 0; i < h.size(); i++) {
            while (top >= 0 && h[i] >= s[0]) all += (s[0]-s[top--]); //计算贡献
            s[++top] = h[i];
        }
        
        //剩余节点处理,计算栈中每一块独立的联通块的贡献
        int hmax = 0;
        while(top >= 0) {
            if (s[top] >= hmax) hmax = s[top];
            else all += (hmax - s[top]);
            top--;
        }
        return all;
    }
};

双指针1

class Solution {
public:
    int trap(vector<int>& h) {
        int all = 0;
        int maxidx = 0, lmax = -1, rmax = -1;
        for (int i = 1; i < h.size(); i++) if (h[maxidx] < h[i]) maxidx = i;
        for (int i = 0; i < maxidx; i++) {
            if (lmax < h[i]) lmax = h[i];
            else all += (lmax - h[i]);
        }
        for (int i = h.size()-1; i > maxidx; i--) {
            if (rmax < h[i]) rmax = h[i];
            else all += (rmax - h[i]);
        }
        return all;
    }
};

双指针2

class Solution {
public:
    int trap(vector<int>& h) {
        int all = 0;
        int l = 0, r = h.size()-1, lmax = 0, rmax = 0;
        while(l < r) { //无需考虑l==r的情况,因为一定有一块作为挡板
            if(h[l] < h[r]) { //r作为l的备胎挡板 因为l的右侧挡板一定满足:x>=h[r]>h[l]
                if (lmax <= h[l]) lmax = h[l]; //l作为左侧挡板
                else all += (lmax - h[l]);
                l++;
            }else {
                if (rmax <= h[r]) rmax = h[r];
                else all += (rmax - h[r]);
                r--;
            }
        }
        return all;
    }
};

容斥定理1

class Solution {
public:
    int trap(vector<int>& h) {
        int N = h.size(), lmax = -1, rmax = -1;
        int S1 = 0, S2 = 0, S, T = 0;
        for (int i = 0; i < N; i++) {
            if (lmax < h[i]) lmax = h[i];
            if (rmax < h[N - i - 1]) rmax = h[N - i - 1];
            S1 += lmax; S2 += rmax; T += h[i];
        }
        S = N * lmax;
        return S1 - T + S2 - S;
    }
};

容斥定理2/双指针3

按列分块

class Solution {
public:
    int trap(vector<int>& h) {
        int T = 0, S = 0;
        int l = 0, r = h.size()-1, lmax = 0, rmax = 0;
        while(l < r) { //由于退出条件一定是最高的挡板,所以可以省略这个挡板的计算
            if(h[l] < h[r]) {
                if (lmax <= h[l]) lmax = h[l];
                S += lmax;
                T += h[l];
                l++;
            }else {
                if (rmax <= h[r]) rmax = h[r];
                S += rmax;
                T += h[r];
                r--;
            }
        }
        return S - T;
    }
};

按行分块

class Solution {
public:
    int trap(vector<int>& h) {
        int T = 0, S = 0; //挡板的总面积 挡板和水的总面积
        int l = 0, r = h.size()-1, storey = 1;
        //按行分块不能忽略l==r的情况,因为底层的都已经被计算;如果要剔除这个情况则需要把T少加最高的挡板,并且S计算出来要扣除最高挡板目前已加入的部分
        while (l < r) { 
            //找到对应层次的边界,并计算低楼层的挡板面积
            while (l < r && h[r] < storey) T += h[r--];
            while (l < r && h[l] < storey) T += h[l++];
            //计算该层对S的贡献
            S += (r - l + 1);
            storey++; //计算下一个楼层
        }
        return S - T - storey + 1;
    }
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小胡同的诗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值