当青训营遇上码上掘金——攒青豆
本文是「青训营 X 码上掘金」主题创作活动的应征文,代码已经发布,文章仅作抛砖引玉。
题目说明
现有 n 个宽度为 1 的柱子,给出 n 个非负整数依次表示柱子的高度,排列后如下图所示,此时均匀从上空向下撒青豆,计算按此排列的柱子能接住多少青豆。(不考虑边角堆积)
以下为上图例子的解析:
输入:height = [5,0,2,1,4,0,1,0,3]
输出:17
解析:上面是由数组 [5,0,2,1,4,0,1,0,3] 表示的柱子高度,在这种情况下,可以接 17 个单位的青豆。
思路说明
显然,在掘金社区中,天上不会下雨,只会下青豆
双指针
拿到题目,我们第一个就是去想这个雨水,啊不对,这个青豆我们应该按行来计算还是按列来计算呢。这两种思路都可行,但是按列计算较为简单,因为根据题意一列的宽度为1,那么只要知道当前这一列它的左侧最高墙与右侧最高墙,取相对较低的一墙进行计算即可(因为青豆会从较低墙掉出去,就像木桶原理一样,即一只木桶盛水的多少,并不取决于桶壁上最高的那块木块,而恰恰取决于桶壁上最短的那块)
计算公式为:
如下图所示,我们可以进行按列计算
那么问题就转化为了如何得到这个leftHeight与rightHeight,这里的话我们就用双指针法对左右两侧进行扫描得到即可,代码如下:
/*
解法1:双指针解法,时间复杂度O(n^2),空间复杂度O(1)
*/
func calBean1(height []int) int {
ans := 0
for i := 1; i < len(height)-1; i++ {
leftHeight, rightHeight := Max(height[:i+1]), Max(height[i:]) //分别寻找左右两侧的最高墙
numInColumn := min(leftHeight, rightHeight) - height[i]
if numInColumn > 0 {
ans += numInColumn
}
}
return ans
}
动态规划
动态规划同样是采用按列计算的方法,这里我们采用DP主要还是为了获取这个leftHeight和rightHeight,只不过我们不再需要每到一列就计算一次,而是在事先就计算好每一列的左侧高墙与右侧高墙。我们使用两次扫描的方法来获取我们想要的leftHeight数组与rightHeight数组。代码如下:
/*
解法2: 动态规划,时间复杂度O(n),空间复杂度O(n)
*/
func calBean2(height []int) int {
ans := 0
n := len(height)
leftHeight, rightHeight := make([]int, n), make([]int, n) //使用数组记录每个下标的左右最高墙
leftHeight[0], rightHeight[n-1] = height[0], height[n-1]
for i := 1; i < n; i++ {
leftHeight[i] = max(leftHeight[i-1], height[i])
}
for i := n - 2; i >= 0; i-- {
rightHeight[i] = max(rightHeight[i+1], height[i])
}
for i := 1; i < n-1; i++ {
numInColumn := min(leftHeight[i], rightHeight[i]) - height[i]
if numInColumn > 0 {
ans += numInColumn
}
}
return ans
}
单调栈
单调栈要比前面两种方法更加难以理解,并且单调栈是按行来计算的,如下图所示:
我们维护一个栈,栈中的元素为数组下标,并且保证栈中新加入的下标对应的高度永远小于栈顶下标对应的高度。
例如对于题目样例中的数组[5,0,2,1,4,0,1,0,3] ,我们的入栈过程为0->1,对应的高度为5->2,而当准备入栈下标2,即对应高度为2的时候,我们发现高度2>高度0,这时候就说明出现了凹槽,我们就可以进行青豆计算了。
计算公式为:
ps. h为左侧高墙右边一块的高度,以此计算行的高度
对上述两个公式进行说明:
公式1:distance代表了当前扫描的下标i到栈中元素的距离,我们的出栈过程是一个while循环,只要当前元素的高度大于栈顶元素且栈不为空就要循环,即只要有凹槽就要进行循环。这个距离就是i到栈顶元素(此时已经进行了出栈操作,可以理解为栈顶即左侧高墙,i下标的高度为右侧高墙),至于这个减1是因为,假设右侧高墙下标5,左侧高墙下标3,那么实际他们的凹槽为下标4这一列,因此应该是5-3-1=1才对。
公式2:等式右边的部分就是按行计算的青豆数量。我们同样选择左右高墙最低的一块,减去这个h(h实时更新,为左侧高墙的右边一块,来确保行的高度,即凹槽的高度),最终乘上距离得到青豆数目。
如果碰到两个连续的柱子高度相同怎么办?
事实上,这时候我们的h与min(...)的值是相同的,因此公式2等式右边为0,即没有凹槽出现便没有青豆积攒!
代码如下:
/*
解法3: 单调栈,时间复杂度O(n),空间复杂度O(n),此处虽然两个for循环但是每个元素最多访问两次,入栈出栈各一次
*/
func calBean3(height []int) int {
ans := 0
n := len(height)
stack := make([]int, n)
for i := 0; i < n; i++ {
for len(stack) > 0 && height[i] > height[stack[len(stack)-1]] {
h := height[stack[len(stack)-1]] // 此处的h最初是凹槽部分的高度,之后就是左侧高墙右边一块的高度,以此来计算这一行的高度
stack = stack[:len(stack)-1] //出栈
distance := i - stack[len(stack)-1] - 1 //此时的栈顶为左侧高墙,例如高墙index为3,i为5,则距离为1
ans += (min(height[stack[len(stack)-1]], height[i]) - h) * distance //左右高墙的最小值减去凹槽再乘上距离
}
stack = append(stack, i)
}
return ans
}
总结
接雨水,啊不,攒青豆可以说是非常经典的题目,可以用多种方法进行解决,也是我接触单调栈的第一道题,当时做得整个人都懵圈了,当然现在其实人也还是懵圈的,只能说是初窥门径吧。