前言
在这里我只想说一句,希望读者可以仔细看,看完博主可以保证,你一定会做题。
什么是动态规划?
- 动态规划(Dynamic Programing),简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
或许有人连重叠子问题是什么都不知道,就是重复的计算,我们举个很简单例子说明一下:
我们都知道斐波那契数列用递归算法得到的,我们画出图示这样的:
在这里f(2)就是一个重叠子问题,它要在递归中不止计算一遍。
- 动态规划中的每一个状态一定是由上一个状态推导出来的,这就是和贪心算法的区别,贪心算法没有状态推到,而是从局部直接选取最优解。
例如:有N件物品和一个最多承受重量W的背包。第i件物品的重量为weight[i],得到的价值是value[i],每个物品只能用一次,求把哪些物品放入背包价值最大。
动态规划中的dp[i]是由dp[j - weight[i]]推导出来的,然后去max(dp[j],dp[j - weight[i] + value[i])。
如果是贪心算法,直接拿最大或者最小就完事了。
所以贪心算法是解决不了贪心算法的问题。
其实我们没必要死扣动态规划和贪心的理论区别,你多做题自然会明白,这就是实践出真知!
而且很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题啊这些,这些东西都是教科书的上定义,晦涩难懂而且不太实用。
大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。
动态规划的解题步骤
我们很多人在做题的时候,把讲义一看,然后自己照着写一遍,AC之后,就以为自己会了。动态规划也是,几乎每道题的dp[i],都代表着不同的意思,题目一改变就不会了。
这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后看题解,然后继续照葫芦画瓢陷入这种恶性循环中。
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。
对于动态规划问题,我将拆解为如下五步曲,这三步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
可能刷过动态规划题目的同学可能都知道递推公式的重要性,感觉确定了递推公式这道题目就解出来了。
其实 确定递推公式 仅仅是解题里的一步而已!
一些同学知道递推公式,但搞不清楚dp数组应该如何初始化,或者正确的遍历顺序,以至于记下来公式,但写的程序怎么改都通过不了。
后序的讲解的大家就会慢慢感受到这三步的重要性了。
斐波那契数列
斐波那契数列是练习动态规划最好的题目,因为我们都理解它,我们都会用递归写出斐波那契数列
class Solution {
public int fib(int n) {
if (n <= 1) return n;
return fib(n - 2) + fib(n - 1);
}
}
看起来代码很少,但时间复杂度却是2^n,也就是指数递增,我相信大家都听说过一句数学中常说的话,指数爆炸。
接下来我们用动态规划来写它,我们用三部曲来写:
- 确认qb数组以及下标含义
qb[i]的含义为:第i个斐波那契数值是dp[i]
- 确定递推公式
斐波那契数列的额递推公式我们应该都知道:dp[i] = dp[i-1]+dp[i-2]
- .dp数组如何初始化
dp[0] = 0;
dp[1] = 1;
- 确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
完整代码:
- 举例推导打牌数组
按照自己的逻辑举个简单例子,成立则对,否则修改。
class Solution {
public int fib(int n) {
if (n <= 1) return n;
//用数组容器来保存解
int[] dp = new int[n + 1];
//初始化
dp[0] = 0;
dp[1] = 1;
for (int i = 2 ; i <= n ; i++) {
//递推公式
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
其实我们还可以将这个算法进一步进行优化,我们能将其空间复杂度变为O(1)
class Solution {
public int fib(int n) {
if (n <= 1) return n;
int[] dp = new int[];
int dp[0] = 0;
int dp[1] = 1;
int result = dp[0] + dp[1];
for (int i = 2 ; i <= n ; i++) {
result = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = result;
}
return result;
}
}
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入:2
输出:2
解释:有两种方法可以爬到楼顶。
1.1 阶 + 1 阶
2.2 阶
示例 2:
输入:3
输出:3
解释:有三种方法可以爬到楼顶。
1.1 阶 + 1 阶 + 1 阶
2.1 阶 + 2 阶
3.2 阶 + 1 阶
思路分析
相信这个例子大家都不陌生
爬一层楼梯有一种方法,爬两种楼梯有两种方法。
当你爬三层楼梯的时候可以由前两种状态推导出来,一次走两个或一个楼梯,这个时候就可以想到动态规划了
- 确定dp数组以及下标含义
dp[i]的含义为:爬i层楼梯有dp[i]种方法
- 确定递推公式
我们从可以从两个方向来想
假设dp[i - 1],上i-1个楼梯有dp[i-1]种方法,那么再上1个楼梯不就是dp[i]嘛
同理dp[i - 2],上i-2个楼梯有dp[i-2]中方法,那么再上2个楼梯不是就是dp[i]嘛
那么dp[i]不就是dp[i-1]与dp[i-2]的和
即dp[i] = dp[i-1]+dp[i-2];
- 确定数组如何初始化
这道题曾经在算法界掀起过一场“腥风血雨”,就是关于dp[0]初始化的问题,有的人认为是0,有的人认为是1,这是由于题目的不严谨造成的,以至于后来加了一个要求,n是正整数
使用dp[0] 定义什么都无所谓
dp[1] = 1;dp[2] = 2;
- 确定遍历顺序
从递归公式中就可以看出是,从前往后的
- 举例推导
java代码:
class Solution {
public int climbStairs(int n) {
if (n <= 1) return n;
//定义dp数组
int[] dp = new int[n + 1];
//初始化
dp[0] = 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];
}
}
我们同样可以进行优化,将空间复杂度变为O(1)
class Solution {
public int climbStairs(int n) {
if (n <= 1) return n;
int[] dp = new int[3] ;
dp[0] = 1;
dp[1] = 1;
dp[2] = 2;
for (int i = 3 ; i <= n ; i++) {
int result = dp[1] + dp[2];
dp[1] = dp[2];
dp[2] = result;
}
return dp[2];
}
}
这都是比较简单的题目,下面我们来一道稍微有难度的
leetCode377. 组合总和 Ⅳ
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
题目数据保证答案符合 32 位整数范围。
示例 1:
输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
示例 2:
输入:nums = [9], target = 3
输出:0
思路分析(一定要仔细看)
以示例1为例,nums = [1,2,3], target = 4
- 若目标值为1,那么组合{1+0},组合数为1
- 若目标值为2,那么组合{1+1,2+0},组合数为2
- 若目标值为3,那么组合{2+1,1+2,3+0},其中2+1中的2的组合数为2,1+2中1的组合数我1,所以总共4,
- 若目标值为4,那么组合{3+1,2+2,1+3},其中3有4种,2有2种,1有1种(这里为什么组合里面没有4,因为nums中不存在4)
- 若目标值为5,那么组合{4+1,3+2,2+3},其中4为7种,3为4种,2为2种(这里组合里没有1+4,nums中没有4)
其实就是目标值减去所给数组nums里的一个值,然后检查这个商值的组合种类,然后这个商在做为被减数减去nums里的一个值,target只能减去nums里面有的数值,不是想减多少就减多少,博主在这里用加来表示是为了读者好理解
这其实和跳楼梯是一个概念的,只是有些同学绕不过那个坎。
- 确定dp数组以及下标的含义
dp[i]的含义:目标值i有dp[i]种组合
- 确定递推公式
这里是一个难点,我们要将好几种情况加起来,所以我们每次都必须在原来的基础上加上某个情况
dp[i] = dp[i] + dp[i - nums[j]]
- 确定数组如何初始化
这个很简单dp[0] = 1
- 确定遍历顺序
从前往后依次遍历,这个没什么好解释的
- 举例说明
其实最后一步就是用来验证的,目的是为了大家写完代码别急着提交,自己按着自己写的代码代入数验证一遍,这有利于我们更一步了解逻辑
java代码:
class Solution {
public int combinationSum4(int[] nums, int target) {
//创建数组容器
int[] dp = new int[target + 1];
//初始化
dp[0] = 1;
for (int i = 1 ; i <= target ; i++) {
for (int j = 0 ; j < nums.length ; j++) {
if (nums[j] <= i) {
//递推公式
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
}
若有误,请指教!