动态规划名字看起来高大上,感觉是种很复杂的算法,令人“望文生畏”,其实一句话概括,就是 数学归纳法,推公式 。动态规划问题的每一个状态是由上一个状态通过 状态转移方程 推导得出(对于存在很多状态的问题,需要画状态图辅助推导出正确的状态转移方程,类似《编译原理》的自动状态机)。动态规划是一种 “聪明的穷举” ,所谓具备 “最优子结构” 和存在 “重叠子问题” 。本质上来说,动态规划是通过引入dp数组这种 “空间换时间” 的方法来降低暴力算法的时间复杂度。
参考Carl的方法,牢牢把握“五部曲”:
确定dp数组以及下标的含义
确定递推公式
dp数组如何初始化
确定遍历顺序
举例推导dp数组
解释说明:
// 时间、空间复杂度O(N)
class Solution {
public:
int fib(int n) {
if (n == 0 || n == 1) return n;
vector<int> dp(n + 1, 0);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n ; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
// 当然可以发现,只需要维护两个数值就可以了,不需要记录整个序列。下面写法空间复杂度O(1)
class Solution {
public:
int fib(int N) {
if (N <= 1) return N;
int dp[2];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= N; i++) {
int sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1];
}
};
class Solution {
public:
int climbStairs(int n) {
if (n < 3) return n;
vector<int> dp(n + 1);
// 本题考虑关于dp[0]的初始化没意义
// i从3开始,直接跳过dp[0]
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
// 还可以优化空间复杂度
// 【扩展】完全背包:每次走m下,几种方法到n层台阶
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
// 初始化包含了将dp[0],dp[1]设为0
// 此题并非跳到最后一级台阶截止,而是要跳出所有楼梯,dp要多一个
vector<int> dp(cost.size() + 1, 0);
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()];
}
};
// 还可以优化空间复杂度O(1)
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector(n, 0));
// 第一行,第一列的所有元素均只有一种到达方式
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
// 从(1,1)开始
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
//如果在起点或终点出现了障碍,直接返回0
if (obstacleGrid[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1] == 1 || obstacleGrid[0][0] == 1) return 0;
vector<vector<int>> dp(obstacleGrid.size(), vector<int>(obstacleGrid[0].size(), 0));
for (int i = 0; i < obstacleGrid.size(); i++) {
if (obstacleGrid[i][0] == 1)
break;
dp[i][0] = 1;
}
for (int j = 0; j < obstacleGrid[0].size(); j++) {
if (obstacleGrid[0][j] == 1)
break;
dp[0][j] = 1;
}
for (int i = 1; i < obstacleGrid.size(); i++) {
for (int j = 1; j < obstacleGrid[0].size(); j++) {
if (obstacleGrid[i][j] == 0) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
}
};
class Solution {
public:
int integerBreak(int n) {
if (n == 2) return 1;
vector<int> dp(n + 1, 0);
dp[2] = 1;
for (int i = 3; i <= n; i++) {
// 因为拆分一个数 n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的
// 只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
// 那么 j 遍历,只需要遍历到 i/2 就可以,后面就没有必要遍历了,一定不是最大值。
for (int j = 1; j <= i / 2; j++) {
// 从1遍历j,然后有两种渠道得到dp[i]
// 一个是j * (i - j) 直接相乘,一个是j * dp[i - j]
// j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘
// j怎么就不拆分呢?j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了
dp[i] = max(dp[i], max(dp[i - j] * j, (i - j) * j));
}
}
return dp[n];
}
};
class Solution {
public:
int numTrees(int n) {
if (n == 1) return n;
vector<int> dp(n + 1, 0);
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i; j++) {
// dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
// j 相当于是头结点的元素,从1遍历到i为止
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
引用自:代码随想录-背包总结篇
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
1、确定dp数组以及下标的含义
使用二维数组,即dp[i] [j]表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少
2、确定递推公式
有两个方向推出来dp[i] [j],
所以递归公式: dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);
3、dp数组如何初始化
首先从dp[i] [j]的定义出发,如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为0;那么很明显当 j < weight[0]的时候,dp[0] [j] 应该是 0,因为背包容量比编号0的物品重量还小,当j >= weight[0]时,dp[0] [j] 应该是value[0],因为背包容量放足够放编号0物品
4、确定遍历顺序
递推数据由上、左上得来,先遍历物品、背包均可。为了好理解建议先遍历物品,即i在外循环j在内循环
5、举例推导dp数组
最终结果就是dp[2] [4]
背包问题其实状态都是可以压缩的。可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i] [j] = max(dp[i] [j], dp[i] [j - weight[i]] + value[i]);与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
【遍历顺序的讨论】
【为什么倒序遍历背包容量?】二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。为什么呢?倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
【为什么二维dp数组历的时候不用倒序?】
因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,本层的dp[i] [j]并不会被覆盖!
【为什么不能先遍历背包容量嵌套遍历物品呢?】
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
五部曲省略,直接上测试代码
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:
只有确定了如下四点,才能把0-1背包问题套到本题上来。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums)
sum += num;
if (sum % 2 == 1) return false;
int target = sum / 2;
// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 总和不会大于20000,背包最大只需要其中一半,所以dp大小设置为10001就可以了
// 但我们已经计算出了实际上总和的一半,因此比一半大即可
vector<int> dp(target + 1, 0);
for (int i = 0; i < nums.size(); i++) {
// 每一个元素一定是不可重复放入,所以从大到小遍历
for (int j = target; j >= nums[i]; j--) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
if (dp[target] == target) return true;
return false;
}
};
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for (int stone : stones)
sum += stone;
int half = sum / 2;
vector<int> dp(half + 1, 0);
for (int i = 0; i < stones.size(); i++) {
for (int j = half; j >= stones[i]; j--) {
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
// 与 416 仅返回值的处理不同
// 此时dp[half]中存放最接近但一定小于等于一半重量的石头,另一堆重量是sum - dp[half]
return sum - 2 * dp[half];
}
};
dp[j] += dp[j - nums[i]] ,注意到有累加求和,对应题目如下:
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
// 本质是找两堆数,差为target,也可以剪枝回溯,但是carl这版的思路还需要在理解一下
// 假设正数和为x,即将但还未添加负号的负数和为sum - x,目标和target = x - (sum - x)
// 则 x = (target + sum) / 2,为背包大小
int sum = accumulate(nums.begin(), nums.end(), 0);
if (abs(target) > sum) return 0;
if ((target + sum) % 2 == 1) return 0;
int bagSize = (target + sum) / 2;
vector<int> dp(bagSize + 1, 0);
// 只有0,无论正负都只有一种方法
dp[0] = 1;
// 0-1背包一维数组遍历准则,先物品再背包,且背包倒序
for (int i = 0; i < nums.size(); i++) {
for (int j = bagSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[bagSize];
}
};
不需要对零钱排序
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for (int i = 0; i < coins.size(); i++) {
for (int j = coins[i]; j <= amount; j++) {
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
class Solution {
public:
// 仅仅问个数,不需要输出全部组合,因此不建议用回溯
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
// 本题求排列数,就是外层for遍历背包,内层for循环遍历物品
for (int i = 1; i <= target; i++) {
for (int j = 0; j < nums.size(); j++) {
// C++测试用例有两个数相加超过int的数据,所以需要在if里加上
if (i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]) {
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
每次上 1~m 阶,一共需要爬到 n 位置,有几种方式
class Solution {
public:
int climbStairs(int n) {
return generalClimbStairs(2, n);
}
int generalClimbStairs(int m, int n) {
// m物品n完全背包
vector<int> dp(n + 1, 0);
dp[0] = 1;
// 求组和,先背包再物品
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (i - j >= 0) {
dp[i] += dp[i - j];
}
}
}
return dp[n];
}
};
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:
【难】这题的dp数组含义不好想。遍历方式虽然是先物品再背包,但是物品是以str形式遍历,且遍历背包时,背包有两个维度。
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// dp[i][j]含义,最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 遍历物品
for (string str : strs) {
int zeroNum = 0, oneNum = 0;
for (char c : str) {
if (c == '0') zeroNum++;
else oneNum++;
}
// 倒序遍历背包,背包有两个维度
for (int i = m; i >= zeroNum; i--) {
for (int j = n; j >= oneNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
return dp[m][n];
}
};
dp[j] = min(dp[j], dp[j - coins[i]] + 1); ,对应题目如下:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
// 本题求最少
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
// 本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数
// 所以本题并不强调集合是组合还是排列,先物品后背包
for (int i = 0; i < coins.size(); i++) {
for (int j = coins[i]; j <= amount; j++) {
if (dp[j - coins[i]] != INT_MAX) { // 如果dp[j - coins[i]]是初始值则跳过
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
// 求最小数,for的内外顺序无所谓
for (int i = 1; i * i <= n; i++) { // 遍历物品
for (int j = i * i; j <= n; j++) { // 遍历背包
dp[j] = min(dp[j - i * i] + 1, dp[j]);
}
}
return dp[n];
}
};
如果 dp[j] = true,且 [j, i] 这个区间子串出现在字典,那么 dp[i]也一定是true(j < i )
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
// 无意义,完全是为了初始化
dp[0] = true;
// 本题是求排列,因为字符串现后顺序不能乱
// 先遍历 背包,再遍历物品
for (int i = 1; i <= s.size(); i++) {
for (int j = 0; j < i; j++) {
string word = s.substr(j, i - j);
// 如果dp[j] = true,且 [j, i] 这个区间子串出现在字典,那么dp[i]也一定是true(j < i )
if (wordSet.find(word) != wordSet.end() && dp[j]) {
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
【少见】二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历
【常用】一维dp数组01背包 只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历,防止重复!
对纯完全背包,两个for循环先后循序无所谓,那么题目稍有变化,可就有所谓了
【少】纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历
如果求组合数就是外层for循环遍历物品,从小到大,内层for遍历背包,从小到大
如果求排列数就是外层for遍历背包,从小到大,内层for循环遍历物品,从小到大
相关题目之前已经介绍过,再次总结如下:
如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:
每件物品最多有Mi件可用,把Mi件摊开,按每行只有一件物品排开,其实就是一个01背包问题了。多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
for (int i = 0; i < nums.size(); i++) {
while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
从一维数组上打劫
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
// 后面的状态依赖前面,一定是从前向后推出
for (int i = 2; i < nums.size(); i++) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[nums.size() - 1];
}
};
从环形一维数组上打劫
class Solution {
public:
// 不用%,二是考虑两种情况
int rob(vector<int>& nums) {
int size = nums.size();
if (size == 0) return 0;
if (size == 1) return nums[0];
return max(robRange(nums, 0, size - 2), robRange(nums, 1, size - 1));
}
// 仅对[begin, end]区间打劫
int robRange(vector<int>& nums, int begin, int end) {
if (begin == end) return nums[begin];
vector<int> dp(nums.size(), 0);
dp[begin] = nums[begin];
dp[begin + 1] = max(nums[begin], nums[begin + 1]);
// 后面的状态依赖前面,一定是从前向后推出
for (int i = begin + 2; i <= end; i++) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[end];
}
};
从树的根节点上打劫
class Solution {
public:
int rob(TreeNode* root) {
vector<int> result = robTree(root);
return max(result[0], result[1]);
}
// 树形dp,{偷当前节点最大打劫值,不偷当前节点最大打劫值}
vector<int> robTree(TreeNode* cur) {
if (cur == nullptr) return vector<int>{0, 0};
// 记录左右子树最大打劫值
// 后序遍历,左、右
vector<int> left = robTree(cur->left);
vector<int> right = robTree(cur->right);
// 根
// 偷cur,那么就不能偷其左右节点
int val1 = cur->val + left[0] + right[0];
// 不偷cur,那么可以偷也可以不偷左右节点,则取较大的情况
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
return {val2, val1};
}
};
class Solution {
public:
// 记忆化递归
unordered_map<TreeNode* , int> umap; // 记录计算过的结果
int rob(TreeNode* root) {
if (root == NULL) return 0;
if (root->left == NULL && root->right == NULL) return root->val;
if (umap[root] != 0) return umap[root]; // 如果umap里已经有记录则直接返回
// 偷父节点
int val1 = root->val;
if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left
if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right
// 不偷父节点
int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
umap[root] = max(val1, val2); // umap记录一下结果
return max(val1, val2);
}
};
1、根据题目中所包含的状态数确定二维 dp 数组的列数,如309题,包含了4种状态和7种转移方程,因此二维dp数组有4列
2、对于仅有买入卖出状态,dp[i] [0]表示第 i 天持有股票时的最大资产,dp[i] [1]表示第 i 天不持有股票时的最大资产
还可以贪心,求左最小和右最大的差值
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
// 只需要记录 当前天的dp状态和前一天的dp状态就可以了,可以使用滚动数组来节省空间
// 开一个2*2的数组即可
vector<vector<int>> dp(len, vector<int>(2, 0));
// 第1天持有股票,必定是先买入尚未盈利,第1天不持有股票,不买就不亏
dp[0][0] = -prices[0];
dp[0][1] = 0;
// 第2天开始
for (int i = 1; i < len; i++) {
// 持有股票时的最大资产,是前一天持有股票时的资产递推到今天/或是今天刚买入
dp[i][0] = max(dp[i - 1][0], -prices[i]);
// 不持有股票时的最大资产,是前一天不持有股票时的资产递推到今天/或是今天刚卖出
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
}
// 实际上,本题一定是最后不持有股票的资产高
return max(dp[len - 1][1], dp[len - 1][0]);
}
};
还可以贪心,拆分利润,累加每天的利润和
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<vector<int>> dp(len, vector<int>(2, 0));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]); // 注意这里是和上一题的唯一不同
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
}
// 最后一定是不持有股票(全部卖出)的资产高
return dp[len - 1][1];
}
};
第i天,一共有五个状态
class Solution {
public:
int maxProfit(vector<int>& prices) {
if (prices.size() == 0) return 0;
// 五个状态
vector<vector<int>> dp(prices.size(), vector<int>(5, 0));
// dp[0][0]不操作
dp[0][1] = -prices[0]; // 第一次持有
// dp[0][2] dp[0][4]第一、二次买卖仍为0
dp[0][3] = -prices[0]; // 第二次持有
for (int i = 1; i < prices.size(); i++) {
dp[i][0] = dp[i - 1][0];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
}
// 第二次卖出手里所剩的钱一定是最多的
return dp[prices.size() - 1][4];
}
};
第i天,一共有 n 个状态
0 没有操作 (其实我们也可以不设置这个状态)
1 第一次持有股票
2 第一次不持有股票
3 第二次持有股票
4 第二次不持有股票
5 …
除了0以外,偶数就是卖出,奇数就是买入
题目要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以了
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if (prices.size() == 0) return 0;
// 一天最多两次交易,总交易数不超过 2 * k
vector<vector<int>> dp(prices.size(), vector<int>(2 * k + 1, 0));
// 奇数是买入,初始化;偶数就是卖出
for (int j = 1; j < 2 * k; j += 2) {
dp[0][j] = -prices[0];
}
for (int i = 1; i < prices.size(); i++) {
// 对于第 i 天股票,每次买入、卖出两种情况,直到 k 笔交易均完成
for (int j = 0; j < 2 * k - 1; j += 2) {
dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);
dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);
}
}
// 一定是交易数多的、最后卖出时获利最大
return dp[prices.size() - 1][2 * k];
}
};
具体可以区分出如下四个状态:
j的状态为:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
if (n == 0) return 0;
vector<vector<int>> dp(n, vector<int>(4, 0));
dp[0][0] -= prices[0]; // 持股票
for (int i = 1; i < n; 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];
}
// 最后结果是取 状态二,状态三,和状态四的最大值
// 状态四是冷冻期,最后一天如果是冷冻期也可能是最大值
return max(dp[n - 1][3], max(dp[n - 1][1], dp[n - 1][2]));
}
};
仅比122多了手续费
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]);
}
};
引用自:代码随想录-动态规划总结篇
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size() == 1) return 1;
vector<int> dp(nums.size(), 1);
int result = 0;
// dp[i]表示i之前包括i的,以nums[i]结尾的最长递增子序列的长度
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
// 位置i的最长升序子序列等于0到j(j < i)的最长升序子序列 + 1 和dp[i]的最大值
if (nums[i] > nums[j])
dp[i] = max(dp[i], dp[j] + 1);
}
result = max(result, dp[i]);
}
return result;
}
};
为了初始化方便,不需要额外操作,本题dp数组的定义比较难理解,且是在遇到text1[i - 1] == text2[j - 1]时给dp[i] [j]赋值,相当于是从左上方到右下方转移
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int len1 = text1.size(), len2 = text2.size();
// dp[i][j]:以下标i - 1为结尾的txet1,和以下标j - 1为结尾的text2,最长离散子序列的长度
// dp[0][0]虽无意义,但只有为 0 时才符合后面的递推公式
vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if (text1[i - 1] == text2[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
// 不需要result,直接返回最大的dp
return dp[len1][len2];
}
};
class Solution {
public:
int maxUncrossedLines(vector<int>& A, vector<int>& B) {
vector<vector<int>> dp(A.size() + 1, vector<int>(B.size() + 1, 0));
for (int i = 1; i <= A.size(); i++) {
for (int j = 1; j <= B.size(); j++) {
if (A[i - 1] == B[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[A.size()][B.size()];
}
};
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
vector<int> dp(nums.size() ,1);
int result = 1;
for (int i = 1; i < nums.size(); i++) {
if (nums[i] > nums[i - 1]) { // 连续记录
dp[i] = dp[i - 1] + 1;
}
result = max(dp[i], result);
}
return result;
}
};
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int len1 = nums1.size(), len2 = nums2.size();
// dp[i][j] 以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]
// dp[0][0]虽无意义,但只有为0时才符合后面的递推公式
vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
int result = 0;
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if (nums1[i - 1] == nums2[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
result = max(result, dp[i][j]);
}
}
return result;
}
};
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
int result = dp[0];
for (int i = 1; i < nums.size(); i++) {
// dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组
// 要么不与前面的子数组连接,自成一派,自己作为一个子数组
dp[i] = max(dp[i - 1] + nums[i], nums[i]);
result = max(result, dp[i]);
}
return result;
}
};
【仅删除】判断s是否是t的子序列
class Solution {
public:
bool isSubsequence(string s, string t) {
// if (s.size() == 0) return true;
// if (t.size() == 0) return false;
// dp[i] [j]表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度
vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
for (int i = 1; i <= s.size(); i++) {
for (int j = 1; j <= t.size(); j++) {
if (s[i - 1] == t[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
// 由于判断 s 是否为 t 的子序列,一定是 t 不匹配回退
else
dp[i][j] = dp[i][j - 1];
}
}
if (dp[s.size()][t.size()] == s.size()) return true;
return false;
}
};
【仅删除】计算t在s的子序列中的出现次数,即s中有多少种删除方式,可以使得剩下的子串为t
class Solution {
public:
int numDistinct(string s, string t) {
// 初始化第一行,s为空时,怎么删除都不能包含t,因此0方法,只有dp[0] [0]有一种方法,后面会覆盖到
vector<vector<uint64_t>> dp(s.size() + 1, vector<uint64_t>(t.size() + 1, 0));
// 初始化第一列,即s中的各子串有几种删除方法,删除之后等于空,显然每个子串只有全删才是空,一种方法
for (int i = 0; i <= s.size(); i++) {
dp[i][0] = 1;
}
for (int i = 1; i <= s.size(); i++) {
for (int j = 1; j <= t.size(); j++) {
// s t 添加了相同的字符时,有两个来源,不好理解
// 不用该元素进行匹配
if (s[i - 1] == t[j - 1])
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
// 不相等时肯定无法匹配,相当于s删除了当前这个元素后进行匹配
else
dp[i][j] = dp[i - 1][j];
}
}
return dp[s.size()][t.size()];
}
};
【仅删除】给定两个单词 word1
和 word2
,返回使得 word1
和 word2
相同所需的最小步数
class Solution {
public:
int minDistance(string word1, string word2) {
// dp[i] [j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
// 第一列,word1 删除直到空的最小次数
// 第一列,word2 删除直到空的最小次数
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
for (int i = 1; i <= word1.size(); i++) {
for (int j = 1; j <= word2.size(); j++) {
// 相等不用删,上一步的状态直接转移过来
if (word1[i - 1] == word2[j - 1])
dp[i][j] = dp[i - 1][j - 1];
// 不相等,要么删除第一个,要么删除第二个,取最小值
// 同时删 dp[i - 1][j - 1] + 2 ,被包含,可不写出
else
dp[i][j] = min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 2});
}
}
return dp[word1.size()][word2.size()];
}
};
【删除、添加、替换】给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数
class Solution {
public:
int minDistance(string word1, string word2) {
// dp 为最小的编辑距离
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
// 子串删除至空串、空串添加至子串的操作次数,添加、删除本质上操作一样,后面看作一类
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
for (int i = 1; i <= word1.size(); i++) {
for (int j = 1; j <= word2.size(); j++) {
// 相等不用操作
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
// 不等,用删除(即添加)和替换的操作的最小值更新
// 替换不需要增删,仅在原来的基础上多一次操作
else {
dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
}
}
return dp[word1.size()][word2.size()];
}
};
// 文本相似度 = 1 - 最小编辑距离 / 最长的字符串长度
// 每次插入、删除、增加操作使得最小编辑距离 + 1
// 输出保留两位小数
#include
using namespace std;
int editDistance(string s1, string s2) {
int len1 = s1.size(), len2 = s2.size();
vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
for (int i = 1; i <= len1; i++) {
dp[i][0] = i;
}
for (int j = 1; j <= len2; j++) {
dp[0][j] = j;
}
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if (s1[i - 1] == s2[j - 1])
dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1));
}
}
return dp[len1 - 1][len2 - 1];
}
int main(){
string s1 = "abcde", s2 = "ace";
int maxLen = max(s1.size(), s2.size());
// 两数相除,只有分子本身是double结果才能显示小数,否则强制转换都没用
double similarity = 1 - (double)editDistance(s1, s2) / maxLen;
// 使用fixed(不用则只保留有效数字)配合setprecision(2)来确保cout输出保留两位小数
cout << fixed << setprecision(2) <<similarity << endl;
return 0;
}
很多这种子序列相关的题目,在定义dp数组的时候很自然就会想题目求什么,我们就如何定义dp数组。绝大多数题目确实是这样,不过本题如果我们定义,dp[i] 为下标i结尾的字符串有dp[i]个回文串的话,我们会发现很难找到递归关系。dp[i] 和 dp[i-1] ,dp[i + 1] 看上去都没啥关系。所以我们要看回文串的性质。
我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。
那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串的下表范围[i,j])是否回文,依赖于,子字符串(下表范围[i + 1, j - 1])) 是否是回文。
class Solution {
public:
int countSubstrings(string s) {
// 布尔类型的dp[i] [j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i] [j]为true,否则为false
// 还未匹配 全部初始化为false
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int result = 0;
// 由下向上 由左到右
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i; j < s.size(); j++) {
if (s[i] == s[j]) {
// i j指向同一个元素,当然是回文子串
// 下标i 与 j相差为1,例如aa,也是回文子串
// 三个字符,只要首尾相同,不论中间是什么,必是回文串
if (j - i <= 2) {
result++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1]){
// i j间隔多个元素时,需要看[i+1,j-1]是否回文
result++;
dp[i][j] = true;
}
}
}
}
return result;
}
};
class Solution {
public:
// 双指针法空间复杂度低,首先确定回文串,找中心然后向两边扩散看是不是对称的
// 在遍历中心点的时候,要注意中心点有两种情况,一个元素可以作为中心点,两个元素也可以作为中心点
int countSubstrings(string s) {
int result = 0;
for (int i = 0; i < s.size(); i++) {
result += extend(s, i, i, s.size()); // 以i为中心,至少返回1
result += extend(s, i, i + 1, s.size()); // 以i和i+1为中心,至少返回0
}
return result;
}
int extend(const string& s, int i, int j, int n) {
int res = 0;
while (i >= 0 && j < n && s[i] == s[j]) {
i--;
j++;
res++;
}
return res;
}
};
贴一个自己根据“五部曲”来分析的dp题解:https://leetcode.cn/problems/longest-palindromic-substring/solution/by-joeyma-nqf0/
返回string子串,用双指针法
class Solution {
public:
int maxLength = INT_MIN;
int left = 0;
int right = 0;
string str = "";
string longestPalindrome(string s) {
int result = 0;
for (int i = 0; i < s.size(); i++) {
extend(s, i, i, s.size()); // 以i为中心,至少返回1
extend(s, i, i + 1, s.size()); // 以i和i+1为中心,至少返回0
}
return s.substr(left, maxLength);
}
void extend(const string& s, int i, int j, int n) {
while (i >= 0 && j < n && s[i] == s[j]) {
if (j - i + 1 > maxLength) {
// 每当遇到更大的子串就记录长度和子串,这样会调用太多次substr
// 也可仅记录长度,最后再返回子串
maxLength = j - i + 1;
left = i;
right = j;
}
i--;
j++;
}
}
};
仅返回子序列长度int,不返回string,优先使用dp
class Solution {
public:
int longestPalindromeSubseq(string s) {
// dp[i] [j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i] [j]
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
// 递推公式计算不到 i 和j相同时候的情况,所以需要手动初始化一下
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
// 遍历顺序由下到上,由左到右
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.size(); j++) {
// 本题要比求回文子串简单一点,因为情况少了一点
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
// 如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列,取最大值
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
// 返回值是第一行最后一个数
return dp[0][s.size() - 1];
}
};
1、首先用 647 5 的二维dp数组,记录[i,j]区间内是否是子串,便于后续切割时候做判断
2、dp[i]:范围是[0, i]的回文子串,最少分割次数是dp[i],可用一个最大值全部初始化,但 dp[0] = 0,表明长度为1,不需切割,本身就是回文串
如果分割后,区间[j + 1, i]是回文子串(用二维数组快速判断),那么dp[i] 就等于 dp[j] + 1,或者是dp[i],二者取较小值
class Solution {
public:
// 返回int,推荐dp,否则可以用记忆化回溯
int minCut(string s) {
int n = s.size();
if (n == 1) return 0;
// 布尔类型的dp[i] [j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i] [j]为true,否则为false
// 647 5 的dp定义
vector<vector<bool>> isPalindromic(n, vector<bool>(n, false));
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
// i j指向同一个元素,当然是回文子串;下标i 与 j相差为1,例如aa,也是回文子串;三个字符,只要首尾相同,不论中间是什么,必是回文串
// i j间隔多个元素时,才需要看[i+1,j-1]是否回文
if (s[i] == s[j] && (j - i <= 2 || isPalindromic[i + 1][j - 1])) {
isPalindromic[i][j] = true;
}
}
}
// dp[i]:范围是[0, i]的回文子串,最少分割次数是dp[i]
vector<int> dp(n);
// 初始化为最多的分割次数,这样才能被后面覆盖
for (int i = 0; i < n; i++) dp[i] = i;
for (int i = 1; i < n; i++) {
// 首先检测以 0 开头字符串是否回文,更新dp
if (isPalindromic[0][i]) {
dp[i] = 0;
continue;
}
// 然后在0~i中遍历每一个分割点 j
for (int j = 0; j < i; j++) {
// dp[j]已有[0,j]的最小分割次数,若[j+1,i]是回文串,更新dp
if (isPalindromic[j + 1][i]) {
dp[i] = min(dp[i], dp[j] + 1);
}
}
}
return dp[s.size() - 1];
}
};
比 300 难,需要维护两个数组,dp[i]:i之前(包括i)最长递增子序列的长度为dp[i](即300题数组),count[i]:以nums[i]为结尾的字符串,最长递增子序列的个数为count[i]
class Solution {
public:
int findNumberOfLIS(vector<int>& nums) {
int n = nums.size();
if (n == 1) return 1;
int maxCount = 0;
// 均初始化为最小值1
// 以i为结尾子序列,最长递增子序列长度
vector<int> dp(n, 1);
// 以i为结尾子序列,最长递增子序列个数
vector<int> count(n, 1);
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
// count 更新思路,不好理解
// 找到了一个更长的递增子序列,那么j为结尾的子串的最长递增子序列的个数,就是最新的以i为结尾的子串的最长递增子序列的个数
if (dp[j] + 1 > dp[i]) {
count[i] = count[j];
}
// 找到了两个相同长度的递增子序列,那么以i为结尾的子串的最长递增子序列的个数 就应该加上以j为结尾的子串的最长递增子序列的个数
else if (dp[j] + 1 == dp[i]) {
count[i] += count[j];
}
// dp 更新同 300 思路
dp[i] = max(dp[i], dp[j] + 1);
}
if (dp[i] > maxCount) maxCount = dp[i];
}
}
// maxCount可能由多个,需要逐个统计出来
int result = 0;
for (int i = 0; i < nums.size(); i++) {
if (maxCount == dp[i])
result += count[i];
}
return result;
}
};