动态规划入门

最近在学习动态规划的知识,刷了一些题目,也看了一些博客和教学视频,本专题主要是根据《九章算法》的课程做了一些学习笔记及总结,编写语言为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枚硬币a_{1},a_{2},...,a_{K}面值加起来是27元,且一定存在最后一枚硬币的面值为a_{K}(这里a_{K}的可能取值为2、5、7)。去除掉最后一枚硬币a_{K},那么前K-1枚硬币组成的面值为27-a_{K}

key1:不去考虑前K-1枚硬币是如何拼出27-a_{K},可以确定的是前K-1枚硬币可以拼出27-a_{K}。即我们不考虑过程,只看能否达到结果。

key2:因为是最优策略,所以前K-1枚硬币组成面值27-a_{K}也是最优策略(不然我们可以找到用更少枚数的硬币拼成面值27-a_{K},然后再加上最后一枚硬币,这最终的硬币枚数比K值要小,这与之前K枚硬币是最优策略的假设矛盾)。

1.2 子问题

这样原问题最少用多少枚硬币拼出27元转化为规模更小的子问题最少用多少枚硬币拼出{\color{Red} 27-a_{K}}元。

那么定义状态dp[X] = 最少用多少枚硬币拼出X元

其实到这一步,我们就可以解决这个问题了,因为我们只需要求解下面这个式子即可:

                                     f(27) = min\left\{ f(27-2)+1,f(27-5)+1,f(27-7)+1\right\}

f(27-2)+1表示最后一枚硬币为2元所需要的最少枚数;f(27-5)+1表示最后一枚硬币为5元所需要的最少枚数;f(27-7)+1表示最后一枚硬币为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] = min\left\{ dp[X-2]+1,dp[X-5]+1,dp[X-7]+1 \right\}

其中数组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)有:

                                                              dp[i][j] = dp[i-1][j] + dp[i][j-1]

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,有:

                                                      dp[j] = OR_{0\leq i<j}(dp[i] AND i+a[i]\geq 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]))

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值