动态规划--斐波那契类型
目录
前言
1 第N个斐波那契数
2 爬楼梯
3 三步问题
4 使用最小花费爬楼梯
5 解码方法
总结
前言
本篇所讲的几个题目都是与斐波那契数的做法与思路类似的题目,所以直接放在一块解决了。
同时,由于第一次接触动态规划,我们也会讲解一下动态规划的做题的方法和步骤。
在动态规划中,我们第一步一般是会创建一个 dp 表,这个dp表一般是一个一维或者二维的数组。
然后我们就需要想办法将表中的数据填满。而表里面的某一项数据就是我们需要的结果。
上面是我们的做题的代码的顺序。而我们的做题思路则是:
1 首先根据题目要求,确定dp表中每一项的状态表示。
2 分析题目要求,写出表中的某一项与他的前面或者后面的项的关联,也就是如何通过前面的项或者后面的项来得到该项,这个推导表达式就是我们所说的状态方程
3 分析dp表的边界情况。 一般来说,一维的dp表我们需要考虑头和尾。而二维的dp表我们考虑一下四条边。 同时,除了边界的情况,我们有时候也需要为其他的项设置一个初始的,不会影响填表的值。
4 确定填表顺序, 这个一般是根据我们的状态转移方程而来的,如果dp[i] 需要用到dp[i-1]等他之前的项,那么我们的填表顺序就是从前往后填表,以此类推。
5 确定返回值。 其实就是根据我们的状态表示和题目要求来决定表中的哪一项或者哪几项是我们需要的返回值。
而编写代码的步骤如下:
1 创建dp表
2 初始化边界
3 填表
4 返回
1 第N个斐波那契数
. - 力扣(LeetCode)
首先我们将动态规划的思想运用于简单的题型中。
对于斐波那契数这个题目,他会给我们一个 n ,我们需要返回第 n 个斐波那契数。
同时斐波那契数的递推公式题目已经给我们了,这其实就是我们所需要的状态转移方程。
那么我们不难想到,我们可以创建一个dp表,然后使用 dp[i] 来表示第 i 个斐波那契数。
根据题目中的递推关系我们可以得出
dp[i] = dp[i-1] + dp[i-2]
同时这个表达式的前提是 i > 1 ,也就是说,第一个斐波那契数是不适用于这个表达式的,因为1-2<0 了,而没有第负数个斐波那契数这样的说法。
在这个题中,我们的填表顺序是从前往后,依次填每一个值,而由于 dp[0]=0 ,那么我们可以将整个dp表都初始化为 0 ,免得我们后续还要再来特殊处理dp[0]。同时需要将 dp[1] 设为 1 。
同时由于我们使用 dp[i] 来表示第 i 个斐波那契数,那么我们需要开 n+1 个空间,最终我们需要返回的是第 n 个斐波那契数,那么我们需要返回 dp[n]
那么我们的代码就是这样的:
class Solution {
public:
int fib(int n) {
//1 创建dp表
vector<int> dp(n+1);
//2 初始化边界
dp[1] = 1;
//3 填表
for(int i = 2 ; i <= n ;++ i )
dp[i] = dp[i-1] + dp[i-2];
//4 返回
return dp[n];
}
};
但是这样写有一个问题就是,我们的代码中是有 dp[1] = 1 这一行的,而我们的n其实可以是0,那么此时 dp[1] 就已经越界访问了,那么对于这样的特殊情况,我们可以提前返回。
class Solution {
public:
int fib(int n) {
//1 创建dp表
vector<int> dp(n+1);
//2 初始化边界
if(n==0) return 0; //返回特殊情况,防止越界访问
dp[1] = 1;
//3 填表
for(int i = 2 ; i <= n ;++ i )
dp[i] = dp[i-1] + dp[i-2];
//4 返回
return dp[n];
}
};
那么其实这个题做到这里就已经完了。
但是这里我们还可以继续优化,由于dp[i] 只需要用到 dp[i-1] 和 dp[i-2] 这俩个值,不需要用到dp[i-3]及以前的数值,同时我们的填表是从前往后依次填的,那么就意味着,当 i 为 3 时,只需要用到 dp[1] 和 dp[2] ,不会用到dp[0] ,而当 i = 4 时,只需要用到 dp[2] 和 dp[3] ,dp[1] 和之前的数值已经不会再用到了,后续填表 i 是不断增大的,那么前面的值就再也不会用到。这其实是一种空间的浪费。
其实,我们只需要使用三个变量就能完成前面的思路。
我们定义 a , b , c 三个变量。
a 永远代表当前遍历到的 i 的 dp[i-2]
b 永远代表当前遍历到的 i 的 dp[i-1]
而c表示当前的 dp[i]
那么就有 c = a + b ;
那么 i 更新的时候,就需要先更新 a = b , b = c ,那么这样一来,a永远是当前的 dp[i-2] ,b永远是当前的 dp[i-1]
最重要返回的就是 c
那么还是一样的,特殊情况也就是 n = 0 的时候,此时要返回 a ,n = 1 的时候,此时要返回 b 。
因为a初始值就是 dp[0] ,b 初始值就是 dp[1] , 那么我们可以把c的初始值给为 dp[2] ,为了方便更新 b。 那么我们第一个填的就是 dp[3]了,也就是 i 需要从 3 开始。
int a = 0 ,b = 1 ,c = 1 ; // dp[0] dp[1] dp[2]
if(n == 0) return a;
if(n == 1) return b;
for(int i = 3 ; i <= n; ++i)
{
a=b;
b=c;
c=a+b;
}
return c;
这种空间优化的方法我们称为滚动数组,就是使用几个变量,通过不断更新变量的值,在逻辑上看成遍历一个数组。
2 爬楼梯
. - 力扣(LeetCode)
爬楼梯也是一个经典的斐波那契类型的题目。
根据题目意思,每次可以爬 1个 或者 2个 台阶。
那么如果我们需要爬到第 i 阶太极,我们就只有两种方式可以到达,
1 从第 i-1 阶台阶爬一个台阶到达
2 从第 i-2 阶台阶爬两个台阶到达
那么 dp[i] 不就是 dp[i-1]+dp[i-2] 吗? 他的递推关系都和斐波那契数一样。
同时,要求爬到第 i 个台阶的方法,我们需要知道爬到 i -1 和 i - 2 的方法,那么填表顺序也是从前往后依次填表。
那么这个题我们就直接写代码了:
class Solution {
public:
int climbStairs(int n) {
//常规dp
// vector<int> dp(n+1);
// dp[1] = 1 ; //爬到第一个台阶只有一种方法
// dp[0] = 1 ; //这一步其实单纯是为了方便填 dp[2] ,实际上dp[0]无意义
// if(n == 1) return 1;
// for(int i = 2; i <= n ;++i)
// dp[i] = dp[i-1] + dp[i-2];
// return dp[n];
//滚动数组
int a = 1 , b = 1 , c = 2; //dp[0] dp[1] dp[2]
if(n==1) return b;
for(int i = 3 ; i <= n ; ++ i)
{
a=b;
b=c;
c=a+b;
}
return c;
}
};
3 三步问题
. - 力扣(LeetCode)
首先看题目要求:这个题目无非就是比前一个爬楼梯的题目多了一种情况,就是可以通过 i-3 一次爬上3阶台阶到达 i 。
如果我们使用上面的状态表示:
dp[i] 表示从第 0 级台阶走到第 i 级台阶的方法数
那么 dp[i] = dp[i-1] + dp[i-2] + dp[i-3] i > 3
那么我们需要初始化 dp[1] 和 dp[2] ,以及为 dp[0]设置一个初始值不影响dp[3]。
首先dp[1] = 1 ,因为从0 到1 只有一种方法就是一次跳一步。 而dp[1] = 2 ,有两种方法,一种是从 0 跳两个台阶,一个是从1跳一个台阶。
而 dp[3] = dp[0] + dp[1] + dp[2] ,而显然,第三个台阶可以有第二个台阶跳一次一步到达,也可以由第 1 个台阶一次跳两步过来,也可以由第0个台阶一次跳三步到达,那么我们最好是将dp[0]初始化为1 ,满足dp[3] = dp[1] + dp[2] + 1
那么我们的代码如下:
class Solution {
public:
int waysToStep(int n) {
//dp[i]表示从第 0 个台阶到达第 i 个的台阶的方法数
//dp[i] = dp[i-1] + dp[i-2] + dp[i-3] i>3
vector<int> dp(n+1);
dp[0] = 1 ; //无实际意义,为了填 dp[3] 时不需要特殊处理
dp[1] = 1;
if(n==1) return 1; //边界情况
dp[2] = 2;
int MOD = 1e9+7; //模数
for(int i = 3 ; i <= n;++i) //从3开始填
{
dp[i] = (dp[i-1] + dp[i-2])%MOD + dp[i-3]; //三个数相加的时候可能已经超出int范围(21亿)了
dp[i]%=MOD;
}
return dp[n];
}
};
同时,我们也可以换一种状态表示方法来完成这个题目的动态规划
我们可以用 dp[i] 表示从第 i 个台阶走到第 n 个台阶的方法数。
假设我们目前在第 i 个台阶,那么我们下一步只能走到 i+1,i+2,i+3 这三个中的一个,那么从第 i 个台阶走到第 n 个台阶的方法数我们就可以先从 i 走到 i+1 , 从 i 走到 i+2 ,从 i 走到i+3 ,那么问题就转换为了,从 i+1 ,i+2 .i+3 走到第n个台阶的方法数的总和就是从 i 走到第 n 个台阶的方法数。
而从第 i+1 个台阶走到第n个台阶的方法数就是 dp[i+1] ,依次类推
dp[i] = dp[i+1] + dp[i+2] + dp[i+3] i > 0 && i <= n - 3
那么这种状态表示下,我们的填表顺序就应该是从后往前依次填表了。
那么怎么初始化呢? dp[n-1] = 1 和 dp[n-2] = 2 这两个还是一样的,现在的问题就是 dp[n] 怎么填?如果从逻辑上来说,dp[n]本来就在第n个台阶,那么他就应该填 0 ,但是我们为了方便填 dp[n-3] ,我们可以把 dp[n] 初始化为 1 ,这样 dp[n-3] 就直接用递推公式来求了。
同时编写代码的过程中我们也要考虑特殊情况, n 的范围是 n>=1 ,当n =1时,dp[n-2]会越界。
题目要求返回的是从第 0 个台阶走到第 n 个台阶的方法数,那么我们需要返回 dp[0]
//dp[i] 表示从第 i 个台阶走到第n个台阶的方法总数
vector<int> dp(n+1);
dp[n] = 1 ; //没有实际意义,为了填 dp[n-3] 的准确性
dp[n-1] = 1;
// n = 1 时,dp[n-2]越界
if(n==1) return 1;
dp[n-2] = 2;
int MOD = 1e9+7;
for(int i = n-3 ; i>=0 ; --i)
{
dp[i] = (dp[i+1] + dp[i+2])%MOD+dp[i+3];
dp[i] %= MOD;
}
//返回从第 0 个台阶到第 n 个台阶的方法总数
return dp[0];
4 使用最小花费爬楼梯
LCR 088. 使用最小花费爬楼梯 - 力扣(LeetCode)
首先还是解析题意,假设cost中有 n 个数据,那么对应的就是第0 ,1 .... n-1 级台阶,而爬到楼顶的意思是爬完所有楼梯,那么我们可以把楼顶视为第 n 个台阶,我们需要求出爬到第n级台阶的最小花费,每次从当前的第 i 个台阶向上爬,需要支付 cost[i] 个体力,支付体力后,可以选择向上爬一个台阶或者两个台阶。
那么对于第 i 个台阶,可以从第 i-1 级台阶,支付cost[i-1] ,一步爬上来。 也可以由第 i-2 级台阶,支付cost[i-2] ,两步爬上来。 而题目要求的是爬上第 n 级台阶的最小花费,那么我们需要选择这两种方法中花费较少,来爬上第 i 级台阶。
那么来设计我们的dp。
状态表示:dp[i] 表示爬到第 i 级台阶所需要的最少花费。
题目中表明可以选择第 0 级或者第 1 级台阶作为起点,那么我们可以初始化 dp[0] = dp[1] = 0
根据我们前面的推到思路,我们能够得出一下状态转移方程
dp[i] = min(dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2]) i >= 2
由状态转移方程可以知道,填写dp[i] 需要用到 dp[i-1] 和dp[i-2] ,那么我们的填表顺序还是从左往右填。
而最终的返回值就是爬到第 n 个台阶的最小花费,那么就是 dp[n];
代码如下:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
//dp[i] 表示从起点(第0或者第1级台阶)爬到第i级台阶的最小花费
int dp[n+1];
//初始化
dp[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];
}
};
我们也可以参考上一个题目的做法,换一种dp思路。
我们可以定义 dp[i] 为从第 i 级台阶爬到第 n 级台阶所需要的最小花费。那么从第 i 级台阶出发,我么可以支付 cost [i] 一次爬到第 i+1 级台阶 或者 第 i+2 级台阶,那么需要取他们的较小值,于是我们能够得出一下的状态转移方程:
dp[i] = cost[i] + min(dp[i+1] , dp[i+2]) 0 <= i <= n - 2
而对于边界条件,dp[n] = 0 , 第n级台阶到本身就在终点,不需要任何花费。而对于dp[n-1] ,只有一种方式爬到第 n 级台阶,那么就是支付 cost[i] 爬一步到 n ,那么dp[n-1] = cost[n-1];
那么这种 dp 思路的填表顺序就是从右往左。
而我们的返回值自然就是 dp[0] 和 dp[1] 中的较小值。
int n = cost.size();
//dp[i] 表示从第 i 级台阶爬到楼顶(第n级台阶)所需的最小花费
int dp[n+1];
//初始化
dp[n] = 0 ;
dp[n-1] = cost[n-1];
for(int i = n -2 ; i >=0 ; -- i)
{
dp[i] = cost[i] + min(dp[i+1] , dp[i+2]);
}
return min(dp[0],dp[1]);
5 解码方法
91. 解码方法 - 力扣(LeetCode)
题目的意思就是: 我们有一种编码规则,字符 1 映射 字母 A ,字符 2 映射 字母 B, 依次类推,"26" 映射字母 Z 。现在题目给我们一个数字字符串,要我们求出由此字符串能解码的方法总数。
为什么同样一个字符串有不同的解码方式呢?题目中也告诉了我们,对于在 26 以内的数字,比如23 ,我们可以分成两部分" 2 " 和 " 3 "来解码,对应 "B" 和 "C" ,也可以直接解码为一个字符 "W"。但是有一个特殊的数字 0 ,我们只可以将 0 视为后置 0 ,比如 10 ,20 ,而不能将其解析为前置 0 ,比如 01 ,02 。
那么对于字符串中的一个数字字符,我们可以有三种解码方法
1 单独解码 ; 2 和他前面的数字合起来进行解码 ; 3 和他后面的数字合起来进行解码)
当然这里的方法3 其实和方法2重叠了,后面计算的时候我们只需要关注其中一个
但是对于数字字符 0 而言,他只有一种解码方式就是和他前面的数字进行解码。
而如果遇上连续的字符 0 ,那么这个数字字符串就是无法解码的,因为两个连续的 0 中的后一个无论如何都无法进行解码。这就是题目中所说的无法解码的情况。如果字符串的第一个字符就是 0,那么也是无法解码的。还有就是如果最后一个字符是 0 ,且他无法与前一个字符结合为一个合法的解码方式,那么这也是无法解码的。
那么这道题我们使用动态规划来做的话也很简单。
首先确定状态表示,我们可以这样表示 :
dp[i] 表示以字符串第 i 个位置(s[i])结尾时,解码方法的总数。
那么对于第 i 个位置的字符,有两种解码方式 1 单独解码 , 2 和前面的字符进行解码,,如果 第 i 个字符选择单独解码,dp[i] = dp[i-1] 。 如果第 i 个字符选择与第 i-1 个字符结合解码,那么第 i-1 个字符就不能先选择解码方式,dp[i] = dp[i-2] ;
那么 dp[i] 的结果就是这两种解码方法的总数。
那么我们由以下状态转移方程:
dp[ i ] = 0;
如果第 i 个字符能够单独解码, dp[ i ] += dp[i-1]
如果第 i 个字符能够与前一个字符结合解码,dp[i] += dp[i-2]
由于状态转移方程中需要用到dp[i-2],那么要求 i >= 2 才能在循环中自动填表,所以初始化的话,我们需要初始化一下 dp[0] 和 dp[1], 如果s[0] 为‘0’ 那么dp[0] = 0 , 否则dp[0] =1;而对于dp[1] ,则需要按照上面的思路来求得。
那么最终返回的就是 dp[n-1] 。
class Solution {
public:
int numDecodings(string s) {
int n = s.size();
//dp[i]表示以s[i]结尾时,解码的总数
vector<int> dp(n);
//初始化
dp[0] = s[0] != '0' ; //如果s[0] =='0',那么dp[0] = 0 ,否则dp[0] = 1
if(n == 1) return dp[0];
if(s[1] != '0') dp[1] += dp[0]; //说明dp[1]可以单独解码
int sum = (s[0]-'0')*10 + s[1]-'0'; //s[1] 与 s[0] 结合解码
if(sum >= 10 && sum <= 26) dp[1] ++;
for(int i = 2 ; i < n ; ++ i)
{
if(s[i] != '0') dp[i] += dp[i-1];
sum = (s[i-1] -'0')*10 + s[i]-'0';
if(sum >= 10 && sum <= 26) dp[i] += dp[i-2];
}
return dp[n-1];
}
};
但是在代码中我们发现了,对于 dp[1] 的初始化,其实逻辑上是和我们的状态方程差不多,唯一不同的就是第二种编码方式的 dp[i] +=dp[i-2] 便成了 dp[i]++ ,那么我们是否可以针对dp[1] 虚拟出一个 dp[i-2] = 1 ,那么这样一来 dp[1] 也能直接在循环中自动填表,而不需要我们手动初始化呢?
自然是可以的,我们只需要在 dp 表中多开一个空间,第一个位置也就是 dp[0] 作为为了方便 dp[1]填表而增加的虚拟空间。 那么对应的,我们的状态表示其实就发生了变化,dp[i]便表示为以s[i-1]为结尾,编码方法的总数。
在很多的动态规划的问题中,都会有这样的问题,就是处理边界问题的时候,有时候多开一个或者一行或者多个空间之后,可以无需手动初始化某些边界,可以在填表过程中通过通用的状态表示方程来填入。
但是这些虚拟位置的值我们是需要具体情况具体分析的。
//多开一个虚拟空间
int n = s.size();
vector<int> dp(n+1);
dp[0] = 1;
dp[1] = s[0]!='0';
for(int i = 2; i <= n ; ++i)
{
//注意新表的dp[i]对应的是s[i-1]
if(s[i-1] !='0') dp[i] += dp[i-1];
int sum = (s[i-2]-'0')*10 + s[i-1] -'0';
if(sum >= 10 && sum <= 26) dp[i] += dp[i-2];
}
return dp[n];
总结
通过几个简单的动态规划的例题,我们大概能够理解动态规划的思路和做题流程,在动态规划问题中,最难想到的其实并不是状态转移方程,而是最基本的状态表示,很多时候,只要我们能够思考出一个巧妙的状态表示,那么状态转移方程也就信手拈来了,在很多时候,我们都能够凭借以往的经验,确定状态表示为: 以... 为起点或者以 ... 为终点,xxxx这样的形式。当然我们上面所讲解的都是最基础入门的动态规划的题目,后续还会更新其他类型的动态规划问题
的做题思路已经步骤。