这里写目录标题
- DP(普通一维问题)
- 背包DP
- 树形DP
- 状压DP
- 数位DP
- DP的常见优化
动态规划和递归的区别就是——》动态规划实现了对子问题的存储
动态规划 题目特点:
- 1.计数(问:how many ways。。。)
- a.有多少种方式 走到右下角
- b.有多少种方法 选出k个数使得和是sum
- 2.求最大值、最小值(最大的一个解题类型)
- a.从左上角走到右下角 路径的最大数字和
- b.最长上升子序列的长度
- 3.求存在性
- a.取石子游戏,先手是否必胜
- b.能不能选出k个数 使得和是sum
一、引入:例题
1.(LintCode 669):Coin Change——第【2】种类型 求最大最小值的DP
有三种硬币,分别面值2元,5元,7元,每种硬币都足够多
买一本书需要27元
求:如何用最少的硬币组合正好付清,不需要对方找钱
1.动态规划组成部分一:确定状态
- 简单的说,解动态规划的时候需要开一个数组,数组的每个元素f[i] 或 f[i][j] 代表着什么(类似于 数学中的,设x,y,z)
- 怎么找 这个状态?——找到“最后一步”(结束条件)
- 虽然我们不知道最优策略是什么,但 最优策略肯定是K枚硬币 :a1+a2+…+ak = 27
- 所以,一定有一枚“最后的”硬币:ak
- 除掉这枚硬币,前面硬币的面值加起来是 27-ak
图示如下:
关键点:
-
我们不关心前面的 k-1枚硬币是怎么拼出 27-ak 的(可能有很多种拼法),我们甚至不知道 ak 和 k 的(值)是什么。
但是 我们确定 前面的硬币拼出了 27-ak -
因为是最优策略,所以拼出27-ak的硬币数也一定是最少的(也就是说,27-ak 也一定是最优策略),否则这就不是最优策略了
(由此归纳出来) 子问题: -
我们将原问题转化成了一个子问题,而且规模更小:最少用多少枚硬币可以拼出 27-ak
-
归纳上述子问题,我们可以得出: 设 状态 f(x) = 最少用多少枚硬币拼出X 【f(x)是硬币数,x是总钱数】
可是,我们还不知道最后那枚硬币ak是多少。 但 我们可以确定:最后那枚硬币只能是2,5或7 所以 if(ak == 2) f(27) = f(27-2) + 1 ; //加上最后这一枚 硬币2 if(ak == 5) f(27) = f(27-5) + 1 ; //。。。硬币5 if(ak == 7) f(27) = f(27-7) + 1 ; //。。。硬币7 除此以外,没有其他的可能了 我们要求最少的硬币数(求三种可能的最小值 即可),所以: f(27) = min{ f(27-2)+1 , f(27-5)+1 , f(27-7)+1 }
-
到这,可能会有一个疑问——》这不是用的 递归 算法吗?
-
我们先来用递归写一下:
int f(int x){ //f(x) = 最少要用多少硬币拼出x
//结束条件
if(x==0) return 0;
int res = MAX_VALUE; //因为要求最小值,所以初始化先为最大值
if(x>=2){ //最后一枚硬币是2元
res = Math.min(res,f(x-2)+1);
}
if(x>=5){ //最后一枚硬币是5元
res = Math.min(res,f(x-5)+1);
}
if(x>=7){ //最后一枚硬币是7元
res = Math.min(res,f(x-7)+1);
}
return res;
}
- 代码执行时的思路:
我们可以看出,有很多数被重复算了很多次—-》这些重新计算完全没必要(浪费时间)—-》所以,我们想要优化这段代码—-》也就是动态规划的思想
动态规划 = 递归+记忆化
动态规划 会将计算过的结果保留下来,需要用的时候直接获取,以此来避免重复的计算
2.动态规划组成部分二:转移方程
其实这一步很简单,我们根据 上一步“确定状态” 归纳出的f(x)就可以写出 转移方程:
- 设 状态f[x] = 最少用多少枚硬币拼出x
- 对于任意x, f[x] = min{ f[x-2]+1 , f[x-5]+1 , f[x-7]+1 } 注意:这里的 f[] 是方括号—-》一开始就说了,动态规划是要定义一个数组来解决问题的
3.动态规划组成部分三:初始条件+边界情况
-
两个问题:
-
x-2 , x-5 , x-7 小于 0 怎么办?
-
递归什么时候停下来?
-
解决:
-
我们定义:如果不能拼出x,那么f[x] = 正无穷
为什么要定义成 正无穷,而不是其他值呢? —-》因为我们的状态转移方程为:f[x] = min{ f[x-2]+1,f[x-5]+1,f[[x-7]+1] }; 例如,x=2时 f[2] = min{ f[0]+1,f[-3]+1,f[-5]+1 }; 而题中,x<0 是不允许出现的(越界条件)—-》f[x]是算不出来的 所以,必须给 f[x] 定义一个值, 但这个值定义为其他任何数都是有风险的(可能会出现另两个值比 f[0]+1 小的情况)-—-》因此,我们定义:拼不出来x f[x] = 正无穷
例如:f[-1]=f[-2]=…=正无穷
f[1] = min{ f[-1]+1 , f[-4]+1 , f[-6]+1 } = 正无穷,表示拼不出来 1
- 初始条件:f[0] = 0 ; //初始条件 就是:用状态转移方程算不出来的,但这个值还有意义,需要手动定义的值
(这里我们清楚的知道,f[0]是得0的—-》x=0,那么不需要硬币去拼)
4.动态规划组成部分四:计算顺序
-
计算顺序: f[1] , f[2] , f[3] , … , f[27]
- 为什么计算顺序(递归顺序)为:f[1],f[2],…,f[27] ? - 难道动态规划的计算顺序都是这样的? 答:并不是,但大多是。 本题 :当我们计算到 f[x] 时,f[x-2],f[x-5],f[x-7]一定都是已经得到结果的了 (因为计算 f[x] 要用到后面那堆东西)
-
小结(求动态规划题的基本思路)
- 1.确定状态
- 最后一步(最优策略中 使用的最后一枚硬币ak)
- 化成子问题(最少的硬币拼出更小的面值27-ak)
- 2.转移方程
- 根据状态,确定转移方程(f[x] = min{ f[x-2]+1 , f[x-5]+1 , f[x-7]+1 }
- 3.初始条件和边界情况
- f[0] = 0
- 如果不能拼出x , f[x] = 正无穷
- 4.计算顺序
- f[0] , f[1] , … , f[x]
========================================
- f[0] , f[1] , … , f[x]
- 1.确定状态
以上为b站视频的总结;以下是我自己的理解
【重点】经典例题(简单一维dp)
1.斐波那契数列
1 1 2 3 5 8 …
这是最经典的递归问题,
但 如果用递归求解,会重复计算一些子问题。
那如何用 动态规划 求解呢。
题目描述:求斐波那契数列的第n项,n<39。
- 递归法
根据递推公式:f(n) = f(n-1)+f(n-2)
int fib(int n){
if(n<2) return n;
return fib(n-1)+fib(n-2);
}
- dp
- 1.状态 : 最后一步是求f[n]
- 2.转移方程:f[n] = f[n-1]+f[n-2]
- 3.初始化:f[1]=1 ;边界条件:n<=1
- 4.计算顺序:1—>n
public int Fibonacci(int n){
if(n <= 1) return n; //边界条件
int[] fib = new int[n+1];
fib[1] = 1; //初始化
fib[2] = 1;
for(int i=2;i<=n;i++){ //计算顺序
fib[i] = fib[i-1] + fib[n-2]; //状态方程
return fib[n];
}
2.矩形覆盖
题目描述:我们可以用2*1的小矩形横着或竖着去覆盖更大的矩形。请问用n个2*1的小矩形无重叠的覆盖一个2*n的大矩形,总共有多少种方法?
-
分析:dp[1] = 1 ; dp[2] = 2
要覆盖2*n的大矩形, 可以先覆盖一个2*1的矩形,再覆盖2*(n-1)的矩形; 也可以先覆盖两个个2*2的矩形,再覆盖2*(n-2)的矩形。 而覆盖2*(n-1)和2*(n-2) 可以看做是子问题,传递下去
- 最后一步:求 dp[n]
- 初始化:dp[1] = 1 ; dp[2] = 2; 边界条件:n<=2
- 转移方程(递归表达式):dp[n] = dp[n-1] + dp[n-2]
- 计算顺序:1-->n
- 递归法
public int rectCover(int n){
if(n<=2) return n;
return rectCover(n-1)+rectCover(n-2);
}
- dp算法
public int rectCover(int n){
if(n<=2) return n;
int[] dp = new int[n+1];
dp[1] = 1;
dp[2] = 2;
for(int i=3;i<=n;i++){
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
3.跳台阶
题目描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级台阶总共有多少种跳法。
-
分析
int[] dp = new int[n]; //dp[i]表示跳到第i级台阶有多少种跳法 状态(最后一步):d[n] 初始化:dp[1] = 1 ; dp[2] = 1 ; 边界条件:n<=2; 状态转移方程:dp[i] = dp[i-1]+dp[i-2]; //dp[i]的状态,要么从i-1的台阶跳1级到i ; 要么从i-2级台阶一次跳2级到i 计算顺序:1-->n //计算 dp[i] 需要先计算 dp[i-1] 和 dp[i-2]
public int jumpFloor(int n){
if(n<=2) return n;
int[] dp = new int[n+1];
dp[1] = 1;
dp[2] = 1;
for(int i=3;i<=n;i++){
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
4.变态跳台阶
题目描述:一只青蛙可以跳上1级台阶,也可以跳上2级…它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
-
分析
最后一步:求 dp[n] //跳上n级台阶的方案数 初始化:dp[1] = 1,dp[2] = 1,...,dp[n] = 1 状态转移方程:dp[i] = dp[i-1]+dp[i-2]+...+dp[1] //从所有台阶上都可以调到i级台阶上去 计算顺序:1-->n
-
代码实现
public int jumpFloorII(int n){
int[] dp = new int[n+1];
Arrays.fill(dp,1); //把dp数组中所有元素初始化为1
//对于每一级台阶,方案数都是前面所有台阶的方案数的和
for(int i=1;i<=n;i++){
for(int j=1;j<i;j++){
dp[i] += dp[j];
}
}
return dp[n];
}
参考资料
- 【动态规划专题班】ACM总冠军、清华+斯坦福大神带你入门动态规划算法-哔哩哔哩】 https://b23.tv/jmLSCrC