动态规划
一、算法解释
二、经典问题
1. 基本动态规划:一维
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
这是十分经典的斐波那契数列题。定义一个数组 dp , dp[i] 表示走到第 i 阶的方法数。因为我们每次可以走一步或者两步,所以第 i 阶可以从第 i-1 或 i-2 阶到达。换句话说,走到第 i 阶的方法数即为走到第 i-1 阶的方法数加上走到第 i-2 阶的方法数。这样我们就得到了状态转移方程 dp[i] = dp[i-1] + dp[i-2]。注意边界条件的处理。
class Solution {
public:
int climbStairs(int n) {
if(n <= 2){
return n;
}
vector<int> dp(n + 1, 1);
for(int i=2; i<=n; ++i){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
进一步的,我们可以对动态规划进行空间压缩。因为 dp[i] 只与 dp[i-1] 和 dp[i-2] 有关,因此可以只用两个变量来存储 dp[i-1] 和 dp[i-2] ,使得原来的 O ( n ) 空间复杂度优化为 O ( 1 ) 复杂度。
class Solution {
public:
int climbStairs(int n) {
if(n <= 2){
return n;
}
int pre1 = 1, pre2 = 2, cur;
for(int i=2; i<n; ++i){
cur = pre1 + pre2;
pre1 = pre2;
pre2 = cur;
}
return cur;
}
};
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
定义一个数组 dp , dp[i] 表示抢劫到第 i 个房子时,可以抢劫的最大数量。我们考虑 dp[i] ,此时可以抢劫的最大数量有两种可能,一种是我们选择不抢劫这个房子,此时累计的金额即为 dp[i-1];另一种是我们选择抢劫这个房子,那么此前累计的最大金额只能是 dp[i-2] ,因为我们不能够抢劫第 i-1 个房子,否则会触发警报机关。因此本题的状态转移方程为 dp[i] = max(dp[i-1], nums[i-1] + dp[i-2])。
class Solution {
public:
int rob(vector<int>& nums) {
if(nums.empty()){
return 0;
}
int n = nums.size();
vector<int> dp(n + 1, 0);
dp[1] = nums[0];
for(int i=2; i<=n; i++){
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[n];
}
};
同样的,我们可以像题目 70 那样,进行空间压缩。
class Solution {
public:
int rob(vector<int>& nums) {
if(nums.empty()){
return 0;
}
int n = nums.size();
if(n == 1){
return nums[0];
}
int pre1 = 0, pre2 = 0, cur;
for(int i=0; i<n; ++i){
cur = max(pre1 + nums[i], pre2);
pre1 = pre2;
pre2 = cur;
}
return cur;
}
};
如果一个数列 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该数列为等差数列。
例如,[1,3,5,7,9]、[7,7,7,7] 和 [3,-1,-5,-9] 都是等差数列。
给你一个整数数组 nums ,返回数组 nums 中所有为等差数组的 子数组 个数。
子数组 是数组中的一个连续序列。
这道题略微特殊,因为要求是等差数列,可以很自然的想到子数组必定满足 num[i] - num[i-1] = num[i-1] - num[i-2]。然而由于我们对于 dp 数组的定义通常为以 i 结尾的,满足某些条件的子数组数量,而等差子数组可以在任意一个位置终结,因此此题在最后需要对 dp 数组求和。
class Solution {
public:
int numberOfArithmeticSlices(vector<int>& nums) {
int n = nums.size();
if(n < 3){
return 0;
}
vector<int> dp(n, 0);
for(int i=2; i<n; ++i){
if(nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2]){
dp[i] = dp[i - 1] + 1;
}
}
return accumulate(dp.begin(), dp.end(), 0);
}
};
2. 基本动态规划:二维
给定一个包含非负整数的 m x n
网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
我们可以定义一个同样是二维的 dp 数组,其中 dp[i][j] 表示从左上角开始到 (i, j) 位置的最优路径的数字和。因为每次只能向下或者向右移动,我们可以很容易得到状态转移方程 dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j],其中 grid 表示原数组。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
for(int i=0; i<m; ++i){
for(int j=0; j<n; ++j){
if(i == 0 && j == 0){
dp[i][j] = grid[i][j];
}else if(i == 0){
dp[i][j] = dp[i][j - 1] + grid[i][j];
}else if(j == 0){
dp[i][j] = dp[i - 1][j] + grid[i][j];
}else{
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
}
return dp[m - 1][n - 1];
}
};
因为 dp 矩阵的每一个值只和左边和上面的值相关,我们可以使用空间压缩将 dp 数组压缩为一维。对于第 i 行,在遍历到第 j 列的时候,因为第 j-1 列已经更新过了,所以 dp[j-1] 代表 dp[i][j-1] 的值;而 dp[j] 待更新,当前存储的值是在第 i-1 行的时候计算的,所以代表 dp[i-1][j] 的值。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<int> dp(n, 0);
for(int i=0; i<m; ++i){
for(int j=0; j<n; ++j){
if(i == 0 && j == 0){
dp[j] = grid[i][j];
}else if(i == 0){
dp[j] = dp[j - 1] + grid[i][j];
}else if(j == 0){
dp[j] = dp[j] + grid[i][j];
}else{
dp[j] = min(dp[j], dp[j - 1]) + grid[i][j];
}
}
}
return dp[n - 1];
}
};
给定一个由 0 和 1 组成的矩阵 mat ,请输出一个大小相同的矩阵,其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。
两个相邻元素间的距离为 1 。
一般来说,因为这道题涉及到四个方向上的最近搜索,所以很多人的第一反应可能会是广度优先搜索。但是对于一个大小 O ( mn ) 的二维数组,对每个位置进行四向搜索,最坏情况的时间复杂度(即全是 1 )会达到恐怖的 O ( m 2 n 2 ) 。一种办法是使用一个 dp 数组做 memoization ,使得广度优先搜索不会重复遍历相同位置;另一种更简单的方法是,我们从左上到右下进行一次动态搜索,再从右下到左上进行一次动态搜索。两次动态搜索即可完成四个方向上的查找。
class Solution {
public:
vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
if(mat.empty()){
return {};
}
int m = mat.size(), n = mat[0].size();
vector<vector<int>> dp(m, vector<int>(n, INT_MAX - 1));
for(int i=0; i<m; ++i){
for(int j=0; j<n; ++j){
if(mat[i][j] == 0){
dp[i][j] = 0;
}else{
if(j > 0){
dp[i][j] = min(dp[i][j], dp[i][j - 1] + 1);
}
if(i > 0){
dp[i][j] = min(dp[i][j], dp[i - 1][j] + 1);
}
}
}
}
for(int i=m-1; i>=0; --i){
for(int j=n-1; j>=0; --j){
if(mat[i][j] != 0){
if(j < n - 1 ){
dp[i][j] = min(dp[i][j], dp[i][j + 1] + 1);
}
if(i < m - 1){
dp[i][j] = min(dp[i][j], dp[i + 1][j] + 1);
}
}
}
}
return dp;
}
};
在一个由 '0'
和 '1'
组成的二维矩阵内,找到只包含 '1'
的最大正方形,并返回其面积。
对于在矩阵内搜索正方形或长方形的题型,一种常见的做法是定义一个二维 dp 数组,其中 dp[i][j] 表示满足题目条件的、以 (i, j) 为右下角的正方形或者长方形的属性。对于本题,则表示以 (i, j) 为右下角的全由 1 构成的最大正方形边长。如果当前位置是 0 ,那么 dp[i][j] 即为 0 ;如果 当前位置是 1 ,我们假设 dp[i][j] = k ,其充分条件为 dp[i-1][j-1] 、 dp[i][j-1] 和 dp[i-1][j] 的值必须都不小于 k − 1 ,否则 (i, j) 位置不可以构成一个面积为 k 2 的正方形。同理,如果这三个值中的的最小值为 k − 1 ,则 (i, j) 位置一定且最大可以构成一个面积为 k 2 的正方形。
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
if(matrix.empty() || matrix[0].empty()){
return 0;
}
int m = matrix.size(), n = matrix[0].size();
int max_side = 0;
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(matrix[i - 1][j - 1] == '1'){
dp[i][j] = min(dp[i - 1][j - 1], min(dp[i][j - 1], dp[i - 1][j])) + 1;
}
max_side = max(max_side, dp[i][j]);
}
}
return max_side * max_side;
}
};
3. 分割类型题
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for(int i=1; i<=n; ++i){
for(int j=1; j*j<=i; ++j){
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
};
一条包含字母 A-Z 的消息通过以下映射进行了 编码 :
'A' -> "1"
'B' -> "2"
...
'Z' -> "26"
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106" 可以映射为:
"AAJF" ,将消息分组为 (1 1 10 6)
"KJF" ,将消息分组为 (11 10 6)
注意,消息不能分组为 (1 11 06) ,因为 "06" 不能映射为 "F" ,这是由于 "6" 和 "06" 在映射中并不等价。
给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数 。
题目数据保证答案肯定是一个 32 位 的整数。
这是一道很经典的动态规划题,难度不大但是十分考验耐心。这是因为只有 1-26 可以表示字母,因此对于一些特殊情况,比如数字 0 或者当相邻两数字大于 26 时,需要有不同的状态转移方程,详见如下代码。
class Solution {
public:
int numDecodings(string s) {
int n = s.length();
if(n == 0) return 0;
int prev = s[0] - '0';
if(!prev) return 0; // 在此判断第一个数字是否为0,下面代码中用于记录前一个数字字符
if(n == 1) return 1; // 字符串长度为1,则数字为1-9之间,此时只有一种解码方式
vector<int> dp(n + 1, 1); // 每个数字至少有一种解码方式,除了0(指放在串首单独的零或前面数字大于2)和大于26的数
for(int i=2; i<=n; ++i){
int cur = s[i - 1] -'0'; // 记录当前的数字字符
if((prev == 0 || prev > 2) && cur == 0){ // 指00 30 40 50...这些情况
return 0;
}
if((prev < 2 && prev > 0) || prev == 2 && cur < 7){ // 表示小于26的二位数字的情况 12 16 22 24...
if(cur){
dp[i] = dp[i - 2] + dp[i - 1];
}else{ // cur为0的情况
dp[i] = dp[i - 2];
}
}else{ // 单独大于2数字的情况
dp[i] = dp[i - 1];
}
prev = cur; // 记录前一个数字字符
}
return dp[n];
}
};
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
类似于完全平方数分割问题,这道题的分割条件由集合内的字符串决定,因此在考虑每个分割位置时,需要遍历字符串集合,以确定当前位置是否可以成功分割。注意对于位置0 ,需要初始化值为真。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int n = s.length();
vector<bool> dp(n + 1, false);
dp[0] = true;
for(int i=1; i<=n; ++i){
for(const string& word: wordDict){
int len = word.length();
if(i >= len && s.substr(i - len, len) == word){
dp[i] = dp[i] || dp[i - len];
}
}
}
return dp[n];
}
};
4. 子序列问题
300. Longest Increasing Subsequence
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
对于子序列问题,第一种动态规划方法是,定义一个 dp 数组,其中 dp[i] 表示以 i 结尾的子序列的性质。在处理好每个位置后,统计一遍各个位置的结果即可得到题目要求的结果。在本题中,dp[i] 可以表示以 i 结尾的、最长子序列长度。对于每一个位置 i ,如果其之前的某个位置 j 所对应的数字小于位置 i 所对应的数字,则我们可以获得一个以 i 结尾的、长度为 dp[j] + 1 的子序列。为了遍历所有情况,我们需要 i 和 j 进行两层循环,其时间复杂度为 O ( n 2 ) 。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size(), max_length = 0;
if(n <= 1) return n;
vector<int> dp(n, 1);
for(int i=0; i<n; ++i){
for(int j=0; j<i; ++j){
if(nums[i] > nums[j]){
dp[i] = max(dp[i], dp[j] + 1);
}
}
max_length = max(max_length, dp[i]);
}
return max_length;
}
};
本题还可以使用二分查找将时间复杂度降低为 O ( n log n ) 。我们定义一个 dp 数组,其中 dp[k] 存储长度为 k+1 的最长递增子序列的最后一个数字。我们遍历每一个位置 i ,如果其对应的数字大于 dp 数组中所有数字的值,那么我们把它放在 dp 数组尾部,表示最长递增子序列长度加 1 ;如果我们发现这个数字在 dp 数组中比数字 a 大、比数字 b 小,则我们将 b 更新为此数字,使得之后构成递增序列的可能性增大。以这种方式维护的 dp 数组永远是递增的,因此可以用二分查找加速搜索。以样例为例,对于数组 [10,9,2,5,3,7,101,4] ,我们每轮的更新查找情况为:
num | dp |
10 | [10] |
9 | [9] |
2 | [2] |
5 | [2,5] |
3 | [2,3] |
7 | [2,3,7] |
101 | [2,3,7,101] |
4 |
[2,3,4,101]
|
最终我们知道最长递增子序列的长度是 4 。注意 dp 数组最终的形式并不一定是合法的排列形式,如 [2,3,4,101] 并不是子序列;但之前覆盖掉的 [2,3,7,101] 是最优解之一。该算法的代码实现如下。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if(n <= 1) return n;
vector<int> dp;
dp.push_back(nums[0]);
for(int i=1; i<n; ++i){
if(dp.back() < nums[i]){
dp.push_back(nums[i]);
}else{
auto itr = lower_bound(dp.begin(), dp.end(), nums[i]);
*itr = nums[i];
}
}
return dp.size();
}
};
1143. Longest Common Subsequence
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
对于子序列问题,第二种动态规划方法是,定义一个 dp 数组,其中 dp[i] 表示到位置 i 为止的子序列的性质,并不必须以 i 结尾。这样 dp 数组的最后一位结果即为题目所求,不需要再对每个位置进行统计。在本题中,我们可以建立一个二维数组 dp ,其中 dp[i][j] 表示到第一个字符串位置 i 为止、到第二个字符串位置 j 为止、最长的公共子序列长度。这样一来我们就可以很方便地分情况讨论这两个位置对应的字母相同与不同的情况了。
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.length(), n = text2.length();
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 - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
};
5. 背包问题
int knapsack(vector<int> weights, vector<int> values, int N, int W){
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for(int i=1; i<=N; ++i){
int w = weights[i - 1], v = values[i - 1];
for(int j=1; j<=W; ++j){
if(j >= w){
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w] + v);
}else{
dp[i][j] = dp[i - 1][j]l
}
}
}
return dp[N][W];
}
int knapsack(vector<int> weights, vector<int> values, int N, int W){
vector<int> dp(W + 1, 0);
for(int i=1; i<=N; ++i){
int w = weights[i - 1], v = values[i - 1];
for(int j=W; j>=w; ++j){
dp[j] = max(dp[j], dp[j - w] + v);
}
}
return dp[W];
}
int knapsack(vector<int> weights, vector<int> values, int N, int W){
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for(int i=1; i<=N; ++i){
int w = weights[i - 1], v = values[i - 1];
for(int j=1; j<=W; ++j){
if(j >= w){
dp[i] = max(dp[i - 1][j], dp[i][j - w] + v)
}else[
dp[i][j] = dp[i - 1][j];
]
}
}
return dp[N][W];
}
int knapsack(vector<int> weights, vector<int> values, int N, int W){
vector<int> dp(W + 1, 0);
for(int i=1; i<=N; ++i){
int w = weights[i - 1], v = values[i - 1];
for(int j=w; j<=W;++j){
dp[j] = max(dp[j], dp[j - w] + v);
}
}
return dp[W];
}
416. Partition Equal Subset Sum
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
本题等价于 0-1 背包问题,设所有数字和为 sum ,我们的目标是选取一部分物品,使得它们的总和为 sum/2 。这道题不需要考虑价值,因此我们只需要通过一个布尔值矩阵来表示状态转移矩阵。注意边界条件的处理。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if(sum % 2) return false;
int target = sum / 2, n = nums.size();
vector<vector<bool>> dp(n + 1, vector<bool>(target + 1, false));
dp[0][0] = true;
for(int i=1; i<=n; ++i){
for(int j=0; j<=target; ++j){
if(j < nums[i - 1]){
dp[i][j] = dp[i - 1][j];
}else{
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[n][target];
}
};
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if(sum % 2) return false;
int target = sum / 2, n = nums.size();
vector<bool> dp(target + 1, false);
dp[0] = true;
for(int i=1; i<=n; ++i){
for(int j=target; j>=nums[i - 1]; --j){
dp[j] = dp[j] || dp[j - nums[i - 1]];
}
}
return dp[target];
}
};
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
这是一个多维费用的 0-1 背包问题,有两个背包大小, 0 的数量和 1 的数量。我们在这里直接展示三维空间压缩到二维后的写法。
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(const string & str: strs){
auto [count0, count1] = count(str);
for(int i=m; i>=count0; --i){
for(int j=n; j>=count1; --j){
dp[i][j] = max(dp[i][j], dp[i - count0][j - count1] + 1);
}
}
}
return dp[m][n];
}
pair<int, int> count(const string & s){
int count0 = s.length(), count1 = 0;
for(const char & c: s){
if(c == '1'){
++count1;
--count0;
}
}
return make_pair(count0, count1);
}
};
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
因为每个硬币可以用无限多次,这道题本质上是完全背包。我们直接展示二维空间压缩为一维的写法。这里注意,我们把 dp 数组初始化为 amount + 1 而不是 -1 的原因是,在动态规划过程中有求 最小值的操作,如果初始化成-1 则会导致结果始终为 -1 。至于为什么取这个值,是因为 i 最大可以取 amount ,而最多的组成方式是只用 1 元硬币,因此 amount + 1 一定大于所有可能的组合方式,取最小值时一定不会是它。在动态规划完成后,若结果仍然是此值,则说明不存在满足条件的组合方法,返回-1 。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
if(coins.empty()) return -1;
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for(int i=1; i<=amount; ++i){
for(const int & coin: coins){
if(i >= coin){
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] == amount + 1? -1: dp[amount];
}
};
6. 字符串编辑
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
· 插入一个字符
· 删除一个字符
· 替换一个字符
类似于题目 1143 ,我们使用一个二维数组 dp[i][j] ,表示将第一个字符串到位置 i 为止,和第二个字符串到位置 j 为止,最多需要几步编辑。当第 i 位和第 j 位对应的字符相同时, dp[i][j] 等于 dp[i-1][j-1];当二者对应的字符不同时,修改的消耗是 dp[i-1][j-1]+1 ,插入 i 位置 / 删除 j 位置的消耗是 dp[i][j-1] + 1 ,插入 j 位置 / 删除 i 位置的消耗是 dp[i-1][j] + 1 。
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.length(), n = word2.length();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(int i=0; i<=m; ++i){
for(int j=0; j<=n; ++j){
if(i == 0){
dp[i][j] = j;
}else if(j == 0){
dp[i][j] = i;
}else{
dp[i][j] = min(
dp[i - 1][j - 1] + ((word1[i - 1] == word2[j - 1])? 0: 1),
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
}
return dp[m][n];
}
};
最初记事本上只有一个字符 'A' 。你每次可以对这个记事本进行两种操作:
· Copy All(复制全部):复制这个记事本中的所有字符(不允许仅复制部分字符)。
· Paste(粘贴):粘贴 上一次 复制的字符。
给你一个数字 n ,你需要使用最少的操作次数,在记事本上输出 恰好 n 个 'A' 。返回能够打印出 n 个 'A' 的最少操作次数。
不同于以往通过加减实现的动态规划,这里需要乘除法来计算位置,因为粘贴操作是倍数增加的。我们使用一个一维数组 dp ,其中位置 i 表示延展到长度 i 的最少操作次数。对于每个位置 j,如果 j 可以被 i 整除,那么长度 i 就可以由长度 j 操作得到,其操作次数等价于把一个长度为 1 的 A 延展到长度为 i/j 。因此我们可以得到递推公式 dp[i] = dp[j] + dp[i/j] 。
class Solution {
public:
int minSteps(int n) {
vector<int> dp(n + 1 , 0);
for(int i=2; i<=n; ++i){
dp[i] = i;
for(int j=2; j*j<=i; ++j){
if(i % j == 0){
dp[i] = dp[j] + dp[i / j];
break;
}
}
}
return dp[n];
}
};
10. Regular Expression Matching
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
· '.' 匹配任意单个字符
· '*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
我们可以使用一个二维数组 dp ,其中 dp[i][j] 表示以 i 截止的字符串是否可以被以 j 截止的正则表达式匹配。根据正则表达式的不同情况,即字符、星号,点号,我们可以分情况讨论来更新 dp 数组,其具体代码如下。
class Solution {
public:
bool isMatch(string s, string p) {
int m = s.size(), n = p.size();
vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false));
dp[0][0] = true;
for(int i=1; i<=n; ++i){
if(p[i - 1] == '*'){
dp[0][i] = dp[0][i - 2];
}
}
for(int i=1; i<=m; ++i){
for(int j=1; j<=n; ++j){
if(p[j - 1] == '.'){
dp[i][j] = dp[i - 1][j - 1];
}else if(p[j - 1] != '*'){
dp[i][j] = dp[i - 1][j - 1] && p[j - 1] == s[i - 1];
}else if(p[j - 2] != '.' && p[j - 2] != s[i - 1]){
dp[i][j] = dp[i][j - 2];
}else{
dp[i][j] = dp[i][j - 1] || dp[i - 1][j] || dp[i][j - 2];
}
}
}
return dp[m][n];
}
};
7. 股票交易
121. Best Time to Buy and Sell Stock
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
我们可以遍历一遍数组,在每一个位置 i 时,记录 i 位置之前所有价格中的最低价格,然后将当前的价格作为售出价格,查看当前收益是不是最大收益即可。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int buy = INT_MIN, sell = 0;
for(int i=0; i<prices.size(); ++i){
buy = max(buy, -prices[i]);
sell = max(sell, prices[i] + buy);
}
return sell;
}
};
188. Best Time to Buy and Sell Stock IV
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
如果 k 大于总天数的一半,那么我们一旦发现可以赚钱就可以进行买卖;这里一半的原因是因为当天股价是不变的,因此一次买卖需要两天。如果 k 小于总天数,我们可以建立两个动态规划数组 buy 和 sell ,对于每天的股票价格, buy[j] 表示在第 j 次买入时的最大收益, sell[j] 表示在第 j 次卖出时的最大收益。
class Solution {
public:
// 主函数
int maxProfit(int k, vector<int>& prices) {
int days = prices.size();
if(days < 2) return 0;
if(k * 2 >= days){
return maxProfitUnlimited(prices);
}
vector<int> buy(k + 1, INT_MIN), sell(k + 1, 0);
for(int i=0; i<days; ++i){
for(int j=1; j<=k; ++j){
buy[j] = max(buy[j], sell[j - 1] - prices[i]);
sell[j] = max(sell[j], buy[j] + prices[i]);
}
}
return sell[k];
}
// 辅函数
int maxProfitUnlimited(vector<int>& prices){
int maxProfit = 0;
for(int i=1; i<prices.size(); ++i){
if(prices[i] > prices[i - 1]){
maxProfit += prices[i] - prices[i - 1];
}
}
return maxProfit;
}
};
309. Best Time to Buy and Sell Stock with Cooldown
给定一个整数数组prices,其中第 prices[i] 表示第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
我们可以使用状态机来解决这类复杂的状态转移问题,通过建立多个状态以及它们的转移方式,我们可以很容易地推导出各个状态的转移方程。如图所示,我们可以建立四个状态来表示带有冷却的股票交易,以及它们的之间的转移方式。其中分为买入状态Buy、卖出状态Sell、买入后状态S1、卖出后状态S2。· 买入状态:即通过买入股票达到的买入状态
· 买入后状态:买入大于等于两天后的持股状态,一直没操作,保持持股
· 卖出状态:通过卖出持有的股票达到卖出状态,可以从买入状态直接操作卖出股票进入卖出状态,也可以在买入之后的持有多天后卖出股票进入卖出状态,这两个过程都会产生收益
· 卖出后状态:度过了冷冻期,大于等于两天前就卖出了股票,一直没操作,保持不持股
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
if(n < 2) return 0;
vector<int> buy(n), sell(n), s1(n), s2(n);
s1[0] = buy[0] = -prices[0];
sell[0] = s2[0] = 0;
for(int i=1; i<n; ++i){
buy[i] = s2[i - 1] - prices[i];
s1[i] = max(s1[i - 1], buy[i - 1]);
sell[i] = max(s1[i - 1], buy[i - 1]) + prices[i];
s2[i] = max(s2[i - 1], sell[i - 1]);
}
return max(sell[n - 1], s2[n - 1]);
}
};
三、巩固练习
583. Delete Operation for Two Strings
646. Maximum Length of Pair Chain
714. Best Time to Buy and Sell Stock with Transaction Fee
欢迎大家共同学习和纠正指教