一.初入动态规划
1.什么是动态规划?
- A:求有多少种方式走到右下角
- B:输出所有走到右下角的路径,哪个可以用动态规划?
- 题A是计数问题,属于动态规划题型的一种,而题B则是DFS
2.动态规划特点
A.计数
- 有多少种方式走到右下角
- 有多少种方法选出k个数使得和是sum
B.最大最小值
- 从左上角走到右下角路径的最大数字和
- 最长上升子序列长度
C.求存在性
- 取石子游戏,先手是否必胜(博弈论)
- 能不能选出k个数使得和是sum
3.例1 零钱兑换(leetcode 322)
- 给定不同的硬币,比如分别为2元,5元,7元,每种硬币足够多
- 买一本书需要27元
- 如何用最少的硬币组合正好付清,不需要对方找钱?
重点是最少,明显属于最值问题,那么如何达到最少的目的?
- 试试直觉,最少的硬币组合->用面值大的硬币(算是贪心的思想),7+7+7=21 21+5=26,还有1块钱凑不出来…
- 改算法,尽量用面值大的硬币,最后如何可以用一种硬币付清就行,7+7+7=21 21+2+2+2=27,6枚
- 然而答案是5枚,7+5+5+5+5=27
动态规划组成部分一:确定状态
- 状态在动态规划中的作用属于定海神针
- 简单的说,解动态规划的时候需要开一个数组,数组的每个元素f[i]或者f[i][j]代表什么(类似解数学题中,x,y,z代表什么)
- 确定状态需要两个意识,最后一步、子问题
最后一步
- 虽然不知最优策略是什么,但是最优策略肯定是K枚硬币a1,a2,…ak面值加起来是27
- 所以一定有一枚最后的硬币:ak
- 除掉这枚硬币,前面硬币面值加起来是27-ak
- 我们不关心前面的k-1枚硬币是怎么拼出27-ak(可能有1种拼法,也可以能有100种),而且我们现在不知道ak和k,但是我们知道前面的硬币拼出27-ak
- 因为是最优策略,所以拼出27-ak的硬币数一定是最少,否则这就不是最优策略了,就矛盾了
- 如下图
- 所以我们需要知道最少用多少枚硬币可以拼出27-ak,而原问题是最少用多少枚硬币拼出27
- 我们将原问题转化成一个子问题,而且规模更小:27-ak
- 为了简化定义,我们设状态f(x)=最少用多少枚硬币拼出x,而原来要求f(27),现在变成了f(27-k)
- 但是此时我们不知道最后那枚硬币ak是多少,最后那枚硬币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)
- 除此以外,没有其他的可能了
- 需要求最少硬币,所以
- f(27)=min{f(27-2)+1,f(27-5)+1,f(27-7)+1}
- f(27)表示拼出27所需最少的硬币数
- f(27-2)+1拼出25所需最少的硬币数,加上最后一枚硬币2
- f(27-5)+1拼出25所需最少的硬币数,加上最后一枚硬币5
- f(27-7)+1拼出25所需最少的硬币数,加上最后一枚硬币7
和递归有什么关系?
- 用递归解法会有点问题,重复计算了很多次,效率低下,直接用数组在数组中计算不香嘛?
- 如何避免?
动态规划组成部分二:转移方程
- 设状态f[x]=最少用多少枚硬币拼出x
- 对于任意x,f[x]=min{f[x-2]+1,f[x-5]+1,f[x-7]+1}拼出x所需最少的硬币数
- f[x-2]+1表示拼出x-2所需最少的硬币数,加上最少一枚硬币2
- f[x-5]+1表示拼出x-5所需最少的硬币数,加上最少一枚硬币5
- f[x-7]+1表示拼出x-7所需最少的硬币数,加上最少一枚硬币7
动态规划组成部分三:初始条件和边界情况
- 两个问题:
- x-2,x-5或者x-7小于0怎么办
- 什么时候停下来
- 如果不能拼出y,就定义f[y]=正无穷
- 例如f[-1]=f[-2]=正无穷
- 所以f[1]=min{f[-1]+1,f[-4]+1,f[-6]+1}=正无穷,表示拼不出来1
- 初始条件:f[0]=0(初始条件用转移方程算不出来,要根据题目来定义)
动态规划组成部分四:计算顺序
- 拼出X所需要的最少硬币数:f[X]=min{f[x-2]+1,f[x-5]+1,f[x-7]+1}
- 初始条件:f[0]=0
- 然后计算f[1],f[2],…f[27]
- 当我们计算到f[x]时,f[x-2],f[x-5],f[X-7]都已经得到结果了
- f[x]=非无穷数,表示最少用多少枚硬币拼出X
- f[x]=正无穷,表示无法用硬币拼出X
- 每一步尝试三种硬币,一共27步,与递归算法比,没有任何重复计算
- 算法时间复杂度(需要进行步数):273(nm)
- 递归时间复杂度:>>27*3
编写代码
public int coinChange(int[] coins, int amount) {
int [] dp = new int[amount+1];
dp[0]=0;
for(int i=1;i<=amount;i++){
dp[i]=Integer.MAX_VALUE;
for(int j=0;j<coins.length;j++){
if(i-coins[j]>=0&&dp[i-coins[j]]!=Integer.MAX_VALUE) {
dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
}
}
}
if(dp[amount]==Integer.MAX_VALUE){
return -1;
}
return dp[amount];
}
4.例2 不同路径(leetcode 62)
- m行n列网格,有一个机器人总左上角触发,每一步只能向下或向右一步,问有多少种方法走到右下角
- 典型的计数型动态规划
动态规划组成部分一:确定状态
- 最后一步,不管何种方式到右下角,总有最后一步挪动,向右或者向下
- 右下角坐标为(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),而原题有多少种方式从左上角走到(m-1,n-1)
- 子问题
- 状态:设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行
- 答案是f[m-1][n-1]
- 时间复杂度(计算步数):O(MN),空间复杂度(数组大小):O(MN)
编写代码
public int uniquePaths(int m, int n) {
int [][] dp = new int [n][m];
for(int i=0;i<n;i++){
for(int j=0;j<m;j++){
if(i==0||j==0){
dp[i][j]=1;
}else{
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
}
return dp[n-1][m-1];
}
5.例3 青蛙跳石头(leetcode 403)
- 有n块石头分别在x轴的0,1,…n-1位置,一只青蛙在石头0,想跳到石头n-1,如果青蛙在第i块石头上,它最多可以向右跳距离ai,问青蛙能否跳到石头n-1上
例子:
输入a=[2,3,1,1,4] 输出:true
输入a=[3,2,1,0,4] 输出;false
第一步:确定状态
第二步:转移方程
f[j]=OR(0<=i=j)
- f[j]表示青蛙能不能跳到石头j
- 枚举上一个跳到的石头i
- f[i]表示青蛙能不能跳到石头i
- 最后一步的距离不能超过ai
第三步:初始条件和边界情况
- 初始条件:f[0]=True,因为青蛙一开始就在石头0
- 没有边界情况
第四步:计算顺序
- 初始化f[0]=true
- 计算f[1],f[2],…,f[n-1]
- 答案是f[n-1]
- 时间复杂度:O(n²),空间复杂度:O(n)
编写代码
public boolean canJump(int[] nums) {
int n = nums.length;
boolean [] dp = new boolean[n];
dp[0]=true;
for(int i=1;i<n;i++){
dp[i]=false;
for(int j=0;j<i;j++){
if(dp[j]&&j+nums[j]>=i){
dp[i]=true;
break;
}
}
}
return dp[n-1];
}
6.例4 乘积最大子数组(leetcode 152)
- 给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积
例子:
输入: [2,3,-2,4] 输出:2*3=6
输入:[-2,0,-1] 输出:0
第一步:确定状态
- 题目是求连续最大的子数组对应的乘积,要考虑乘积就一定要分为两种情况,两个正数相乘为最大或者是两个负数相乘为最大
- 最后一步:前面相乘的结果为sum,再乘以an,为数组中乘积最大的连续子数组
- 按照乘法性质,如果你想让sum*an最大,无非两种可能性
- an为负数,sum一定是前n-1位乘积最小的数
- an为正数,sum一定是前n-1位乘积最大的数
- 本来求整个数组中乘积最大的连续子数组的乘积(长度n),现在我们要求数组前(长度n-1)乘积最大和最小的连续数组的乘积
- 子问题
- 设imax[i]为前i位乘积最大的数,imin[i]为前i位乘积最小的数
第二步:转移方程
- 当nums[i]小于0时
imax[i]=Math.max(imin[i-1]*nums[i],nums[i])
imin[i]=Math.min(imax[i-1]*nums[i],nums[i])
- 当nums[i]大于等于0时
imax[i]=Math.max(imax[i-1]*nums[i],nums[i])
imin[i]=Math.min(imin[i-1]*nums[i],nums[i])
第三步:初始条件和边界情况
- 初始条件:imax[0]=nums[0],imax[0]=nums[0]
- 没有边界情况
第四步:计算顺序
- 初始化imax[0]=nums[0],imax[0]=nums[0]
- 计算从1到n-1
- 时间复杂度:O(n),空间复杂度:O(n)[可以优化成O(2)]
第五步:编写代码
public int maxProduct(int[] nums) {
int n = nums.length;
int result = nums[0];
int [] imax = new int[n];
int [] imin = new int[n];
imax[0]=nums[0];
imin[0]=nums[0];
for(int i=1;i<n;i++){
if(nums[i]<0){
imax[i] = Math.max(imin[i-1]*nums[i],nums[i]);
imin[i] = Math.min(imax[i-1]*nums[i],nums[i]);
}else{
imax[i] = Math.max(imax[i-1]*nums[i],nums[i]);
imin[i] = Math.min(imin[i-1]*nums[i],nums[i]);
}
result = Math.max(imax[i],result);
}
return result;
}
- 优化
- 你会发现它i只会和i-1有关,所以我们可以对这题的空间进行优化
public int maxProduct(int[] nums) {
int n = nums.length;
int result = nums[0];
int imax=nums[0],imin=nums[0];
for(int i=1;i<n;i++){
if(nums[i]<0){
imax = imax^imin;
imin = imax^imin;
imax = imax^imin;
}
imax = Math.max(imax*nums[i],nums[i]);
imin = Math.min(imin*nums[i],nums[i]);
result = Math.max(imax,result);
}
return result;
}
- 常见动态规划类型
- 坐标型
- 序列型
- 划分型
- 区间型
- 背包型
- 最长序列型
- 博弈型
- 综合型