「青训营 X 码上掘金」 主题4 攒青豆的思路历程

88 阅读3分钟

当青训营遇上码上掘金,本次我选择的是,青训营 X 码上掘金的主题4. 常刷leetcode的同学可能知道,这改自lc上一个非常经典的题目“接雨水”,本次我将以三种方法来逐步推进去解析这个问题。

思路源泉

单调栈

在本题中我们需要维护一个单调递减的栈,用来计算青豆的总量。以所给的例子来看。

image.png

输入数组为 height = [5,0,2,1,4,0,1,0,3]

模拟单调栈填入过程

我们来模拟一遍,单调栈的填入过程

首先前两个元素满足单调递减,填入栈中 <5,0>

当下一个元素填入前,我们可以观察到5 0 2三个元素之间存在一个凹槽,也就是说,只要不满足单调递减的元素,比与前面的元素形成凹槽。那么进而可以理解为,每一次出栈,都代表可以塞入青豆。

如何计算青豆的数量

根据木桶原理,木桶的盛水量取决于最短的木板,此原理拿到这里同样适用。 继续模拟,如果想将2填入栈中,必要弹出栈中比他小的元素,此时弹出0; 计算这个凹槽可以填入的数量---n

n = min(准备填入的元素,还在栈顶的元素)-弹出的元素

此时可以装入青豆的数目为2.

image.png

图中部分已被填入。

继续模拟,下一个元素1,满足递减,填入。 再下一个4,此时栈顶元素小于4,不满足单调递减,1出栈,按照上述规则填入青豆,此时变为了

image.png

重点来啦 1出栈后,如果继续按照上述规则的话,那么只能填入以下豆豆,这样是不符合要求的

image.png

所以我们不仅要考虑高度,还要考虑宽度。毕竟青豆面积计算公式就是高度乘以宽度。

因此不妨把填入栈中元素换成数组下标,这样可以利用下标计算出宽度。

具体代码实现如下:

//单调栈
public static int saveBeans3(int[] height){
    //0,1,0,2,1,0,1,3,2,1,2,1
    //使用ArrayDeque会比stack快一些,因为stack继承的是vector,里面都加入了重量级锁。
    ArrayDeque<Integer> stack = new ArrayDeque<>();
    int sum = 0;
    for(int i = 0;i < height.length; i++){
        while(!stack.isEmpty()&&height[stack.peek()]<height[i]){
            Integer poll = stack.poll();
            //注意这里!!,当栈空时,没必要进行后续的操作了,青豆会在旁边“漏出来”
            if(stack.isEmpty()){
                break;
            }
            //计算短板
            int min = Math.min(height[i],height[stack.peek()]);
            //给总和加上高度和宽度的乘积
            sum+=(min-height[poll])*(i-stack.peek()-1);
        }
        stack.push(i);
    }
    return sum;
}

动态规划

这个思路和上一个略有不同,但只有一点点不同,我们将不考虑宽度,只从高度入手,将问题分解成若干个求每个高度下可以填入青豆的数目的子问题。

我们还是贯彻木桶理论,将题中所给例子按每个高度划分出来。

image.png

仔细观察,每个高度可以填入青豆的数量只与左边最长木板和右边最长木板的最小值决定

image.png

我们用这个列举例子,他的左边最长木板的高度是5,右边最长高度木板是4,那么他只与两者的最小值(4)有关系,所以这一列可以存入4 -自身高度(2) = 2个豆。

这样就明朗了,维护两个dp数组,一个从头开始遍历记录左边最大值,另一个从右边开始遍历记录右边最大值。

最后再整体遍历一边,累加各个高度的青豆数量(左右边界不计入,木桶的边界怎么可能装东西嘛)

代码如下:

//动态规划
public static int saveBeans2(int[] height){
    int sum = 0;
    int[] dpL = new int[height.length];
    dpL[0] = height[0];
    int[] dpR = new int[height.length];
    dpR[height.length-1] = height[height.length-1];
    for (int i = 1;i<height.length;i++){
        dpL[i] = Math.max(height[i],dpL[i-1]);
    }
    for (int i = height.length-2;i>=0;i--){
        dpR[i] = Math.max(height[i],dpR[i+1]);
    }

    for (int i = 1;i<height.length-1;i++){
        int max = Math.min(dpL[i-1],dpR[i+1]);
        if (max>height[i]){
            sum+=max-height[i];
        }
    }
    return sum;
}

双指针

本思路是基于动态规划思路的优化,务必事先理解上一思路!!

在动态规划思路中,我们一共遍历了三次数组才能得出结论(也可以将左边的dp数组压缩状态,变为两次)。 那么如何才可以一次遍历就计算出来呢

我们使用两个指针,left和right,分别从两头出发。因为单个方向的最长木板的维护我们可以通过同方向的遍历进行压缩。 这样我们使用两个指针同时从两边向中间遍历,同时去维护左右两边最长木板的高度值就好了。

image.png

从左右两边的端开始,如果height[hright.length-1]<height[0],那么height[height.length-2]这一列可以容纳青豆的数量是不是只与右边的3有关系,即使height[height.length-2]的左边有比3更短的木板,但那个木板并不是左边方向的最大值(因为比5小嘛),即使height[height.length-2]的左边有比5还高的木板,那么根据木桶原理我们还是要选择右边的3,对不对。

image.png

那么我们使用两个指针,比较height[left-1]和height[right+1]的值,哪一边小我跟谁玩,计算完数量后,这个指针向中间移动。

代码如下

//双指针
public static int saveBeans1(int[] height){
    int sum = 0,maxL = 0,maxR = 0;
    int left = 1,right = height.length-2;
    while(left<=right){
        if (height[left-1]<height[right+1]){
            maxL = Math.max(maxL,height[left-1]);
            if (maxL>height[left]){
                sum+=maxL-height[left];
            }
            left++;
        }else{
            maxR = Math.max(maxR,height[right+1]);
            if (maxR>height[right]){
                sum+=maxR-height[right];
            }
            right--;
        }
    }
    return sum;
}

尾声

以上三个方法的效率是依次递增的,如果上面有说的不好的地方烦请提出,共同进步。