什么是动态规划?
动态规划,英文为Dynamic Programing,简称dp,常用于解决一个问题有若干重叠子问题的题目。动态规划中每一个状态都是从上一个状态推导出来了,这一点区分于贪心,同时由这一点得出递推公式,即状态转移方程。
如何使用动态规划的思想解题?
使用dp解题,有五个步骤:
(1)确定dp数组及其下标的含义(定义一个dp数组,用带有下标的句子解释这个数组)
//假设有这样一个问题:
/*假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?*/
//那么我定义出的dp[i]的含义为“爬到第i层有dp[i]种方法”
(2)确定递推公式(分析题意及示例写出递推公式)
(3)确定dp数组如何初始化(根据递推公式,初始化dp数组)
(4)确定递推顺序(根据递推公式,确定递推顺序)
大部分题目都是从前往后递推,但也会有很少一部分题目(如背包问题的压缩版)需要从后往前递推才是合理的。至于二维dp[i][j],要注意先遍历i还是先遍历j,还是两者都可以。
(5)打印dp数组
这一点非必须(但其实常常用到),仅在debug的时候可能用到。
有大佬将上述5个步骤成为“动规五部曲”,我也依葫芦画瓢了。
当代码AC失败时如何debug?
将打印出来的dp数组和自己推导的dp数组比较,
如果打印结果和推导结果是一样的,说明思路没有问题,那么看看是不是递推公式、初始化或者遍历顺序有问题;
如果打印结果和推导结果不一样,那可能就是思路有问题了。
简单的动态规划例题:
4.1斐波那契数
题目:斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。
示例1:
输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例2:
输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例3:
输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
0 <= n <= 30
分析题目:
使用动规五部曲:
(1)确定dp数组及下标的含义:
dp[i]第i个表示斐波那契数的值是dp[i]。
(2)确定递推公式:
这个题写的很清楚——“后面的每一项数字都是前面两项数字的和”,那么递推公式就为“dp[i]=dp[i-1]+dp[i-2]”
(3)dp数组如何初始化:
由递推公式及题目“该数列由 0 和 1 开始”可知,数组的初始化如下:
dp[0] = 0;
dp[1] = 1;
(4)确定递推顺序:
从递推公式dp[i]=dp[i-1]+dp[i-2]”可以看出,每一项都是由前两项共同推导出来的,即前面的值确定后面的值,同时初始化条件是i=0和i=1,处于最考前的位置,那么我们可以从前往后遍历。
(5)打印dp数组:
根据斐波那契数列的概念,可以推出,前10个dp数组应该是这样:
1 1 2 3 5 8 13 21 34 55
2.C/C++代码如下:
#include<bits/stdc++.h>
using namespace std;
int N=30;
//定义dp数组
vector<int> dp(N+1);//从第0个数到第n个数,一共有n+1个数,开数组也要开这么大,同时dp数组中每个元素的默认值是0
int main(){
int n;
cin >> n;
//初始化dp数组
dp[0]=0;
dp[1]=1;
//遍历
if(n<=1) cout << n << endl;
for(int i=2;i<=n;i++){
//写递推公式
dp[i]=dp[i-1]+dp[i-2];
//打印dp数组
//cout << dp[i] << ' ';
}
cout << dp[n] << endl;
return 0;
}
3.注意:写代码的时五个步骤的顺序应该是:
(1)定义dp数组
(2)初始化dp数组
(3)遍历
(4)写递归公式
(5)打印dp数组
与分析题目的时候略有不同。
4.2爬楼梯
题目:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例1.
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例2.
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
提示
1 <= n <= 45
分析题目
(1)确定dp数组
题目中说“有多少种不同的方法可以爬上楼顶”,那么我可以定义一个dp[i]数组,表示从楼下开始向上爬,爬到第i阶有dp[i]种不同的方法。
(2)确定递推公式
由于题目设定“每次可以爬 1 或 2 个台阶”,那么假如我们想要爬到第i阶,可以从第i-1阶下一次迈步,爬1阶到第i阶,也可以从第i-2阶下一次迈步,爬2阶到第i阶,那么根据数学概率的基本原理——加法原理,我们可以写出递推公式:dp[i]=dp[i-1]+dp[i-2](其实就是斐波那契数列)
(3)初始化dp数组
由于n是正整数,我们不需要考虑i=0的情况
由递推公式可知每一项dp都是由数组中该项的前两项推导出来,将这个逻辑推到极致,可知我们至少需要知到dp[1]和dp[2],因此dp数组的初始化如下:
dp[1]=1;
dp[2]=2;
(4)确定遍历顺序
由(3)可知,遍历顺序由前到后
(5)打印dp数组
经推导,dp数组的前5项如下:
1 2 3 5 8
C/C++代码如下
#include<bits/stdc++.h>
using namespace std;
int N=45;
vector<int> dp(N);
int main(){
int n;
cin >> n;
dp[1]=1;
dp[2]=2;
if(n<=2) cout << n << endl;
for(int i =3; i<= n;i++){
dp[i]=dp[i-1]+dp[i-2];
//打印dp数组
//cout << dp[i] << ' ';
}
cout << dp[n] << endl;
return 0;
}
4.3最小花费爬楼梯
题目:
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。
示例 2:
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
提示:
2 <= cost.length <= 1000
0 <= cost[i] <= 999
分析题目:
动规五部曲
(1)确定dp数组:
题目中说“请你计算并返回达到楼梯顶部的最低花费”,那么我就定义一个dp[i],表示到达第i层阶梯时的最低花费为dp[i]
(2)确定递推公式:
这是上一个问题的变种,加了一个cost数组,表示在当前层i若要向上爬需要有一定的花费,即cost[i],通过cost数组的长度,我们可以知道到达楼梯顶部,其实是从第i个阶梯向上再爬一次,进行一定花费,顶部的编号其实是i+1。
结合对上一个问题的分析,可知第i层的最少花费可以由第i-1层或第i-2层的最少花费+继续向上爬一步的花费结合加法原理推导出来,即dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
(3)确定初始化条件:
由递推公式可知,每一项都是由该项的前两项推导出来,将这个逻辑推到极致,那么我么至少需要直到dp[0]和dp[1]的值,那么该题的初始化条件如下:
dp[0]=0;
dp[1]=0; //在没有看清题目要求前,我写的是dp[1]=cost[1],以为只能从下标为0的阶梯开始
因为是可以从下标为0或者下标为1的阶梯直接出发的,可以没有经历向上爬的过程,所以最少的花费是0
(4)确定遍历顺序:
由于每一项都是由前两项推导出来的,由前导后,因此遍历顺序也是从后向前
(5)打印dp数组
很容易地将示例2中到达各个阶梯地最小花费写出来:
输入:cost = [1,100,1,1,1,100,1,1,100,1],1一共有10个阶梯需要爬
//数组下标 0 1 2 3 4 5 6 7 8 9
输出:6
dp[0]=dp[1]=0,dp[2]=1,dp[3]=dp[4]=2,dp[5]=dp[6]=3
dp[7]=dp[8]=4,dp[9]=5,dp[10]=6
C/C++代码:
#include<bits/stdc++.h>
using namespace std;
vector<int> cost; //建立一个动态的cost数组,长度暂时不定
vector<int> dp(cost.size()+1); //cost数组的长度+1,就事dp数组的长度,原因是我们的dp数组的下标是从0到cost.size()的,数组的大小应该是cost.size()+1
int main(){
//向cost数组中输入数据
while(1){
int m;
cin >> m;
cost.push_back(m); //将当前输入地整数压入cost数组中
if(char c=getchar()=='\n') break; //读到回车停止输入
}
dp[0]=0;
dp[1]=0;
for(int i=2; i<=cost.size();i++){
dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
//打印dp数组
//cout << dp[i] << " " ;
}
cout << dp[cost.size()]<< endl;
return 0;
}
这次我的代码采用的是传统的OJ方式,所以不能直接在力扣上AC,要抽象成函数才可以通过AC,后续题目我会提供传统OJ和适应力扣的OJ方式的代码。