基础知识
动态规划,英文:Dynamic Programming,简称DP。
动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
题目
1. 爬楼梯( LeetCode 70 )
难度: 简单
题目表述:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
代码(C++):
class Solution {
public:
int climbStairs(int n) {
int p = 0, q = 1, r = 0;
for (int i = 1; i <= n; i++) {
r = p + q;
p = q;
q = r;
}
return r;
}
// 完全背包求排列数
// 一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。本题 m = 2
int climbStairs(int n) {
vector<int> dp(n + 1);
dp[0] = 1;
for (int j = 1; j <= n; j++) {
for (int i = 1; i <= 2; i++) {
if (j >= i) dp[j] += dp[j - i];
}
}
return dp[n];
}
};
题解:
1.斐波那契数列的第n项 : f(x)=f(x−1)+f(x−2)
2.一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。完全背包求排列数问题,本题 m = 2。
2. 使用最小花费爬楼梯( LeetCode 746 )
难度: 简单
题目表述:
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。请你计算并返回达到楼梯顶部的最低花费。
代码(C++):
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size() + 1);
dp[0] = 0;
dp[1] = 0;
for (int i = 2; i <= cost.size(); i++) {
dp[i] = min(dp[i - 2] + cost[i - 2], dp[i - 1] + cost[i - 1]);
}
return dp[cost.size()];
}
};
题解:
3. 不同路径( LeetCode 62 )
难度: 中等
题目表述:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?
代码(C++):
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector<int>(n));
for (int i = 0; i < m; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n; j++) {
dp[0][j] = 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];
}
};
题解:
动态规划转移方程:f(i,j)=f(i−1,j)+f(i,j−1)
4. 不同路径 II( LeetCode 63 )
难度: 中等
题目表述:
网格中的障碍物和空位置分别用 1 和 0 来表示。
代码(C++):
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(), n = obstacleGrid[0].size();
vector<int> dp(n, 0);
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
dp[j] = 1;
}
for (int i = 1; i < m; i++) {
for (int j = 0; j < n; j++) {
if (obstacleGrid[i][j] == 1) dp[j] = 0;
else if (j > 0) dp[j] += dp[j - 1];
}
}
return dp[n - 1];
}
};
题解:
5. 整数拆分( LeetCode 343 )
难度: 中等
题目表述:
给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
代码(C++):
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n + 1);
dp[2] = 1;
for (int i = 3; i <= n; i++) {
for (int j = 1; j <= i / 2; j++) {
dp[i] = max({dp[i], j * (i - j), j * dp[i - j]});
}
}
return dp[n];
}
};
题解:
j * (i - j) 是单纯的把整数拆分为两个数相乘,而 j * dp[i - j] 是拆分成三个以及三个以上的个数相乘。
6. 不同的二叉搜索树( LeetCode 96 )
难度: 中等
题目表述:
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
代码(C++):
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= i; j++) {
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
题解:
dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
7. 分割等和子集( LeetCode 416 )
难度: 中等
题目表述:
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
代码(C++):
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if (sum % 2) return false;
int m = nums.size();
int target = sum / 2;
vector<int> dp(target + 1, 0);
for (int i = 0; i < m; i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
return dp[target] == target;
}
};
题解:
背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。
问题转化为在 sum / 2 容量的背包中最多可以装多少。dp[j] 表示: 容量为j的背包,所背的物品价值最大可以为dp[j]。
8. 最后一块石头的重量 II( LeetCode 1049 )
难度: 中等
题目表述:
每一回合,从中选出任意两块石头,然后将它们一起粉碎。最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。
代码(C++):
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for (const auto &s: stones) {
sum += s;
}
int target = sum / 2;
vector<int> dp(target + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = target; j >= stones[i]; j--) {
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] - dp[target];
}
};
题解:
尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小。
计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的。
上一题相当于是求背包是否正好装满,而本题是求背包最多能装多少。
问题转化为在 sum / 2 容量的背包中最多可以装多少。dp[j]表示容量为j的背包,最多可以背最大重量为dp[j]。
9. 目标和( LeetCode 494 )
难度: 中等
题目表述:
返回可以向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数构造一个 表达式 、运算结果等于 target 的不同 表达式 的数目。
代码(C++):
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size(), sum = 0;
for (const int &d : nums ) {
sum += d;
}
if ((target + sum) % 2 || abs(target) > sum) return 0;
int left = (target + sum) / 2;
vector<int> dp(left + 1, 0);
dp[0] = 1;
for (int i = 0; i < n; i++) {
for (int j = left; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[left];
}
};
题解:
left - right = target,left + right = sum => right = sum - left,则,
left - (sum - left) = target => left = (target + sum)/2 。
问题转化为装满容量为left的背包有几种方法。dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
10. 一和零( LeetCode 474 )
难度: 中等
题目表述:
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
代码(C++):
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
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];
}
};
题解:
m 和 n相当于是一个背包,两个维度的背包
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
11. 零钱兑换 II( LeetCode 518 )
难度: 中等
题目表述:
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
代码(C++):
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];
}
};
题解: 本质是求完全背包 组合数 (先遍历物品,再遍历背包)
12.组合总和 Ⅳ( LeetCode 377 )
难度: 中等
题目表述:
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。顺序不同的序列被视作不同的组合。
代码(C++):
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int j = 0; j <= target; j++) {
for (int i = 0; i < nums.size(); i++) {
if (j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]]) dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
};
题解: 本质是求完全背包 排列数 (先遍历背包,再遍历物品)
13. 零钱兑换( LeetCode 322 )
难度: 中等
题目表述:
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数
代码(C++):
class Solution {
public:
//动态规划
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 0; i < coins.size(); i++) {
for (int j = coins[i]; j <= amount; j++) {//遍历背包【正序】
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
};
题解: 本质是求完全背包 组合数 / 排列数(for循环嵌套顺序无所谓)
要求最少硬币数量,硬币是组合数还是排列数都无所谓!所以两个for循环先后顺序怎样都可以!
14.完全平方数( LeetCode 279 )
难度: 中等
题目表述:
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
代码(C++):
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i * i <= n; i++) {// 遍历物品
for (int j = i * i; j <= n; j++) {// 遍历背包
dp[j] = min(dp[j], dp[j - i * i] + 1);
}
}
return dp[n];
}
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i <= n; i++) { // 遍历背包
for (int j = 1; j * j <= i; j++) { // 遍历物品
dp[i] = min(dp[i - j * j] + 1, dp[i]);
}
}
return dp[n];
}
};
题解: 本质是求完全背包 组合数 / 排列数(for循环嵌套顺序无所谓)
dp[j]:和为j的完全平方数的最少数量为dp[j]
⭐15.单词拆分( LeetCode 139 )
难度: 中等
题目表述:
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
代码(C++):
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int i = 1; i <= s.size(); i++) { // 遍历背包
for (string word : wordDict) { // 遍历物品
int len = word.size();
if (i >= len && dp[i - len] && word == s.substr(i - len, len)) {
dp[i] = true;
break;
}
}
}
return dp[s.size()];
}
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); //substr(起始位置,截取的个数)
if (wordSet.find(word) != wordSet.end() && dp[j]) {
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
题解: 本质是求完全背包 排列数 (先遍历背包,再遍历物品)
强调物品之间顺序,一定是 先遍历 背包,再遍历物品。
16. 打家劫舍( LeetCode 198 )
难度: 中等
题目表述:
不能连续偷两间相邻的房屋,能够偷窃到的最高金额。
代码(C++):
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];
}
};
题解: 当前房屋偷与不偷取决于 前一个房屋和前两个房屋是否被偷了。
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i],i间有偷或不偷两种情况
17. 打家劫舍 II( LeetCode 213 )
难度: 中等
题目表述:
所有的房屋都 围成一圈,不能连续偷两间相邻的房屋,能够偷窃到的最高金额。
代码(C++):
class Solution {
public:
int robRange(vector<int>& nums, int left, int right) {
if (left == right) return nums[left];
vector<int> dp(nums.size());
dp[left] = nums[left];
dp[left + 1] = max(nums[left], nums[left + 1]);
for (int i = left + 2; i <= right; i++) {
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[right];
}
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
return max(robRange(nums, 0, nums.size() - 2), robRange(nums, 1, nums.size() - 1));
}
};
题解: max(考虑包含首元素,不包含尾元素, 考虑包含尾元素,不包含首元素)
18. ⭐打家劫舍 III( LeetCode 337 )
难度: 中等
题目表述:
房屋的排列类似于一棵二叉树,不能连续偷两间相邻的房屋,能够偷窃到的最高金额。
代码(C++):
class Solution {
public:
// 递归
unordered_map<TreeNode*, int> umap;
int rob(TreeNode* root) {
if (!root) return 0;
if (!root->left && !root->right) return root->val;
if (umap[root]) return umap[root];
int val1 = rob(root->left) + rob(root->right); // 不偷父节点
int val2 = root->val; //偷父节点
if (root->left) {
val2 += rob(root->left->left);
val2 += rob(root->left->right);
}
if (root->right) {
val2 += rob(root->right->left);
val2 += rob(root->right->right);
}
umap[root] = max(val1, val2);
return umap[root];
}
// 动态规划:长度为2的数组,0:不偷,1:偷
vector<int> robTree(TreeNode* root) {
if (!root) return {0, 0};
vector<int> left = robTree(root->left);
vector<int> right = robTree(root->right);
int val1 = max(left[0], left[1]) + max(right[0], right[1]); // 不偷父节点
int val2 = root->val + left[0] + right[0]; // 偷父节点
return {val1, val2};
}
int rob(TreeNode* root) {
vector<int> res = robTree(root);
return max(res[0], res[1]);
}
};
题解: 树形DP
下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱。
19. 买卖股票的最佳时机 ( LeetCode 121 )
难度: 简单
题目表述:
只能买卖一次
代码(C++):
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
if (n == 0) {
return 0;
}
vector<vector<int>> dp(n, vector<int>(2));
dp[0][0] = 0;
dp[0][1] = -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], -prices[i]);
}
return dp[n - 1][0];
}
// 空间复杂度降为O(1)
int maxProfit(vector<int>& prices) {
int n = prices.size();
if (n == 0) {
return 0;
}
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; i++) {
dp0 = max(dp0, dp1 + prices[i]);
dp1 = max(dp1, -prices[i]);
}
return dp0;
}
};
题解: 股票全程只能买卖一次,那么推导第i天持有股票时用到的dp[i-1][0]一定就是0。
k = 1 维度k可省略
T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])
T[i][0][0] = 0
20. 买卖股票的最佳时机 II( LeetCode 122 )
难度: 中等
题目表述:
可以买卖多次
代码(C++):
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
if (n == 0) return 0;
vector<vector<int>> dp(n, vector<int>(2));
dp[0][0] = 0;
dp[0][1] = -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]);
}
return dp[n - 1][0];
}
// 空间复杂度降为O(1)
int maxProfit(vector<int>& prices) {
int n = prices.size();
if (n == 0) return 0;
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; i++) {
int newdp0 = max(dp0, dp1 + prices[i]);
dp1 = max(dp1, dp0 - prices[i]);
dp0 = newdp0;
}
return dp0;
}
};
题解: 一只股票可以买卖多次,那么推导第i天持有股票时用到的dp[i-1][0]就是之前买卖过的利润。
k = +∞ k 和 k - 1 可以看成是相同的,维度k可省略
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i]) = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i])
T[i - 1][k - 1][0] = T[i - 1][k][0]
⭐21. 买卖股票的最佳时机 III( LeetCode 123 )⭐
难度: 困难
题目表述:
最多买卖两次
代码(C++):
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>> dp1(prices.size(), vector<int>(2, 0));
vector<vector<int>> dp2(prices.size(), vector<int>(2, 0));
dp1[0][0] = 0;
dp1[0][1] = -prices[0];
dp2[0][0] = 0;
dp2[0][1] = -prices[0];
for (int i = 1; i < prices.size(); i++) {
dp1[i][0] = max(dp1[i - 1][0], dp1[i - 1][1] + prices[i]);
dp1[i][1] = max(dp1[i - 1][1], -prices[i]);
dp2[i][0] = max(dp2[i - 1][0], dp2[i - 1][1] + prices[i]);
dp2[i][1] = max(dp2[i - 1][1], dp1[i - 1][0] - prices[i]);
}
return dp2[prices.size() - 1][0];
}
int maxProfit(vector<int>& prices) {
int n = prices.size();
int profitOne0 = 0, profitOne1 = -prices[0], profitTwo0 = 0, profitTwo1 = -prices[0];
for (int i = 1; i < n; i++) {
// 更新用到上一行结果,故 按照 k 的反序更新 2->1
profitTwo0 = max(profitTwo0, profitTwo1 + prices[i]);
profitTwo1 = max(profitTwo1, profitOne0 - prices[i]);
profitOne0 = max(profitOne0, profitOne1 + prices[i]);
profitOne1 = max(profitOne1, -prices[i]);
}
return profitTwo0;
}
};
题解:
T[i][2][0] = max(T[i - 1][2][0], T[i - 1][2][1] + prices[i])
T[i][2][1] = max(T[i - 1][2][1], T[i - 1][1][0] - prices[i])
T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])
注意到第 i 天的最大收益只和第 i - 1 天的最大收益相关,空间复杂度可以降到 O(1),但是要注意更新的先后顺序,优先更新会用到其他尚未更新的变量的变量。
22. 买卖股票的最佳时机 IV( LeetCode 188 )
难度: 困难
题目表述:
最多买卖k次
代码(C++):
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
if (k >= n / 2) return maxProfit(prices);
vector<vector<vector<int>>> dp(n, vector<vector<int>>(k + 1, vector<int>(2)));
for (int j = 0; j < k + 1; j++) {
dp[0][j][0] = 0;
dp[0][j][1] = -prices[0];
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < k + 1; j++) {
dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i]);
dp[i][j][1] = max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i]);
}
}
return dp[n - 1][k][0];
}
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2));
dp[0][0] = 0;
dp[0][1] = -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]);
}
return dp[n - 1][0];
}
};
// 空间复杂度降到 O(k)
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
if (k >= n / 2) return maxProfit(prices);
vector<vector<int>> dp(k + 1, vector<int>(2));
for (int j = 1; j < k + 1; j++) {
dp[j][0] = 0;
dp[j][1] = -prices[0];
}
for (int i = 1; i < n; i++) {
for (int j = k; j > 0; j--) {
// 注意内层更新用到上一行结果,故反序遍历
dp[j][0] = max(dp[j][0], dp[j][1] + prices[i]);
dp[j][1] = max(dp[j][1], dp[j - 1][0] - prices[i]);
}
}
return dp[k][0];
}
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; i++) {
// 注意更新均要用到上一行,因此需要暂存更新后的结果,待全部变量完成更新后再更新进维护变量
int newdp0 = max(dp0, dp1 + prices[i]);
int newdp1 = max(dp1, dp0 - prices[i]);
dp0 = newdp0;
dp1 = newdp1;
}
return dp0;
}
};
题解:
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i])
本题是最通用的情况,如果 k 超过一个临界值,最大收益就不再取决于允许的最大交易次数,而是取决于股票价格数组的长度,因此可以进行优化。一个有收益的交易至少需要两天(在前一天买入,在后一天卖出,前提是买入价格低于卖出价格)。如果股票价格数组的长度为 n,则有收益的交易的数量最多为 n / 2(整数除法)。因此 k 的临界值是 n / 2。如果给定的 k 不小于临界值,即 k >= n / 2,则可以将 k 扩展为正无穷,此时问题等价于题 6 。
23. 最佳买卖股票时机含冷冻期( LeetCode 309 )
难度: 中等
题目表述:
买卖多次,卖出有一天冷冻期
代码(C++):
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2));
dp[0][0] = 0;
dp[0][1] = -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], (i >= 2 ? dp[i - 2][0] : 0) - prices[i]);
}
return dp[n - 1][0];
}
// 空间复杂度降到O(1)
int maxProfit(vector<int>& prices) {
int n = prices.size();
int preProit0 = 0, profit0 = 0, profit1 = -prices[0];
for (int i = 1; i < n; i++) {
int newprofit0 = max(profit0, profit1 + prices[i]);
profit1 = max(profit1, preProit0 - prices[i]);
preProit0 = profit0;
profit0 = newprofit0;
}
return profit0;
}
};
题解:
k 为正无穷但有冷冻期,意味着在 i 天买入的时候应该使用 i - 2 天的卖出([0])收益
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 2][k][0] - prices[i])
24. 买卖股票的最佳时机含手续费( LeetCode 714 )
难度: 中等
题目表述:
买卖多次,每次有手续费
代码(C++):
class Solution {
public:
// 买入时扣手续费
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0] - fee;
for (int i = 1; i < n; i++) {
int tmp0 = max(dp0, dp1 + prices[i]);
dp1 = max(dp1, dp0 - prices[i] - fee);
dp0 = tmp0;
}
return dp0;
}
// 卖出时扣手续费
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; i++) {
int tmp0 = max(dp0, dp1 + prices[i] - fee);
int dp1 = max(dp1, dp0 - prices[i]);
dp0 = tmp0;
}
return dp0;
}
};
题解:
k 为正无穷但有手续费
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i] - fee)
25. 最长递增子序列( LeetCode 300 )
难度: 中等
题目表述:
索引不连续的递增
代码(C++):
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int ans = 1, n = nums.size();
vector<int> dp(n, 1);
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) dp[i] = max(dp[i], dp[j] + 1);
}
ans = max(ans, dp[i]);
}
return ans;
}
int lengthOfLIS(vector<int>& nums) {
int n = nums.size(), ans = 0, len = 1;
vector<int> d(n + 1, 0);
d[len] = nums[0];
for (int i = 1; i < n; i++) {
if (nums[i] > d[len]) {
len++;
d[len] = nums[i];
} else {
int l = 1, r = len;
while (l < r) {
int mid = (l + r) / 2;
if (d[mid] >= nums[i]) {
r = mid;
} else {
l = mid + 1;
}
}
d[l] = nums[i];
}
}
return len;
}
};
题解: 以nums[i]结尾
dp[i]表示以nums[i]结尾的最长递增子序列的长度
26. 最长连续递增序列( LeetCode 674 )
难度: 简单
题目表述:
索引连续的递增
代码(C++):
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int ans = 1;
for (int i = 1; i < nums.size(); i++) {
if (nums[i] > nums[i - 1])
dp[i] = dp[i - 1] + 1;
ans = max(ans, dp[i]);
}
return ans;
}
};
题解: 以nums[i]结尾
dp[i]表示以nums[i]结尾的连续递增的子序列长度为dp[i]
⭐27. 最长重复子数组( LeetCode 718 )
难度: 中等
题目表述:
索引连续的公共
代码(C++):
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
int ans = 0;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (nums1[i - 1] == nums2[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
ans = max(ans, dp[i][j]);
}
}
return ans;
}
int findLength(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
vector<int> dp(n + 1, 0);
int ans = 0;
for (int i = 1; i <= m; i++) {
for (int j = n; j >= 1; j--) {
if (nums1[i - 1] == nums2[j - 1])
dp[j] = dp[j - 1] + 1;
else // 注意这里不相等的时候要有赋0的操作
dp[j] = 0;
ans = max(ans, dp[j]);
}
}
return ans;
}
};
题解: 以nums[i]结尾
dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组(连续子序列)长度为dp[i][j]。
⭐28. 最长公共子序列( LeetCode1143 )
难度: 中等
题目表述:
索引不连续的公共
代码(C++):
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1[i - 1] == text2[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]);
}
}
return dp[m][n];
}
};
题解: 以 i - 1结尾
dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列长度为dp[i][j]
无需知道前一个的公共的位置,只用根据当前的字符判重就可以推导当前dp状态,故不需要像题25一样通过遍历来找到前一个递增尾部数字,来和当前进行比较,推导出当前是否需要+1。
29. 不相交的线( LeetCode1035 )
难度: 中等
题目表述:
现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:
nums1[i] == nums2[j],且绘制的直线不与任何其他连线(非水平线)相交。
索引不连续的公共
代码(C++):
class Solution {
public:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (nums1[i - 1] == nums2[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[m][n];
}
};
题解: 以 i - 1结尾
本质是求最长公共子序列
30. 最大子数组和( LeetCode53 )
难度: 中等
题目表述:
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
代码(C++):
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
int ans = dp[0];
for (int i = 1; i < nums.size(); i++) {
dp[i] = max(nums[i], dp[i - 1] + nums[i]);
ans = max(ans, dp[i]);
}
return ans;
}
};
题解: 以nums[i]结尾
dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]。
31. 判断子序列( LeetCode392 )
难度: 简单
题目表述:
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
代码(C++):
class Solution {
public:
bool isSubsequence(string s, string t) {
int m = s.size(), n = t.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s[i - 1] == t[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = dp[i][j - 1];
}
}
return dp[m][n] == m;
}
};
题解: 以 i - 1结尾
dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。
本题 如果删元素一定是字符串t,而 题28 最长公共子序列 是两个字符串都可以删元素。
⭐32. 不同的子序列( LeetCode115 )
难度: 困难
题目表述:
给你两个字符串 s 和 t ,统计并返回在 s 的 子序列 中 t 出现的个数。
代码(C++):
class Solution {
public:
int numDistinct(string s, string t) {
int m = s.size(), n = t.size();
vector<vector<uint64_t>> dp(s.size() + 1, vector<uint64_t>(t.size() + 1));
for (int i = 0; i <= m ; i++) dp[i][0] = 1;
for (int i = 1; i <= m ; i++) {
for (int j = 1; j <= n; j++) {
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[m][n];
}
};
题解: 以 i - 1结尾
dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。
33. 两个字符串的删除操作( LeetCode583 )
难度: 中等
题目表述:
给定两个单词 word1 和 word2 ,返回使得 word1 和 word2 相同所需的最小步数。
每步 可以删除任意一个字符串中的一个字符。
代码(C++):
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) dp[i][0] = i;
for (int j = 1; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; 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], dp[i][j - 1]) + 1;
}
}
return dp[m][n];
}
};
题解: 以 i - 1结尾
dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。
34. 编辑距离( LeetCode 72 )
难度: 困难
题目表述:
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入、删除、替换
代码(C++):
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; 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], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;
}
}
return dp[m][n];
}
};
题解: 以 i - 1结尾
dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。
⭐35. 回文子串( LeetCode647 )
难度: 中等
题目表述:
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
代码(C++):
class Solution {
public:
int countSubstrings(string s) {
int n = s.size();
vector<vector<bool>> dp(n, vector<bool>(n, false));
int cnt = 0;
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
if (s[i] == s[j]) {
if (j - i <= 1) {
dp[i][j] = true;
cnt++;
} else if (dp[i + 1][j - 1]) {
dp[i][j] = true;
cnt++;
}
}
}
}
return cnt;
}
};
题解: 索引连续的回文
dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
⭐36. 最长回文子序列( LeetCode516 )
难度: 中等
题目表述:
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
代码(C++):
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n = s.size();
vector<vector<int>> dp(s.size(), vector<int>(n, 0));
for (int i = n - 1; i >= 0; i--) {
dp[i][i] = 1;
for (int j = i + 1; j < n; j++) {
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
return dp[0][n - 1];
}
};
题解: 索引不连续的回文
dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]
小结
解题步骤
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
组合类问题仅仅是求个数的话,就可以用dp,递推公式类似:dp[j] += dp[j - nums[i]] - dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
背包问题
动态规划 是解决「0−1 背包问题」和「完全背包问题」的标准做法。
0-1背包: 如果限定每件物品最多只能选取 1 次 (即0 或 1 次),则问题称为 0-1背包问题。
完全背包: 如果每件物品最多可以选取无限次,则问题称为 完全背包问题。
多重背包: 是0-1背包的一种,第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。在01背包里面在加一个for循环遍历一个每种物品的数量。
记第 i 件物品的重量 (weight) 为 wi ,价值 (value) 为 vi。dp[i][j] 表示前 i 件物品放入一个容量为 j 的背包可以获得的最大价值。
「0-1背包状态转移方程」:dp[i][j] = max/min{ dp[i−1][j], dp[i−1]
[j−wi ]+vi } , 0 ≤ wi ≤ j
「完全背包状态转移方程」:dp[i][j] = max/min{ dp[i−1][j], dp[i]
[j−wi ]+vi }, 0 ≤ wi <=j
差别(以红色标注)在于第二项中的状态转移是来自上一行还是本行。
如果 i 的最优解只和 i - 1 的最优解相关,可去掉 [i] 来降低空间复杂度,使二维降为一维,但需要注意:「完全背包问题」内层循环正序,而「0-1 背包问题」中内层循环反序。
因为正序决定了计算dp[j] 的 dp[j−wi ]来自本行,而反序决定dp[j−wi ]来自上一行。
1. 0-1背包
纯 0 - 1 背包 是求 给定背包容量 装满背包 的最大价值是多少。
416. 分割等和子集 是求 给定背包容量,能不能装满这个背包。
1049. 最后一块石头的重量 II 是求 给定背包容量,尽可能装,最多能装多少
494. 目标和 是求 给定背包容量,装满背包有多少种方法。
474. 一和零 是求 给定背包容量,装满背包最多有多少个物品。
在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。内层反序!!!
2. 完全背包
内层正序!!!
此外,还有一个关键点在于for循环嵌套的顺序:
组合数(无序):外层遍历物品,内层遍历背包。
排列数(有序):外层遍历背包,内层遍历物品。
求组合数:518.零钱兑换II
求排列数:377. 组合总和 Ⅳ、70. 爬楼梯进阶版(完全背包)、139.单词拆分
求最小数(顺序无所谓):322. 零钱兑换、279.完全平方数
股票问题
持有股票的数量是隐藏的关键因素,该因素影响第 i 天可以进行的操作,进而影响最大收益。因此对 T[i][k] 的定义需要分成两项:
- T[i][k][0] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 0 份股票的情况下可以获得的最大收益;
- T[i][k][1] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 1 份股票的情况下可以获得的最大收益。
基准情况:
T[-1][k][0] = 0, T[-1][k][1] = -prices[0]
T[i][0][0] = 0, T[i][0][1] = -prices[0]
状态转移方程:
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i])
注:第一天对应 i = 0;因为每次交易包含两次成对的操作,买入和卖出,只有买入操作会改变允许的最大交易次数。
最终答案是 T[n - 1][k][0],因为结束时持有 0 份股票的收益一定大于持有 1 份股票的收益。
同背包问题类似,如果 i 的最优解只和 i - 1 的最优解相关,可去掉 [i] 来降低空间复杂度,但需要注意:内层循环反序 / 优先更新会用到其他尚未更新的变量的变量。
因为计算新变量要用到的变量均是来自上一行。
子序列问题
1. 索引不连续的( 递增 / 公共 )子序列: 最长递增子序列、最长公共子序列、不相交的线
2. 索引连续的( 递增 / 公共 )子序列: 最长连续递增序列、最长重复子序列、最大子数组和
dp定义:数组以nums[i]结尾(含nums[i]),字符串以 i - 1 结尾(不一定含s[i - 1])
若是 索引连续 / 递增的前后依赖关系,则dp需要以nums[i]结尾
编辑距离: 判断子序列、不同的子序列、两个字符串的删除操作、编辑距离
字符串以 i - 1 结尾(不一定含s[i - 1])
回文: 从左下角开始遍历
1. 索引连续的回文:
回文子串:[i,j]闭区间是否是回文 bool
2. 索引不连续的回文:
最长回文子序列: [i,j]范围内最长回文长度
参考链接
玩转 LeetCode 高频 100 题
https://leetcode.cn/problems/coin-change/solution/by-flix-su7s/
https://leetcode.cn/circle/article/qiAgHn/
LeetCode 刷题攻略