算法03贪心与动态规划
1. 贪心与动态规划概述
1.贪心
1.1贪心概述
贪心算法(英语:greedy algorithm),又称贪婪算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。比如在旅行推销员问题中,如果旅行员每次都选择最近的城市,那这就是一种贪心算法。
1.2贪心常用场景
贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。
常用场景如下:
- 区间问题:贪心算法可以用于解决最小覆盖问题、区间选取问题等
- 小数背包问题:不同于0-1背包问题(动态规划),物品是可以分割,只装进背包一部分
- 图论中的问题:例如,Prim和Kruskal算法用于找到图的最小生成树,Dijkstra算法用于找到单源最短路径
- 与常识紧密结合的问题:分饼干、找零问题等
1.3贪心解题思路
对于贪心相关的算法题,其实没有一个比较固定的模板,常常和经验和常识有关,但这里还是做一个结构化的总结:
1.有些问题的最优解,很直观地可以想到是由一个或多个子问题“转移”而来,这时我们可以直接使用dp
2.如果一个问题我们难以从“整体”-》“局部”思考,但是可以从“局部”-》“整体”思考,并且找不出反例,这就可以考虑使用贪心,这个思考的过程其实就是我们的贪心策略
3.确定贪心策略后,就去模拟贪心的过程就可以解决问题了,通常是使用“循环”
2.动态规划
2.1动态规划概述
动态规划(Dynamic Programming,DP)是一种算法设计方法,它通过将复杂问题分解为更简单的子问题来解决。这种方法特别适用于那些具有重叠子问题和最优子结构特性的问题。
动态规划的核心在于两个主要概念:状态定义和状态转移。状态定义涉及如何描述问题的各个阶段或步骤,而状态转移则涉及如何从一个状态到达另一个状态,通常通过状态转移方程来描述。
2.2动态规划常用场景
- 最优子结构性质:如果问题的最优解所包含的子问题的解也是最优(
这里的优不一定指的是“数量”的多少,也可以是“能否”,能(true)为优
)的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。 - 无后效性:即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
- 子问题重叠性质:子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率,降低了时间复杂度(记忆化)。
在算法题中,dp常常适合于解决以下问题:
- 最优化问题:如寻找最短路径、最大利润、最小成本等。
- 计数问题:如计算不同路径的数量或组合的可能性。
- 决策问题:如资源分配、背包问题、生产计划等,需要在多个可能的选择中找到最优解。
- 序列问题:如最长公共子序列、最长递增子序列等,涉及到字符串或序列的分析。
2.3动态规划解题模板
对于求最优解的问题、总体问题可以分解为局部问题,我们常常可以使用动态规划求解,对于dp解题步骤,常常有以下步骤:
1.确定dp数组及其下标含义
2.确定递推公式
3.确定递推顺序
4.dp数组如何初始化
5.打印dp数组(debug用)
3.浅谈贪心与动态规划的关系
- 一般而言,对于二者都可以解决的问题,dp相对于贪心是一种暴力
- 贪心背后包含的“智力成本”一般大于dp,也就是说贪心以人的“思考” 换 计算机的空间和时间:
- 贪心算法对每个子问题的解决方案都做出选择,不能回退
- 动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。
2.贪心经典题目
区间问题
3.动态规划经典题目
3.1体会“结构化”的思考过程
(1)746. 使用最小花费爬楼梯
原题链接
这是一个典型的最优化问题,目标是最小化爬上楼梯的成本。我们可以采用动态规划(Dynamic Programming, DP)方法解决,因为它满足动态规划的三大核心特征:
- 最优子结构:到达最后一阶楼梯的最小成本,必然依赖于到达倒数第一阶和倒数第二阶楼梯的最小成本。
- 无后效性:一旦计算出到达某个阶梯的最小成本,这个结果不会因后续的计算而改变。
- 子问题重叠:在计算过程中,相同的子问题会被反复计算多次,使用状态转移表可以避免重复计算,提高效率。
注意点:楼梯的实际阶数比cost数组的长度多1,因为楼梯顶部(即目标位置)不计入成本序列。
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
//分析:这是一个最优化问题,我们考虑使用动态规划或者贪心
//我们选择使用动态规划,这个问题具有动态规划的三个基本特点:
//(1)最优子结构:问题的最优解所包含的子问题的解也是最优的
// 如果想要到达最后一个楼梯的花费最小,那么到达倒数第一和倒数第二个楼梯的花费也要最小
//(2)无后效性:子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的解
// 第i个楼梯的最小花费确定了,后续i+1一直到n阶的花费不会对i的最小花费产生影响
//(3)子问题重叠性性质:每次产生的问他他并不总是新问题,有些子问题会被计算多次。对每一个子问题只计算一次,
//然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率,降低了时间复杂度
// 第2阶的最小花费可能会被第3和第4阶的最小花费在计算时使用
//本题有个小陷阱:
//楼梯的阶数比cost数组大1,楼梯顶部不在花费序列中
//可以这样解释:在楼梯顶部不需要,也不能够再向上爬了
//特殊情况
//如果只有0或1阶需要爬,直接空降到顶部,不需要花费
if(cost.size() <= 1) return 0;
//1.确定dp:确定dp数组及其下标含义
//到达第i阶楼梯的最小花费为dp[i]
vector<int> dp(cost.size() + 1, 0);
//4.dp数组如何初始化
//初始化和声明合并了
//3.确定递推顺序
//由递推公式可以看出,需要由前向后递推
for(int i = 2; i <= cost.size(); i++)
//2.确定递推公式
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i-2]);
return dp[cost.size()];
}
};
(2)力扣118.杨辉三角
解析见代码注释。
class Solution {
public:
vector<vector<int>> generate(int numRows) {
//分析:从第三行开始
//每个数都是其左上和右上之和
//左边缘和右边缘都是1,不由求和得来
//动态规划解题基本性质:
//(1)状态转移,从第三行开始,dp[i][j] = dp[i][j-1] + dp[i-1][j],i表示行,j表示列
// (2) 无后效性:子问题一旦解决后,不再改变,即不受在这之后的包含它的更大的问题的求解决策影响
//(3)最优子结构的性质
//1.确定dp数组及其下标含义:
//第i行第j列的元素的值是dp[i][j]
vector<vector<int>> dp(numRows);
//3.确定递推顺序
//从前往后推,并且每次从每行第二个元素开始
for(int i = 0 ;i < numRows; i++){
for(int j = 0; j <= i; j++){
//4.dp数组如何初始化
//第一列和每行最后一个元素所有元素都是1
if(j == 0 || i == j){
//dp[i][j] = 1;
dp[i].push_back(1);
continue;
}
//2.确定递推公式
//dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
dp[i].push_back(dp[i-1][j-1] + dp[i-1][j]);
}
}
return dp;
}
};
(3)力扣343.整数拆分
原题链接
这也是一道最优化问题,我们可以使用动态规划或贪心求解。如果要使用贪心,我们需要结合一定的数学推导。这里我们选择使用动态规划。这道题满足动态规划三大核心特征:
- 最优子结构性质:每个正整数拆分对应的最大乘积依赖于比它小的正整数最大乘积
- 无后效性:一旦计算出一个正整数的最大乘积,该乘积不再改变,不受在这之后、包含这个正整数的最大乘积影响
- 子问题重叠:在计算过程中,相同的子问题会被反复计算多次,使用状态转移表可以避免重复计算,提高效率。
class Solution {
public:
int integerBreak(int n) {
//分析:动态规划
//(1)最优子结构性质:
//(2)无后效性
//(3)子问题重叠
//1.确定dp数组及其下标:整数i拆分后可以获得的最大乘积为dp[i]
vector<int> dp(n + 1, 0);
//4.dp数组如何初始化
//0不是正整数,1无法进行拆分,二者默认初始化为0
dp[2] = 1;
//3.确定递推顺序
for(int i = 3; i <= n; i++)
//2.确定递推公式:
//i至少要进行两次次拆分
//两次拆分:j*(i-j)
//三次及以上j * dp[i-j]
for(int j = 1; j <= i / 2; j++){
//迭代法+暴力枚举+数学,求出i拆分后的最大乘积
//迭代:dp[i]会被下一轮最大的值覆盖
//暴力枚举:内层for循环枚举当前i的每一种拆分可能,j从1~i/2
//数学:i拆分为i-j和j与j和i-j的乘积是一样的
dp[i] = max({dp[i], j*dp[i-j], j * (i-j)});
}
return dp[n];
}
};
(4)不同的二叉搜索树:
二叉搜索树的定义:二叉搜索树根节点的左子树的所有节点都比根节点小,右子树的所有节点都比根节点大,每颗子树也是如此
给定一个有序序列 1,2,…,n,为了构建出一棵二叉搜索树,我们可以遍历每个数字 i,将该数字作为树根,将 1~(i−1) 序列作为左子树,将 (i+1)~n 序列作为右子树。接着我们可以按照同样的方式递归构建左子树和右子树,也就是说,更大的树由更小的树构成,并且不受更大的树如何组合影响(无后效性)
在上述构建的过程中,由于根的值不同,因此我们能保证每棵二叉搜索树是唯一的。
由此可见,原问题可以分解成规模较小的两个子问题(最优子结构),且子问题的解可以复用(子问题的重叠性质),因此我们可以用动态规划来求解本题。
class Solution {
public:
int numTrees(int n) {
//思路:
//由i个节点构成的二叉搜索树的数量等于以每个节点作为根,同时分配给左右子树不同数量的节点
// 1.确定dp数组及其下标含义:由1~i节点构成的二叉搜索树有dp[i]种
vector<int> dp(n + 1, 0);
// 4.dp数组初始化:
dp[0] = 1;
// 3.确定递推顺序:
//由于更大的dp[i]依赖于更小的dp[i]因此我们需要从前往后递推
//举一个例子:
//dp[5] = dp[0]*dp[4] + dp[1]*dp[3] + dp[2]*dp[2] + dp[3]*dp[1] + dp[4]*dp[0]
//由1~5节点构成的二叉搜索树数量依赖于1~1,1~2,1~3,1~4节点构成的二叉搜索树的数量
for (int i = 1; i <= n; i++) {
// 2.确定递推公式
// dp[n] =dp[0]*dp[n-1] + dp[1]*dp[n-2] + dp[2]*dp[n-3] + ... + dp[n
// -1]*dp[0]
//枚举+累和
//枚举以1~i的每一个节点作为根节点的二叉搜素树
//累和枚举的结果,即为i个节点能够构成的不同二叉搜索树的数量
for (int j = 1; j <= i; j++) {
//左子树节点序列:1~j-1
//根节点序列:j
//右子树节点序列:j+1~i
dp[i] += dp[j-1] * dp[i - j];
}
}
return dp[n];
}
};
3.2体会“状态转移方程”系列
(1)力扣121.买卖股票的最佳时机(一次买卖)
问题描述
在给定的 prices
数组中,prices[i]
表示第 i
天的股票价格。我们需要找到最佳的买卖时机以获取最大利润,且只能进行一次交易(一次买入和一次卖出)。
动态规划特征
- 最优子结构:在第
i
天的最优解依赖于第i-1
天的状态。 - 无后效性:一旦计算出第
i
天的最优解,其结果不会因后续的操作而改变。 - 子问题重叠:在计算过程中,相同的子问题会被反复计算,通过DP可以避免重复计算,提高效率。
动态规划五部曲
-
确定 DP 数组及其下标含义
dp[i][0]
表示第i
天持有股票时的最大利润。dp[i][1]
表示第i
天不持有股票时的最大利润。
-
确定递推公式
- 第
i
天持有股票:
可以由两种情况转移而来:dp[i][0] = max(dp[i - 1][0], -prices[i]);
- 第
i-1
天已经持有股票,那么今天继续持有。 - 第
i-1
天不持有股票,今天买入股票(只允许买一次)。
- 第
- 第
i
天不持有股票:
可以由两种情况转移而来:dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
- 第
i-1
天已经不持有股票,那么今天继续不持有。 - 第
i-1
天持有股票,今天卖出股票。
- 第
- 第
-
确定递推顺序
- 从前向后遍历数组,因为每一天的状态依赖于前一天的状态。
-
DP 数组如何初始化
- 第一天持有股票的利润是
-prices[0]
(因为买入股票)。 - 第一天不持有股票的利润是
0
(因为没有进行任何交易)。
- 第一天持有股票的利润是
-
打印 DP 数组(debug 用)
- 可以在每次状态转移后打印
dp
数组,以调试和验证算法的正确性。
- 可以在每次状态转移后打印
代码与详细注释
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if(len == 0) return 0;
// 1. 确定dp数组及其下标含义:
// dp[i][0]表示第i天持有股票所得的最大利润
// dp[i][1]表示第i天不持有股票所得的最大利润
vector<vector<int>> dp(len, vector<int>(2));
// 4. dp数组如何初始化
// 第一天持有股票的利润是 -prices[0]
dp[0][0] = -prices[0];
// 第一天不持有股票的利润是 0
dp[0][1] = 0;
// 3. 确定递推顺序:从前向后
for(int i = 1; i < len; i++) {
// 2. 确定递推公式
// 第i天持有股票,可能是第i-1天就持有的,也可能是第i天买入的
dp[i][0] = max(dp[i - 1][0], -prices[i]);
// 第i天不持有股票,可能是第i-1天就不持有,也可能是第i天卖出的
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
// 返回最后一天不持有股票的最大利润
return dp[len - 1][1];
}
};
(2)力扣122.买卖股票的最佳时机Ⅱ(多次买卖)
问题描述
在给定的 prices
数组中,prices[i]
表示第 i
天的股票价格。我们需要找到最佳的买卖时机以获取最大利润,可以进行多次交易(多次买入和卖出)。
动态规划特征
- 最优子结构:在第
i
天的最优解依赖于第i-1
天的状态。 - 无后效性:一旦计算出第
i
天的最优解,其结果不会因后续的操作而改变。 - 子问题重叠:在计算过程中,相同的子问题会被反复计算,通过 DP 可以避免重复计算,提高效率。
动态规划五部曲
-
确定 DP 数组及其下标含义
dp[i][0]
表示第i
天持有股票时的最大利润。dp[i][1]
表示第i
天不持有股票时的最大利润。
-
确定递推公式
- 第
i
天持有股票:
可以由两种情况转移而来:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
- 第
i-1
天已经持有股票,那么今天继续持有。 - 第
i-1
天不持有股票,今天买入股票。
- 第
- 第
i
天不持有股票:
可以由两种情况转移而来:dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
- 第
i-1
天持有股票,今天卖出股票。 - 第
i-1
天已经不持有股票,那么今天继续不持有。
- 第
- 第
-
确定递推顺序
- 从前向后遍历数组,因为每一天的状态依赖于前一天的状态。
-
DP 数组如何初始化
- 第一天持有股票的利润是
-prices[0]
(因为买入股票)。 - 第一天不持有股票的利润是
0
(因为没有进行任何交易)。
- 第一天持有股票的利润是
-
打印 DP 数组(debug 用)
- 可以在每次状态转移后打印
dp
数组,以调试和验证算法的正确性。
- 可以在每次状态转移后打印
代码与详细注释
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if(len == 0) return 0;
// 1. 确定dp数组及其下标含义:
// dp[i][0]表示第i天持有股票所得的最大利润
// dp[i][1]表示第i天不持有股票所得的最大利润
vector<vector<int>> dp(len, vector<int>(2));
// 4. dp数组如何初始化
// 第一天持有股票的利润是 -prices[0]
dp[0][0] = -prices[0];
// 第一天不持有股票的利润是 0
dp[0][1] = 0;
// 3. 确定递推顺序:从前向后
for(int i = 1; i < len; i++) {
// 2. 确定递推公式
// 第i天持有股票,可能是第i-1天就持有的,也可能是第i天买入的
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
// 第i天不持有股票,可能是第i-1天就不持有,也可能是第i天卖出的
dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
// 返回最后一天不持有股票的最大利润
return dp[len - 1][1];
}
};
(3)力扣123.买卖股票的最佳时期(两轮买入卖出)
这道题是典型的动态规划问题,目标是在最多进行两次买卖操作的情况下,获取股票的最大利润。我们可以采用动态规划方法来解决,因为它满足动态规划的三大核心特征:
- 最优子结构:每一天的最大利润可以通过前一天的状态来决定。
- 无后效性:当前的状态只依赖于前一天的状态。
- 子问题重叠:在计算过程中,相同的子问题会被多次计算。
代码:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0 || len == 1) return 0;
// 1. 确定dp数组及其下标含义
// dp[i][0][0] 表示第i天属于第1轮,持有股票的最大利润
// dp[i][0][1] 表示第i天属于第1轮,不持有股票的最大利润
// dp[i][1][0] 表示第i天属于第2轮,持有股票的最大利润
// dp[i][1][1] 表示第i天属于第2轮,不持有股票的最大利润
vector<vector<vector<int>>> dp(len, vector<vector<int>>(2, vector<int>(2, 0)));
// 4. dp数组如何初始化
dp[0][0][0] = -prices[0]; // 第0天,第1轮,持有股票
dp[0][0][1] = 0; // 第0天,第1轮,不持有股票
dp[0][1][0] = INT_MIN; // 第0天,第2轮,持有股票(初始化为不可能的极小值)
dp[0][1][1] = INT_MIN; // 第0天,第2轮,不持有股票(初始化为不可能的极小值)
// 3. 确定递推顺序:从前向后遍历
for (int i = 1; i < len; i++) {
// 2. 确定递推公式
// 第i天,第1轮,持有股票
dp[i][0][0] = max(dp[i - 1][0][0], -prices[i]);
// 第i天,第1轮,不持有股票
dp[i][0][1] = max(dp[i - 1][0][1], dp[i - 1][0][0] + prices[i]);
// 第i天,第2轮,持有股票
dp[i][1][0] = max(dp[i - 1][1][0], dp[i - 1][0][1] - prices[i]);
// 第i天,第2轮,不持有股票
dp[i][1][1] = max(dp[i - 1][1][1], dp[i - 1][1][0] + prices[i]);
}
// 返回最后一天,不持有股票的最大利润(可以选择在第1轮或第2轮结束)
return max(dp[len - 1][0][1], dp[len - 1][1][1]);
}
};
(4)买卖股票的最佳时机Ⅳ(最多买卖k轮)
这道题是一个典型的动态规划问题,目标是在最多进行 k
次买卖操作的情况下,获取股票的最大利润。我们可以采用动态规划方法来解决,因为它满足动态规划的三大核心特征:
- 最优子结构性质:每一天的最大利润可以通过前一天的状态来决定。
- 无后效性:当前的状态只依赖于前一天的状态。
- 子问题重叠性质:在计算过程中,相同的子问题会被多次计算。
动态规划五部曲分析
-
确定 DP 数组及其下标含义:
dp[i][j]
表示第i
天的状态为j
时的最大现金。j
的取值范围为0
到2*k
,其中:j=0
表示不操作。j=1
表示第一次买入。j=2
表示第一次卖出。j=3
表示第二次买入。j=4
表示第二次卖出。- 依此类推,
j=2*k-1
表示第k
次买入,j=2*k
表示第k
次卖出。
-
确定递推公式:
dp[i][j+1] = max(dp[i-1][j] - prices[i], dp[i-1][j+1])
:- 第
i
天处于买入状态。可能由第i-1
天卖出或不操作状态转移过来,也可能延续第i-1
天的买入状态。
- 第
dp[i][j+2] = max(dp[i-1][j+1] + prices[i], dp[i-1][j+2])
:- 第
i
天处于卖出状态。可能由第i-1
天买入状态转移过来,也可能延续第i-1
天的卖出状态。
- 第
-
确定递推顺序:
- 从前向后遍历数组
prices
。
- 从前向后遍历数组
-
DP 数组如何初始化:
- 在第0天,所有的卖出状态都初始化为0(即
dp[0][0] = 0
,dp[0][2] = 0
, …)。 - 所有的买入状态初始化为极小值(表示不可能的状态),但第0天的第一次买入需要特殊处理
dp[0][1] = -prices[0]
。
- 在第0天,所有的卖出状态都初始化为0(即
-
打印 DP 数组(debug 用):
- 在每次状态转移后,可以打印
dp
数组以调试和验证算法的正确性。
- 在每次状态转移后,可以打印
代码详解
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int len = prices.size();
if (len == 0 || len == 1) return 0;
// 1. 确定 dp 数组及其下标含义
// dp[i][j] 表示第 i 天的状态为 j 时的最大现金
// j 的取值范围为 0 到 2*k
vector<vector<int>> dp(len, vector<int>(2 * k + 1, 0));
// 4. dp 数组如何初始化
// 初始化所有的买入状态
for (int j = 1; j < 2 * k; j += 2) {
dp[0][j] = -prices[0];
}
// 3. 确定递推顺序:从前向后遍历数组 prices
for (int i = 1; i < len; i++) {
// 2. 确定递推公式
for (int j = 0; j < 2 * k - 1; j += 2) {
// 第 i 天处于买入状态
dp[i][j + 1] = max(dp[i - 1][j] - prices[i], dp[i - 1][j + 1]);
// 第 i 天处于卖出状态
dp[i][j + 2] = max(dp[i - 1][j + 1] + prices[i], dp[i - 1][j + 2]);
}
}
// 返回最后一天不持有股票的最大利润
return dp[len - 1][2 * k];
}
};
(5)力扣309. 买卖股票的最佳时期含冷冻期
在做这道题目之前,我曾经做过几道力扣股票系列的其他题目。与其他题目不同的是,这道题目增加了一个冷冻期,使得状态变得十分复杂。
一开始,我只考虑了3个状态:
- 买入状态
- 卖出状态
- 冷冻期
这种状态定义方式使我只能通过50%的测试用例。究其原因,是上述三个状态会导致状态转移发生混乱(不准确)。具体而言,冷冻期其实将卖出状态分为了两个部分,而冷冻期只能由前一个部分(即“今天卖出状态”)转移来,不能由“保持卖出状态”转移。如果将两个卖出状态合并,我们定义的状态转移方程将会存在状态转移错误,即冷冻状态也可以由“保持卖出状态”转移。
如下图所示:
修改为4个状态后,这道题目成功通过了所有测试用例。深入思考和分析这道题目之后,我对“状态转移”四个字的理解更加深刻。下面是具体的代码及其分析。
动态规划五部曲分析
-
确定 DP 数组及其下标含义:
dp[i][0]
:第i
天处于买入状态时的最大利润。dp[i][1]
:第i
天处于保持卖出状态时的最大利润。dp[i][2]
:第i
天处于今天卖出状态时的最大利润。dp[i][3]
:第i
天处于冷冻期状态时的最大利润。
-
确定递推公式:
dp[i][0] = max(dp[i-1][0], max(dp[i-1][3] - prices[i], dp[i-1][1] - prices[i]))
:- 今天买入状态,可以由前一天保持买入状态、冷冻期状态买入或者保持卖出状态买入转移而来。
dp[i][1] = max(dp[i-1][1], dp[i-1][3])
:- 今天保持卖出状态,可以由前一天保持卖出状态或者前一天冷冻期状态转移而来。
dp[i][2] = dp[i-1][0] + prices[i]
:- 今天卖出状态,由前一天买入状态转移而来。
dp[i][3] = dp[i-1][2]
:- 今天冷冻期状态,由前一天卖出状态转移而来。
-
确定递推顺序:
- 从前向后遍历数组
prices
。
- 从前向后遍历数组
-
DP 数组如何初始化:
- 第0天买入状态初始化为
-prices[0]
,表示第0天买入股票后的最大利润。 - 其他状态初始化为0或
INT_MIN
,表示不可能的状态。
- 第0天买入状态初始化为
-
打印 DP 数组(debug 用):
- 在每次状态转移后,可以打印
dp
数组以调试和验证算法的正确性。
- 在每次状态转移后,可以打印
代码详解
class Solution {
public:
int maxProfit(vector<int>& prices) {
// 1. 确定 DP 数组及其下标含义
vector<vector<int>> dp(prices.size(), vector<int>(4, 0));
// 4. DP 数组如何初始化
dp[0][0] = -prices[0];
dp[0][1] = 0;
dp[0][2] = INT_MIN; // 初始化为不可能的极小值
dp[0][3] = INT_MIN;
// 3. 确定递推顺序
for (int i = 1; i < prices.size(); i++) {
// 2. 确定递推公式
// 买入状态
dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]));
// 保持卖出状态
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
// 今天卖出
dp[i][2] = dp[i - 1][0] + prices[i];
// 冷冻状态
dp[i][3] = dp[i - 1][2];
// 5. 打印 DP 数组(debug 用)
// cout << dp[i][0] << ' ' << dp[i][1] << ' ' << dp[i][2] << ' ' << dp[i][3] << endl;
}
// 返回最后一天的最大利润
return max(dp[prices.size() - 1][3], max(dp[prices.size() - 1][1], dp[prices.size() - 1][2]));
}
};
通过上述分析,我们可以确保算法的正确性,并且通过动态规划方法有效地解决了含冷冻期的买卖股票问题。这样的方法利用了动态规划的三大特征,可以高效地找到最优解。
(6)力扣714.买卖股票的最佳时机(含手续费)
原题链接
这道题目我是紧随309做的,用自己的思路一次AC,但是有新的体会,我的代码:
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
//1.确定dp数组及其下标含义
//dp[i][0]:买入付费状态
//dp[i][1]:买入保持状态
//dp[i][2]:卖出状态(不持有股票)
//第i天的状态为j,最大现金为dp[i][j]
vector<vector<int>> dp(prices.size(), vector<int>(3,0));
//4.dp数组如何初始化
dp[0][0] = -prices[0] - fee;
dp[0][1] = -prices[0] - fee;
//3.确定递推公式
for(int i = 1; i < prices.size(); i++){
//2.确定递推公式
//买入付费状态
dp[i][0] = dp[i-1][2]-prices[i] - fee;
//买入保持状态
dp[i][1] = max(dp[i-1][0], dp[i-1][1]);
//卖出状态
dp[i][2] = max(max(dp[i-1][0] + prices[i], dp[i-1][1] + prices[i]), dp[i-1][2]);
//cout << dp[i][0] << ' ' << dp[i][1] <<' ' << dp[i][2] << ' ' << endl;
}
return dp[prices.size() - 1][2];
}
};
在这个解法中,我将买入状态分为了“买入付费状态”和“买入保持状态”,使得状态定义显得冗余。受到上一道题(309题)的影响,我考虑了买入时的手续费支付问题。然而,实际上,手续费的支付时机既可以放在买入时(我的方案),也可以放在卖出时(大佬的方案)。
大佬的解法:
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2, 0));
dp[0][0] -= prices[0]; // 持股票
for (int i = 1; i < n; i++) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee);
}
return max(dp[n - 1][0], dp[n - 1][1]);
}
};
总而言之,定义状态的多少其实不是解体的关键,解题的关键是我们的状态不混乱、能够正确转移。如果将状态更加精细化可以帮助我们理清思路,状态转移正确,这也是可为的。
3.3 子序列问题
(1)力扣300.最长递增子序列与力扣674.最长连续递增序列
//2.确定递推公式
//以nums[i]结尾的子序列的最大长度是由nums[i]前面的子序列接上nums[i]后仍然是一个递增子序列的长度的最大值
for(int j = 0; j < i; j++){
if(nums[j] >= nums[i]) continue;
dp[i] = max(dp[i], dp[j] + 1);
}
后者:
//2.确定递推公式
//以nums[i]结尾的子序列的最大长度要么是初始化的1,即只有nums[i]一个元素,因为nums[i]相对前一个元素不是严格递增的,要么是nums[i]之前的元素严格递增子序列的长度+1,此时,nums[i]是一次比上一个元素更大的一个元素
if(nums[i] > nums[i - 1]) dp[i] = dp[i - 1] + 1;
(2)力扣718.最长重复子数组
这道题目在开始时我是有些不知道如何下手的,但当我想到只有当数组1的一个元素等于数组2的一个元素时,重复子序列的长度才能加1,因此二者的i推公式不同:
if(nums1[i] == nums2[j])
我立马就想到最直接的方法是使用二维dp,之后状态转移变得简单起来,完整代码如下:
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
//1.确定dp数组及其下标含义
//以下标i对应元素结尾的数组1与以下标j对应元素结尾的数组2的最长重复子数组为dp[i][j]
vector<vector<int> > dp(nums1.size(), vector<int>(nums2.size(), 0));
//4.dp数组如何初始化
//dp[1][j] = dp[0][j - 1] + 1 ---->所有dp[0][j]都需要初始化
//dp[i][1] = dp[i - 1][0] + 1 ---->所有dp[i][0]都需要初始化
bool isSame = false;
for(int j = 0; j < nums2.size(); j ++){
if(nums1[0] == nums2[j]){
dp[0][j] = 1;
isSame = true;
}
}
for(int i = 0; i < nums1.size(); i++){
if(nums1[i] == nums2[0]){
dp[i][0] = 1;
isSame = true;
}
}
//默认两个数组中可以没有相同元素
int result = 0;
//经历过初始化后,如果发现有
if(isSame) result = 1;
//3.确定递推顺序
for(int i = 1; i < nums1.size(); i++){
//2.确定递推公式
for(int j = 1; j < nums2.size(); j++){
//如果数组1的当前元素与数组2的当前元素相等,最长长度在上一个状态(数组1以i-1结尾,数组2以j-1结尾)的基础上+1
//题目隐含的着“子数组”是连续的意思,因此dp[i][j] 只能由dp[i - 1][j - 1]转移而来,而不是dp[i - 1][j]或dp[i][j - 1]
if(nums1[i] == nums2[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
//cout << dp[i][j] << " ";
///收集结果
if(dp[i][j] > result) result = dp[i][j];
}
//cout << endl;
}
return result;
}
};
3.5体会“遍历顺序”
“遍历顺序”是跟着“动态转移方程来的”,我开始没有这个概念,在做完647. 回文子串,我后知后觉,代码如下:
class Solution {
public:
int countSubstrings(string s) {
//1.确定dp数组及其下标含义
//dp[i][j]表示区间【i,j】是否是回文串,0表示不是,1表示是
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
//4.dp数组如何初始化
int result = 0;
//3.确定遍历顺序
//根据递推公式,在dp表中,dp[i+1][j-1]在dp[i][j]下方、左方从下到上,从左到右
for(int i = s.size() - 1; i >=0; i--){
for(int j = i; j < s.size(); j++){
//2.确定递推公式
//dp[i][j]由dp[i+1][j-1]推导出来
if(s[i] == s[j]){
//同一个字符或两个相邻的字符
if(j - i <= 1){
dp[i][j] = 1;
result++;
}
else{
if(dp[i+1][j-1]){
dp[i][j] = 1;
result++;
}
}
}
//如果i和j不相等,不更新dp
}
}
return result;
}
};
3.6体会“背包问题”
(1)力扣416.分割等和子集
class Solution {
public:
bool canPartition(vector<int>& nums) {
//分析:判断能否分割等和子集,即要找到一些元素,使得它们的和是数组总和的一半
//总和的一半,就相当于是背包容量了,而每个数组元素的大小,也就是物品的价值和重量了
//问题就变为了:从数组中选元素,每个元素只选一次,装满背包的最大价值能否是数组元素总和的一般
//该问题符合动态规划的三个特征:
//(1)最优子结构性质:从所有元素中选取元素获取最大价值包含了从部分元素装满部分背包容量的最大价值这个子问题
//(2)无后效性:一条选择路径上的某个阶段性背包价值和不受这条路径上后续阶段的选择的总和的影响
//(3)子问题的重叠性质:一条物品选择子路径可能会被多条完整选择路径包含
//思路:套入0-1背包的模板
//求数组总和,确定背包容量
int sum = 0;
for(int i = 0; i < nums.size(); i++){
sum += nums[i];
}
if(sum % 2 != 0) return false;
int target = sum / 2;
//基于动态规划思想求解指定背包容量下的最大值
//1.确定dp数组及其下标含义
//装满容量为j的背包的最大价值为dp[j]
vector<int> dp(10001, 0);
//4.dp数组如何初始化
//每个数组元素都大于0,那么我们dp默认值可以初始化为0,这样保证每个dp都会被更大的值覆盖
//3.确定遍历顺序
for(int i = 0; i < nums.size(); i ++){
//2.确定递推公式,也即状态转移方程
//背包容量要倒序遍历,保证一个物品只使用一次
//同时,倒序遍历保证了“滚动数组”在使用时的正确的状态转移:当前滚动轮的dp是由上一轮更小下标dp转移而来,如果是顺序遍历,会导致上一轮还没有转移,在这一轮就已经更新了
for(int j = target; j >= nums[i]; j--){
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
//剪枝:在使用部分物品就已经找到了目标值,后续就没有必要继续装包了
if(dp[j] == target) break;
}
//剪枝:在使用部分物品就已经找到了目标值,后续就没有必要继续装包了
if(dp[target] == target) break;
}
return dp[target] == target;
//返回结果
}
};
(2)力扣474.一零和
这道题是一个最优化问题,也是一个背包问题,背包的容量有两个维度:1和0的数量。那么我们就可以考虑使用DP来解决,将题目带入DP的三大特征:
- 最优子结构性质:某一个大容量的背包的最大长度(大小/价值)由这个比这个背包更小容量的某一个背包的最大长度构成。
- 无后效性:一个小容量的背包的长度确定了,当背包容量扩大后,原容量能装的最大长度不变,新容量能装的最大长度不会影响到原容量
- 子问题重叠性质:举一个例子,装容量为8的背包获取最大长度的计算方法与装容量为100的背包的计算方法是一样的:
for (int j = bagSize; j >= nums[i]; j--) {
// 更新 dp 数组,当前容量 j 的方法数等于之前容量 不能选i字符串 和 现在容量j选了i字符串,背包剩余容量为j-nums[i] 的方法数之和
dp[j] += dp[j - nums[i]];
}
完整代码:
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// 1. 确定dp数组及其下标含义
// dp[i][j] 表示使用最多 i 个 0 和 j 个 1 能够构成的字符串的最大数量
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 4. dp数组如何初始化
// 初始化时,dp数组全部设置为0,因为在没有选择任何字符串的情况下,0个0和0个1构成的字符串数量为0
// dp[i][j] 初始化为0, 代表在没有选择任何字符串的情况下能够构成的字符串数量为0
// 这里已经在定义dp数组时初始化为0了: vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 遍历每一个字符串
for(string str: strs){
int oneNum = 0;
int zeroNum = 0;
// 统计当前字符串中的 0 和 1 的数量
for(char c : str){
if(c == '0') zeroNum++;
else oneNum++;
}
// 3. 确定递推顺序
// 遍历背包容量时,必须从后向前遍历,避免重复计算
for(int i = m; i >= zeroNum; i--){
for(int j = n; j >= oneNum; j--){
// 2. 确定递推公式
// 选择当前字符串 str 后,dp[i][j] 的值应为:
// 选择当前字符串的方案数 dp[i - zeroNum][j - oneNum] + 1 和不选择当前字符串的方案数 dp[i][j] 的最大值
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
// 5. 打印dp数组(debug用)
// 用于调试,查看dp数组的状态
/*
for(int i = 0; i <= m; i++){
for(int j = 0; j <= n; j++){
cout << dp[i][j] << " ";
}
cout << endl;
}
*/
// 返回能够构成的字符串的最大数量
return dp[m][n];
}
};
(3)力扣518.零钱兑换
class Solution {
public:
int change(int amount, vector<int>& coins) {
// 1. 确定dp数组及其下标含义
// dp[j] 表示金额为 j 的时候,硬币组合的总数
vector<int> dp(amount + 1, 0);
// 4. dp数组如何初始化
// 初始化 dp[0] = 1,因为凑成金额为 0 的唯一方法是使用0个硬币
dp[0] = 1;
// 3. 确定递推顺序
// 遍历每一种硬币,从前向后更新 dp 数组
for(int i = 0; i < coins.size(); i++){
// 对于每个硬币,从该硬币的面值开始更新到金额 amount
for(int j = coins[i]; j <= amount; j++){
// 2. 确定递推公式
// dp[j] 当前的值等于不使用当前硬币的组合数 dp[j]
// 加上使用当前硬币的组合数 dp[j - coins[i]]
dp[j] += dp[j - coins[i]];
}
}
// 5. 打印dp数组(debug用)
/*
for(int i = 0; i <= amount; i++){
cout << "dp[" << i << "] = " << dp[i] << endl;
}
*/
// 返回凑成金额 amount 的组合数
return dp[amount];
}
};
(4)力扣139.单词拆分
我们需要确定给定的字符串 s
能否被拆分成一个或多个字典中的单词。我们可以采用动态规划(Dynamic Programming, DP)方法解决这个问题,因为它满足动态规划的三大核心特征:
- 最优子结构:字符串
s
的前i
个字符能否被拆分成字典中的单词,依赖于j
到i
的子字符串是否在字典中,以及j
之前的字符能否被拆分。 - 无后效性:一旦计算出字符串
s
的前i
个字符能否被拆分,这个结果不会因后续的计算而改变。 - 子问题重叠:在计算过程中,相同的子问题会被反复计算多次,使用 DP 数组可以避免重复计算,提高效率。
动态规划五部曲分析
-
确定 DP 数组及其下标含义
dp[i]
表示字符串s
的前i
个字符能否被拆分成字典中的单词。
-
确定递推公式
- 对于每个位置
i
,检查从j
到i
的子字符串s[j, i)
是否在字典中。 - 如果
s[j, i)
在字典中,且dp[j]
为true
,则dp[i]
也为true
。 - 递推公式为:如果
wordSet.find(s.substr(j, i - j)) != wordSet.end() && dp[j]
,则dp[i] = true
。
- 对于每个位置
-
确定递推顺序
- 外层循环遍历字符串的每个位置
i
(相当于背包),从1
到s.size()
。 - 内层循环遍历字符串的每个子串(相当于物品),从
0
到i
。
- 外层循环遍历字符串的每个位置
-
DP 数组如何初始化
dp[0]
表示空字符串可以被拆分,因此初始化为true
。
-
打印 DP 数组(debug 用)
- 在计算过程中,可以通过打印
dp
数组来查看每个位置是否能被拆分,以便调试和理解算法。
- 在计算过程中,可以通过打印
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
//1.确定dp数组及其下标含义:
//dp[i]表示字符串s的前i个字符能被拆分成字典中的单词
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
//4.dp数组如何初始化
//dp[0]表示空字符串可以被拆分,因此初始化为true
dp[0]= true;
//3.确定递推顺序
// 外层循环遍历字符串的每个位置 i(相当于背包)
for(int i = 1; i <= s.size(); i++){
//内存循环遍历字符串的子串(相当于物品)
for(int j = 0; j < i; j++){
//2.确定递推公式:
//如果串word再字典中,且串word前面的dp[j]为true,则dp[i]也为true
//字符串s的截取范围为0~s.size()-1,与dp的下标范围不一样,对于dp的1~s.size()
string word = s.substr(j, i - j);
if(wordSet.find(word) != wordSet.end() && dp[j]){
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
3.7体会树形dp
(1)力扣337.打家劫舍Ⅲ
问题描述
在一个二叉树中,每个节点代表一个房屋,每个房屋都有一个价值。我们需要确定在不相邻(父子关系)房屋被盗的情况下,能盗取的最大价值。这是一个典型的动态规划问题,具有以下特征:
- 最优子结构:当前节点的最优解依赖于其子节点的最优解。
- 无后效性:一旦确定某个节点的最优解,其结果不会因后续操作而改变。
- 子问题重叠:递归过程中相同子问题会被多次计算,通过DP可以优化。
动态规划五部曲分析
-
确定 DP 数组及其下标含义
- 每个节点对应一个长度为
2
的数组dp
,dp[0]
表示不偷该节点,dp[1]
表示偷该节点。
- 每个节点对应一个长度为
-
确定递推公式
- 若偷当前节点
cur
:则左右子节点不能偷,即val1 = cur->val + left[0] + right[0]
。 - 若不偷当前节点
cur
:则左右子节点可偷可不偷,取决于最大值,即val2 = max(left[0], left[1]) + max(right[0], right[1])
。
- 若偷当前节点
-
确定递推顺序
- 采用后序遍历,从底向上计算每个节点的最优解。
-
DP 数组如何初始化
- 空节点返回
{0, 0}
,表示不偷和偷的价值均为0
。
- 空节点返回
-
打印 DP 数组(debug 用)
- 可在递归中打印每个节点的
dp
数组,检查计算过程是否正确。
- 可在递归中打印每个节点的
代码与详细注释
class Solution {
public:
int rob(TreeNode* root) {
// 递归调用 robTree 函数,获取根节点的两种状态下的最大偷取金额
vector<int> result = robTree(root);
// 返回不偷根节点和偷根节点中的较大值
return max(result[0], result[1]);
}
// robTree 函数用于计算某个节点的最大偷取金额
// 返回值是一个长度为2的数组:第0个元素表示不偷该节点的最大金额,第1个元素表示偷该节点的最大金额
vector<int> robTree(TreeNode* cur) {
if (cur == nullptr) return vector<int>{0, 0}; // 空节点返回{0, 0}
// 后序遍历,先计算左右子节点的值
vector<int> left = robTree(cur->left);
vector<int> right = robTree(cur->right);
// 偷当前节点,左右子节点不能偷
int val1 = cur->val + left[0] + right[0];
// 不偷当前节点,左右子节点可以偷也可以不偷,取其最大值
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
// 返回当前节点的两种状态下的最大金额
return {val2, val1};
}
};
编辑距离问题
“距离”也可以理解为“操作次数”,这类问题可以进一步具体化为:
- 两字符串的最大公共子串长度(相对位置不变,不必连续)
- 两字符串的最长重复子串长度(相对位置不变,需要连续)
- 字符串1在字符串2中出现的次数(相对位置不变,不必连续)
- 编辑字符串1和字符串2使它们相同的最小操作次数(只能进行“删除”操作)
- 编辑字符串1使之成为字符串2的最小操作次数(可以进行“删除”、“增加”、“替换”操作)