本文要为大家带来的是dp动态规划,相信这是令很多同学头疼的一个东西,也是在大厂面试中很喜欢考的一个经典算法
本文总共会通过四道题来逐步从浅至深地带读者逐步认识dp动态规划
首先在讲解题目之前,我们要先来说说动态规划理论基础,让大家知道到底什么是【动态规划】
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大
dp
的状态,通过dp[j-weight[i]]
推导出dp[j]
,然后得出 状态转移方程max(dp[j], dp[j - weight[i]] + value[i])
,才能计算出最终的结果所以大家在做题的时候只要牢牢记住它们而言的最本质区别即可,题目刷多了,自然也就很好区分
清楚了动态规划的基本概念后,我们再来看看在解决动态规划的问题时的解题步骤
一些同学可能想为什么要先确定递推公式,然后再考虑初始化呢?
所以我们要先根据dp的状态,来推导出状态转移方程,接着再通过去分析这个方程,然后推敲出dp数组要怎样去做一个初始化才比较合适,不会导致越界的问题。所以对于那些只知道记公式但是不知道如何初始化和遍历数组的同学,就会显得尴尬
相信大家在写代码的时候一定会出现各种各样的Bug,那要如何去解决呢,还记得之前的 LeetCode转VS调试技巧 吗?
在了解了一些动态规划的基础理论之后,我们还是要进入实际的问题
原题传送门:力扣509.斐波那契数
首先第一道动态规划的入门题必须选择的就是【斐波那契数】,下面我会给出三种解法
class Solution {
public:
int fib(int n) {
if(n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
};
接下去的话就是我们本题的重点即 动态规划 的解法
n + 1
的数组vector<int> dp(n + 1);
从左向右
dp[i] = dp[i - 2] + dp[i - 1];
dp
数组去做一个初始化的工作,此时我们需要去考虑的就是这个越界的问题,因为当前的dp[i]
依赖的是前两个数,所以若此刻的i == 0
的话,前两个数就会发生越界的情况;若是i == 1
,第一个数就会发生越界,所以 我们要对前两个数做一个初始化的操作dp[0] = 0, dp[1] = 1;
2
的地方开始向后遍历for(int i = 2; i <= n; ++i)
{
dp[i] = dp[i - 2] + dp[i - 1];
}
dp[n]
return dp[n];
以下即为整体代码展示
class Solution {
public:
int fib(int n) {
if(n <= 1) return n;
// 1.创建dp表
vector<int> dp(n + 1);
// 2.初始化
dp[0] = 0, dp[1] = 1;
// 3.遍历填表
for(int i = 2; i <= n; ++i)
{
dp[i] = dp[i - 2] + dp[i - 1];
}
// 4.返回值
return dp[n];
}
};
读者可以通过执行结果来看看dp动态规划解法的优势
再下面一种方法则是通过 滚动数组 的形式进行求解
int dp[2];
for(int i = 2; i <= n; ++i)
{
sum = dp[0] + dp[1];
// 迭代
dp[0] = dp[1];
dp[1] = sum;
}
其余的没有大变化,代码如下:
class Solution {
public:
int fib(int n) {
if(n <= 1) return n;
int sum = 0;
int dp[2]; // 一维数组模拟
dp[0] = 0, dp[1] = 1;
for(int i = 2; i <= n; ++i)
{
sum = dp[0] + dp[1];
// 迭代
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1]; // 最后累加的结果存入了dp[1]
}
};
稍做了一些优化,看看运行效率
原题传送门:力扣1137.第 N 个泰波那契数
看完斐波那契数之后,我们再来看一个东西叫做【泰波那契数】,不知读者有否听说过呢?
1、题目解读
首先我们来解读一下本题的意思
Tn+3 = Tn + Tn+1 + Tn+2
,读者可能看不太懂,我们将其做一个转换为Tn = Tn-1 + Tn-2 + Tn-3
,即把所有n都统一-3
。那么第N个泰波那契数就等于前面3个泰波那契数的和T1 + T2 + T3 = 4
2、解题方法
看完了上面对于题目的分析之后,下面我将介绍两种解法
① dp动态规划
首先的话就是本题需要掌握的重点即【动态规划】的解法,我们要分成五步去求解
dp
数组的,那么对于【状态表示】的含义就是dp表里的值所表示的含义
那这个状态表示是怎么来的呢?
① 第一个呢就是按照题目要求来,即dp[i]
表示的就是第i个泰波那契数列的值
② 第二个呢则是需要读者有丰富的刷题经验,可以读完题目之后就得出对应的结果
③ 第三个呢则是在分析问题的过程中,发现重复的子问题
如果读者之前有接触过类似的动态规划问题的话,就会看到一些题解里讲:这道题的 状态表示 是怎样的,然后就直接讲本题的 状态表示方程,根本没有说这道题的状态表示是怎么来的。这个得来的过程我会在其他动态规划的题目中进行讲解
所以读者在解类似的问题时一定要知道下面的这个【状态表示方程】是怎么来的,做到 “ 知其然,知其所以然 ”
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
0
的话,那么i - 1
、i - 2
、i - 3
这些就会造成 越界dp[0]
、dp[1]
、dp[2]
分别初始化为【0】【1】【1】,那我们在后面遍历计算的时候就只需要从下标为3的位置开始即可 dp[0] = 0, dp[1] = dp[2] = 1;
for(int i = 3; i <= n; ++i)
{
// 状态转移方程
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
return dp[n]
即可但是呢,若只考虑上面的这一些,在提交的时候是会出现越界的情况,因为在题目中给出的n的范围为
[0, 37]
,因此对于dp[0]
还好说,但对于后面的数据就会出现越界的情况
因此我们还需要去考虑一些边界的问题
// 边界问题处理
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
整体代码会在最后给出
② 迭代优化✔
看完上面这一种,我们再来看看其是否可以去做一个优化
a
、b
、c
,它们累加后的值可以放到变量d
中a = b; b = c; c = d;
d
return d;
3、复杂度
对于第一种dp的解法,其时间复杂度为 O ( n ) O(n) O(n),而对于第二种迭代的解法时间复杂度为 O ( 1 ) O(1) O(1)
对于第一种dp的解法,其空间复杂度为 O ( n ) O(n) O(n),而对于第二种迭代的解法时间复杂度为 O ( 1 ) O(1) O(1)
所以就这么对比下来迭代优化的方法还是值得大家去掌握的
4、Code
首先是第一种dp动态规划的解法
class Solution {
public:
int tribonacci(int n) {
// 边界问题处理
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
// 1.创建dp表
vector<int> dp(n + 1);
// 2.初始化
dp[0] = 0, dp[1] = 1, dp[2] = 1;
// 3.填表
for(int i = 3; i <= n; ++i)
{
// 状态转移方程
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
// 4.返回值
return dp[n];
}
};
然后的话是第二种利用迭代优化的方法
class Solution {
public:
// 空间优化
int tribonacci(int n) {
// 特殊情况处理
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
int a = 0, b = 1, c = 1, d = 0;
for(int i = 3; i <= n; ++i)
{
d = a + b + c;
// 迭代
a = b; b = c; c = d;
}
return d;
}
};
原题传送门:力扣70.爬楼梯
我们再来看一道和斐波那契数很像的题目,看了代码后你就会有这样的感觉
dp动态规划
dp[1]
和dp[2]
,而不是去初始化dp[0]
,因为台阶并没有0号台阶,而是从1号开始class Solution {
public:
int climbStairs(int n) {
if(n <= 2) return n;
vector<int> dp(n + 1);
dp[1] = 1; // 从第一层楼梯开始初始化
dp[2] = 2;
for(int i = 3; i <= n; ++i)
{
dp[i] = dp[i - 2] + dp[i - 1];
}
return dp[n];
}
};
原题传送门:面试题0801.三步问题
看完了爬楼梯之后,我们再来做一个进阶,解决一个三步问题
[1]
号台阶有1种方法,到[2]
号台阶有2种方法,分别是1 1
和0 2
,到[3]
号台阶则是有1 1 1
、0 2 1
、0 1 2
、0 3
,可以看做是【1 + 1 + 2 = 4】,那么以此类推到达第4号台阶即为【1 + 2 + 4 = 7】那分析完题目后我们就要根据动规五部曲来完成对题目的分析
dp[i]
表示的就是 到达 i 位置时,一共有多少种方法i
位置往前推出i - 3
、i - 2
、i - 1
这三个位置i
的位置即为dp[i - 1]
、dp[i - 2]
、dp[i - 3]
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
i == 1
的话,前面的三个数就会发生越界的问题i
位置的值依赖于前3个值,所 以我们的填表顺序就需要【从左往右】来进行dp[n]
class Solution {
public:
int waysToStep(int n) {
// 1.创建dp表
vector<int> dp(n + 1);
// 2.初始化
dp[1] = 1, dp[2] = 2, dp[3] = 4;
// 3.填表
for(int i = 4; i <= n; ++i)
{
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
// 4.返回值
return dp[n];
}
};
// 考虑越界问题
if(n == 1 || n == 2) return n;
if(n == 3) return 4;
signed integer overflow —— 有符号数的整数溢出
原题:结果可能很大,你需要对结果模1000000007
这里我们先去定义一个常量,因为对于1000000007
它的值的即为1e9 + 7
// 定义常量
const int MOD = 1e9 + 7;
然后在填表遍历的时候就可以去对所计算出来的值做一个取余的操作
dp[i] = ((dp[i - 1] + dp[i - 2]) % MOD + dp[i - 3]) % MOD;
以下即为整体的代码展示:
class Solution {
public:
int waysToStep(int n) {
// 定义常量
const int MOD = 1e9 + 7;
// 考虑越界问题
if(n == 1 || n == 2) return n;
if(n == 3) return 4;
// 1.创建dp表
vector<int> dp(n + 1);
// 2.初始化
dp[1] = 1, dp[2] = 2, dp[3] = 4;
// 3.填表
for(int i = 4; i <= n; ++i)
{
dp[i] = ((dp[i - 1] + dp[i - 2]) % MOD + dp[i - 3]) % MOD;
}
// 4.返回值
return dp[n];
}
};
然后就看到很顺利地通过了
原题传送门:746.使用最小花费爬楼梯
知道了怎么去爬楼梯之后,我们再来做一个进阶:如何利用最小的花费去爬楼梯,本题很锻炼大家的dp思维,准备好,发车了
cost[n]
的位置才对接下去我们就可以开始通过一步一步地去进行求解
dp[i]
所表示的含义是什么,即到达i位置时的最小花费
i
的位置之前或者之后的状态去推导出【状态转移方程】,来表示dp[i]
dp[i]
转换为了两个子问题:
i - 1
位置,然后支付 cost[i - 1]
,走一步i - 2
位置,然后支付 cost[i - 2]
,走二步dp数组 —— 到达 i 位置的最小花费来表示
;后者的话则可以转换为cost数组 —— 从每个台阶上爬需要支付的费用
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
0号
和 1号
台阶的时候我们是不需要支付任何费用的,并且为了防止不越界,我们在初始化的时候应该令dp[0] = dp[1] = 0
dp[i]
是依赖于dp[i - 1]
和dp[i - 2]
,所以我们需要先算出前面的2个数才能去得到这个dp[i]
,因此这个填表的顺序应该是 从左向右dp[n]
了class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
if(n <= 1) return n;
// 1.定义dp数组
vector<int> dp(n + 1);
// 2.初始化
dp[0] = dp[1] = 0;
// 3.填充dp数组
for(int i = 2; i <= n; ++i)
{
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
// 4.返回结果
return dp[n];
}
};
以下是提交后的结果
看完解法一之后,我们再来看看解法二
dp[i]
表示的 从i位置出发,到达楼顶时的最小花费
dp[i]
表示为到达i位置时的最小花费
i
位置出发也可以分为两种
i + 1
位置到终点i + 2
位置到终点cost[i] + dp[i + 1]
cost[i] + dp[i + 2]
dp[i] = min(cost[i] + dp[i + 1], cost[i] + dp[i + 2]);
接下去我们需要考虑的就是这个初始化的问题,首先读者要清楚的是我们在开这个
dp
数组的时候大小应该是多大呢?是n
呢,还是n + 1
呢?
[n]
,为什么?原因就在于我们到达n
级台阶的时候是不需要支付任何费用的,即dp[n] = 0
,所以去开出这个空间也是浪费的,所以这一块地方应该是作为 楼梯顶部 才对dp[n - 1]
到这个顶部的位置所需要支付的费用即为cost[n - 1]
,那么从这个dp[n - 2]
到这个顶部的位置所需要支付的费用即为cost[n - 2]
dp[i + 1]
、dp[i + 2]
的值,才可以去推导出dp[i]
的值,所以我们的填表顺序应该是从右往左的才对dp
数组表示的是 从第i
个位置出发到达楼顶的最小花费[0]
或第[1]
个位置出发,所有最后的结果就是取这两个位置所需花费的最小值class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
// 1.定义dp数组
vector<int> dp(n);
// 2.初始化【防止越界】
dp[n - 1] = cost[n - 1], dp[n - 2] = cost[n - 2];
// 从后往前遍历
for(int i = n - 3; i >= 0; --i)
{
dp[i] = min(dp[i + 1] + cost[i], dp[i + 2] + cost[i]);
}
return min(dp[0], dp[1]);
}
};
来看看优化后的效率
力扣91. 解码方法
最后再来一道压轴题,本题是力扣里中等难度的题目。AC本题,可以让你对dp的理解进一步提升
s
,然后要我们去对这个字符串做解码的操作,规则如下
'A' -> "1"
'B' -> "2"
'C' -> "3"
'Z' -> "26"
了解了题目的基本规则后,我们通过分析示例来进一步理解题目
s = “12”
,那我们的解码方式就有 两种,一种是“A B”(1,2)
,一种则是L(12)
s = “226”
,那我们的解码方式就有 三种,一种是“B B F”(2,2,6)
,第二种是V F(22,6)
,第三种呢则是B Z(2,26)
s = “06”
,对于这个而言其实是存在问题的,因为“06”
是无法映射到【F】的,只有“6”
才可以映射到【F】,所以对于这种并不存在解码的方式好,当我们分析完题目中所给到的示例后,就需要通过dp动态规划来解决本题,接下去就是算法原理的讲解
dp[i]
则表示 以 i 位置为结尾时的解码方法总数i
这个位置去做一个解码,第二种呢则是i - 1
和i
这两个位置去结合,结合完之后再去做解码的操作i + 1
这个位置呢?这个的话你就要反上去继续看我们所讲到的这个状态表示了。因为我们考虑的是 以 i 位置为结尾时的解码种数,对于后面的情况此时还没有考虑到,所以呢我们不需要去理会i + 1
这个位置dp
数组的时候就就分为以下两种情况下:
s[i]
去单独解码,分为两种情况,那对于单独的一个数其取值就有1 ~ 9
的可能性,如果这个数为0
的话则表示解码失败s[i - 1]
与s[i]
合并去解码的话,我们就需要去考虑两个数了,第一个数要比10
来得大,否则的话就要出现我们上面所讲到的06
这种情况,第二个数的话要比26
来的小,若二者都满足这种情况的话,则可以解码成功;否则的话就表示解码失败i
这个位置单独去做解码的话,其实就相当于把[0, i - 1]
解码的所有的方案数后面统一加一个字符就可以了,具体如下图所示i - 1
为结尾的解码总数就可以表示为dp[i - 1]
[0, i - 2]
这段区间中的解码总数,即dp[i - 2]
接下去我们就来考虑初始化的问题
dp[0]
和dp[1]
这两个位置的值,对于dp[0]
来说,我们是对单个位置上的数去做一个解码,那出现的情况也就只有【0】和【1】两种,数据的范围要在0 ~ 9
之间dp[1]
来说,我们要对合并的两数去做一个解码,那此时就会存在3种情况
[0]
即为这二者都不存在的时候[1]
即为这二者中只有一者存在的情况[2]
即为二者都存在的情况接下去我们再来看填表顺序
从左往右
dp[i] = dp[i - 1] + dp[i - 2]
最后的话是关于返回值这一块,因为我们要找的是到第 i 个位置的解码总数,不过题目给出的是一个字符串,对于字符串的最后一个位置是
n - 1
,那么我们最后返回的结果dp[i - 1]
由于本题代码比较得复杂,所以接下去分步骤讲解一下
dp
表// 创建dp表
int n = s.size();
vector<int> dp(n);
dp[0]
,还记得我们上面分析的吗,当这个编码的范围在1 ~ 9
之间的时候,所以在这里我们可以采取一个逻辑判断,让dp[0]
只接收当前s[0]
这个字符的值不为0的情况// 初始化dp[0]
dp[0] = (s[0] != '0');
dp[1]
,首先考虑到两数单独编码的情况,若均不为0的话则表示可以进行解码// 1.两数单独编码
if(s[0] != '0' && s[1] != '0')
dp[1] += 1;
- ‘0’
转换为数字才可以,接下去根据我们刚才所分析的,去判断这个数是否在符合的范围内,若是的话才表示可以解码// 2.两数结合
int t = (s[0] - '0') * 10 + s[1] - '0'; // 字符串转数字
if(t >= 10 && t <= 26)
dp[1] += 1;
dp[0]
和dp[1]
已经做了初始化,所以我们从第3个位置开始即可,然后你就可以发现这个填表的情况和我们在初始化时的场景非常类似,这里就不细说了// 填表
for(int i = 2;i < n; ++i) // 从第三个位置开始遍历
{
// 单独编码的情况
if(s[i] != '0') dp[i] += dp[i - 1];
// 两数结合的情况
int t = (s[i - 1] - '0') * 10 + s[i] - '0';
if(t >= 10 && t <= 26) dp[i] += dp[i - 2];
}
dp[n - 1]
即可return dp[n - 1];
以下是整体的代码展示:
class Solution {
public:
// 状态转移公式
// dp[i] = dp[i - 1] + dp[i - 2]
int numDecodings(string s) {
// 创建dp表
int n = s.size();
vector<int> dp(n);
// 初始化dp[0]
dp[0] = (s[0] != '0');
// 处理边界情况
if(n == 1) return dp[0];
// 初始化dp[1]
// 1.两数单独编码
if(s[0] != '0' && s[1] != '0')
dp[1] += 1;
// 2.两数结合
int t = (s[0] - '0') * 10 + s[1] - '0'; // 字符串转数字
if(t >= 10 && t <= 26)
dp[1] += 1;
// 填表
for(int i = 2;i < n; ++i) // 从第三个位置开始遍历
{
// 单独编码的情况
if(s[i] != '0') dp[i] += dp[i - 1];
// 两数结合的情况
int t = (s[i - 1] - '0') * 10 + s[i] - '0';
if(t >= 10 && t <= 26) dp[i] += dp[i - 2];
}
return dp[n - 1];
}
};
以下是执行结果
最后来总结一下本文所学习的内容
以上就是本文所要介绍的所有内容,感谢您的阅读