动态规划(Dynamic Programming, DP)是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式来求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
1.基本思想
-
最优子结构:问题的最优解包含其子问题的最优解。这意味着,如果我们能找到子问题的最优解,我们就可以通过组合这些子问题的解来找到整个问题的最优解。
-
重叠子问题:在求解问题的过程中,相同的子问题会被重复求解多次。动态规划通过保存这些子问题的解(通常是在一个数组或哈希表中),从而避免了大量的重复计算。
2.动态规划的类型
-
线性动态规划:子问题的解只依赖于前一个或前几个子问题的解。例如,斐波那契数列、背包问题(0/1背包、完全背包等)、最长上升子序列等。
-
区间动态规划:子问题的解依赖于某个区间内的子问题的解。例如,矩阵链乘法、石子合并等。
-
树形动态规划:子问题的解依赖于树形结构中的子节点的解。例如,树的直径、树的最大独立集等。
-
图论动态规划:子问题的解依赖于图结构中的邻居节点的解。例如,最短路径问题(虽然通常使用其他算法如Dijkstra或Bellman-Ford求解,但也可以看作一种特殊的动态规划)、状态压缩DP(用于解决某些特殊的图论问题)。
3.动态规划的基本步骤
-
定义状态表示 :dp表里面的值表示的含义。
怎么来?:
1.题目要求
2.经验+题目要求
3.分析问题的过程中发现重复子问题 -
状态转移方程:我们需要找到一种方式,根据子问题的解来构造出整个问题的解。这通常是通过一个或多个状态转移方程来实现的。
-
初始化:保证填表的时候不发生越界。
-
填表顺序:保证填表顺序正确,填写当前状态时,需要的状态已经计算过了。
-
**返回值:**确定正确的返回值。
4**.动态规划的优缺点**
优点:
- 能够高效地解决具有重叠子问题和最优子结构的问题。
- 相对于暴力搜索,动态规划能够显著减少计算量。
缺点:
- 需要找到问题的最优子结构和重叠子问题,这有时并不容易。
- 对于某些问题,动态规划的空间复杂度可能很高(例如,需要保存所有子问题的解)。
- 动态规划通常只能找到问题的最优解(或近似最优解),而不能给出所有可能的解。
动态规划是一种非常强大的算法设计技术,但它也需要一定的经验和技巧来正确地应用。在实践中,动态规划通常与其他算法和技术结合使用,以解决更复杂的问题。
5.例题
5.1第 N 个泰波那契数
泰波那契序列 Tn 定义如下:
T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的条件下 Tn+3 = Tn + Tn+1 + Tn+2
给你整数
n
,请返回第 n 个泰波那契数 Tn 的值。示例 1:
输入:n = 4 输出:4 解释: T_3 = 0 + 1 + 1 = 2 T_4 = 1 + 1 + 2 = 4
示例 2:
输入:n = 25 输出:1389537
解法(动态规划)
算法流程(按照基本步骤一步步来分析)
1.状态表示:这道题可以「根据题目的要求」直接定义出状态表示:
dp[i] 表示:第 i 个泰波那契数的值。
2.状态转移方程:题目已经非常贴心的告诉我们了:
dp[i] = dp[i-1] + dp[i-2] + dp[i - 3]
3.初始化:从我们的递推公式可以看出,dp[i] 在 i=0 以及 i=1 的时候是没有办法进行推导的,因为 dp[-2]或 dp[-1] 不是一个有效的数据。因此我们需要在填表之前,将 0,1,2位置的值初始化。题目中已经告诉我们 dp[0]= 0,dp[1] = dp[2] = 1。
4.填表顺序:毫无疑问是「从左往右」。
5.返回值:应该返回 dp[n] 的值。
cpp
class Solution {
public:
int tribonacci(int n) {
if(n==0)
return 0;
if(n==1||n==2)
return 1;
vector<int> dp(n+1);
dp[0]=0;dp[1]=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];//返回值
}
};
5.2三步问题
三步问题。有个小孩正在上楼梯,楼梯有n阶台阶,小孩一次可以上1阶、2阶或3阶。实现一种方法,计算小孩有多少种上楼梯的方式。结果可能很大,你需要对结果模1000000007。
示例1:
输入:n = 3 输出:4 说明: 有四种走法
示例2:
输入:n = 5 输出:13
解法(动态规划)
算法思路
1.状态表示这道题可以根据「经验+题目要求」直接定义出状态表示:
dp[i] 表示:到达 i 位置时,一共有多少种方法。
2.状态转移方程以 i 位置状态的最近的一步,来分情况讨论:
如果 dp[i]表示小孩上第 ī 阶楼梯的所有方式,那么它应该等于所有上一步的方式之和:
i.上一步上一级台阶,dp[i] += dp[i - 1];
ii.上一步上两级台阶,dp[i] += dp[i - 2];
iii.上一步上三级台阶,dp[i] += dp[i - 3];综上所述,dp[i]= dp[i- 1]+ dp[i - 2]+ dp[i需要注意的是,这道题目说,由于结果可能很大,需要对结果取模。
在计算的时候,三个值全部加起来再取模,即(dp[i+dp[i-2]+ dp[i-3])% MOD是不可取的,n取题目范围内最大值时,网站会报错 signedinteger overflow。
对于这类需要取模的问题,我们每计算一次(两个数相加/乘等),都需要取一次模。否则,万一发生了溢出,我们的答案就错了
3.初始化从我们的递推公式可以看出,dp[i] 在i=0,i=1以及 i=2 的时候是没有办法进行推导的,因为 dp[-3] dp[-2] 或 dp[-1] 不是一个有效的数据。因此我们需要在填表之前,将1,2,3位置的值初始化。根据题意,dp[1]=1,dp[2]=2,dp[3]=4。
4.填表顺序毫无疑问是「从左往右」。
5.返回值
返回到 dp[n] 的值。
cpp
class Solution {
public:
const int MOD=1e9+7;
int waysToStep(int n) {
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];
}
};
5.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 。
注意注意:
在这道题中,数组内的每-下标[0,n-1]表示的都是楼层,而顶楼的位置其实是在n的位置!!!
解法(动态规划)
算法思路:解法一:
1.状态表示:这道题可以根据「经验+题目要求」直接定义出状态表示:
第一种:以 i 位置为结尾.....
dp[i]表示:到达 i 位置时的最小花费。(注意:到达 i 位置的时候,i 位置的钱不需要算上)
2.状态转移方程:根据最近的一步,分情况讨论:
- 先到达 i-1 的位置,然后支付 cost[i- 1],接下来走一步走到 i 位置:dp[i-1] + csot[i-1];
- 先到达 i-2 的位置,然后支付 cost[i- 2],接下来走一步走到 i 位置:dp[i-2] + csot[i-2]。
3.初始化:
从我们的递推公式可以看出,我们需要先初始化 i=0,以及 i=1位置的值。容易得到 dp[0]= dp[1] = 0,因为不需要任何花费,就可以直接站在第 0 层和第 1 层上。
4.填表顺序:根据「状态转移方程」可得,遍历的顺序是「从左往右」
5.返回值:根据「状态表示以及题目要求」,需要返回 dp[n]位置的值。
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost)
{
int n=cost.size();
vector<int> dp(n+1);//dp表
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];
}
};
解法二:
1.状态表示:这道题可以根据「经验+题目要求」直接定义出状态表示:
第二种:以 i 位置为起点......
dp[i]表示:从 i 位置出发,到达楼顶,此时的最小花费。
2.状态转移方程:根据最近的一步,分情况讨论:
- 支付cost[i],往后走一步,接下来从 i+1 的位置出发到终点: dp[i + 1] + cost[i]
- 支付cost[i],往后走两步,接下来从 i+2 的位置出发到终点:dp[i+2] + cost[i]
我们要的是最小花费,因此 dp[i] = min(dp[i + 1],dp[i + 2])+ cost[i]。
3.初始化:为了保证填表的时候不越界,我们需要初始化最后两个位置的值,结合状态表示易得:dp[n-1]=cost[n-1],dp[n-2]=cost[n-2]。
4.填表顺序:根据「状态转移方程」可得,遍历的顺序是「从右往左」。
5.返回值:根据「状态表示以及题目要求」,需要返回 dp[n]位置的值。
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost)
{
int n=cost.size();
vector<int> dp(n);
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],dp[i+2])+cost[i];
}
return min(dp[0],dp[1]);
}
};
5.4解码方法
一条包含字母
A-Z
的消息通过以下映射进行了 编码 :
"1" -> 'A'
"2" -> 'B'
...
"25" -> 'Y'
"26" -> 'Z'
然而,在 解码 已编码的消息时,你意识到有许多不同的方式来解码,因为有些编码被包含在其它编码当中(
"2"
和"5"
与"25"
)。例如,
"11106"
可以映射为:
"AAJF"
,将消息分组为(1, 1, 10, 6)
"KJF"
,将消息分组为(11, 10, 6)
- 消息不能分组为
(1, 11, 06)
,因为"06"
不是一个合法编码(只有 "6" 是合法的)。注意,可能存在无法解码的字符串。
给你一个只含数字的 非空 字符串
s
,请计算并返回 解码 方法的 总数 。如果没有合法的方式解码整个字符串,返回0
。题目数据保证答案肯定是一个 32 位 的整数。
示例 1:
输入:s = "12" 输出:2 解释:它可以解码为 "AB"(1 2)或者 "L"(12)。
示例 2:
输入:s = "226" 输出:3 解释:它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
示例 3:
输入:s = "06" 输出:0 解释:"06" 无法映射到 "F" ,因为存在前导零("6" 和 "06" 并不等价)。
解法(动态规划)
算法思路:类似于斐波那契数列~
1.状态表示:根据以往的经验,对于大多数线性 dp,我们经验上都是「以某个位置结束或者开始」做文章,这里我们继续尝试「用i位置为结尾」结合「题目要求」来定义状态表示。
dp[i]表示:字符串中 [0,i]区间上,一共有多少种编码方法。
2.状态转移方程:定义好状态表示,我们就可以分析 i 位置的 dp 值,如何由「前面」或者「后面」的信息推导出来。
关于 i 位置的编码状况,我们可以分为下面两种情况:
i.让 i 位置上的数单独解码成一个字母;
ii.让 i 位置上的数与 i-1 位置上的数结合,解码成一个字母。
下面我们就上面的两种解码情况,继续分析:
让i位置上的数单独解码成一个字母,就存在「解码成功」和「解码失败」两种情况:
i.解码成功:当 i 位置上的数在[1,9]之间的时候,说明 i 位置上的数是可以单独解码的,那么此时[0,i]区间上的解码方法应该等于[0,i- 1]区间上的解码方法。因为[0,i- 1]区间上的所有解码结果,后面填上一个 i 位置解码后的字母就可以了。此时 dp[i]=dp[i-1];
ii.解码失败:当 i 位置上的数是 0 的时候,说明 i 位置上的数是不能单独解码的,那么此时 [0,i]区间上不存在解码方法。因为 i 位置如果单独参与解码,但是解码失败了,那么前面做的努力就全部白费了。此时 dp[i]=0。
让 i 位置上的数与 i- 1 位置上的数结合在一起,解码成一个字母,也存在「解码成功」和「解码失败」两种情况:
i.解码成功:当结合的数在[10,26]之间的时候,说明[i-1,i]两个位置是可以解码成功的,那么此时[0,i]区间上的解码方法应该等于[0,i- 2]区间上的解码方法,原因同上。此时 dp[i]= dp[i- 2];
ii.解码失败:当结合的数在[0,9]和[27 ,99]之间的时候,说明两个位置结合后解码失败(这里一定要注意 00 01 02 03 04这几种情况),那么此时[0,i]区间上的解码方法就不存在了,原因依旧同上。此时dp[i]=0。
综上所述: dp[i]最终的结果应该是上面四种情况下,解码成功的两种的累加和(因为我们关心的是解码方法,既然解码失败,就不用加入到最终结果中去),因此可以得到状态转移方程(dp[i]默认初始化为 0):
i.当 s[i]上的数在[1,9]区间上时: dp[i] += dp[i-1];
ii.当 s[i-1]与 s[i]上的数结合后,在[10,26] 之间的时候: dp[i] += dp[i-2];
如果上述两个判断都不成立,说明没有解码方法,dp[i]就是默认值 0。
3.初始化:方法一(直接初始化):
由于可能要用到 i-1 以及 i-2 位置上的 dp 值,因此要先初始化「前两个位置」。
初始化 dp[0]:
i.当s[0] == '0'时,没有编码方法,结果dp[0] = 0。
ii.当s[0] != '0'时,能编码成功,结果dp[0] = 1。
初始化 dp[1]:
i.当s[1] 在[1 , 9]之间时,能单独编码,此时 dp[1] += dp[0](原因同上,dp[1]默认为0)
ii.当s[0] 与s[1] 结合的数在 [10 , 26]之间时,说明在前两个字符中,又有一种编码方式,此时dp[1] += 1
方法二(添加辅助位置初始化)
可以在最前面加上一个辅助节点,帮助我们初始化。使用这种技巧要注意两个点:
i.辅助节点里的值要保证后续填表是正确的。
ii.下标的映射关系。
4.填表顺序
从左往右
5.返回值
返回 dp[n-1] 的值,表示在[0 , n-1]区间上的编码方法。
cpp
class Solution {
public:
int numDecodings(string s)
{
if(s[0]=='0')
return 0;
int n=s.size();
vector<int> dp(n);
if(s[0]>'0'&&s[0]<='9')
dp[0]=1;
if(n==1)
return dp[0];
if(s[1]>'0'&&s[1]<='9')
dp[1]=1;
int m=(s[0]-'0')*10+s[1]-'0';
if(m>=10&&m<=26)
dp[1]+=1;
for(int i=2;i<n;i++)
{
//单独编码
if(s[i]!='0')
{
dp[i]+=dp[i-1];
}
//和前面一个数联合起来编码
int m=(s[i-1]-'0')*10+s[i]-'0';
if(m>=10&&m<=26)
dp[i]+=dp[i-2];
}
return dp[n-1];
}
};