动态规划刷题大纲
如果某一问题有很多重叠子问题,使用动态规划是最有效的,动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的
例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大
动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系,所以贪心解决不了动态规划的问题,动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式
对于动态规划问题,Carl将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了
1、确定dp数组(dp table)以及下标的含义
2、确定递推公式
3、dp数组如何初始化
4、确定遍历顺序
5、举例推导dp数组
为什么要先确定递推公式,然后再考虑初始化呢?因为一些情况是递推公式决定了dp数组要如何初始化!
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样,如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了,如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题
这是动态规划的整体概述,讲解了什么是动态规划,动态规划的解题步骤,以及如何debug
第一遍代码,因为给出递推式了,只要用一个数组记录每个位置上的数值即可
class Solution {
public:
int fib(int n) {
int a[n+1];
a[0] = 0;
if(n >= 1) {
a[1] = 1;//如果输入为0就不能整这句了
}
int i = 2;
while(i <= n) {
a[i] = a[i - 1] + a[i - 2];
i++;
}
return a[n];
}
};
动规五部曲:
这里我们要用一个一维dp数组来保存递归的结果
1、确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2、确定递推公式
为什么这是一道非常简单的入门题目呢?
因为题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];
3、dp数组如何初始化
题目中把如何初始化也直接给我们了,如下:
dp[0] = 0;
dp[1] = 1;
4、确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2] 中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
5、举例推导dp数组
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
代码随想录C++代码如下:
class Solution {
public:
int fib(int N) {
if (N <= 1) return N;//N==1就不用往下整,没有dp[1]初值
vector<int> dp(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];
}
};
只需要维护两个数值就可以了,不需要记录整个序列,代码如下:
class Solution {
public:
int fib(int N) {
if (N <= 1) return N;
int dp[2];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= N; i++) {
int sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;//维护两个数值
}
return dp[1];
}
};
时间复杂度:O(n)
空间复杂度:O(1)
本题还可以使用递归解法来做
代码如下:
class Solution {
public:
int fib(int N) {
if (N < 2) return N;
return fib(N - 1) + fib(N - 2);
}
};
时间复杂度:O(2n)
空间复杂度:O(n),算上了编程语言中实现递归的系统栈所占空间
Carl通过一道简单的面试题,模拟面试的场景,来带大家逐步分析递归算法的时间复杂度,最后找出最优解,来看看同样是递归,怎么就写成了 O(n) 的代码
面试题:求x的n次方
想一下这么简单的一道题目,代码应该如何写呢。最直观的方式应该就是,一个for循环求出结果,代码如下:
int function1(int x, int n) {
int result = 1; // 注意 任何数的0次方等于1
for (int i = 0; i < n; i++) {
result = result * x;
}
return result;
}
为了降低时间复杂度,考虑一下递归算法,可以写出了如下这样的一个递归的算法,使用递归解决了这个问题
int function2(int x, int n) {
if (n == 0) {
return 1; // return 1 同样是因为0次方是等于1的
}
return function2(x, n - 1) * x;
}
递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数
递归了n次时间复杂度是O(n),每次进行了一个乘法操作,乘法操作的时间复杂度一个常数项O(1),所以这份代码的时间复杂度是 n × 1 = O(n)
这个时间复杂度就没有达到面试官的预期。于是又写出了如下的递归算法的代码:
int function3(int x, int n) {
if (n == 0) return 1;
if (n == 1) return x;
if (n % 2 == 1) {
return function3(x, n / 2) * function3(x, n / 2)*x;
}
return function3(x, n / 2) * function3(x, n / 2);
}
来分析一下,首先看递归了多少次呢,可以把递归抽象出一棵满二叉树。刚刚同学写的这个算法,可以用一棵满二叉树来表示(为了方便表示,选择n为偶数16),如图:
当前这棵二叉树就是求x的n次方,考虑n为16的情况,n为16的时候,进行了多少次乘法运算呢?
这棵树上每一个节点就代表着一次递归并进行了一次相乘操作,所以进行了多少次递归的话,就是看这棵树上有多少个节点
熟悉二叉树话应该知道如何求满二叉树节点数量,这棵满二叉树的节点数量就是23 + 22 + 21 + 20 = 15,可以发现:这其实是等比数列的求和公式,这个结论在二叉树相关的面试题里也经常出现
这么如果是求x的n次方,这个递归树有多少个节点呢,如下图所示:(m为深度,从0开始)
时间复杂度忽略掉常数项-1之后,这个递归算法的时间复杂度依然是O(n)。对,你没看错,依然是O(n)的时间复杂度
那么O(logn)的递归算法应该怎么写呢?
想一想刚刚给出的那份递归算法的代码,是不是有哪里比较冗余呢,其实有重复计算的部分
于是又写出如下递归算法的代码:
int function4(int x, int n) {
if (n == 0) return 1;
if (n == 1) return x;
int t = function4(x, n / 2);// 这里相对于function3,是把这个递归操作抽取出来
if (n % 2 == 1) {
return t * t * x;
}
return t * t;
}
再来看一下现在这份代码时间复杂度是多少呢?
依然还是看他递归了多少次,可以看到这里仅仅有一个递归调用,且每次都是n/2 ,所以这里我们一共调用了log以2为底n的对数次
每次递归了做都是一次乘法操作,这也是一个常数项的操作,那么这个递归算法的时间复杂度才是真正的O(logn)
第一遍代码
int climb[n+1]表示有多少种不同的方法可以爬到0~n阶
走一步的方法数加走两步的方法数,递推式正好是斐波那契数列
class Solution {
public:
int climbStairs(int n) {
int climb[n+1];//有多少种不同的方法可以爬到0~n阶
if(n == 1) {
return 1;
}
climb[0] = 1;
climb[1] = 1;
for(int i = 2; i <= n; i++) {
climb[i] = climb[i-1] + climb[i-2];
//走一步的方法数加走两步的方法数,递推式正好是斐波那契数列
}
return climb[n];
}
};
本题大家如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了
我们来分析一下,动规五部曲:
定义一个一维数组来记录不同楼层的状态
1、确定dp数组以及下标的含义
dp[i]: 爬到第i层楼梯,有dp[i]种方法
2、确定递推公式
如何可以推出dp[i]呢?从dp[i]的定义可以看出,dp[i] 可以有两个方向推出来
首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]了么
还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了么
那么dp[i]就是 dp[i - 1]与dp[i - 2]之和,所以dp[i] = dp[i - 1] + dp[i - 2]
在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏,这体现出确定dp数组以及下标的含义的重要性
3、dp数组如何初始化
再回顾一下dp[i]的定义:爬到第i层楼梯,有dp[i]种方法
那么i为0,dp[i]应该是多少呢,这个可以有很多解释,但基本都是直接奔着答案去解释的,例如强行安慰自己爬到第0层,也有一种方法,什么都不做也就是一种方法即:dp[0] = 1,相当于直接站在楼顶,但总有点牵强的成分(第一遍代码就是这样)
那还这么理解呢:我就认为跑到第0层,方法就是0啊,一步只能走一个台阶或者两个台阶,然而楼层是0,直接站楼顶上了,就是不用方法,dp[0]就应该是0
其实这么争论下去没有意义,大部分解释说dp[0]应该为1的理由其实是因为dp[0]=1的话在递推的过程中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1
从dp数组定义的角度上来说,dp[0] = 0 也能说得通
需要注意的是:题目中说了n是一个正整数,题目根本就没说n有为0的情况,所以本题其实就不应该讨论dp[0]的初始化!
dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的,所以Carl的原则是:不考虑dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义
4、确定遍历顺序
从递推公式dp[i] = dp[i - 1] + dp[i - 2];
中可以看出,遍历顺序一定是从前向后遍历的
5、举例推导dp数组
举例当n为5的时候,dp table(dp数组)应该是这样的
代码随想录 C++代码如下:
// 版本一
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) { // 注意i是从3开始的
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(n)
当然依然也可以,优化一下空间复杂度,代码如下:
// 版本二
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n;
int dp[3];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
int sum = dp[1] + dp[2];
dp[1] = dp[2];
dp[2] = sum;
}
return dp[2];
}
};
时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( 1 ) O(1) O(1)
这道题目还可以继续深化,就是一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶
这又有难度了,这其实是一个完全背包问题,但力扣上没有这种题目,所以后续Carl在讲解背包问题的时候,今天这道题还会从背包问题的角度上来再讲一遍,实现代码:
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) { // 把m换成2,就可以AC爬楼梯这道题
if (i - j >= 0) dp[i] += dp[i - j];
}
}
return dp[n];
}
};
第一遍代码,costN[n] 表示达到 0~n 阶楼梯的最低花费
对于递推式,每到新的一级台阶,就两种方案,每种方案都依托之前的最优方案
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size() + 1;
int costN[n];//达到0~n阶楼梯的最低花费
costN[0] = 0;
costN[1] = 0;//初始值
for(int i = 2; i < n; i++) {
costN[i] = min(costN[i - 2] + cost[i - 2], costN[i - 1] + cost[i - 1]);
//每到新的一级台阶,就两种方案,每种方案都依托之前的最优方案
}
return costN[cost.size()];
}
};
题目中说 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯” 也就是相当于 跳到 下标 0 或者 下标 1 是不花费体力的, 从 下标 0 下标1 开始跳就要花费体力了
1、确定dp数组以及下标的含义
使用动态规划,就要有一个数组来记录状态,本题只需要**一个一维数组dp[i]**就可以了,dp[i]的定义:到达第i台阶所花费的最少体力为dp[i],对于dp数组的定义,一定要清晰!
2、确定递推公式
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2],dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1],dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]
那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?
一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
3、dp数组如何初始化
看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么**只初始化dp[0]和dp[1]**就够了,其他的最终都是dp[0]dp[1]推出
那么 dp[0] 应该是多少呢? 根据dp数组的定义,到达第0台阶所花费的最小体力为dp[0],那么有同学可能想,那dp[0] 应该是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的话,dp[0] 就是 cost[0] 应该是1
新题目描述中明确说了 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说 到达 第 0 个台阶是不花费的,但从 第0 个台阶 往上跳的话,需要花费 cost[0],所以初始化 dp[0] = 0,dp[1] = 0
4、确定遍历顺序
最后一步,递归公式有了,初始化有了,如何遍历呢?
本题的遍历顺序其实比较简单,简单到很多同学都忽略了思考这一步直接就把代码写出来了。因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了
但是稍稍有点难度的动态规划,其遍历顺序并不容易确定下来。例如:01背包,都知道两个for循环,一个for遍历物品嵌套一个for遍历背包容量,那么为什么不是一个for遍历背包容量嵌套一个for遍历物品呢? 以及在使用一维dp数组的时候遍历背包容量为什么要倒序呢?
这些都与遍历顺序息息相关,当然背包问题后续都会重点讲解的
5、举例推导dp数组
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
如果代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样的
可以优化空间复杂度,因为dp[i]就是由前两位推出来的,那么也不用dp数组了,C++代码如下:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int dp0 = 0;
int dp1 = 0;
for (int i = 2; i <= cost.size(); i++) {
int dpi = min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
dp0 = dp1; // 记录一下前两位
dp1 = dpi;
}
return dp1;
}
};
时间复杂度:O(n)
空间复杂度:O(1)
旧力扣描述,如果按照 第一步是花费的,最后一步不花费,那么代码是这么写的,提交也可以通过
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size());
dp[0] = cost[0]; // 第一步有花费
dp[1] = cost[1];
for (int i = 2; i < cost.size(); i++) {
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
}
// 注意最后一步可以理解为不用花费,所以取倒数第一步,第二步的最少值
return min(dp[cost.size() - 1], dp[cost.size() - 2]);
}
};