动态规划入门:从记忆化搜索到递推
本篇笔记基于b站灵茶山艾府。
198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
递归
我们可以 将大问题转换为规模更小的子问题 。
比如从第一个房子或者最后一个房子开始思考,下面从最后一个房子举例。
考虑最后一个房子是选还是不选
- 如果不选,问题就变成前n-1个房子的问题
- 如果选,问题就变成前n-2个房子的问题
从最后一个房子思考到第一个房子,就可以得到一棵二叉搜索树了:
当确定当前是选还是不选时,就确定下一次递归参数中的 i 。
dfs( i )的含义就是从前i个房子中得到的最大金额,如果不选当前第i个房子,问题就变成前i-1个房子的最大金额,也就是dfs( i - 1 ),如果选,问题就变成前i-2个房子的最大金额加上当前房子的金额,也就是dfs( i - 2 )+nums[i],同时,返回值就是前i个房子的最大金额。
class Solution:def rob(self, nums: List[int]) -> int:n = len(nums)def dfs(i):# 递到空节点了,返回0if i < 0:return 0return max(dfs(i - 1), dfs(i - 2) + nums[i])return dfs(n - 1)
也可以从第一个房子开始:
class Solution:def rob(self, nums: List[int]) -> int:n = len(nums)def dfs(i):if i >= n:return 0return max(dfs(i + 1), dfs(i + 2) + nums[i])return dfs(0)
运行结果:
由于时间复杂度是指数级别的,所以毫无疑问会超时
所以我们考虑不必要的重复计算,从而引出记忆化搜索
记忆化搜索
在计算dp[i]时,我们需要dp[i-1]与dp[i-2]的信息,而在计算dp[i-1]的信息时,由于已经用到了dp[i-2](因为计算dp[i-1]需要dp[i-2]与dp[i-3]的信息),所以dp[i-2]对于dp[i]来说已经 可以 是已知的了,所以不需要再次重复地计算dp[i-2],此时可以用一个memory数组来存储已经计算过的信息。
由于只有n个节点,时间复杂度也从指数级别降到了O( n )
对于python可以用cache装饰器:
cache
装饰器是 functools
模块中提供的一个工具,用于实现记忆化(Memoization)。它会自动缓存函数的返回值。当函数被多次调用时,如果输入参数相同,它会直接返回缓存的结果,而不会重新计算。
class Solution:def rob(self, nums: List[int]) -> int:from functools import cachen = len(nums)@cache # 使用cache装饰器def dfs(i):# 递到空节点了,返回0if i < 0:return 0return max(dfs(i - 1), dfs(i - 2) + nums[i])return dfs(n - 1)
也可以自己实现:
class Solution:def rob(self, nums: List[int]) -> int:n = len(nums)cache = [-1] * n # 答案不可能是负数def dfs(i):# 递到空节点了,返回0if i < 0:return 0# 如果记忆数组当前值不为-1,说明之前已经被赋值过,直接返回值,不需要重新计算if cache[i] != -1:return cache[i]res = max(dfs(i - 1), dfs(i - 2) + nums[i])cache[i] = resreturn resreturn dfs(n - 1)
从cache数组来看空间复杂度为O ( n ) ,有没有办法可以将空间复杂度优化到O(1)?
递推
在记忆化搜索中,我们从dfs( i )递到dfs( i - 1 )和dfs( i - 2 ),再从dfs( i - 1 )递到dfs( i - 2 )和dfs( i - 3 )…逐渐递到dfs( 0 ),再从dfs( 0 )直接归到dfs( i ),但是既然我们已经知道要从dfs(0)归到dfs( i ),不如 去掉递,直接归 ,直接从最下面开始计算,这就是递推。
将记忆化搜索改成递推很简单,只需要将dfs函数改成dp数组,递归的过程改成循环,递归的边界就用dp数组初始值代替。
class Solution:def rob(self, nums: List[int]) -> int:n = len(nums)if n == 1:return nums[0]dp = [0] * n # 记录从下归到上的过程中的值dp[0] = nums[0] # 初始化,对应dfs函数中判断边界的部分dp[1] = max(nums[0], nums[1])for i in range(2, n):dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]) # 对应归的过程return dp[-1]
dp [ i ] 的含义就是前i个房子的最大价值
但是我们每次计算当前dp[i]的值,实际只需用到dp[i-1]与dp[i-2]的值,除此之外前面的值也是只用过一次就不要了,所以可以每次只维护三个值:dp[i]、dp[i-1]、dp[i-2],从而将空间复杂度降为 O(1) 。
class Solution:def rob(self, nums: List[int]) -> int:n = len(nums)if n == 1:return nums[0]i = nums[0] # 对应dp[i-2]j = max(nums[0], nums[1]) # 对应dp[i-1]for x in range(2, n):k = max(i + nums[x], j) # 对应dp[i]i = jj = kreturn k