当青训营遇上码上掘金,主题4 - 攒青豆,三种方法(暴力法、备忘录法、双指针法)思路分析,详解及完整代码。
问题描述
现有 n 个宽度为 1 的柱子,给出 n 个非负整数依次表示柱子的高度,排列后如下图所示,此时均匀从上空向下撒青豆,计算按此排列的柱子能接住多少青豆。(不考虑边角堆积)
解题思路
问题分析
对于这个问题,我们取每一个柱子的位置来看,可以发现,当我们向每一个位置撒青豆时,有点类似木桶效应,超过了该位置左右两边最高的柱子的青豆就会掉出去。
即总结成一句话,当前位置能存储的青豆数取决于该位置左右两边最高的柱子的更小者,而该位置具体能存的青豆数为该值减去该位置柱子高度。
方法一 暴力法
因此,最简单的方法我们可以想到的是,当我们从左往右遍历时,计算出当前位置左右两边的最高的柱子,通过比较得到更小者并减去当前位置高度,把每个位置相加即可。
这就是暴力法的思想,代码的核心部分是嵌套的两层 for 循环,关键代码如下
// 暴力法
func violent(height []int) int {
//遍历记录当前位置能存储雨水数
rain := 0
for i := 0; i < len(height); i++ {
cur_left_max := 0
cur_right_max := 0
//遍历查找当前位置左边的最高墙
for l := i; l >= 0; l-- {
if height[l] > cur_left_max {
cur_left_max = height[l]
}
}
//遍历查找当前位置右边的最高墙
for r := i; r < len(height); r++ {
if height[r] > cur_right_max {
cur_right_max = height[r]
}
}
rain += min(cur_left_max, cur_right_max) - height[i]
}
return rain
}
代码的时间复杂度为 O(n^2) ,空间复杂度为 O(1)
方法二 dp/备忘录解法
分析暴力法,我们发现其中计算最高柱子时进行了很多次的重复遍历,因此我们重新分析该情景:
当我们在每一个位置时,想要的是其左边所有柱子高度的最高值与右边所有柱子高度的最高值,而当我们从左往右遍历移动时,左边的柱子数加一而右边的柱子数减一,而其余的柱子不变。
因此我们可以考虑dp思想,备忘录的解法。在暴力法中,我们计算左右两边的最大值是从当前位置开始,分别向左/向右遍历,那我们就可以在整体开始之前,先对整组柱子做一个向右以及一个向左的遍历,并各自设置一个变量,记录当前前面柱子的最大值。当遍历到达一个新柱子时,如果其高于当前的最大值,则其为当前方向柱子的最大值;如果其没有高于当前的最大值,则当前方向柱子的最大值仍为之前的最大值。
我们使用两个与柱子数相同的数组,分别记录从左向右以及从右向左两个方向上,到达当前位置时其左边/右边柱子的最大值。当我们遍历过程中,只需要进行查询即可。关键代码如下:
// dp/备忘录解法
func dp(height []int) int {
//记录每个位置其左边的墙的最大高度
h_left := make([]int, len(height))
left_max := 0
for i := 0; i < len(height); i++ {
if height[i] > left_max {
left_max = height[i]
h_left[i] = height[i]
} else {
h_left[i] = left_max
}
}
// fmt.Println(h_left)
//记录每个位置其右边的墙的最大高度
h_right := make([]int, len(height))
right_max := 0
for i := len(height) - 1; i >= 0; i-- {
if height[i] > right_max {
right_max = height[i]
h_right[i] = height[i]
} else {
h_right[i] = right_max
}
}
// fmt.Println(h_right)
//遍历记录当前位置能存储雨水数
rain := 0
for i := 0; i < len(height); i++ {
rain += min(h_left[i], h_right[i]) - height[i]
}
return rain
}
代码的时间复杂度为 O(n) ,空间复杂度为 O(n)
方法三 双指针
备忘录解法已经是一个很优秀的方法,但其在空间上的开销仍不尽人意,因此我们考虑以下改进。
之前的两个方法中,我们一直默认从左往右遍历柱子,但实际上这并不需要。我们取左边分析:
对于最左边的位置,其存放不了青豆;
对于左边第二个位置,其存放青豆的限制为左边第一个位置的高度与该位置右边所有柱子的高度的最大值,但分析后我们可以发现,存在一种特殊情况:
当左边第一个位置的柱子高度低于右边某一柱子高度时,由于木桶效应,该位置能存放的青豆数与右边其他柱子高度都无关了。即该位置能存放的青豆的值已经确定为左边第一个位置的高度减去当前柱子高度。
因此我们可以发现,对于每一个柱子,都会存在这样的特殊情况,而我们只需要从左右两边,同时从外向内进行遍历,知道两个指针相遇,就可以找到每个柱子的特殊情况,这就是双指针法的思想。关键代码如下:
// 双指针
func dualPointer(height []int) int {
i := 0
j := len(height) - 1
rain := 0
cur_max := 0
//直到两指针相遇
for i < j {
if height[i] <= height[j] {
//i处于最左边的起始位置
if i == 0 {
cur_max = height[i]
i++
continue
}
//遇到更高的柱子
if height[i] > cur_max {
cur_max = height[i]
}
rain += cur_max - height[i]
i++
} else {
//j处于最左右边的起始位置
if j == len(height)-1 {
cur_max = height[j]
j--
continue
}
//遇到更高的柱子
if height[j] > cur_max {
cur_max = height[j]
}
rain += cur_max - height[j]
j--
}
}
return rain
}
代码的时间复杂度为 O(n) ,空间复杂度为 O(1)
测试对比
使用了40万个数据作为测试样例,运行结果与时间对比如下:
三种方法测试对比结果如下(备忘录法由于在从左向右与从右向左使用了两次遍历,因此时间大约为双指针法的两倍,如果合并后则时间相近)
完整代码
完整代码如下:
package main
import (
"fmt"
"time"
)
func min(a int, b int) int {
if a < b {
return a
} else {
return b
}
}
// 暴力法
func violent(height []int) int {
//遍历记录当前位置能存储雨水数
rain := 0
for i := 0; i < len(height); i++ {
cur_left_max := 0
cur_right_max := 0
//遍历查找当前位置左边的最高墙
for l := i; l >= 0; l-- {
if height[l] > cur_left_max {
cur_left_max = height[l]
}
}
//遍历查找当前位置右边的最高墙
for r := i; r < len(height); r++ {
if height[r] > cur_right_max {
cur_right_max = height[r]
}
}
rain += min(cur_left_max, cur_right_max) - height[i]
}
return rain
}
// dp/备忘录解法
func dp(height []int) int {
//记录每个位置其左边的墙的最大高度
h_left := make([]int, len(height))
left_max := 0
for i := 0; i < len(height); i++ {
if height[i] > left_max {
left_max = height[i]
h_left[i] = height[i]
} else {
h_left[i] = left_max
}
}
// fmt.Println(h_left)
//记录每个位置其右边的墙的最大高度
h_right := make([]int, len(height))
right_max := 0
for i := len(height) - 1; i >= 0; i-- {
if height[i] > right_max {
right_max = height[i]
h_right[i] = height[i]
} else {
h_right[i] = right_max
}
}
// fmt.Println(h_right)
//遍历记录当前位置能存储雨水数
rain := 0
for i := 0; i < len(height); i++ {
rain += min(h_left[i], h_right[i]) - height[i]
}
return rain
}
// 双指针
func dualPointer(height []int) int {
i := 0
j := len(height) - 1
rain := 0
cur_max := 0
//直到两指针相遇
for i < j {
if height[i] <= height[j] {
//i处于最左边的起始位置
if i == 0 {
cur_max = height[i]
i++
continue
}
//遇到更高的柱子
if height[i] > cur_max {
cur_max = height[i]
}
rain += cur_max - height[i]
i++
} else {
//j处于最左右边的起始位置
if j == len(height)-1 {
cur_max = height[j]
j--
continue
}
//遇到更高的柱子
if height[j] > cur_max {
cur_max = height[j]
}
rain += cur_max - height[j]
j--
}
}
return rain
}
func trap(height []int) int {
// return violent(height)
// return dp(height)
return dualPointer(height)
}
func main() {
height := []int{5, 0, 2, 1, 4, 0, 1, 0, 3}
var test_height [400000]int //测试数组
for i := range test_height {
test_height[i] = height[i%len(height)]
}
// fmt.Println(test_height)
start := time.Now() //获取当前时间
// res := trap(height[:])
res := trap(test_height[:])
elapsed := time.Since(start) //获取从开始到执行完成用时
fmt.Println(res)
fmt.Println(elapsed)
}