动态规划
动态规划思想:将原问题拆解成若干子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案。
求解思路
- 大多数动态规划问题都是一个递归问题;
- 在递归的过程中会发现很多重叠子问题(出现重复计算子问题的情况);
- 可以使用记忆化搜索的方式来解决问题;
- 通常解决动态规划问题时,先自顶向下的思考问题,最后再通过自底向上的动态规划解决问题。
在动态规划中都是通过这种思路来解决问题的。
下面以求解斐波那契数列来说明上面的过程。
斐波那契数列
递归问题
这个式子可以很容易的写成递归形式。
def fib(n):
if n == 0:
return 0
if n == 1:
return 1
return fib(n-1) + fib(n-2)
递归形式的思路虽然简单,但是这段代码的耗时是指数增长的。
在jupyter上求fib(30)
就耗时256毫秒。
重叠子问题
下面我们来看下为什么计算这么慢。
如果我们想要计算fib(5)
,那么根据定义,我们要计算fib(4)
和fib(3)
。
如果要计算4,那么就要计算3和2。以此类推,我们可以画出整个计算斐波那契数列的递归树。
在这个递归树种,每个叶子节点都到了1或0的终止条件。这里每个节点都是一次计算。
从上图可以发现,我们进行了多次的重复计算。
拿求解fib(2)
来说,我们计算了3次。
记忆化搜索-自顶向下的解决问题
对于这些重复计算,是否可以只计算一次呢。很简单的思路是用一个数据结构将之前的计算结果保存起来。
memo = {} #保存计算结果
def fib(n):
if n == 0:
return 0
if n == 1:
return 1
if n not in memo: # 如果没有计算过再去计算
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
这种方式就是记忆化搜索。我们使用了递归搜索的方式,但是使用了memo
进行记忆。
现在计算起来就非常快了。
fib(1000)
也可以计算了。
记忆化搜索的实质是在递归的基础上加上记忆化的过程。
递归是一种自顶向下的解决问题。也就是说,我们没有从最基本的问题开始解决,而是假设最基本的问题已经解决了。我们已经会求fib(n-1)
和fib(n-2)
了,那么求fib(n)
就是把它们加起来就好了。
通常如果我们能自顶向下的解决问题的话,我们也能自底向上的解决问题。
动态规划-自底向上的解决问题
memo = {}
def fib(n):
memo = {0:0,1:1} #memo中存的就是第i个斐波那契数列
for i in range(2,n+1):
memo[i] = memo[i-1] + memo[i-2]
return memo[n]
这样的一个过程就是自底向上的解决问题,我们先解决小数据量上的结果(i
是从2
开始的),然后层层递推来解决更大的数据量的问题。
这样的过程就是动态规划。那动态规划和记忆化搜索有什么区别呢,一个最重要的区别就是动态规划去掉了记忆化搜索中的递归调用。我们知道执行递归方法时在操作系统底层会进入入栈出栈操作,实会有一些性能和时间上的开销的。并且动态规划的代码非常简短,高效。
通常解决动态规划问题时,先自顶向下的思考问题,最后再通过自底向上的动态规划解决问题。
动态规划状态转移
打家劫舍问题
其中限制条件是偷取的房子不能相邻,也就是相隔1、2、3…个房子都是可以的。假设选定了0号房子,那么2,3,4…都是可以考虑的。
根据此画出递归树。
从这颗递归树可以看出,存在重叠子问题。并且每个问题都是在求解最优化的值,子问题的最优化的值配合当前的决策,在每一步中选择一个最大值,就能得到原问题的最优解。
相当于具有最优子结构性质。所以实现了递归算法后,可以使用记忆化搜索或动态规划的方式来解决。
定义状态
状态可以理解为递归函数的定义,或dp
数组。从递归树来看就是每个节点。
这个问题状态的定义为: 考虑偷取[x…n-1]范围内的房子(函数的定义)。
这里的考虑意味着,可能会偷取[x…n-1]范围内的房子,而不是一定偷取x房子。而有些问题中,这里可能会定义成一定偷取x房子,这两种状态是不一样的。
状态定义了函数要做什么。
定义状态的转移
状态转移定义了函数要怎么做。
根据对状态的定义,决定状态的转移:
f(0) = max(v(0) + f(2),v(1) + f(3),v(2) + f(4), ... , v(n-3) + f(n-1),v(n-2), v(n-1)}
上面这个等式称为状态转移方程。
我们定义了一个函数为考虑偷取[x…n-1]范围内的房子。这里
表示考虑偷取[0…n-1]范围内的房子,就是我们的初始情况。
用了max
就是求取 价值最大的解法,v(0) + f(2)
表示偷取0号房子的价值加上考虑偷取[2…n-1]范围内的房子。
v(n-2), v(n-1)
这两种情况都没有间隔为1的房子了,所以单独写出来即可。
我们的递归函数其实就是在实现状态转移方程。
下面通过代码来解决这个问题。首先是递归的版本:
class Solution(object):
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
# 函数dp
def dp(nums, x):
if x >= len(nums):
return 0
res = 0
# 实现状态转移方程,循环偷取nums[i...len(nums)-1]范围内的房子,并求价值最大
for i in range(x,len(nums)):
res = max(res, nums[i] + dp(nums,i+2)) #v[i] = nums[i]
return res
return dp(nums,0)
但是递归的解法会超时,因此这里改成记忆化搜索版本:
class Solution(object):
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
# memo[i]表示抢劫nums[i...n-1]所获得的最大收益
memo = [-1] * len(nums)
# 函数dp
def dp(nums, x):
if x >= len(nums):
return 0
if memo[x] != -1:
return memo[x]
res = 0
# 实现状态转移方程,循环偷取nums[i...len(nums)-1]范围内的房子,并求价值最大
for i in range(x,len(nums)):
res = max(res, nums[i] + dp(nums,i+2)) #v[i] = nums[i]
memo[x] = res
return res
return dp(nums,0)
下面我们再改成动态规划的版本:
class Solution(object):
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
if not nums:
return 0
n = len(nums)
# memo[i]表示抢劫nums[i...n-1]所获得的最大收益
memo = [-1] * n
# 最基础的子问题,根据这个值由底向上的推出所有解。
memo[n-1] = nums[n-1]
# 从n-2开始,到最终的解0结束
# [n-2...0]
for i in reversed(range(n-1)):
# [i,n-1]
for j in range(i,n):
memo[i] = max(memo[i],nums[j] + (memo[j+2] if j+2 < n else 0)) # 三元表达式的括号很重要
return memo[0]