大家好,我是牛牛,终于又到了最爱的周五啦,大家是不是已经无心工作,就等着愉快的周末到来?
不瞒你说,牛牛也是这样!今天就给大家分享一道有趣的算法题,希望能给大家带来一个好心情~
故事起源
故事,要从住在森林里的大熊发现了一个装满苹果的山洞说起...
熊熊们山洞之旅遇到的问题,归纳起来就是:给定一个装满苹果的山洞,只能从洞口和洞尾拿苹果,每轮两只熊交替拿,并且只能拿一个,谁拿的苹果总重量最大谁就获胜,那谁会是最终的赢家呢?
问题剖析
假定山洞里苹果的分布为:1KG,3KG,14KG,7KG。
直觉上来说,是不是我们每轮拿最大的,就一定赢?如果是这种策略,那么大熊第一轮拿7KG,此时的状况就变成了:
这时候很显然,二熊会拿最右边14KG的苹果:
现在山洞里只剩下1KG和3KG两个苹果,无论大熊拿哪一个,它手上苹果总重量,也没二熊的重。难道这是大熊的必输局?
那么,让我们换种拿法,重来一遍。
如果第一轮,大熊选择拿第一个苹果,那此时状态就变成了:
这个时候,二熊只能从3KG、7KG里面选,无论拿哪个,大熊第三轮拿到14KG都能获胜。因此,只要开局拿1KG,大熊就是必胜的。
所以说,每轮选最大这种策略行不通,我们还需要考虑到对后面苹果的影响。
相信经过这个分析,大家已经对题目比较清楚,甚至有点解题思路了吧?别着急,现在我们就一起来看看有哪些解决办法吧。
花式取苹果
深度优先递归法
我们可以把问题抽象出来,大熊第一轮去取苹果,是否能赢取决于两个因素:一个是取的苹果的重量,另一个是二熊对剩下的进行选择。再仔细一想,二熊面临的问题,其实和大熊开始时是一模一样的。这样思路就很明显了,我们可以用递归的方法去解决问题。
下面这张图,可以帮助我们辅助思考。
我们从最右边往左边看,每一轮都选择当前的最优解,整个流程走下来,就是递归的实际执行流程了。
经过上述分析,代码也很清晰明了:
func CanFistBearWin(nums []int) bool {
return picker(0, len(nums)-1, nums) >= 0
}
// i, j 表示当前子问题中,山洞左右从哪里开始
func picker(i, j int, nums []int) int {
if i == j {
return nums[i]
}
head := nums[i] - picker(i+1, j, nums)
end := nums[j] - picker(i, j-1, nums)
return max(head, end)
}
记忆化递归法
问题虽然解决了,但是跑了一下测试用例,发现用时100ms以上。这样看来,性能上是不是还有优化空间呢?
可以看到,图上是有重复子问题的,那我们完全可以将子问题的解答记录下来。等到后面再遇到的时候,直接返回结果,而不用再递归下去,这样就可以节约大量时间了。
动态规划法
在上面的分析中,我们已经把问题拆分为子问题,既然是子问题,又可以递归解决,那么就不由地会去想,能不能通过动态规划,递推解决?
依照惯例,我们需要一个dp数组,在上面的分析中不难看出,这个数组应该是二维的,dp[i][j]就表示左边起点为i,右边起点为j的山洞,在先手的情况下,熊熊能拿多少KG的苹果。
既然要用动态规划,那么一定要找到一个公式。我们前面分析过,如果大熊先手,是否能赢取决于两个因素:一个是取的苹果的重量,另一个是二熊对剩下的进行选择。
根据这两个因素,我们可以转化为下列公式:
dp[i][j] = Max(nums[i]-dp[i+1][j], nums[j]-dp[i]dp[j-1])
再结合下面的图来进行推导。我们还是以四个苹果为例,为苹果从左到右编上号。
0号位置对应的dp[0][0]就是其苹果的重量,我们从1号位置开始,对每个位置往前递推:
🍎红苹果配合代码食用更佳:
func CanFistBearWin(nums []int) bool {
// 提前计算数组长度,避免反复消耗
length := len(nums)
dp := make([][]int, length)
// 子数组初始化
for i := 0; i < length; i++ {
dp[i] = make([]int, length)
// 左右在一个点的情况,取了这个苹果即可
dp[i][i] = nums[i]
}
// j表示山洞右侧位置
for j := 1; j < length; j++ {
// i表示山洞左侧位置
for i := j-1; i >= 0; i-- {
head := nums[i] - dp[i+1][j]
end := nums[j] - dp[i][j-1]
dp[i][j] = max(head, end)
}
}
return dp[0][length-1] >= 0
}
果不其然,成功拿到了苹果,熊熊开心地跳起了舞~
熊熊复盘
解决此类问题的核心思想,是将从洞两端选择苹果这个问题抽离出来,成为子问题。然后,在此基础上,我们建立了递归解法,根据实际运行的结果,使用记忆化搜索优化,提高了性能。
解决了问题就算完成任务了?我们的目标可是成为一名追求极致的工程师,当然会想还有没有其它解决方案。于是,我们将同一种抽象模型,从递归转换成递推,玩了把动态规划。
换个角度看问题,生命会展现出另一种美。生活中不是缺少美,而是缺少发现。算法也一样,同一个问题,学着用多个角度去思考、解决,你会发现它别样的魅力!