文章目录
- 1. DP解题步骤
- 2. [第N个泰波那契数](https://leetcode.cn/problems/n-th-tribonacci-number/description/)
- 3. [三步问题](https://leetcode.cn/problems/three-steps-problem-lcci/description/)
- 4. [使用最小花费爬楼梯](https://leetcode.cn/problems/min-cost-climbing-stairs/description/)
- 5. [解码方法](https://leetcode.cn/problems/decode-ways/)
1. DP解题步骤
动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。
解题步骤如下:
1)明确dp数组的含义
一般会创建一个数组dp,这个数组称作DP表,DP表里面存放的元素所表示的含义就是一个状态表示。
如:dp[ i ]表示为第 i 个泰波那契数的值,dp[ i ]就是一个状态表示。
2)确定状态转移方程
如:泰波那契数中状态转移方程是:Tn+3 = Tn + Tn+1 + Tn+2
进一步推导出:Tn = Tn-1 + Tn-2 + Tn-3
3)初始化DP表
根据状态转移方程得出初始条件,并迭代计算后面的值填写到DP表中。
注意:数组一定要注意不要越界了!
4)确定填表顺序
填写当前状态的时候,确保之前的状态的值已经填入。即填表顺序应该是从 已填入 往 没填入 的方向填入。
5)确定返回值
如:题目要求返回第 n 个泰波那契数的值,那么返回值就是dp[ n ]。
2. 第N个泰波那契数
这题的初始条件和状态转移方程已经在题目中给出了,下面我们直接填写DP表即可,但要注意边界条件的判断,代码如下:
class Solution {
public:
int tribonacci(int n) {
// 处理边界情况
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
// 创建DP表
vector<int> dp(n + 1);
// 初始化DP表
dp[0] = 0; dp[1] = 1; dp[2] = 1;
// 填写DP表
for(int i = 3; i <= n; i++)
{
dp[i] = dp[i-1] + dp[i-2] + dp[i-3]; // 状态转移方程
}
return dp[n];
}
};
空间优化/状态压缩
我们考虑到状态转移方程是根据前三个值推出后面的值的,也就是说我们在使用状态转移方程时可以考虑用若干个变量进行迭代更新,可以不使用DP表的一整块空间,这样就节省了空间。
图1所示,是采取DP表的一整块空间进行迭代更新,变量i基本上遍历了整块数组空间,因此空间复杂度为O(N)。
class Solution {
public:
int tribonacci(int n) {
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
// 初始化DP表
int a = 0, b = 1, c = 1, d = 0;
for(int i = 3; i <= n; i++)
{
// 根据状态转移方程求下一个位置的值
d = a + b + c;
// 更新a,b,c
a = b; b = c; c = d;
}
return d;
}
};
时间复杂度:for循环遍历数组,因此时间复杂度是O(N)
空间复杂度:图1的空间复杂度是O(N),图2的空间复杂度是O(1)
3. 三步问题
1)明确dp数组的含义
dp[i] 表示到达第 i 个台阶时,一共有多少种方法。
2)确定状态状态转移方程
由题意的"小孩一次可以上1阶、2阶或3阶"来划分问题:如图1所示,小孩到达第i个台阶的最后一步是从第i - 1或i - 2或i - 3个台阶过来的,那么到达第i - 1个台阶有dp[i-1]种方法,到达第i - 2个台阶有dp[i-2]种方法,到达第i - 3个台阶有dp[i-3]种方法,所以到达第i个台阶应该是上述方法的和:
dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
3)初始化DP表
初始化:
如上图所示红色的箭头代表从0阶到1阶,那么到1阶的所有方法数量应该是dp[1] = dp[0] = 1,因此只有一种方法;
绿色箭头:到2阶可以从从0阶过来,到2阶也可以从1阶过来,那么到2阶的所有方法数量应该是dp[2] = dp[0] + dp[1] = 1 + 1 = 2;
同理蓝色箭头:dp[3] = dp[0] + dp[1] + dp[2] = 1 + 1 + 2 = 4.
插入dp表:
从第4号台阶开始使用状态转移方程,为什么要从第4步开始?因为小孩一次只能走1阶、2阶或3阶,我要计算一共有多少种方法,其实就是要计算到达前面的阶梯的方法之和,但是我们不能超过数组的范围,所以从4号台阶开始。
细节问题要注意:
我们在做加法的时候可能会造成数据超过整型的最大值的情况,因此每做一次加法就要进行一次取模操作。
class Solution {
public:
int waysToStep(int n) {
if(n == 1 || n == 2) return n;
if(n == 3) return 4;
const int MOD = 1e9 + 7;
// 创建DP表
vector<long long> dp(n+1);
dp[1] = 1; dp[2] = 2; dp[3] = 4;
for(int i = 4; i <= n; ++i)
{
// 插入DP表
dp[i] = ((dp[i-1] + dp[i-2]) % MOD + dp[i-3]) % MOD;
}
return dp[n];
}
};
时间复杂度:for循环遍历数组,因此时间复杂度是O(N)
空间复杂度:空间复杂度是O(N)
空间优化/状态压缩
int waysToStep(int n) {
if(n == 1 || n == 2) return n;
if(n == 3) return 4;
const int MOD = 1e9 + 7;
long long a = 1, b = 2, c = 4, d = 0;
for(int i = 4; i <= n; ++i)
{
d = ((a + b) % MOD + c) % MOD;
a = b;
b = c;
c = d;
}
return d;
}
时间复杂度:for循环遍历数组,因此时间复杂度是O(N)
空间复杂度:空间复杂度是O(1)
4. 使用最小花费爬楼梯
1)明确dp数组的含义
dp[i] 表示到达第 i 个台阶时的最小花费,i表示第i个台阶;那么从i个阶梯上去需要消耗cost[i].
2)确定状态状态转移方程
因为每次能爬一个或者两个台阶,所以第 i 个台阶一定是从第 i -1个台阶或第 i -2个台阶过来的,那么第 i 个台阶的最小花费等于到来之前的台阶的最小花费加上跳过台阶的花费之和,用公式表达如下:
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
3)初始化DP表
因为可以从0或者1开始出发,所以0阶梯或者1阶梯的最小花费都为0。
4)确定填表顺序
填表顺序从前往后
5)确定返回值
如:题目要求返回第 n 个泰波那契数的值,那么返回值就是dp[ n ]。
方法一:
int minCostClimbingStairs(vector<int>& cost) {
// 确定dp数组: 含义是到达第i个台阶的最小花费
vector<int> dp(cost.size() + 1);
dp[0] = dp[1] = 0;
// 到达第i个台阶的最小花费为dp[i]
for(int i = 2; i <= cost.size(); i++)
{
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
}
return dp[cost.size()];
}
方法二:空间优化/状态压缩
int minCostClimbingStairs(vector<int>& cost) {
// 确定dp数组: 含义是到达第i个台阶的最小花费
vector<int> dp(3);
dp[0] = dp[1] = dp[2] = 0;
// 到达第i个台阶的最小花费为dp[i]
for(int i = 2; i <= cost.size(); i++)
{
dp[2] = min(dp[1] + cost[i-1], dp[0] + cost[i-2]);
dp[0] = dp[1];
dp[1] = dp[2];
}
return dp[2];
}
5. 解码方法
1)明确dp数组的含义
dp[i]
表示:在给定的字符串中,从0到i位置,所有解码方法总数。
2)确定状态状态转移方程
根据最近的一步划分问题:字符串s的最后一个字符分为两种情况,情况一s[i]自成一组,解码总数取决于dp[i-1];情况二s[i]和s[i-1]成一组,解码总数取决于dp[i-2].
状态转移方程不难得出:
dp[i] = dp[i-1] + dp[i-2]
3)初始化DP表
dp[0]的初始化要么是0要么是1;dp[1]的初始化在解码失败的时候填0,解码成功时要么是1要么是2
4)确定填表顺序
填表顺序从前往后,因为状态转移方程要先知道i前面的状态。
5)确定返回值
我们要知道字符串的解码总数,即从0到n-1的解码总数,所以返回值为:dp[n-1]
代码如下:
int numDecodings(string s) {
size_t n = s.size();
vector<int> dp(n);
if(s[0] != '0') dp[0] = 1; // 单独的一个数为0即解码错误
if(n == 1) return dp[0]; // 处理边界情况
if(s[0] != '0' && s[1] != '0') dp[1]++;
int t = (s[0] - '0') * 10 + (s[1] - '0'); // s[0]和s[1]组合的解码总数
if(t >= 10 && t <= 26) dp[1]++; // 这种情况就自然的将06这种情况考虑了
for(int i = 2; i < n; i++){
// 情况1:最后一个字母成一组
if(s[i] != '0') dp[i] += dp[i-1];
// 情况2:最后两个字母合并成一组
int t = (s[i-1] - '0') * 10 + (s[i] - '0');
if(t >= 10 && t <= 26) dp[i] += dp[i-2];
}
return dp[n-1];
}
上面我们发现,在初始化dp[1]时,和后面的状态更新代码出现了重复,那么我们能不能将二者合并呢?也就是说,我们能不能将dp[1]放在for循环里去初始化?
按照现在的情况来说是不可以的,如果当i==1时,for循环中的dp[i-2]则会越界,也就是说在for循环之前必须要有两个变量初始化!
解决这个问题可以换个思路,我们扩充dp数组一个维度(将此时的0号下标视为虚拟节点),将原来的dp数组下标i
映射到新dp数组的下标i+1
。
当然虚拟节点的赋值也是要考虑的,一定要确保后面的dp[2]初始化正确.
int numDecodings(string s) {
size_t n = s.size();
vector<int> dp(n+1);
// 虚拟节点
dp[0] = 1; // 这里填1是为了保证后面的填表正确
// 真实的第一个节点
dp[1] = s[1-1] != '0'; // 单独的一个数为0即解码错误
for(int i = 2; i <= n; i++){ // 注意dp数组的i对应着s的i-1
// 情况1:最后一个字母成一组
if(s[i-1] != '0') dp[i] += dp[i-1];
// 情况2:最后两个字母合并成一组
int t = (s[i-1-1] - '0') * 10 + (s[i-1] - '0');
if(t >= 10 && t <= 26) dp[i] += dp[i-2];
}
return dp[n];
}