一、什么是动态规划?
给定一个矩形网络,一个机器人从左上角出发,每次可以向下或向右走一步
题目A:求有多少种方式走到右下角(√ 可用动态规划求解)
题目B:输出所有走到右下角的路径(× 递归)
动态规划题目特点
1.计数
- 有多少种方式走到右下角
- 在 n 个数中,有多少种方法选出 k 个数使得和为 sum
2.求最值
- 从左上角走到右下角路径的最大数字和
- 给定一个序列,求最长上升子序列长度
3.存在性
- 取石子游戏,先手是否必胜
- 能不能选出 k 个数使得和为 sum
二、常规解题步骤
1. 确定状态
状态在动态规划中的作用属于“定海神针”。
状态,简单来说就是在解动态规划题目时需要一个数组,数组的每个元素 f[i] 或者 f[i][j] 代表什么
- 类似于数学中 x,y,z 代表什么
- 确定状态的两个意识:
- 最后一步:最优策略中的最后一个决策
- 子问题:与原问题一样,只是规模变小了
2. 转移方程
将函数 f(X) 转换为数组 f[X]
3. 初始条件和边界情况
边界值:保证数组不要越界。如果不能拼出Y,就定义f[Y]=正无穷
例如:f[-1] = f[-2] = … = 正无穷
初始条件:由转移方程算不出来,需要手动定义。
4. 计算顺序
利用之前的计算结果
大多数动态规划的题目都是从小到大的计算顺序,f[0],f[1],f[2],f[3]……
大多数二维数组的计算顺序:从上到下,从左到右
三、例题图解
1. 零钱兑换问题
求最值的动态规划问题
思路:
a. 确定状态
最后一步:虽然我们不知道最优策略是什么,但是最优策略肯定是 k 枚硬币面值加起来是27------所以一定有一枚最后的硬币ak,除掉这枚硬币之后,其余硬币面值加起来是27-ak
关键点一:我们不关心前面的 k-1 枚硬币是怎么拼出27-ak的(可能有一种拼法,可能有很多种),而且我们甚至都还不知道 k 和ak,但是我们确定前面的硬币拼出了 27-ak
关键点二:因为是最优策略,所以拼出来的27-ak的硬币个数一定要是最少,否则就不是最优策略了
子问题:
原来我们要求最少多少枚硬币拼出27,现在我们要求最少多少枚硬币拼出 27-ak 。
问题的规模减小了,也就是确定了子问题,子问题出来便可以确定状态
为了简化定义,我们设状态 f(X)=最少用多少枚硬币拼出X
原问题是f(27)现在变成求 f(27-ak)
因为最后一枚硬币只能是2,5,7,所以:
如果ak=2,f(27)=f(27-2)+1(加上最后一枚硬币2)
如果ak=5,f(27)=f(27-5)+1(加上最后一枚硬币5)
如果ak=7,f(27)=f(27-7)+1(加上最后一枚硬币7)
b. 转移方程
设状态 f[x]=最少多少枚硬币拼出x
c. 初始条件和边界情况
f[X]=min{f[X-2]+1,f[X-5]+1,f[X-7]+1}
问题:X-2,X-5,X-7小于0怎么办?怎么停下来?
f[1] = min{f[-1]+1,f[-4]+1,f[-6]+1} = 正无穷,表示拼不出来1
初始条件:f[0]=0
因为若由转移方程计算得出 f[0]=正无穷,但是已知 f[0]=0
d. 计算顺序
初始条件:f[0]=0
然后计算:f[1],f[2]……f[27]
当我们计算f[X]时,f[X-2],f[X-5],f[X-7]都已经得到结果了
每一种尝试3种硬币,一共27步。
与递归算法相比,没有重复运算。
时间复杂度(需要进行的步数):27*3
e. 实现代码
public class Solution{
//A:{2,5,7} M:27
public int coinChange(int[] A, int M) {
int[] f = new int[M+1]; //0……n:[n+1]
int n = A.length;
f[0] = 0; //初始化
//拼出 f[1],f[2],……,f[27] f[i]
for (int i = 1;i <= M ;i++){
f[i] = Integer.MAX_VALUE;//初始化为无穷大,假设拼不出来
//最后一枚硬币 A[j]
for (int j = 0;j < n;j++){
//需要拼的数 i 一定大于 A[j]; 无穷大+1 越界
if (i >= A[j] && f[i-A[j]] != Integer.MAX_VALUE){
f[i] = Math.min(f[i-A[j]]+1,f[i]);
}
}
}
if(f[M] == Integer.MAX_VALUE){
f[M] = -1; //若拼不出来27,返回-1
}
return f[M];
}
}
2. 唯一路径问题
计数型动态规划问题
a. 确定状态
最后一步:无论机器人用何种方式到达右下角,总有最后挪动的一步-----向右或向下
右下角坐标设为(m-1,n-1),那么前一步机器人一定是在(m-2,n-1)或(m-1,n-2)处
子问题:
那么,假设有X种方式走到(m-2,n-1),有Y种方式走到(m-1,n-2),则最后有X+Y种方式走到(m-1,n-1)
即,子问题是机器人有多少种方式从左上角走到(m-2,n-1)和(m-1,n-2)
b. 转移方程
c. 初始条件和边界情况
初始条件:f[0][0] = 1,因为机器人只有一种方式到左上角
边界情况:i = 0 或 j = 0,则前一步只能有一个方向过来–>f[i][j] = 1
d. 计算顺序
- f[0][0] = 1
- 计算第0行:f[0][0],f[0][1],……,f[0][n-1]
- 计算第1行:f[1][0],f[1][1],……,f[1][n-1]
- ……
- 计算第m-1行:f[m-1][0],f[m-1][1],……,f[m-1][n-1]
- 答案是 f[m-1][n-1]
- 时间复杂度(计算步数):O(MN)
- 空间复杂度(数组大小):O(MN)
e. 代码实现
public 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];
}
}
3. 跳跃游戏问题
存在型动态规划问题
a. 确定状态
最后一步:如果青蛙能跳到最后一块石头n-1,我们考虑它跳的最后一步。
- 这一步是从石头 i 跳过来,i < n-1
- 这时主要满足两个条件:
- 青蛙可以跳到石头 i
- 最后一步不超过跳跃的最大距离:n-1-i <= ai
子问题:
那么,我们需要知道青蛙能不能跳到石头 i(i < n-1),而我们原来要求青蛙能不能跳到石头 n-1
状态:设 f[j] 表示青蛙能不能跳到石头 j
b. 转移方程![在这里插入图片描述](https://img-blog.csdnimg.cn/2021040314561866.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L215amVzcw==,size_16,color_FFFFFF,t_70)
OR:for循环时只要有一个 i 满足条件(能跳到 j)就是true
c. 初始条件和边界情况
初始条件:f[0] = true,因为青蛙一开始就站在石头0
边界情况:没有越界
d. 计算顺序
由转移方程决定,从小到大算,答案为f[n-1]
- 时间复杂度:O(N²)
- 空间复杂度(数组大小):O(N)
e. 代码实现
public class Solution{
public boolean canJump(int[] A) {
int n = A.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++) { //枚举之前的石头 上一跳:i-->j
if (f[i] && i + A[i] >= j){
f[j] = true;
break;
}
}
}
return f[n-1];
}
}