文章目录
什么是动态规划?
动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更小的子问题来求解的算法设计技术。动态规划通常应用于有重叠子问题和最优子结构性质的问题。其基本思想是将问题分解成子问题,分别求解这些子问题,并将其结果保存起来以供后续使用,从而避免重复计算。
动态规划的关键步骤包括:
- 定义子问题:将原问题分解成若干个子问题。
- 确定状态和状态转移方程:通过递推公式或状态转移方程,确定子问题之间的关系。
- 确定初始条件和边界条件:确定递推的起点。
- 自底向上或自顶向下求解:通过保存子问题的解(通常使用数组或表格),从最基本的子问题开始逐步求解最终问题。
动态规划的应用场景:
- 斐波那契数列:通过保存已经计算过的斐波那契数,避免重复计算。
- 背包问题:解决在容量有限的背包中如何选择物品使得总价值最大化的问题。
- 最长公共子序列(LCS):寻找两个序列的最长公共子序列。
- 矩阵链乘法:确定矩阵链乘法的最优计算顺序。
- 最短路径问题:例如Dijkstra算法和Floyd-Warshall算法。
动态规划的两种实现方式:
- 自顶向下(Top-Down):也称为记忆化搜索,利用递归的方式自顶向下解决问题,同时使用备忘录(数组或哈希表)存储已经计算过的结果。
- 自底向上(Bottom-Up):也称为迭代法,从最基本的子问题开始,逐步解决较大的子问题,最终得到原问题的解
这次我们主要讲的是斐波那契数列模型,这种线性dp模型 还是很简单的。
斐波那契数列模型的dp问题应该如何分析?
首先我们我们要知道在2动态规划中存在状态这个词,状态(State)是指在问题求解过程中用于描述当前子问题的一个特定条件或情形。状态通常包含所有必要的信息,以便能够通过状态转移方程(递推公式)从一个状态转移到另一个状态,从而一步步构建最终问题的解。
比如我们求解斐波那契数列的第 n项,这时状态表示就看一看做 dp[n]
,我们求的就是dp[n]
,dp[n]
就表示斐波那契数列的第n项。所以第一步,我们首先要确定状态表示,这个dp状态表示的是什么?
第二步就是推出状态转移方程,什么是状态转移方程呢?这个名字听起来很抽象,其实可以理解成一个关系式,可以用前面推出后面,或者用后面推出前面的一个关系式子。在斐波那契数列中很容易可以知道斐波那契的状态转移方程就是:dp[i]=dp[i-1]+dp[i-2]
,推导出状态转移方程之后我们就要考虑初始化的问题,因为对于一个dp数组中,如果我们的状态转移方程中的i取到1的时候dp[i-1]
和dp[i-2]
是会越界的,所以对于这道题的初始化问题,我们就只需要初始化dp[0]
和dp[1]
,初始化这个步骤之后,我们应该考虑一下填表的顺序的问题,对于斐波那契数列来说,我们求得是斐波拉契数列的第n项的值,所以这里我们的填表顺序应该是从左到右填表,最后我们应该考虑的是返回值的问题,求的是斐波拉契数列的第n项,所以最后的返回值应该是dp[n],做斐波拉契数列模型大概就是这几步。
有关题目
1.第n个太波那契数
题目链接
题目:
样例输出和输入:
这道题的题意很简单就是让我们求太波那契数列的第n项
算法原理:
还是根据上面讲的,首先确定状态表示,这道题我们要求太波那契数列的第n项的值,所以这道题的dp[i]就表示太波那契数列的第n项的值。
第二步就是确定状态转移方程,这道题的状态转移方程很简单,题目已经告诉我们了dp[i]=dp[i-1]+dp[i-2]+dp[i-3]
这就是这道题的状态转移方程。。
第三步就是初始化问题,由于当i为0的时候,dp[-1]和dp[-2]还有dp[-3]都越界了,所以这道题初始化得初始化三个数,把前三个数初始化了,根绝题目前三个dp的值应该是0,1,1。
第四步就是填表顺序,,很显然这道题的填表顺序是从左往右填表。
第五步是返回值的问题,返回值肯定是返回dp[n].
代码展示:
cpp
class Solution {
public:
int tribonacci(int n) {
//处理边界问题
if(n==0)
{
return 0;
}
if(n==2||n==1)
{
return 1;
}
//创建dp表
vector<int> dp(n+1);
//初始化
dp[0]=0,dp[1]=1,dp[2]=1;
//填表
for(int i=3;i<=n;i++)//O(N)
{
dp[i]=dp[i-1]+dp[i-2]+dp[i-3];
}
//确定返回值
return dp[n];
}
};
运行结果:
2.三步问题
题目链接
题目:
样例输出和输入:
这道题的题目意思很简单就是一个孩子一次可以上1阶台阶或者2阶台阶或者3阶台阶,求这个孩子上台阶有多少种方法,题目给出的n是有多少个台阶,首先我们来看示例1,三个台阶,我们先推一下:
算法原理:
根据上面题目的意思我们已经差不多知道算法原理了。
第一步:确定状态表示,这道题求的是走到第1n个台阶的方法,所以状态表示就是dp[i],dp[i]就表示到达第i个台阶的方法。
第二步:状态转移方程,状态转移方程我们只需要看最近的几个变量,对于dp[i]来说dp[i-1]和dp[i-2]还有dp[i-3]就是最近,可以看见,dp[i]其实是前面几种状态走三步或者1步或者2步的方法之和,也就是说dp[i]=dp[i-1]+dp[i-2]+dp[i-3]
第三步:初始化问题,初始化也很容易看出来,只修要初始化dp[0],dp[1],dp[2]这三个dp表的值即可。
第四步:填表顺序,很显然这道题的填表顺序是从前往后。
第五步:返回值,这道题的返回值是返回到第n个台阶的方法,也就是返回dp[n];
代码展示:
cpp
class Solution
{
public:
int waysToStep(int n)
{
const int MOD=1e9+7;
if(n==1||n==2)
{
return n;
}
if(n==3)
{
return 4;
}
vector<int> dp(n+1);
dp[1]=1,dp[2]=2,dp[3]=4;
for(int i=4;i<=n;i++)
{
//第一个加法取模,第二个加法也要取模,因为数据量很大
dp[i]=((dp[i-1]+dp[i-2])%MOD+dp[i-3])%MOD;
}
return dp[n];
}
};
运行结果:
3.使用最小花费爬楼梯
题目链接
题目:
样例输出和输入:
这道题cost数组中的每个值表示从当前值开始爬向上爬一个或者两个台阶需要体力,这道题让我们求最小的体力花费,首先我们来看看第一个例子,首先,在做题之前我们要搞清楚梯子的顶在哪里,示例一的最小花费是115也就是从第二个值快开始爬爬到顶需要15体力,如果顶是20的话,这要看好像也么什么不对的,但是如果顶是20,我们从10开始爬不是也能到达20吗,那最小的花费步应该是10吗,所以这里顶应该是在20右边,这才合理,搞清楚题之后,我们来看看示例二:
算法原理:
第一步:确定状态表示,这道题让我们求爬到顶的最小花费,我们dp[i]可以表示为到达第i个台阶的最小花费。
第二步:确定状态转移方程,求出dp[i],很显然爬到dp[i]有两种方法一种是从第i-1个阶梯到i,还有一种是从第i-2个阶梯到第i个阶梯,所以我们只需要看这两个阶梯谁的最小花费更小,再加上i-2和i-1的当前花费就可以求出第i个阶梯的最小花费了。
从上图可以得到最小花费:dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
第三步初始化,这道题初始化需要初始化dp[0]和dp[1],因为是从这两个台阶起步的,所以当前花费就是零,这两个dp值只需要初始化为0。
第四步填表顺序,填表顺序只需要从左向右填。
第五步返回值问题:返回值是爬到顶的最小花费,顶是n。所以返回的是dp[n]。
代码展示:
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
//先创建dp数组,这里最后返回的是dp[n]
int n=cost.size();
int dp[n+1];
dp[0]=0,dp[1]=0;
for(int i=2;i<=n;i++)
{
dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
}
return dp[n];
}
};
运行结果:
4.解码方法
题目链接
题目:
样例输出和输入:
这道题题目很简单,首先A-Z对应1-26,然后我们编码,看看例子:
第一个例子:
s=12->(1 2)(A B)->(12)(L)-------两种情况
s=226-(2 2 6)(B B F)->(22 6)(V F)->(2 26)(B Z)----------三种情况
注意:如果有前导零的话是不能编码的。
算法原理:
第一步状态表示:很显然这道题我们可以将dp表定义为第i个字母有多少种编码方式,也就是dp[i]。
第二步状态转移方程:这道题的状态转移方程需要用临近i推,首先有两种情况,第一种是i位置能否有编码,还有一种情况是i-1位置和i位置一起是否能编码:
第三步:初始化初始化我们只需要初始化0位置和1位置,这两个位置不会让dp表越界
第四步:填表顺序,很显然是从前往后填表。
第五步:返回值,这道题是求编码的方法,编码是编到n-1,所以这道题是返回dp[n-1]
代码展示:
cpp
class Solution {
public:
int numDecodings(string s)
{
int n=s.size();
vector<int> dp(n);
dp[0]=s[0]!='0';
if(s.size()==1)
{
return dp[0];
}
if(s[0]!='0'&&s[1]!='0')dp[1]+=1;
int t=(s[0]-'0')*10+(s[1]-'0');
if(t>=10&&t<=26)dp[1]+=1;
for(int i=2;i<n;i++)
{
int dp1=0,dp2=0;
if(s[i]>'0'&&s[i]<='9')
{
dp1=dp[i-1];
}
else
{
dp1=0;
}
int tmp=(s[i-1]-'0')*10+s[i]-'0';
if(tmp>=10&&tmp<=26)
{
dp2=dp[i-2];
}
else{
dp2=0;
}
dp[i]=dp1+dp2;
}
return dp[n-1];
}
};
运行结果:
总结
通过本文的讲解,我们深入了解了动态规划在解决斐波那契数列问题中的应用。斐波那契数列作为一个经典的递归问题,通过引入动态规划技术,不仅可以有效地降低时间复杂度,还可以避免重复计算,提高算法的效率。
我们分别探讨了自底向上(迭代法)实现方式,展示了它们在解决斐波那契数列问题中的具体步骤和优劣对比。通过这些实例,我们可以看出,动态规划是一种强大且实用的算法设计思想,适用于许多具有重叠子问题和最优子结构性质的问题。
希望本文能够帮助你更好地理解动态规划的基本原理和应用方法,并激发你在其他算法问题中探索和应用动态规划的兴趣。未来,随着你对动态规划的进一步研究和实践,相信你会发现其在解决复杂问题时的巨大潜力和广泛应用。
谢谢阅读!如果你有任何问题或建议,欢迎在评论区与我交流讨论。