LeetCode力扣刷题——深入浅出动态规划

动态规划


一、算法解释

        这里我们引用一下维基百科的描述:“动态规划(Dynamic Programming, DP )在查找有很多 重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间 · · · · · · 动态规划只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。”
        通俗一点来讲,动态规划和其它遍历算法(如深/ 广度优先搜索)都是将原问题拆成多个子问 题然后求解,他们之间最本质的区别是,动态规划保存子问题的解,避免重复计算。解决动态规划问题的关键是找到状态转移方程,这样我们可以通过计算和储存子问题的解来求解最终问题。
        同时,我们也可以对动态规划进行空间压缩,起到节省空间消耗的效果。这一技巧笔者将在之后的题目中介绍。
        在一些情况下,动态规划可以看成是带有状态记录(memoization )的优先搜索。状态记录的意思为,如果一个子问题在优先搜索时已经计算过一次,我们可以把它的结果储存下来,之后遍历到该子问题的时候可以直接返回储存的结果。动态规划是自下而上的,即先解决子问题,再解决父问题;而用带有状态记录的优先搜索是自上而下的,即从父问题搜索到子问题,若重复搜索到同一个子问题则进行状态记录,防止重复计算。如果题目需求的是最终状态,那么使用动态搜索比较方便;如果题目需要输出所有的路径,那么使用带有状态记录的优先搜索会比较方便。

二、经典问题

1. 基本动态规划:一维

70. 爬楼梯

70. Climbing Stairs

        假设你正在爬楼梯。需要 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 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

198. 打家劫舍

198. House Robber

        你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

        给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

        定义一个数组 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& nums) {
        if(nums.empty()){
            return 0;
        }
        int n = nums.size();
        vector 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& 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

413. 等差数列划分

413. Arithmetic Slices

        如果一个数列 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该数列为等差数列。

        例如,[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& nums) {
        int n = nums.size();
        if(n < 3){
            return 0;
        }
        vector dp(n, 0);
        for(int i=2; i

2. 基本动态规划:二维

64. 最小路径和

64. Minimum Path Sum

        给定一个包含非负整数的 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>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector> dp(m, vector(n, 0));
        for(int i=0; i
        因为 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>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector dp(n, 0);
        for(int i=0; i

​​​​​​542. 01 矩阵

542. 01 Matrix

        给定一个由 0 和 1 组成的矩阵 mat ,请输出一个大小相同的矩阵,其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。

        两个相邻元素间的距离为 1 。

        一般来说,因为这道题涉及到四个方向上的最近搜索,所以很多人的第一反应可能会是广度优先搜索。但是对于一个大小 O ( mn ) 的二维数组,对每个位置进行四向搜索,最坏情况的时间复杂度(即全是 1 )会达到恐怖的 O ( m 2 n 2 ) 。一种办法是使用一个 dp 数组做 memoization ,使得广度优先搜索不会重复遍历相同位置;另一种更简单的方法是,我们从左上到右下进行一次动态搜索,再从右下到左上进行一次动态搜索。两次动态搜索即可完成四个方向上的查找。
class Solution {
public:
    vector> updateMatrix(vector>& mat) {
        if(mat.empty()){
            return {};
        }
        int m = mat.size(), n = mat[0].size();
        vector> dp(m, vector(n, INT_MAX - 1));
        for(int i=0; i 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;
    }
};

221. 最大正方形

221. Maximal Square

        在一个由 '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 的正方形。

LeetCode力扣刷题——深入浅出动态规划_第1张图片

class Solution {
public:
    int maximalSquare(vector>& matrix) {
        if(matrix.empty() || matrix[0].empty()){
            return 0;
        }
        int m = matrix.size(), n = matrix[0].size();
        int max_side = 0;
        vector> dp(m + 1, vector(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. 分割类型题

279. 完全平方数

279. Perfect Squares

        给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。

        完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

class Solution {
public:
    int numSquares(int n) {
        vector 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];
    }
};

91. 解码方法

91. Decode Ways

        一条包含字母 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 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];
    }
};

139. 单词拆分

139. Word Break

        给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

        注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

        类似于完全平方数分割问题,这道题的分割条件由集合内的字符串决定,因此在考虑每个分割位置时,需要遍历字符串集合,以确定当前位置是否可以成功分割。注意对于位置0 ,需要初始化值为真。
class Solution {
public:
    bool wordBreak(string s, vector& wordDict) {
        int n = s.length();
        vector 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. 最长递增子序列

300. Longest Increasing Subsequence

        给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

        子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

        注意:按照 LeetCode 的习惯,子序列(subsequence)不必连续,子数组(subarray)或子字符串 (substring)必须连续。
        对于子序列问题,第一种动态规划方法是,定义一个 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& nums) {
        int n = nums.size(), max_length = 0;
        if(n <= 1)  return n;
        vector dp(n, 1);
        for(int i=0; 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& nums) {
        int n = nums.size();
        if(n <= 1)  return n;
        vector dp;
        dp.push_back(nums[0]);
        for(int i=1; i

1143. 最长公共子序列

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> dp(m + 1, vector(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. 背包问题

        背包问题是一种组合优化的 NP 完全问题:有 N 个物品和容量为 W 的背包,每个物品都有自己的体积 w 和价值 v,求拿哪些物品可以使得背包所装下物品的总价值最大。如果限定每种物 品只能选择 0 个或 1 个,则问题称为 0-1 背包问题;如果不限定每种物品的数量,则问题称为无界背包问题或完全背包问题。
        我们可以用动态规划来解决背包问题。以 0-1 背包问题为例。我们可以定义一个二维数组 dp存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。在我们遍历到第 i 件物品时,在当前背包总容量为 j 的情况下,如果我们不将物品 i 放入背包,那么 dp[i][j] = dp[i-1][j],即前 i 个物品的最大价值等于只取前 i-1 个物品时的最大价值;如果我们将物品 i 放入背包,假设第 i 件物品体积为 w ,价值为 v ,那么我们得到 dp[i][j] = dp[i-1][j-w] + v 。我们只需在遍历过程中对这两种情况取最大值即可,总时间复杂度和空间复杂度都为 O ( NW )
int knapsack(vector weights, vector values, int N, int W){
    vector> dp(N + 1, vector(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];
}
LeetCode力扣刷题——深入浅出动态规划_第2张图片 0-1 背包问题 - 状态转移矩阵样例

        我们可以进一步对 0-1 背包进行空间优化,将空间复杂度降低为 O ( W ) 。如图所示,假设我们目前考虑物品 i = 2 ,且其体积为 w = 2 ,价值为 v = 3 ;对于背包容量 j ,我们可以得到 dp[2][j] = max(dp[1][j], dp[1][j-2] + 3)。这里可以发现我们永远只依赖于上一排 i = 1 的信息,之前算过的其他物品都不需要再使用。因此我们可以去掉 dp 矩阵的第一个维度,在考虑物品 i 时变成 dp[j] = max(dp[j], dp[j-w] + v)。这里要注意我们在遍历每一行的时候必须逆向遍历,这样才能够调用上一行物品 i-1 时 dp[j-w] 的值;若按照从左往右的顺序进行正向遍历,则 dp[j-w] 的值在遍历到 j 之前就已经被更新成物品 i 的值了。                                
int knapsack(vector weights, vector values, int N, int W){
    vector 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];
}
LeetCode力扣刷题——深入浅出动态规划_第3张图片 完全背包问题 - 状态转移矩阵样例
        
        在完全背包问题中,一个物品可以拿多次。如图上半部分所示,假设我们遍历到物品 i = 2,且其体积为 w = 2 ,价值为 v = 3 ;对于背包容量 j = 5 ,最多只能装下 2 个该物品。那么我们的状态转移方程就变成了 dp[2][5] = max(dp[1][5], dp[1][3] + 3, dp[1][1] + 6) 。如果采用这种方法,假设背包容量无穷大而物体的体积无穷小,我们这里的比较次数也会趋近于无穷大,远超 O ( NW ) 的时间复杂度。
        怎么解决这个问题呢?我们发现在 dp[2][3] 的时候我们其实已经考虑了 dp[1][3] dp[2][1] 的情况,而在时 dp[2][1] 也已经考虑了 dp[1][1] 的情况。因此,如图下半部分所示,对于拿多个物品的情况,我们只需考虑 dp[2][3] 即可,即 dp[2][5] = max(dp[1][5], dp[2][3] + 3) 。这样,我们就得到了完全背包问题的状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i][j-w] + v) ,其与 0-1 背包问题的差别仅仅是把状态转移方程中的第二个 i-1 变成了 i
int knapsack(vector weights, vector values, int N, int W){
    vector> dp(N + 1, vector(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];
}
        同样的,我们也可以利用空间压缩将时间复杂度降低为 O ( W ) 。这里要注意我们在遍历每一
行的时候必须正向遍历,因为我们需要利用当前物品在第 j-w 列的信息。
int knapsack(vector weights, vector values, int N, int W){
    vector 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];
}
        压缩空间时到底需要正向还是逆向遍历呢?物品和体积哪个放在外层,哪个放在内层呢?这取决于状态转移方程的依赖关系。在思考空间压缩前,不妨将状态转移矩阵画出来,方便思考如何进行空间压缩。
        如果您实在不想仔细思考,这里有个简单的口诀:0-1 背包对物品的迭代放在外层,里层的体积或价值逆向遍历;完全背包对物品的迭代放在里层,外层的体积或价值正向遍历

416. 分割等和子集

416. Partition Equal Subset Sum

        给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

        本题等价于 0-1 背包问题,设所有数字和为 sum ,我们的目标是选取一部分物品,使得它们的总和为 sum/2 。这道题不需要考虑价值,因此我们只需要通过一个布尔值矩阵来表示状态转移矩阵。注意边界条件的处理。
class Solution {
public:
    bool canPartition(vector& nums) {
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if(sum % 2) return false;
        int target = sum / 2, n = nums.size();
        vector> dp(n + 1, vector(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& nums) {
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if(sum % 2) return false;
        int target = sum / 2, n = nums.size();
        vector 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];
    }
};

474. 一和零

474. Ones and Zeroes

        给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

        请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

        如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

        这是一个多维费用的 0-1 背包问题,有两个背包大小, 0 的数量和 1 的数量。我们在这里直接展示三维空间压缩到二维后的写法。
class Solution {
public:
    int findMaxForm(vector& strs, int m, int n) {
        vector> dp(m + 1, vector(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 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);
    }
};

322. 零钱兑换

322. Coin Change

        给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

        计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

        你可以认为每种硬币的数量是无限的。

        因为每个硬币可以用无限多次,这道题本质上是完全背包。我们直接展示二维空间压缩为一维的写法。
        这里注意,我们把 dp 数组初始化为 amount + 1 而不是 -1 的原因是,在动态规划过程中有求 最小值的操作,如果初始化成-1 则会导致结果始终为 -1 。至于为什么取这个值,是因为 i 最大可以取 amount ,而最多的组成方式是只用 1 元硬币,因此 amount + 1 一定大于所有可能的组合方式,取最小值时一定不会是它。在动态规划完成后,若结果仍然是此值,则说明不存在满足条件的组合方法,返回-1
class Solution {
public:
    int coinChange(vector& coins, int amount) {
        if(coins.empty())   return -1;
        vector 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. 字符串编辑

72. 编辑距离

72. Edit Distance

        给你两个单词 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> dp(m + 1, vector(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];
    }
};

650. 只有两个键的键盘

​​​​​​650. 2 Keys Keyboard

        最初记事本上只有一个字符 '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 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. 正则表达式匹配

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> dp(m + 1, vector(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. 买卖股票的最佳时机

121. Best Time to Buy and Sell Stock

        给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

        你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

        返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

        我们可以遍历一遍数组,在每一个位置 i 时,记录 i 位置之前所有价格中的最低价格,然后将当前的价格作为售出价格,查看当前收益是不是最大收益即可。
class Solution {
public:
    int maxProfit(vector& prices) {
        int buy = INT_MIN, sell = 0;
        for(int i=0; i

188. 买卖股票的最佳时机 IV

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& prices) {
        int days = prices.size();
        if(days < 2)    return 0;
        if(k * 2 >= days){
            return maxProfitUnlimited(prices);
        }
        vector buy(k + 1, INT_MIN), sell(k + 1, 0);
        for(int i=0; i& prices){
        int maxProfit = 0;
        for(int i=1; i prices[i - 1]){
                maxProfit += prices[i] - prices[i - 1];
            }
        }
        return maxProfit;
    }
};

309. 最佳买卖股票时机含冷冻期

309. Best Time to Buy and Sell Stock with Cooldown

        给定一个整数数组prices,其中第  prices[i] 表示第 i 天的股票价格 。​

        设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

        卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
        注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

        我们可以使用状态机来解决这类复杂的状态转移问题,通过建立多个状态以及它们的转移方式,我们可以很容易地推导出各个状态的转移方程。如图所示,我们可以建立四个状态来表示带有冷却的股票交易,以及它们的之间的转移方式。其中分为买入状态Buy、卖出状态Sell、买入后状态S1、卖出后状态S2。
· 买入状态:即通过买入股票达到的买入状态
· 买入后状态:买入大于等于两天后的持股状态,一直没操作,保持持股
· 卖出状态:通过卖出持有的股票达到卖出状态,可以从买入状态直接操作卖出股票进入卖出状态,也可以在买入之后的持有多天后卖出股票进入卖出状态,这两个过程都会产生收益
· 卖出后状态:度过了冷冻期,大于等于两天前就卖出了股票,一直没操作,保持不持股
LeetCode力扣刷题——深入浅出动态规划_第4张图片 题目 309 - 状态机状态转移

class Solution {
public:
    int maxProfit(vector& prices) {
        int n = prices.size();
        if(n < 2)   return 0;
        vector buy(n), sell(n), s1(n), s2(n);
        s1[0] = buy[0] = -prices[0];
        sell[0] = s2[0] = 0;
        for(int i=1; i

三、巩固练习

213. 打家劫舍 II

213. House Robber II

53. 最大子数组和

53. Maximum Subarray

343. 整数拆分

343. Integer Break

583. 两个字符串的删除操作

583. Delete Operation for Two Strings

646. 最长数对链

646. Maximum Length of Pair Chain

376. 摆动序列

376. Wiggle Subsequence

494. 目标和

494. Target Sum

714. 买卖股票的最佳时机含手续费

714. Best Time to Buy and Sell Stock with Transaction Fee


欢迎大家共同学习和纠正指教

你可能感兴趣的:(LeetCode,每日一练:经典算法题,数据结构与算法——经典题目,c语言,c++,leetcode,算法,数据结构)