最近在学习动态规划的知识,刷了一些题目,也看了一些博客和教学视频,本专题主要是根据《九章算法》的课程做了一些学习笔记及总结,编写语言为python,动态规划是算法中难度比较大,考察比较多的一种。动态规划常用于解决:有重叠子问题的最优化问题。
动态规划的题目众多,常见的动态规划的题目有以下3类:
1、计数型动态规划:
一般这样描述:有多少种方式走到右下角;有多少种方式选出k个数使得和为Sum等。
2、最值型动态规划:
一般这样描述:最长上升子序列长度;最少用多少枚硬币拼出某个数额等。
3、存在型动态规划:
一般这样描述:取石子游戏先手能否必胜;青蛙跳石头,每一步跳的步长是变化的,能否跳到最后一块石头上。
接下来通过几道例题来对以上三类题目进行说明。
1. Coin Change(最值型动态规划)
题目描述:有3种硬币,面值分别为2元,5元和7元,每种硬币的数量都足够多;买一本书需要27元,如何用最少的硬币组合正好付清,不需要对方找钱。
input: coin = [2,5,7],target = 27 ouput:5(7+5+5+5+5=27 )
动态规划有四步曲,所有的动态规划的问题都可以‘套用’四步曲:
1. 确定状态:
状态在动态规划中有着定海神针般的作用,通常会开一个数组一维数组dp[]或二维数组dp[][],并确定数组中的元素dp[i]或dp[i][j]代表什么含义,确定状态通常也需要两个步骤:最后一步和子问题
1.1 最后一步
最优策略中肯定会有K枚硬币面值加起来是27元,且一定存在最后一枚硬币的面值为(这里的可能取值为2、5、7)。去除掉最后一枚硬币,那么前枚硬币组成的面值为。
key1:不去考虑前枚硬币是如何拼出,可以确定的是前枚硬币可以拼出。即我们不考虑过程,只看能否达到结果。
key2:因为是最优策略,所以前枚硬币组成面值也是最优策略(不然我们可以找到用更少枚数的硬币拼成面值,然后再加上最后一枚硬币,这最终的硬币枚数比K值要小,这与之前K枚硬币是最优策略的假设矛盾)。
1.2 子问题
这样原问题最少用多少枚硬币拼出27元转化为规模更小的子问题最少用多少枚硬币拼出元。
那么定义状态dp[X] = 最少用多少枚硬币拼出X元
其实到这一步,我们就可以解决这个问题了,因为我们只需要求解下面这个式子即可:
表示最后一枚硬币为2元所需要的最少枚数;表示最后一枚硬币为5元所需要的最少枚数;表示最后一枚硬币为7元所需要的最少枚数。我们可以用递归的方法来求解这个问题:
def coinChange(money)->int:
if money == 0: # 拼出0元,需要0枚硬币
return 0
res = float('inf') # 初始化res为正无穷
# 若最后一枚硬币为2元
if money >= 2:
res = min(coinChange(money-2)+1,res)
# 若最后一枚硬币为5元
if money >= 5:
res = min(coinChange(money-5)+1,res)
# 若最后一枚硬币为7元
if money >= 7:
res = min(coinChange(money-7)+1,res)
return res
if __name__ == '__main__':
print(coinChange(27))
但是我们知道,递归方法的时间复杂度是指数级的,它做了许多重复计算,效率很低。所以,这种方法不建议。那么该如何用动态规划的思想去解决这个问题呢,接下来看四步曲的第二步。
2. 转移方程:
设状态dp[X] = 最少用多少枚硬币拼出X元,对任意的X有:
其中数组dp[X]的元素值表示,拼出X元所需要的最少的硬币数。因为我们要拼出27元,所以需要开一个长度为28的数组。数组从dp[0]-dp[27],分别表示拼出0-27元需要的最少硬币数。
转移方程是动态规划程序的主体部分,如果能正确写出转移方程,那么动态规划的问题就解决一半了。
3. 初始条件和边界情况:
执行转移方程通常会有一些条件或者边界情况的限制,如果不能正确的判定初始条件和边界情况,那么最终也不会得到正确的结果的。
对于本题,会存在不能拼出某些钱X的情况,很容易想到负的钱数不能拼出来。那么我们可以把不能拼出的钱数dp[X] = 正无穷,即:dp[-1]=dp[-2]=dp[-3]=...=正无穷。如dp[1] = min(dp[1-2]+1,dp[1-5]+1,dp[1-7]+1) = min(dp[-1]+1,dp[-4]+1,dp[-6]+1) =正无穷。初始条件就是dp[0] = 0,即拼出0元需要0枚硬币。
4. 计算顺序:(一般是从小到大,对于二维数组,从上到小,从左到右)
初始条件:dp[0] = 0
然后计算dp[1],dp[2],dp[3],...,dp[27],当计算到dp[27]时,我们需要用到dp[20],dp[22],dp[25]三个值,由于在计算dp[27]时,dp[20],dp[22],dp[25]都已经计算出来并存储到数组dp中了,所以这减少了不必要的重复计算。
结果:dp[27]
每一步会尝试三种硬币(2元、5元以及7元),所以此算法的时间复杂度为:27*3
下图为数组中的元素值:
至此,这个道题的分析部分结束,接下来写程序。
5. 代码实现:
def coinChange(money,coin):
'''
input: money:要拼的钱数 coin:硬币的面值[2,5,7]
output: dp[money+1]:拼成money元最少需要的硬币数
'''
dp = [0] * (money+1) # 开一个长度为money+1的数组
dp[0] = 0 # 初始条件
for i in range(1,money+1):
dp[i] = float('inf') # 将下标为1-money的元素初始化为正无穷
# 选择最后一枚硬币
for j in range(len(coin)):
# 当要拼的钱数大于硬币的面值,且能拼出i-coin[j]时
if i >= coin[j] and dp[i-coin[j]] != float('inf') and dp[i-coin[j]]+1<dp[i]:
dp[i] = min(dp[i],dp[i-coin[j]]+1)
# 若不能拼出面值money,返回-1
if dp[money] == float('inf'):
return -1
return dp[money]
if __name__ == '__main__':
print(coinChange(27,[2,5,7]))
2. Unique Paths(计数型动态规划)
题目描述:给定m行n列的网格,有一个机器人从左上角(0,0)出发,每一步可以向下或者向右走一步。问有多少种不同的方式走到右下角。
input: m = 4,n = 8 output:120
1. 确定状态:
1.1 最后一步:
无论机器人如何走到右下角(m-1,n-1),总是存在最后一步,且最后一步存在两种情况:从右下角的左边格子(m-1,n-2)右移一步或者从右下角的上边格子(m-2,n-1)下移一步。
1.2 子问题:
若机器人有X中方式从左上角走到位置(m-1,n-2)处,有Y种方式从左上角走到位置(m-2,n-1)处,那么有X+Y中方式从左上角走到位置(m-1,n-1)处。
这样原问题有多少种不同的方式走到右下角(m-1,n-1)转化为规模更小的子问题有多少种方式走到位置(m-1,n-2)和位置(m-2,n-1)处。
状态:dp[i][j] = 机器人有多少种方式从左上角走到位置(i,j)处。
2. 转移方程:
设状态dp[i][j] = 机器人有多少种方式从左上角走到位置(i,j)处,对任意的格子(i,j)有:
3. 初始条件和边界情况:
初始条件:dp[0][0] = 1,机器人只有一种方式走到左上角。边界情况:当i=0或j=0时,前一步只能由一个方向过来,即第一行中只能从左往右走,第一列中只能从上往下走,所以有dp[i][j] = 1
4. 计算顺序:
按照从上到下,从左到右的顺序计算。由初始条件dp[0][0] = 0,计算:
第0行:dp[0][0],dp[0][1],dp[0,2],...,dp[0][n-1]
第1行:dp[1][0],dp[1][1],dp[1][2],...,dp[1][n-1]
...
第m-1行:dp[m-1][0],dp[m-1][1],dp[m-1][3],...,dp[m-1][n-1]
结果:dp[m-1][n-1]
时间复杂度:O(mn),空间复杂度(数组大小):O(mn)
5. 代码实现:
def uniquePath(array)->int:
'''
input: array:要走的网格
output: dp[m-1][n-1]:从左上角走到右下角的方式数
'''
# 获取网格的行数
m = len(array)
if m == 0:
return 0
# 获取网格的列数
n = len(array[0])
if n == 0:
return 0
dp = [[0]*n for i in range(m)] # 创建一个m行n列的数组,元素为0
for i in range(m):
for j in range(n):
# 初始条件和边界情况
if i == 0 or j == 0:
dp[i][j] = 1
else:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
if __name__ == '__main__':
print(uniquePath(4,8))
3. Jump Game(存在型动态规划)
题目描述:有n块石头分别在x轴的0,1,...,n-1位置,一只青蛙在石头0想跳到石头n-1,如果青蛙在第i块石头上,它最多可以向右跳距离a[i],问青蛙能否跳到石头n-1
input:[2,3,1,1,4] output:True input:[3,2,1,0,4] output:False
1. 确定状态:
1.1 最后一步:
若青蛙能跳到最后一块石头,那么我们假设它是从石头i跳过来的。那么需要满足两个条件:
青蛙能到达石头i;石头i到石头n-1的距离(即最后一步的距离)不能超过可跳跃的最大距离即:n-1-i<=a[i]
1.2 子问题:
这样原问题青蛙能否跳到石头n-1转化为规模更小(i<n-1)的子问题青蛙能不能跳到石头i。
状态:dp[j] = 青蛙能不能跳到石头j
2. 转移方程:
设状态为dp[j] = 青蛙能不能跳到石头j,有:
3. 初始条件和边界情况:
初始条件:dp[0] = True,青蛙一开始就在石头0,无边界情况。
4. 计算顺序:
由初始条件dp[0] = True,计算dp[1],dp[2],...,dp[n-1]
结果:dp[n-1]
时间复杂度:O(n^2)(用贪心算法复杂度为O(n)),空间复杂度:O(n)
5. 代码实现:
def jumpGame(stone)->bool:
if stone is None or len(stone)<0: # 若石头数组为空返回0
return 0
n = len(stone)
dp = [False] * n # 定义n个元素的数组,默认值为False
dp[0] = True # 初始条件
for i in range(1,n):
for j in range(i):
# 若能到达石头j且石头j到石头i的距离小于最大跳跃距离,则能到达石头i
if dp[j] and j + stone[j] <= i:
dp[i] = True
break
return dp[n-1]
if __name__ == '__main__':
print(jumpGame([2,3,1,1,4]))