1.动态规划
动态规划主要作用是利用已经求得信息来帮助解后面的问题。
1.1 递归
基本上所有的动态规划问题,都可以转换为递归问题。但是递归虽然能够解决出问题,但它需要消耗的时间和空间是非常巨大的。
递归的最主要的作用就是穷尽所有的可能,是一种暴力的解题方式。
我们以一个递归问题来转换为动态规划,看看动态规划的优势在哪里。
1.1.1 动态规划经典题目 - 斐波那契数列
题目:斐波那契数列的前两个为1,后面的为当前数字的前面两个只和(fib[i] = fib[i-1] + fib[i-2]),求斐波那契数列的第i个数字。
题解1 - 递归
public int recur_fib(int i){
if (i == 1 || i == 2){
return 1;
}
return recur_fib(i-1) + recur_fib(i-2);
}
问题:
当我们需要求得数列中的某个数的时候,都会进行两次递归,但是其实在递归过程中有很多数是已经求得过的。
比如:求数列中的第5个数,首先会进入方法,然后执行recur(4) + recur(3),在执行recur(4)的时候会执行recur(3) + recur(2)。这时我们就可以发现,其实recur(3),是前面已经求过一次的,但是我们没有对这个求得的数据进行保存,因此会再重新算一次(递归一次),这个执行是重复的。这就是重复子问题。
小结:
- 因此我们可以得出,对于一些问题,我们需要通过某种方式来得到数据,并且我们将这些已经求得数据保存起来,当我们需要的时候直接使用,那么我们就可以减少不必要的运算执行。
- 并且我们可以看到斐波那契数列中除了开头的两个,后面的每一个数字的值只和它前面的两个数字有关。(fib(i) = fib(i-1) + fib(i-2) )
- 我们可以得出,需要求得的答案是在前面已有的基础上得到的。
- 递归其实可以看出来是一种第顶向下穷尽的方式,但是对于这道题,我们其实从底向上来做会更加方便(前一条观点)。
像这种所求的答案需要从前面以求得并保存的数据和通过从底向上来求得结果的方式,我们就可以通过动态规划来做。
1.2 动态规划
现在我们利用前面的结论来做这道题。
- 创建一个数组来保存数据
- 已知条件,初始赋值
- 自底向上,求得结果
- 找寻动态规划转移方程
public int dp_fib(int i){
//1.创建一个数组来保存已经求得的数据,自底向上,最后到达i,所以数组的大小为i
int[] dp = new int[i];
//2.已知条件,初始赋值
dp[0] = 1;
dp[1] = 1;
//3.自底向上
for (int j = 2; j < i ; j++) {
//4.根据已有的条件求得新的数据并保存,以便下次使用,动态转移方程
dp[j] = dp[j-1] + dp[j-2];
}
//5.返回结果
return dp[i-1];
}
注意
:数组的大小,要根据题目具体设置。这里的斐波那契数列是从1开始的,但是我们的数组的下标是从0开始的(编程语言默认数组从0开始),所以我们创建的数组长度为i,并且数组i-1就是最后找的数组中的数值。 关于动态转移方程的设置,我认为的是在找寻动态转移方程的时候,不能局限于当前已有的数据,而是找到一个比较靠后的位置,来分析找寻规律,这个结果是通过什么得到的,如果这个结果可以通过前面已得到数据来求得,那么就可以使用动态规划解题。 对于斐波那契数列,我们就可以自己在纸上将数列的前几个数据写出来,然后分析比较靠后的数值,然后来发现规律找到最后的数值是通过前面两个的和得到(当然这道题,本身题目就已经给了转移方程,如果没有的时候,我们就可以站的高一点,来找寻规律),最后设置动态规划转移方程,这个还是需要多练,多看。 转移方程一定要在比较高的位置开始看,容易发现规律。
1.3 题目练习
剑指 Offer 10- II. 青蛙跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
分析:
根据题目,初始条件就是,1个台阶的时候,只有一种跳法;
2个台阶的时候,可以每次跳1个台阶,也可以一次跳2个台阶,共两种跳法;
对于后面的从第3级台阶开始,我们就可以看成,是在它的前一个台阶跳1个台阶或者在它前面两个台阶处一次性跳2个台阶。
public int numWays(int n) {
//优化:减少代码运行次数。
if (n == 0 || n == 1){
return 1;
}
//1.创建数组,保存数据
int[] dp = new int[n+1];
//2.已知条件,默认赋值
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
注:这里的数组大小为n+1,因为不会出现0个台阶的情况,因此下标为0被占用,因此需要对数组进行加大1。
LeetCode 746. 使用最小花费爬楼梯
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。
请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。
分析:
爬楼梯的升级版。
还是一样每次都只能爬一到两个阶梯,但是如果你到达这个台阶就需要花费相应的金钱。
每一个台阶的花费不一样,并不是爬的最少就是最好的。
那么,
我们需要首先找到计算花费的方程,找方程,我们需要站的够高来看,才能看的清除。
这里我们就以终点前面的一个或者两个台阶来看,因为这道题目是我们到达这个台阶以后先支付再爬,因此到达终点的时候我们是不需要付费的。
转移方程:dp[i] = Math.min(dp[i-1],dp[i-2]) + cost[i];
首先我们需要从已保存的数据中找到前一个台阶的花费和前两个台阶的花费,然后比较大小,选择小的,再加上这层往上需要支付的。
public int minCostClimbingStairs(int[] cost) {
//优化:创建数组需要依靠传入的数据时,一般将长度保存起来便于后面使用。
int len = cost.length;
//1.创建数组,保存数据
int[] dp = new int[len];
//2.已知条件,默认赋值
/*
* 根据我们的分析和题目,我们知道到达最后一个台阶时是不需要支付费用的
* 这里我们默认赋值也是进行再到达前的赋值,从第一个台阶开始跳或从第二个台阶开始跳
* */
dp[0] = cost[0];
dp[1] = cost[1];
//3.从底向上
for (int i = 2; i < len; i++) {
//4.转移方程
dp[i] = Math.min(dp[i-1],dp[i-2]) + cost[i];
}
//5.返回结果
return Math.min(dp[len-1],dp[len-2]);
}
已知条件的初始化,一定是跟转移方程有关。
这两个一定是其中一个给另外一个服务,先设置初始条件,那么转移方程就要根据初始条件变化;先找到转移方程,同理。
推荐使用后者。
LeetCode 62. 不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
分析:
首先拿到这道题,分析这是一到简单的动态规划,还是其他的如0/1问题等。
这道题我们可以看到,到达最后的终点,有多少种可能?不用我们选择路径,而是每条路径都需要我们选择,因此这道题用简单的动态规划即可。
第一步,站的够高,来看这道题的规律。锁定终点。题目告诉我们机器人只能向右或向下移动,那么对于终点来说,只有可能从上边和从左边到达。那么动态转移方程我们可以得到了(其实和楼梯很像,只是从一次跳1个台阶或2个台阶变成了,从上或从下到达),因为问的是所有的可能性(像这种问所有可能性的问题,其实用递归可以求得)那么就将两种到达的可能性都加起来,所以最后我们得到的动态规划转移方程为:
dp [m] [n] = dp [m-1] [n] + dp [m] [n-1];
其中:m和n代表了移动的下标,m-1,n表示从左边到达终点,m,n-1表示从上边到达。
这道题还有一个小坑,就是我们可以发现如果我们初始条件只定义dp [0] [0] = 1,那么就会造成永远也得不到正确结果,这是为什么呢?
这是因为我们的动态递归循环中,是从1,1开始的,那么所有在m或n上等于0的情况都不可能被遍历到,那么对于0,m和n,0(注意:这里的m和n的意思是:从0到m和从0到n,就是第一行和第一列),永远都是0(数组中没有显示赋值,默认赋值为0),那么后面的结果就不可能正确。因此我们要显示的为第一行和第一列进行显示的赋值。
- 创建一个数组来保存数据
- 已知条件初始赋值
- 自低向上,求得结果
- 设置动态转移方程
public int uniquePaths(int m, int n) {
//1.创建一个数组,来保存每一个点的路径和
int[][] dp = new int[m][n];
//2.已知条件,初始赋值,对于第一行或第一列来说只有一直向右或一直向下才可能到达,所以只有一种情况
for(int i=0; i<n; i++){
dp[0][i] = 1;
}
for(int i=0; i<m; i++){
dp[i][0] = 1;
}
//3.自底向上,求得结果
for(int i=1; i<m; i++){
for(int j=1; j<n; j++){
//4.设置动态转移方程
dp [i][j] = dp[i][j-1] + dp[i-1][j];
}
}
//5.返回结果
return dp[m-1][n-1];
}
这里我们可以看到,对于初始赋值时,我们要关注我们的底是从何开始,开始前的值都应该显示的进行赋值。
这道题也可以用递归。
LeetCode 343. 整数拆分
这道题和剑指offer上的剪绳子其实是一样的题,只是一个数字一个是长度。
分析:
这道题在做完以后,看了后面的题解,只想说一句,数学牛逼!
但是用动态规划来还是很好做的,还是第一步找规律,站的高一点,我们直接看题目给的n时的情况。
对于n,它可以分成很多段,只要和为n就行,最后得到积最大即可。
那么对于n来说它的乘积是怎样组成的呢?
我们可以试着将n先分一部分出来,从2开始(因为1乘以任何数都是本身,那么没有必要从1开始),那么现在已经分了一段出来了,那么积为多少呢? 积 = 2 * (n-2); 但对于(n-2)来看,它又可以无限的分下去,但是这道题只要得到积最大即可,那么我们就可以把(n-2)的最大积求出来 ,然后再去求2 * dp[n-2] 的最大值。
所以我们现在可以得出第一部分的动态转移方程: dp[n] = 2 * dp[n-2]; 但是对于每次分的部分来说,这次是2,下一次分的部分就是3,那么转移方程就变为: dp[n] = 3 * dp[n-3]; 那么对于这种数字递增的那么我们就可以使用for循环来动态递增了,转变为:dp[n] = i * dp[n-i]; (i的范围从2到n-1,不能包括n,因为不可能把全部都分了吧,那么整个数字都没有,n * 0 = 0)。
好了,现在我们已经得到动态转移方程了,现在下一步,我们就应该通过自低向上来得到每一个dp[n-i]了。
public int integerBreak(int n) {
//优化:减少运行次数
if(n <= 2){
return 1;
}
//1.创建数组,保存数据,这里我们最后是要达到n的,所以数组的长度为n+1
int[] dp = new int[n+1];
//2.已知条件,默认赋值
dp[2] = 1;
//3.第一个for循环是自底向上
for(int i=3; i<=n;i++){
//第二个for循环就是将数字不断划分部分来找到最大值
for(int j=2;j<i; j++){
//4.设置动态转移方程
//注意:这里的需要用两个max,因为在数字划分的时候,是在当前for循环的,但是i是不变的,那么如果在循环中求得了最大值,就需要通过一个max嵌套来保存
//方便理解可以写成这样:int temp = Math.max(j*(i-j),j*dp[i-j])); 求得当前划分的最大值
// dp[i] = Math.max(dp[i],temp); 跟已经得到最大值进行比较,保存最大的
dp[i] = Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));
}
}
//5.返回结果
return dp[n];
}
这里的自底向上是从3开始的,因为只有数字2才可能划分数字出来。
j从2开始的,就如前面所说的,一个数字如果划分一部分为1,1乘以任何数都为本身,没有必要,所以直接从2开始划分。
1.4 动态规划(背包问题)
前面的题目和主要是在一种状态下的,下面我们研究多个状态下的动态规划问题。
1.4.1 经典题目:背包问题
问题:有一个背包给定一定的容量,现在需要装物品,每一个物品有固定的价值和大小,如何才能使背包装的价值最大?
分析:
- 每一个物品都有自己的价值和重量
- 背包的容量有限
- 每一个物品能装多次
我们还是应用前面的自底向上,不断获取数据来充实我们的数组,利用已有的数据来得到结果。
首先简化这个问题,这里提供了多个物品,那么我们可以先设定只有一个物品,那么对于背包来说只有一种选择,只有背包的容量大于物品的重量时,才能将物品装入,容量为n倍时,可以装入n个物品,最大价值也就可以相应的求出。
接下来,我们把难度加大,现在有两个物品可以选择。我们可以怎么做呢?
做前面整数拆分题时,我们是将数字先拆分出来一部分然后再算积的值,当时的转移方程是 dp[i] = Math.max(j * (i-j), j * dp[i-j]); 对于背包问题我们也可以这样思考。
现在背包容量是固定的,那么我们可以先全部只装第一种物品来求得最大值。
我们可以让背包的容量从1开始不断递增,直到给出的容量大小,然后不断装入这唯一的物品,将容量填满,将每次容量大小下的最大价值保存下来,那么可得方程:
dp[i] = v[1] + dp[i-w[1]];
注:dp[i]:背包的容量,不断增大; v[1]:第一个物品的价值;dp[i-w[1]]:剩余容量能够装入的最大价值。
现在开始考虑第二个物品的装配是否会影响已经求得的最大值,那么和数字拆分一样,我们可以先将尝试第二种物品加入(背景:第一个物品需要容量为1,价值为15;第二个物品需要容量为2,价值为20),此时装入第二个物品,那么剩下的容量就可以装物品一或物品二(只要将剩下的容量填满就行,不限制数量)。
那么对于这种情况我们可以得到:容量4的背包的最大价值 = 物品2的价值 + 剩余容量能装入物品的价值。
物品2的价值我们是可以得到的那么剩余容量能装入物品的价值是多少呢?
为了使整体得到的dp[i]最大,物品2的价值已经固定只能由后半部分剩余容量装入的物品价值最大,整体才会最大。
那么此时尝试加入物品2,那么可以得到的方程为:dp[i] = v[2] + dp[i-w[2]];
为了求得最大值,那么我们就应该和在不加入物品情况下得到的最大值进行比较,那么在不加入新物品得到的最大值是多少呢?
dp[i]本身,其实很好理解,我们是先固定物品,然后不断的扩大物品的容量,那么dp[i]本身已经保存的值就是上一个没有加入新物品时的最大值。
那么现在,尝试加入物品2以后求最大值的方程为:dp[i] = Math.max(dp[i],v[2] + dp[i-w[2]]);
但是物品不只有两个,有多个,那么我们就可以尝试不断加入新的物品,物品的下标用j表示,那么方程就可以变为:
dp[i] = Math.max(dp[i],dp[i-w[j]] + v[j]);
注:w[j]代表第j个物品的重量;v[j]代表第j个物品的价值。
我们看到有两个未知数,一个是背包容量的动态递增,一个是物品的动态加入,那么说明就需要两个for循环来动态扩展。
前面我们说了,我们是在固定物品的情况下动态扩展背包容量,这样我们就可以让新加入的物品和在没有加入这个新物品时进行比较,选取最大的。
那么就是物品的动态加入是外层for循环,背包的动态扩容是在内存for循环,现在我们就可以写我们的方法。
没有图解,只有文字,可能不太好理解。见谅。
核心:我们需要前面没有加入新物品的最大值数据,在获得数据下,进行尝试加入新的物品,来判断加入新物品后是否会对最大值影响。
public int findPackeMax(int[] value,int[] wight,int capacity){
int len = capacity;
//1.创建数组,保存数据,最后是要到达背包的容量,数组大小容量+1
int[] dp = new int[len+1];
//2.初始赋值,容量为0,那么价值也为0
dp[0] = 0;
//3.自底向上
//先遍历物品,不断加入新的物品
for (int i = 1; i < wight.length; i++) {
//再遍历背包,固定容量capacity
for (int j = 0; j <= capacity; j++) {
//只有背包的容量大于当前需要尝试加入的新物品的需要的容量才能进行装入
if (j >= i)
dp[j] = Math.max(dp[j],value[i] + dp[j-wight[i]]);
}
}
//3.返回结果
return dp[len];
}
总结:
对于背包这类物品不断加入的问题,我们需要用多个for循环来进不断加入物品和不断扩大背包容量,只有这样我们转移方程中的动态获取部分( dp[i - w[j]] ),才有可能得到最好的结果。
动态规划的核心是,利用已经求得数据,在前面获取的数据基础上得到下一个需要的解,但获取的数据不是随便来的,一定是有用的进行保存,这里有用的数据就是没有加入新物品时的最大值。
1.5 题目练习
LeetCode 518. 零钱兑换 II
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
分析:
题目中说硬币可以无限使用。
还是前面的背包问题一样,但是不用考虑价值最大,我们只需要凑出来就可以了。
还是和上面一样,固定物品,遍历价值。
注:下面我是先遍历的物品,再遍历的背包容量,对于这种无限制数量的题都是可以的。
public int change(int amount, int[] coins) {
int len = amount;
//1.创建数组,保存数据,最后要到达amount因此长度为amount+1
int[] dp = new int[len+1];
//2.已知条件,默认赋值
//这里我们认为如果和为0,是有一种情况的。事实:题目也是这么规定的
dp[0] = 1;
//3.自底向上
for(int i=0; i<coins.length; i++){
for(int j=1; j<=len; j++){
//跟背包问题一样,首先需要装的下才行,这里就是硬币的值不能超过和,如果超过那么就没有必要算
//后面并且是优化,也可以不写。只有新放入的硬币,减去以后存在组合的可能性(即不为0)才有可能组成新的组合
if(j >= coins[i] && dp[j - coins[i]] != 0){
//因为问的是所有可能性,即在不添加新硬币求得可能加上加入新硬币求得的可能的和
dp[j] = dp[j] + dp[j-coins[i]];
}
}
}
//4.返回结果
return dp[len];
}
核心:不断扩容,不断添加新的物品。
这里就是求组合,组合不要求顺序,只有能够组成就行。
排列要求顺序,既要能够组合出来,并且顺序还不能是一样。
LeetCode 322. 零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
分析:
这道题和前面很类似,但是又不尽相同。
这里要求的不是求组成价值的个数,而是需要求的是组成指定数目所需最小硬币的个数。
首先还是和前面一样,我们固定硬币的种类,然后不断扩大值的大小。
怎么找寻规律呢?先站的够高,我们来看看值最大时,加入最后一枚硬币的选项时的情况。(要求组成的值为11,有三种硬币,分别为1,2,5),此时值是11,最后一枚硬币为5,我们尝试加入,可得方程:
dp[11] = dp[11 - 5];
加入最后一枚硬币以后,现在剩下需要组成的值为7,我们就查看组成dp[7]所需要的硬币数量是多少,得到以后再加1。
因为加入的最后一枚硬币也是组成11的一个部分,那么方程为:
dp[11] = d[11-5] + 1;
那么dpp[7]从哪里来呢?还记得自底向上吗?通过价值不断扩容到7时得到的。
我们将硬币的种类和价值的大小来进行自底向上,可以得到一个方程:
dp[i] = Math.min(dp[i], dp[i-coins[j]] + 1);
注:i为最后需要组成的数,j代表硬币在数组中的下标。
然后如前面一样,先for循环物品,再for循环价值。
这道题有个坑,因为我们要求组成的个数最小,就需要使用min来求得最小值。
但是在初始化的时候,如果我们只初始化dp[0] = 0; 那么就会出现一个问题整个dp数组最后的值都是0。
这是因为,min永远会取0这个值。
这个时候我重新分析题目,发现题目说了可能会有不能组成的情况,需要返回-1,并且当没有进行硬币组合求个数的时候,数组的初始值应该为无穷大,因为我们还没有进行组合嘛,且还有可能这个值没有组合的情况。
是否能够组合和组合需要个数都不知道,且我们求的最小的组成个数,那么对比初始,如果有组合的情况,数组上的值就应该被替换,后面如果出现更小的情况,也会替换,这样我们才能获得组合个数的最小值。
class Solution { public int coinChange(int[] coins, int amount) { int len = amount; //1.创建数组,保存数据,最后会到达amount因此数组容量+1 int[] dp = new int[len+1]; //2.已知条件,初始赋值 for (int i = 0; i<=len; i++) { dp[i] = 65535; } //价值为的时候没有组成的可能性,题目要求 dp[0] = 0; //3.自底向上,扩展数组 for(int i=0; i<coins.length; i++){ for(int j=1; j<=len; j++){ if(j >= coins[i]){ //只有大于当前硬币的值才进行 dp[j] = Math.min(dp[j],1+dp[j-coins[i]]); } } } //4.返回结果,如果值为65535说明没有组合的情况返回-1 return dp[len]==65535?-1:dp[len]; }}
这道题本身就是背包问题,看似简单了,实则还是需要debug和分析题目来使题目完整。
总结:
- 一定要动态的扩展物品和背包的容量
- 可以用二维数组
- 一些可以动态加入物品和动态扩展背包容量的题都可以用动态规划尝试解答
- 自底向上,获取有用数据,不断保存,需要时使用
- 和原有、没有加入新物品时的背包进行比较,得到最优解