动态规划(Dynamic Programming)入门

前言

算法实验课的题目是一道关于动态规划(Dynamic Programming)的题目,正好借这个机会,学习一下动态规划(Dynamic Programming)。

动态规划简单介绍

动态规划(Dynamic Programming,简称DP)的基本想法就是将原问题转化为一系列相互联系的子问题,然后通过逐层递推来求得最后的解。

动态规划快速而高效解决问题的关键就是利用历史记录,来避免重复计算。

动态规划的题目特点

一般来说,我们面对各式各样的算法题,可能会有许多不同的想法,有些题目适合动态规划,有些题目可能不适合动态规划,那么哪些题目适合动态规划呢?

当我们看见如下三种类型的题目时,可以首先考虑一下,DP是否可以成为解决这道题的方法(当然,不一定是最优的算法)

  • 计数型算法题
    1. 有多少种方式走到右下角
    2. 有多少种方式选出k个数,使其和为sum
  • 求最大最小值型算法题
    1. 从左上角走到右下角路径的最大数字和
    2. 最长上升子序列长度
  • 求存在性算法题
    1. 取石子游戏,先手是否必胜
    2. 能不能选出k个数使得和为sum

例题分析

对于以上三种题型,我们举三个简单的题目来总结出动态规划问题的解题思路。

例题一:Coin Change(LeetCode 332)

题目描述:

You are given an integer array coins representing coins of different denominations and an integer amount representing a total amount of money.
Return the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.
You may assume that you have an infinite number of each kind of coin.
您将获得一个整数数组,表示不同面额的硬币,以及一个整数金额,表示总金额。
返回您需要的最少数量的硬币,以弥补该金额。如果这些硬币的任何组合都无法弥补这一数额,则返回-1。
你可以假设每种硬币的数量是无限的。

举例分析一下

我们有三种硬币,面值分别为2元、5元和7元,且假设每种硬币有无限多。
买一本书需要27元。
如何用最少的硬币组合正好付清?

分析:
拿到问题的时候,我首先想到的是尽可能用大面值
即7+7+7=21
21+5=26
没有得到27

那我们改变一下策略,尽量使用大面值,最后可以使用一种硬币付清就行
所以我们得到了
21+2+2+2=27
所以得到答案为6,但是这个答案是错误的

正确答案是5,7+5+5+5+5=27

为什么会出现这种情况呢?换句话说,第一种算法有什么问题呢?

对于每一种正确的算法,我们应该可以通过数学证明出该方法是正确的,但实际上,我们并不能证明这种算法是正确的,贪心只是局部最优,但对于整体而言,它不一定是最优的。

那么利用动态规划该如何解决这个问题呢?

我们运用DP解决问题时,只需要把握以下四个部分就行:

  • 确定状态
  • 转移方程
  • 初始条件和边界条件
  • 计算顺序

确定状态

解动态规划的时候需要开一个数组,数组的每个元素f[i]或者f[i][j] 代表什么,就像数学中,x,y,z表示什么。

确定状态需要两步

  1. 最后一步

    对于这个问题,虽然我们还不知道最优策略是什么,但是我们知道最优策略一定是k枚硬币,a1,a2…ak相加得到27

    所以一定有最后一枚硬币ak

    除去ak,前面的硬币加起来就是27-ak
    在这里插入图片描述
    事实上,我们并不关心前面k-1枚硬币是怎么组成27-ak的,它可以有很多种方式,但肯定至少有一种,而且我们可以知道,前面k-1枚硬币组成27-ak的策略一定是最优的。

    也就是说,我们的最后一步就是(27-ak)+ak=27

  2. 子问题

    原来的问题是最少用多少枚硬币组成27
    现在的问题变成了最少用多少枚硬币组成27-ak

    我们把这种问题一样,规模变小的问题称为原问题的子问题。

    为了简化表达,我们设状态f[X]=最少用多少枚硬币组成X

    等等,此刻,我们似乎还不知道ak是多少
    但是,毫无疑问,ak只能是2,5,7三个数的其中一个
    如果ak=2,那么f[27]=f[27-2]+1(加上最后一枚硬币)
    如果ak=5,那么f[27]=f[27-5]+1(加上最后一枚硬币)
    如果ak=7,那么f[27]=f[27-7]+1(加上最后一枚硬币)

    为了满足硬币最少的需求,我们就得到了:
    f[27]=min{f[27-2]+1,f[27-5]+1,f[27-7]+1}

写到这里可能就有很多小伙伴们会直呼这不就是递归吗?

那我们就用递归来看看,递归伪代码如下:
在这里插入图片描述
毫无疑问,当X=0时,拼成0的方法有0种

那么为什么定义初始值为正无穷呢?
关于这一点,我们会在DP的第三步:初始条件和边界条件中具体讲解。

这个方法有什么问题?
理论上,这个方法可以解决问题,但是递归有如下的问题:
在这里插入图片描述
我们可以看到,每一次递归的时候,都会进行大量的重复计算,这样程序的效率是非常低下的,那么如何避免?

我们来看一看本文的主角DP是如何做的。

转移方程

DP的一个重点就是转移方程,将原问题转移到子问题。
我们设f[X]=最少用多少枚硬币组成X
那么转移方程就是:f[X]=min{f[X-2]+1,f[X-5]+1,f[X-7]+1}

初始条件和边界条件

对于f[X]=min{f[X-2]+1,f[X-5]+1,f[X-7]+1} 这个转移方程
我们有两个问题:

  1. X-2,X-5,X-7小于0怎么办?什么时候停下来
  2. 什么时候停下来?

如果不能拼出Y,我们就定义f[Y]为正无穷
例如f[-1]、f[-2]=正无穷
当然,对于数组下标,我们不可能让它等于负数,那么我们只需要在要用到f[负数]的时候就让它返回正无穷就行

同时我们也可以得到f[1]=min{f[1-2]+1,f[1-5]+1,f[1-7]+1}=正无穷,表示拼不出1

同时我们也可以得到初始条件f[0]=0

什么时候要定义初始化呢?
对于f[0],我们通过转移方程求解时得到的是正无穷,而我们明明知道f[0]的值为0,那么我们就可以手动定义为0

计算顺序

最少用多少枚硬币组成X:f[X]=min{f[X-2]+1,f[X-5]+1,f[X-7]+1}

初始化条件:f[0]=0

那么计算顺序应该怎么安排呢?

我们知道想要求f[8],我们就必须知道f[6],f[3],f[1],所以什么进行从小到大的顺序进行计算。

与递归相比:
DP每一步尝试3种硬币,一共尝试27步
所以,算法时间复杂度为:27*3,远小于递归的算法时间复杂度

小结与代码实现

求最值型动态规划

DP四个部分:

  1. 确定状态:最后一步和子问题
  2. 转移方程
  3. 确定初始条件和边界情况
  4. 确定计算顺序

消除冗余,加速计算

OK,我们现在代码实现一下这道题(LeetCode 332 Coin Change)

class Solution {
    
    
    public int coinChange(int[] coins, int amount) {
    
    
        int[] f=new int[amount+1];
        int n=coins.length;
        
        //初始条件
        f[0]=0;
        
        for(int i=1;i<=amount;++i){
    
    
            f[i]=Integer.MAX_VALUE;
            //最后一步
            for(int j=0;j<n;++j){
    
    
                if(i>=coins[j]&&f[i-coins[j]]!=Integer.MAX_VALUE){
    
    
                     f[i]=Math.min(f[i-coins[j]]+1,f[i]);//转移方程
                }
            }
        }
        if(f[amount]==Integer.MAX_VALUE){
    
    
            f[amount]=-1;
        }
        return f[amount];
    }
}
}

例题二:Unique Paths(LeetCode 62)

A robot is located at the top-left corner of a m x n grid (marked ‘Start’ in the diagram below).
The robot can only move either down or right at any point in time. The robot is trying to reach the bottom-right corner of the grid (marked ‘Finish’ in the diagram below).
How many possible unique paths are there?
机器人位于m x n网格的左上角(下图中标记为“开始”)。
机器人只能在任何时间点向下或向右移动。机器人正试图到达网格的右下角(下图中标记为“Finish”)。
有多少种可能的唯一路径?
在这里插入图片描述

确定状态

  1. 最后一步
    在这里插入图片描述
    如果右下角finsh为(m-1,n-1)
    那么前一步,机器人一定在(m-2,n-1)或者(m-1,n-2)

  2. 子问题
    如果机器人有X种方式走到(m-2,n-1),有Y种方式走到(m-1,n-2),那么机器人有X+Y种方式走到finsh

    现在问题就转换为机器人有多少种方式走到(m-2,n-1和(m-1,n-2)

状态:设f[i][j]为机器人有多少种方式走到(i,j)

转移方程

对任意一个格子:f[i][j]=f[i-1][j]+f[i][j-1]

初始条件和边界情况

初始条件:f[0][0]=1
边界情况:当i=0或者j=0时,f[i][j]=1

计算顺序

f[0][0]=1
计算第0行,从左往右
计算第1行,从左往右

计算第m-1行,从左往右

小结与代码实现

class Solution {
    
    
    public int uniquePaths(int m, int n) {
    
    
        int[][] f=new int[m][n];
        for(int i=0;i<m;++i){
    
    //从上到下
            for(int j=0;j<n;++j){
    
    //从左到右
                if(i==0||j==0){
    
    
                    f[i][j]=1;//初始条件
                }
                else{
    
    
                    f[i][j]=f[i-1][j]+f[i][j-1];//转移方程
                }
            }
        }
        return f[m-1][n-1];
    }
}

例题三:Jump Game(LeetCode 55)

问题描述:

You are given an integer array nums. You are initially positioned at the array’s first index, and each element in the array represents your maximum jump length at that position.
Return true if you can reach the last index, or false otherwise.
您将获得一个整数数组nums。您最初位于数组的第一个索引处,数组中的每个元素表示该位置处的最大跳转长度。
如果可以到达最后一个索引,则返回true,否则返回false。

在这里插入图片描述

确定状态

  1. 最后一步

    如果能到达n-1,那么我们考虑它的最后一步是从i跳来的(0<=i<n-1),那就需要满足两个条件

    1. 可以到达i
    2. 最后一步不能超过跳跃的最大距离
  2. 子问题

    我们能不能到达i

状态:设f[j]表示能不能跳到j

转移方程

设f[j]表示能不能跳到j:
f[j]=OR
AND表示前后都要成立
OR表示只有有一种情况满足即可

初始条件和边界情况

初始条件:f[0]=True
没有边界情况

计算顺序

从小到大

小结与代码实现

时间复杂度:O(N^2),空间复杂度(数组大小):O(N)

注意:对于此题,贪心是更好的解决算法,此处仅仅展示DP可以实现。

class Solution {
    
    
    public boolean canJump(int[] nums) {
    
    
        int n=nums.length;
        boolean[] f=new boolean[n];
        f[0]=true;//初始条件
        
        for(int j=1;j<n;++j){
    
    
            f[j]=false;
            for(int i=0;i<j;++i){
    
    
                if(f[i]&&i+nums[i]>=j){
    
    //转移方程
                    f[j]=true;
                    break;
                }
            }
        }
        return f[n-1];
    }
}

总结

DP的路刚刚开始,仅仅是以上四步法还不够解决所有问题,革命尚未成功,同志仍需努力!

猜你喜欢

转载自blog.csdn.net/idler123/article/details/121045644