动态规划->算法实现

综述

动态规划分为:基础题目、背包问题、打家劫舍、股票问题、子序列问题

基础题目:
斐波那契数列:leetcode509
爬楼梯:leetcode70
使用最小花费爬楼梯:leetcode746
不同路径:leetcode62
不同路径Ⅱ:leetcode63
整数划分:leetcode343
不同的二叉搜索树:leetcode96

背包问题:
0.1背包:
分割等和子集:leetcode416
最后一块石头的重量Ⅱ:leetcode1049
目标和:leetcode494
一和零:leetcode474
完全背包:
零钱兑换Ⅱ:leetcode518
组合总数Ⅳ:leetcode377
爬楼梯(完全背包解法):leetcode70
零钱兑换:leetcode322
完全平方数:leetcode279
单词拆分:leetcode139

打家劫舍:
打家劫舍:leetcode198
打家劫舍Ⅱ:leetcode213
打家劫舍Ⅲ:leetcode377

股票问题:
买卖股票的最佳时机(只能买卖一次):leetcode121
买卖股票的最佳时机Ⅱ(可以买卖多次):leetcode122
买卖股票的最佳时机Ⅲ(最多买卖两次):leetcode123
买卖股票的最佳时机Ⅳ(最多买卖k次):leetcode188
买卖股票的最佳时机含冷冻期(买卖多次,卖出有一天冷冻期):leetcode309
买卖股票的最佳时机含手续费(买卖多次,每次有手续费):leetcode714

子序列问题:
子序列(不连续):
最长上升子序列:leetcode300
最长公共子序列:leetcode1143
不相交的线:leetcode1035
子序列(连续):
最长连续递增序列:leetcode674
最长重复子数组:leetcode718
最大子序和:leetcode53
编辑距离:
判断子序列:leetcode392
不同的子序列:leetcode115
两个字符串的删除操作:583
编辑距离:leetcode72
回文:
回文子串:leetcode647
最长回文子序列:leetcode516

引言

动态规划

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的
只要现在的状态是由上一个状态推导出来的,那就是用动态规划
动规五部曲:
1.确定dp数组和下标的含义
2.确定递推公式
3.dp数组如何初始化
4.确定遍历顺序
5.动手举例推导dp数组

如果写题时出现了错误,则打印dp数组,看看是否和自己动手推导的dp数组一致

01背包

二维数组实现

问题:
有 n 件物品和一个最多能背重量为 w 的背包。第i件物品的重量是 weight[i],得到的价值是 value[i]。
每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
如果暴力解法,那么每个物品有 取 和 不取 两个状态,因此可使用 回溯算法,时间复杂度是 o(2^n)
而使用动态规划可以大大降低时间复杂度
举例:
物品为:
在这里插入图片描述
使用动态规划五部曲:

  1. dp[i][j] 表示从下标为 0-i 的物品中取,放进背包容量为 j 的背包,所得的最大价值
    在这里插入图片描述
  2. 如果不取物品 i,那么 dp[i][j] = dp[i - 1][j];
    如果取物品 i,那么 dp[i][j] = dp[i - 1][j - weight[i]] + value[i],也就是背包容量为 j - weight[i] 的背包最大的价值 + 物品 i 的价值value[i]
    因此递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
  3. 初始化:如果 j = 0,那么就是背包容量为0,因此 dp[i][0] = 0;
    如果 i = 0,那么就是在容量为 j 的背包中放物品0,容量 j < weight[0] 时,dp[0][j] = 0;
    容量 j >= weight[0] 时,dp[0][j] = value[0]
  4. 遍历顺序:由递推公式知 dp[i][j] 是由左上方或者上方的数据推出来的,因此是从前到后,从上到下的顺序遍历
    并且 先遍历物品后遍历背包 和 先遍历背包后遍历物品 是一样的结果,反正 dp[i][j] 是由左上方或者上方的数据推出来的
  5. 对上述例子进行推导:
    在这里插入图片描述
    代码:
void test_2_wei_bag_problem1() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagweight = 4;

    // 二维数组
    vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));

    // 初始化
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }

    // weight数组的大小 就是物品个数
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
            if (j < weight[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

        }
    }

    cout << dp[weight.size() - 1][bagweight] << endl;
}

int main() {
    test_2_wei_bag_problem1();
}

一维数组实现

可以使用滚动数组的方法将 二维dp数组 转换成 一维dp数组
由于递推公式是:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
因此可以将 dp[i - 1] 那一层拷贝到 dp[i] 上,
则公式为 dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]); 这就变成了一维dp数组

  1. 此时 dp[j] 表示容量为 j 的背包所背物品的最大价值 dp[j]
  2. 递推公式是:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
  3. 初始化: dp[0] 肯定是0,由于递推公式是取价值的最大值,因此只要价值是正数,那么初始化dp数组就是0
  4. 遍历顺序:遍历物品是从前到后的,遍历背包是从后向前的,因为 dp[j] 是由前面的 dp[j] 推出来的,从后向前的遍历可以保证前面的 dp[j] 不变;如果从前向后遍历,那么此时前面的 dp[j] 已经变了,不是二维数组中的 dp[i][j - 1] 了已经
    因此对于一维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();
}

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i]
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

二维数组实现

和01背包相比,其他的都一样,只有初始化和递推公式不一样
01背包每个物品只能放一次,而完全背包可以放多次,因此初始化需要考虑将第0个物品放多次:

for (int j = weight[0]; j <= bagWeight; j++) {
     int num = j / weight[0];//可放入第0个物品的数量
     dp[0][j] = num * value[0];
}

01背包的递推公式是:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
因为每个物品只能放一次,所以 拿物品 j 时,dp[i][j] 是由 dp[i - 1][j - weight[i]] + value[i] 推出来
但是完全背包物品无限,所以 拿物品 j 时,dp[i][j] 是由 dp[i][j - weight[i]] + value[i] 推出来
所以递推公式是:dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
同样的,可以 外层遍历物品内层遍历背包,同时也可以 外层遍历背包内层遍历物品

for (int i = 1; i < dp.size(); i++) {
    for (int j = 0; j < dp[0].size(); j++) {
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = std::max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
    }
}

一维数组实现

对于01背包,因为每个物品只能遍历一次,所以内层遍历背包时,是倒序遍历的,为的就是不让物品重复添加
而完全背包每个物品可以重复放,因此内层遍历背包时直接顺序遍历即可,即

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

需要注意的是:
纯完全背包问题的一维数组实现是可以 外层遍历物品内层遍历背包,同时也可以 外层遍历背包内层遍历物品
但是当求组合数时,只能先遍历物品,再遍历背包容量,这样可以保证只出现存放顺序 {物品1,物品2} ,而不出现{物品2,物品1}
当求排列数时,只能先遍历背包容量,再遍历物品,这样可以保证存放顺序 {物品1,物品2} 和 {物品2,物品1} 都会出现,因为物品在内层,1、2 和 2、1 不一样
举个例子:
nums = [1, 2, 3],如果物品在外循环,那么只有 {1,3} 这样的集合,不会有 {3,1} 这样的集合,因为nums 遍历放在外层,3只能出现在1后面;而物品在内循环,就会出现 {1,3} 和 {3,1}

二维数组实现01背包和完全背包时,由于进行了初始化,i 都是从1开始遍历的
一维数组实现01背包和完全背包时,由于未进行初始化,i 都是从0开始遍历的

多重背包

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包可以把物品的数量摊开,从而变成01背包问题
如:
在这里插入图片描述
摊开:
在这里插入图片描述
这样就变成了01背包了
代码:

#include<iostream>
#include<vector>
using namespace std;
int main() {
    int bagWeight,n;
    cin >> bagWeight >> n;
    vector<int> weight(n, 0); 
    vector<int> value(n, 0);
    vector<int> nums(n, 0);
    for (int i = 0; i < n; i++) cin >> weight[i];
    for (int i = 0; i < n; i++) cin >> value[i];
    for (int i = 0; i < n; i++) cin >> nums[i];    
    
    for (int i = 0; i < n; i++) {
        while (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++) { // 遍历物品,注意此时的物品数量不是n
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

这样会超时,原因就是在遍历 while 循环时,一直对 vector 进行 push_back,导致 vector 多次动态扩容
改进:把 所有物品数量都计算好,一起申请vector的空间

刷题总结

基础题目总结

主要还是把握好递推公式,多打印dp数组看看

背包问题总结

在这里插入图片描述
物品只能取1次是01背包
物品能取无限次是完全背包

对于完全背包:
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。

纯01背包和完全背包递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
背包的价值和重量都是nums[i]:dp[j] = max(dp[j], dp[j - nums[i]] +nums[i])
求方法数:dp[j] += dp[j - nums[i]],需要注意的是初始化时dp[0] = 1
求背包中最大元素个数,那么每个元素的价值就是1:dp[j] = max(dp[j], dp[j - weight[i]] + 1)且初始化dp[j] = 0
求最小元素个数:dp[j] = min(dp[j], dp[j - weight[i]] + 1)且初始化dp[j] = INT_MAX(为了可以用较小值覆盖),并且dp[0] = 0(背包容量为0的元素个数一定为0)

打家劫舍总结

主要 二叉树 和 动态规划 的结合,这个需要关注 遍历顺序 和 状态转移也就是通过某个容器来记录每个点的状态
所以需要多练打家劫舍Ⅲ

股票问题总结

  • 只能买卖一次:
    1个状态:持有 不持有
dp[i][0] = max(dp[i - 1][0], -prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
  • 可以买卖多次:
    2个状态:持有 不持有
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]);
  • 最多买卖两次:
    4个状态:第一次持有 第一次不持有 第二次持有 第二次不持有
dp[i][0] = max(dp[i - 1][0], -prices[i]);
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]);
  • 最多买卖k次:
    2 * k 个状态:第一次持有 第一次不持有 第二次持有 第二次不持有 …
if (j == 0) dp[i][j] = max(dp[i - 1][j], -prices[i]);//第一次持有需要0-prices[i]
else if (j % 2 == 0) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1] - prices[i]);
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1] + prices[i]);
  • 买卖多次,卖出有一天冷冻期
    3个状态:持有 不持有 冷冻期
dp[i][0] = max(dp[i - 1][0], dp[i - 1][2] - prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
dp[i][2] = dp[i - 1][1];
  • 买卖多次,每次有手续费
    买卖多次的延申,只需要在卖出时扣掉手续费 fee 就可以了
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);

子序列问题总结

首先这类题中,dp 数组一般就是所求的东西设为 dp 数组
递推公式需要注意是哪些操作(增、删、改),如果是删,是两个数组都可以删还是只能删一个
初始化也要注意,有时候就是初始化出错了。初始化中 需要注意 dp[i][0] 代表什么,应该是什么值; dp[0][j] 代表什么,应该是什么值;dp[0][0] 代表什么,应该是什么值。
遍历顺序一般是顺序遍历

对于子序列问题,最重要的就是 if (nums[i - 1] == nums[j - 1]) dp[i][j] = ...
总是忘记用 i - 1 去对比,这样就会导致结果出错,并且打印 dp 数组和自己预想的差别很大,并且找不到任何有可能出错的地方
那么这时候肯定就是 i - 1 写错了,写成了 i ,那么肯定不对
对于回文子串和回文子序列问题,就是用 i 去对比的,这是 dp[i][j] 就指的是 s[i] 和 s[j] 比较的情况了

每个题的递推公式总结:
最长上升子序列:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
最长连续递增子序列:if (nums[i] > nums[i - 1]) dp[i] = dp[i - 1] + 1;
最长重复子数组:

if (nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = 0;

最长公共子序列:

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]);

最大子序和:dp[i] = max(dp[i - 1] + nums[i], nums[i]);
判断子序列:

if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = dp[i][j - 1];

不同的子序列:

if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
else dp[i][j] = dp[i - 1][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] + 2, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));

编辑距离:

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] + 1, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));

回文子串:

if (i + 1 >= j - 1) dp[i][j] = (s[i] == s[j]);//字符子串长度小于等于2的情况
else dp[i][j] = dp[i + 1][j - 1] && (s[i] == s[j]);//字符子串长度大于2的情况

最长回文子序列:

if (s[i] == s[j]) {
    if (i == j) dp[i][j] = 1;//对角线
    else dp[i][j] = dp[i + 1][j - 1] + 2;//非对角线
} else {
    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}

基础题目

斐波那契数列

题目

leetcode509
在这里插入图片描述

题解

这是动态规划的最经典最基本的题目
递推公式就是: dp[i] = dp[i - 1] + dp[i - 2];

class Solution {
public:
    int fib(int n) {
        if (n == 0) return 0;
        if (n == 1) return 1;
        vector<int> dp(n + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i < dp.size(); i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

爬楼梯

题目

leetcode70
在这里插入图片描述

题解

dp[i]指的是爬到第 i 层的方法数
递推公式: dp[i] = dp[i - 1] + dp[i - 2];

class Solution {
public:
    int climbStairs(int n) {
        if (n < 3) return n;
        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i < dp.size(); i++) dp[i] = dp[i - 1] + dp[i - 2];
        return dp[n];
    }
};

进阶

如果一步一个台阶、两个台阶…、m个台阶,怎么处理
这是一个完全背包问题
台阶数n是背包的容量,1、2、…、m是物品
并且1、2和2、1是不一样的,也就是说求的是排列数,那么外循环就是背包,内循环就是物品
求方法数,因此递推公式是: dp[i] += dp[i - nums[j]];
由于dp[0]是一切累加的来源,因此初始化dp[0] = 1; 其他的初始化为0

class Solution {
public:
    int climbStairs(int n) {
        int m = 2;//此题中m为2
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        for (int i = 1; i < dp.size(); i++) {
            for (int j = 1; j <= m; j++) {
                if (i - j >= 0) dp[i] += dp[i - j];
            }
        }
        return dp[n];
    }
};

不同路径

题目

leetcode62
在这里插入图片描述

题解

dp[i][j]是到第i行j列的路径数量
递推公式:dp[i][j] = dp[i - 1][j] + dp[i][j - 1];

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector<int>(n));//创建m*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];
    }
};

整数拆分

题目

leetcode343
在这里插入图片描述

题解

dp[i] 指的是下标为i时,乘积最大化的值
先举例看看:
在这里插入图片描述
比如 i = 10 的时候,可以将 i 分成 4 + 6,那么对应的4 * 9 = 36即是要求的最大乘积
因此,dp[i] 是和之前的值有关的
需要遍历 i 可分成的值,进行乘积,找到最大值
因此递推公式是 dp[i] = max( dp[i], max( j * (i - j), j * dp[i - j]) );
为什么没有 dp[j] * dp[i - j] 呢,看下图
在这里插入图片描述
红色的一定比蓝色的大,因此 j * dp[i - j] 可能是较大值, j * (i - j) 可能是最大值,唯独 dp[j] * (i - j) 不可能是最大值,而 dp[j] * dp[i - j] 已经包含在 j * dp[i - j] 中了。

class Solution {
public:
    int integerBreak(int n) {
        if (n == 2) return 1;
        if (n == 3) return 2;
        vector<int> dp(n + 1);
        dp[2] = 1;
        dp[3] = 2;
        for (int i = 4; i <= n; i++) {
            for (int j = 2; j <= i / 2; j++) {
                dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]));
            }
        }
        return dp[n];
    }
};

不同的二叉搜索树

题目

leetcode96
在这里插入图片描述

题解

观察
在这里插入图片描述
在这里插入图片描述
因此,dp[3] 和 dp[1]、dp[2] 是有关的
递推公式: dp[i] += dp[j - 1] * dp[i - j];
其中 j 指的是中间节点,dp[j - 1] 指的是左子树的二叉搜索树的个数,dp[i - j] 指的是右子树的二叉搜索树的个数
由于 j - 1 和 i - j 可能为0,因此初始化中 dp[0] 需要为1,否则乘出来会出现0

class Solution {
public:
    int numTrees(int n) {
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i < dp.size(); i++) {
            for (int j = 1; j <= i; j++) {
                dp[i] += dp[j - 1] * dp[i - j];
            }
        }
        return dp[n];
    }
};

背包问题

分割等和子集

题目

leetcode416
在这里插入图片描述

题解

这道题其实就是找能不能选一些元素,使其和等于sum / 2
是一道典型的01背包问题
背包的容量是 sum / 2,同时最后需要判断背包在 sum / 2 的容量里面能不能装满
也就是判断 dp[sum / 2] == sum / 2,由于背包需要的价值和容量都是 sum / 2
因此此题中,物品的重量是 num[i],同时物品的价值也是 num[i]
这样就转成了01背包问题
代码如下:

class Solution {
public:
    //01背包问题,背包能接受的重量是sum(nums) / 2,价值是sum(nums) / 2;每个元素的重量是数字大小,价值是元素的数字大小
    bool canPartition(vector<int>& nums) {
        //求nums的和
        int sum = 0;
        for (int ele : nums) {
            sum += ele;
        }

        if (sum % 2 != 0) return false;
        int bagWeight = sum / 2;

        //一维背包创建并初始化
        vector<int> dp(bagWeight + 1, 0) ;

        //递推
        for (int i = 0; i < nums.size(); i++) {
            for (int j = bagWeight; j >= nums[i]; j--) {
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }

        //如果在sum/2的重量的背包中,正好装满,价值也是sum/2,那么就是满足题意的,返回ture
        if (dp[bagWeight] == bagWeight) return true;
        return false;
    }
};

目标和

题目

leetcode494
在这里插入图片描述

题解

这道题就是需要将 nums 分成 加法组plusArr 和 减法组subArr
那么 plusArrSize - subArrSize = target
而 plusArrSize + subArrSize = sum,因此 plusArrSize = (sum + target) / 2
而此题就变成了背包问题:容量为 plusArrSize 的背包装 nums 的元素,有多少种方法可以装满
虽然是背包问题,但不是纯背包问题,纯背包问题指的是在容量为 bagWeight 的背包装元素,最大价值是多少
而此问题求的是方法数,递推公式是不一样的
用五部曲进行分析:

  1. dp[j] 表示容量为 j 的背包装元素的方法数
  2. 不选物品 nums[i] 时,方法数 dp[j] 就是原来的方法数 dp[j]
    选物品 nums[i] 时,方法数 dp[j] 就是 背包容量为 j - nums[i] 的方法数
    所以递推公式是:dp[j] = dp[j] + dp[j - nums[i]];dp[j] += dp[j - nums[i]];
  3. 初始化:由于递推公式是累加,因此 dp[0] 必须为1,后面才能累加出结果,如果 dp[0] = 0,那么累加一直就是0.
    因此 dp[0] = 1; 其他的为0
  4. 遍历顺序:外层遍历物品,顺序遍历;内层遍历背包,倒序遍历
class Solution {
public:
    //背包问题,求背包重量为(target + sum) / 2组合的个数,dp[j]数组记为重量为j时的组合个数
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for (int ele : nums) sum += ele;
        
        if (sum < abs(target)) return 0;//target的绝对值 > sum 的话,根本凑不出来
        if ((target + sum) % 2 != 0) return 0;//除不尽也凑不出来
        int bagSize = (sum + target) / 2;

        vector<int> dp(bagSize + 1, 0);
        dp[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];

    }
};

一和零

题目

leetcode474
在这里插入图片描述

题解

本题目是01背包问题
物品是 strs 中的元素,物品的重量有两个维度,分别是 0 的个数 和 1 的个数
要求 背包中能装元素的最大个数,那么 元素的个数 就是价值,所以每一个元素的价值都是 1
那么就转换成 二维的01背包问题
dp[i][j]:最多有 i 个0和 j 个1的 strs 的最大子集的大小为 dp[i][j]。
代码:

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>> dp(m + 1, vector<int> (n + 1, 0)); // 默认初始化0
        for (string str : strs) { // 遍历物品
        
            int oneNum = 0, zeroNum = 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];
    }
};

零钱兑换Ⅱ

题目

leetcode518
在这里插入图片描述

题解

这道题是完全背包问题,求的是组合数
由组合数可以得知两点:
首先求的是方法数,所以递推公式是 dp[j] += dp[j - coins[i]] 且初始化 dp[0] = 0
其次只需要组合,不需要排列,因此外层遍历物品,内层遍历背包

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];
    }
};

组合总数Ⅳ

题目

leetcode377
在这里插入图片描述

题解

这道题要求的是组合的个数,因此可以用动态规划偷个懒,时间复杂度是 o(n * target)
如果要求 组合是哪些,比如{1,1,1,1}组合 和 {1,1,2}组合,那只能用回溯法,时间复杂度是 o(2^n)
所以这道题是完全背包问题,求的是排列数
由排列数可以得知两点:
首先求的是方法数,所以递推公式是 dp[j] += dp[j - coins[i]] 且初始化 dp[0] = 0
其次只需要排列,不需要组合,因此外层遍历背包,内层遍历物品

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        vector<unsigned 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] += dp[j - nums[i]];
            }
        }
        return dp.back();
    }
};

零钱兑换

题目

leetcode322
在这里插入图片描述

题解

此题中,背包容量是 amount,需要计算最少元素个数
因此,物品的重量是 coins[i],每个物品的价值是1,因为每个硬币的个数就是1
所以递推公式是:dp[j] = min(dp[j], dp[j - coins[i]] + 1);
由于是求最小值,因此初始化时就将 dp数组 需要初始化 INT_MAX,方便取最小值进行覆盖
但是凑足总金额为0所需钱币的个数一定是0,所以 dp[0] = 0;
由于此题中求的是背包背相应的重量时价值最小,不涉及组合和排序,因此哪个放外层循环都可以

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]]是初始值则跳过
//跳过的原因是可能会先遍历到coins[i]=5的情况,那么dp[1],dp[2]..dp[4]就还是INT_MAX,所以需要跳过,
//包括如果匹配不到amount时,仍然是初始值INT_MAX,可以直接返回-1。不理解的话直接手推一个实例就懂了
                    dp[j] = min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }
        
        return dp.back() == INT_MAX ? -1 : dp.back();
    }
};

单词拆分

题目

leetcode139
在这里插入图片描述

题解

这道题回溯可以直接求,但是动态规划更快,物品可以无限使用,因此是完全背包问题
分析五部曲:

  1. dp[i] : 字符串长度为i的话,dp[i]为true,表示前0 ~ i-1 的字符串可以拆分为一个或多个在字典中出现的单词
  2. 这一段 s[a ~ b] 能在字典里面找到,并且之前的段 s[0 ~ a] 也可以找到,那么 s[0 ~ b]就可以在字典里找到,因此递推公式是:dp[i] = dp[0 ~ a] && dp[a ~ b]
  3. 初始化中,由于后续的 dp[i] 都是由 dp[0] 推导而来的,因此 dp[0] = true; 其他的初始化为 false
  4. 由示例可知,apple 后跟 pen 后又跟 apple,因此 apple 会重复出现,所以物品在内循环

以示例2举例:

在这里插入图片描述
由于 s[0]==1,并且 s[0~4] 即 apple 与物品0匹配,因此 dp[5]=1

在这里插入图片描述

由于 i==0 时,即遍历物品0时,dp[5]==1,因此遍历到i=1即物品1时,直接跳过

在这里插入图片描述
由于 s[5]==1,并且 s[5~7] 即pen与物品1匹配,因此 dp[8]=1

在这里插入图片描述
由于 s[8]==1,并且 s[8 ~12] 即 apple 与物品0匹配,因此 dp[13]=1

在这里插入图片描述
由于 i==0 时,即遍历物品0时,dp[13]==1,因此遍历到 i=1 即物品1时,直接跳过

代码如下:

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        vector<bool> dp(s.size() + 1, false);
        dp[0] = true; //初始化
        for (int j = 0; j < dp.size(); j++) { //遍历背包
            for (int i = 0; i < wordDict.size(); i++) { //遍历物品
                if (j < wordDict[i].size()) continue; //防止dp[j - wordDict[i].size()]出现错误
                if (dp[j]) continue; //如果之前的单词已经可以匹配了,即dp[j]为0,那么后续的单词就不用再匹配了。比如在j=5时已经匹配了apple,那么就不用再匹配pen了
                string temp = s.substr(j - wordDict[i].size(), wordDict[i].size());//找出s[a ~ b],和单词进行对比
                dp[j] = dp[j - wordDict[i].size()] && wordDict[i] == temp;//递推公式
            }
        }
        return dp.back();
    }
};

打家劫舍问题

打家劫舍

题目

leetcode198
在这里插入图片描述

题解

这个题就比较简单了,dp[i] 指的是 对于第 i 家不管偷不偷,反正金额最大
对于 dp[i],就是偷和不偷两个情况
如果偷,那么上一家就不能偷,则 dp[i] = dp[i - 2] + nums[i]
如果不偷,那么上一家就可以偷,则dp[i] = dp[i - 1]
所以递推公式是dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);

class Solution {
public:
    int rob(vector<int>& nums) {
        if (nums.size() == 1) return nums[0];
        if (nums.size() == 2) return max(nums[0], nums[1]);
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);

        for (int i = 2; i < dp.size(); i++) {
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
        }

        return dp.back();
    }
};

打家劫舍Ⅲ

题目

leetcode377
在这里插入图片描述

题解

对于此问题,仍然是考虑一个节点的时候,考虑偷还是不偷
偷的话就要考虑它的上上个节点
不偷的话就考虑它的上个节点
因此一定是要后序遍历,因为通过递归函数的返回值来做下一步计算
那么返回值就是返回一个 数对pair<int, int>,其中 first 记录的是此节点的上个节点的最大值
second 记录的是此节点的最大值
那么将其返回给下一个节点,下一个节点就得到它的上上个节点和上个节点了,就变成了简单的打家劫舍问题了

class Solution {
public:
    int rob(TreeNode* root) {
        pair<int, int> agoAndBefore = traversal(root);
        return max(agoAndBefore.first, agoAndBefore.second);
    }


private:
    //pair<int, int> agoAndBefore中记录:first记录的是这个节点的上个节点偷的最大值,
    //second记录的是这个节点偷的最大值
    pair<int, int> traversal(TreeNode* root){
        if (root == nullptr) return {0, 0};//空节点的上个节点和此节点都是0
        pair<int, int> leftPair = traversal(root->left);
        pair<int, int> rightPair = traversal(root->right);

        pair<int, int> agoAndBefore;
        agoAndBefore.first = leftPair.second + rightPair.second;//这个节点的上个节点
        //偷的最大值 是左右子节点的second相加
        agoAndBefore.second = max(leftPair.first + rightPair.first + root->val, leftPair.second + rightPair.second);
        //这个节点偷的最大值是 上上节点的值(左右子节点的first)+这个节点的值 与 
        //上个节点的值(左右节点的second相加) 的最大值

        return agoAndBefore;
    }
};

时间复杂度:O(n),每个节点只遍历了一次
空间复杂度:O(n):
空间复杂度分为递归调用栈 和 额外的pair对象

  1. 在递归时,系统都会在内存中创建一个新的函数栈帧,用于保存函数的局部变量和返回地址等信息,递归时调用栈的深度可能会达到二叉树的高度,因此递归调用栈是 O(n)
  2. 由于每遍历一个节点都会创建 pair 数组,所以 n 个节点就是 n 个pair,而pair中是两个 int,pair 的复杂度是常数,所以 n 个 pair 的空间复杂度为 O(n)

所以总的空间复杂度为 O(n)

代码随想录中返回的 pair<int, int> 记录的是:first是不偷,second是偷,那么返回给下一个节点,下一个节点不偷的话就可以考虑 first 和 second 了,偷的话就考虑 first 和 下个节点的 val。

如果是暴力法直接遍历二叉树:

class Solution {
public:
    int rob(TreeNode* root) {
        if (root == NULL) return 0;
        if (root->left == NULL && root->right == NULL) return root->val;
        // 偷父节点
        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的左右孩子
        return max(val1, val2);
    }
};

这个方法每个节点算了至少三次,遍历到此节点时一次,当儿子算一次,当孙子算一次
所以时间复杂度大,动态规划相当于用容器记录了遍历过的每个节点的状态,这样每个节点只遍历一次就够了

股票问题

买卖股票的最佳时机(只能买卖一次)

题目

leetcode121
在这里插入图片描述

题解

这道题可以直接贪心,但是贪心不容易推广,因此建议直接动态规划
动态规划五部曲:

  1. dp[i][0] 表示第i天持有股票所得最多现金,是持有,有可能前几天就买了,也有可能是今天买的
    dp[i][1] 表示第i天不持有股票所得最多现金,同样的,可能前几天卖出的,也有可能是今天卖的
  2. 第i天持有股票即dp[i][0], 那么可以由两个状态推出来:
    ①今天不买,那么就是昨天持有股票的钱 dp[i - 1][0]
    ②今天买,那么就是 一开始的现金0 - 股票的钱prices[i]
    第i天不持有股票即dp[i][1], 也可以由两个状态推出来:
    ①今天不卖,那么就是昨天不持有股票的钱 dp[i - 1][1]
    ②今天卖,那么就是昨天 持有股票的钱(买了股票之后的钱) + 今天股票的钱prices[i]
    因此递推公式是:
dp[i][0] = max(dp[i - 1][0], -prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
  1. 初始化第0天:dp[0][0] = -prices[0]; dp[0][1] = 0;
  2. 遍历顺序:从前向后
    代码:
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2, 0));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;

        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = max(dp[i - 1][0], -prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
        }

        return dp[dp.size() - 1][1];
    }
};

可以使用滚动数组将空间复杂度从 o(n) 降低到 o(1)

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(2, vector<int>(2));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;

        for (int i = 1; i < prices.size(); i++) {
            dp[i % 2][0] = max(dp[(i - 1) % 2][0], -prices[i]);
            dp[i % 2][1] = max(dp[(i - 1) % 2][1], dp[(i - 1) % 2][0] + prices[i]);
        }

        return dp[(prices.size() - 1) % 2][1];
    }
};

同样的,后续的股票问题也可以使用滚动数组降低空间复杂度,后续就不再赘述,可以动手实现

买卖股票的最佳时机Ⅱ(可以买卖多次)

题目

leetcode122
在这里插入图片描述

题解

由于可以多次买卖,因此只有在递推公式和一次买卖不同
第i天持有股票即 dp[i][0], 那么可以由两个状态推出来:
①今天不买,那么就是昨天持有股票的钱 dp[i - 1][0]
②今天买,那么就是 之前操作不持有股票的现金dp[i - 1][1] - 股票的钱prices[i]
因此递推公式:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;

        for (int i = 1; i < prices.size(); 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[dp.size() - 1][1];
    }
};

买卖股票的最佳时机Ⅲ(最多买卖两次)

题目

leetcode123
在这里插入图片描述

题解

之前每天有两种状态:持有股票 不持有股票
不管是一次买卖还是多次买卖都是这样
但是规定了只有两次买卖的话,就需要具体区分了每天的状态了:
第一次持有股票,第一次不持有股票,第二次持有股票,第二次不持有股票
因此申请的 dp 数组是 n * 4 的 vector
那么 dp[i][0], dp[i][1], dp[i][2], dp[i][3] 分别对应上述四个状态
递推公式是:

dp[i][0] = max(dp[i - 1][0], -prices[i]);
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]);

代码:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(4));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;
        dp[0][2] = -prices[0];
        dp[0][3] = 0;

        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = max(dp[i - 1][0], -prices[i]);
            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]);
        }

        return dp[dp.size() - 1][3];
    }
};

需要注意的是第二次不持有股票的钱一定比第一次不持有股票的钱多,因为循环期间一直在做 max 操作,因此直接返回 dp[dp.size() - 1][3] 即可

可以通过滚动数组完成以降低空间复杂度,由于 dp[i][0], dp[i][1], dp[i][2], dp[i][3] 是从 dp[i - 1][0], dp[i - 1][1], dp[i - 1][2], dp[i - 1][3] 推导的,因此内层需要倒序,类似于01背包:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        if (prices.size() <= 1) return 0;
        vector<int> dp(4);

        dp[0] = -prices[0];//dp[0]是第一次持有股票的钱
        dp[1] = 0;//dp[1]是第一次卖出股票的钱
        dp[2] = -prices[0];//dp[2]是第二次持有股票的钱
        dp[3] = 0;//dp[3]是第二次卖出股票的钱

        for (int i = 1; i < prices.size(); i++) {
            dp[3] = max(dp[3], dp[2] + prices[i]);
            dp[2] = max(dp[2], dp[1] - prices[i]);
            dp[1] = max(dp[1], dp[0] + prices[i]);
            dp[0] = max(dp[0], -prices[i]);
        }

        return dp[3];
    }
};

买卖股票的最佳时机Ⅳ(最多买卖k次)

题目

leetcode188
在这里插入图片描述

题解

类似于两次买卖股票,只不过换成了 k 罢了
买卖 k 次有 2 * k 个状态,因此申请 n * (2 * k) 的 vector

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
        vector<vector<int>> dp(prices.size(), vector<int>(2 * k, 0));//偶数是持有股票,奇数是不持有股票

        //初始化
        for (int j = 0; j < 2 * k; j++) {
            if (j % 2 == 0) dp[0][j] = -prices[0];
        }
        
        for (int i = 1; i < prices.size(); i++) {
            for (int j = 0; j < 2 * k; j++) {
                if (j == 0) dp[i][j] = max(dp[i - 1][j], -prices[i]);//第一次持有需要0-prices[i]
                else if (j % 2 == 0) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1] - prices[i]);
                else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1] + prices[i]);
            }
        }

        return dp[dp.size() - 1][2 * k - 1];
    }
};

买卖股票的最佳时机含冷冻期(买卖多次,卖出有一天冷冻期)

题目

leetcode309
在这里插入图片描述

题解

这个题加入了冷冻期,那么每天就有三种状态了:买入,卖出,冷冻期
申请 n * 3 的 vector,dp[i][0], dp[i][1], dp[i][2] 分别对应上述三种状态
①今天买入:昨天可能是 无状态也就是之前就买入了dp[i - 1][0] ,或是 冷冻期dp[i - 1][2] - prices[i]
②今天卖出:昨天可能是 无状态也就是之前就卖出了dp[i - 1][1],或是 买入dp[i - 1][0] + prices[i]
③今天是冷冻期,昨天只有一种可能,那么就是昨天卖出了dp[i - 1][1]
因此递推公式:

dp[i][0] = max(dp[i - 1][0], dp[i - 1][2] - prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
dp[i][2] = dp[i - 1][1];

整体代码:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        //每天有三种状态,买入,卖出,冷冻
        vector<vector<int>> dp(prices.size(), vector<int>(3, 0));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;
        dp[0][2] = 0;

        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][2] - prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
            dp[i][2] = dp[i - 1][1];
        }

        return max(dp[dp.size() - 1][1], dp[dp.size() - 1][2]);
    }
};

子序列问题

最长上升子序列

题目

leetcode300
在这里插入图片描述

题解

这是子序列问题的第一个题,需要明确的是子序列是离散的,子数组是连续的(也称连续子序列)
五部曲:

  1. dp[i] 表示以 nums[i] 结尾的递增子序列的最大长度
  2. 由于本题是求的子序列,也就是离散的,因此 dp[i] 和 nums[0 ~ i - 1] 都有关,因此需要遍历0 ~ i - 1
    如果 nums[i] > nums[j],那么 dp[j] + 1 就是 dp[i],但是由于需要对比多个 j,因此需要取最大
    递推公式:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
  3. 对于每一个数,他自身就是一个递增子序列,因此初始化是 dp[i] = 1
  4. 遍历顺序:从前到后
    需要注意的是由于 dp[i] 指的是以 nums[i] 结尾的最大长度,因此 dp.back() 不一定是最大的,可能以中间的某个数结尾是最大的,因此需要一个 res 在遍历的过程中去取最大
    代码:
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> dp(nums.size(), 1);
        int res = 1;

        for (int i = 1; i < dp.size(); i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
            }
            res = max(res, dp[i]);
        }

        return res;
    }
};

时间复杂度: O(n^2)
空间复杂度: O(n)

最长连续递增序列

题目

leetcode674
在这里插入图片描述

题解

本题和上一题的区别就在于 连续,是求最长连续递增子序列
那么 dp[i] 就只和 dp[i - 1] 有关了,就不用再弄个循环遍历 j
递推公式:if (nums[i] > nums[i - 1]) dp[i] = dp[i - 1] + 1;
为什么没有取最大? 因为 dp[i] 初始化就是 1,一定没有 dp[i - 1] + 1 大
上题有 max,是因为上提有内层循环一直再遍历 j

class Solution {
public:
    int findLengthOfLCIS(vector<int>& nums) {
        vector<int> dp(nums.size(), 1);
        int res = 1;

        for (int i = 1; i < dp.size(); i++) {
            if (nums[i] > nums[i - 1]) dp[i] = dp[i - 1] + 1;
            res = max(res, dp[i]);
        }

        return res;
    }
};

时间复杂度:O(n)
空间复杂度:O(n)

所以说:不连续递增子序列的跟前0~i 个状态有关,连续递增的子序列只跟前一个状态有关

最长重复子数组

题目

leetcode718
在这里插入图片描述

题解

用二维数组可以记录两个字符串的所有比较情况
所以直接定义二维vector
五部曲:

  1. dp[i][j] :以下标i - 1为结尾的 nums1,和以下标j - 1为结尾的 nums2,最长重复子数组长度为dp[i][j]
    为什么是 i-1 为结尾,因为这样方便初始化
  2. 当 nums[i - 1] 和 nums2[j - 1] 相等时,那么 dp[i][j] 比 dp[i-1][j-1] 大1
    递推公式:if (nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
    因此两层 for 循环就是从 1 开始遍历的
  3. 根据dp[i][j]的定义,dp[i][0] 和 dp[0][j] 其实都是没有意义的,那么 dp[i][0] 和 dp[0][j] 要初始值,是为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1;
    这样的话,dp[i][0] 和 dp[0][j] 初始化为0,根据if (nums1[0] == nums2[j - 1]) dp[0][j] = dp[0][j - 1] + 1;
    那么对于 nums1[0] 和 nums2 相等的地方,就会初始化为1;相应的,对于 nums2[0] 和 nums1 相等的地方,也会初始化为1
    这样不用多写两个循环去初始化:
// 要对第一行,第一列经行初始化
for (int i = 0; i < nums1.size(); i++) if (nums1[i] == nums2[0]) dp[i][0] = 1;
for (int j = 0; j < nums2.size(); j++) if (nums1[0] == nums2[j]) dp[0][j] = 1;
  1. 两层都是从前到后

由于任何一处的 dp[i][j] 都有可能是最大值,不一定是最右下角的是最大值,因此需要一个 res 去记录
代码:

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
    	//申请二维数组并初始化
        vector<vector<int>> dp(nums1.size() + 1, vector<int>(nums2.size() + 1, 0));
        int res = 0;

        for (int i = 1; i < nums1.size() + 1; i++) {
            for (int j = 1; j < nums2.size() + 1; j++) {
                if (nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;//递推公式
                res = max(res, dp[i][j]);
            }
        }

        return res;
    }
};

时间复杂度:O(n × m)
空间复杂度:O(n × m)

同样也可以用滚动数组,来降低空间复杂度,那么递推公式就是dp[j] = dp[j - 1] + 1;
由于不能先将 dp[j - 1] 覆盖,因此内层循环就是 从后向前遍历,类似于01背包

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        vector<int> dp(nums2.size() + 1, 0);
        int res = 0;

        for (int i = 1; i < nums1.size() + 1; i++) {
            for (int j = nums2.size(); j > 0; j--) {
                if (nums1[i - 1] == nums2[j - 1]) dp[j] = dp[j - 1] + 1;
                else dp[j] = 0; // 注意这里不相等的时候要有赋0的操作
                if (dp[j] > res) res = dp[j];
            }
        }

        return res;
    }
};

如果 dp[i][j] 表示的是以下标 i 为结尾的 nums1,和以下标 j 为结尾的 nums2,最长重复子数组长度为dp[i][j],那么不仅初始化麻烦,中间有些实现的逻辑也有很多细节不容易想到,具体见代码随想录
因此推荐 dp[i][j] 记录的是 i-1 和 j-1 是否相等的情况 即上述叙述的方法

最长公共子序列

题目

leetcode1143
在这里插入图片描述

题解

五部曲:

  1. dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]
  2. 主要就是两大情况: text1[i - 1] == text2[j - 1] , text1[i - 1] != text2[j - 1]
    如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;
    如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的
    所以递推公式是:
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]);
  1. dp[i][0] 是 text1 有长度,text2 空字符串,所以公共子序列是0
    dp[0][j] 是 text1 空字符串,text2 有长度,所以公共子序列是0

最小重复子数组是计算连续重复的数组,这时候 dp[i][j] 指的是以 nums1[i - 1] 和 nums[j - 1] 为结尾的重复数组的长度,意味着如果nums1[i - 1]和nums[j - 1]不等,则dp[i][j] = 0;
最小公共子序列是计算连续不重复序列,这时候 dp[i][j] 指的是 text1 中 0 ~ i - 1 和 text2 中 0 ~ j - 1 的重复序列长度,意味着 text1[i - 1] 和 text2[j - 1] 不等,则 dp[i][j] = dp[i][j - 1] 和 dp[i - 1][j] 等之前的长度

代码:

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
        for (int i = 1; i < text1.size() + 1; i++) {
            for (int j = 1; j < text2.size() + 1; 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[text1.size()][text2.size()];
    }
};

时间复杂度: O(n * m)
空间复杂度: O(n * m)

不相交的线

题目

leetcode1035
在这里插入图片描述

题解

本题其实就是求两个字符串的最长公共子序列的长度,和上道题是一样的

最大子序和

题目

leetcode53
在这里插入图片描述

题解

这道题可以直接贪心,又快又准

如果采用动态规划,dp[i] 指的是以 nums[i] 为结尾的的最大子数组和
因此如果 dp[i - 1] 是负的,那么加上 nums[i] 就会减小,不如直接以 i 为开头,即最大子数组的和是nums[i]
如果 dp[i - 1] 是正的,那么加上 nums[i] 就会增大,因此可以直接最大子数组和是 dp[i - 1] + nums[i]
所以递推公式是:dp[i] = max(dp[i - 1] + nums[i], nums[i]);

代码:

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];//初始化
        int res = nums[0];
        for (int i = 1; i < nums.size(); i++) {
            dp[i] = max(dp[i - 1] + nums[i], nums[i]);
            res = max(res, dp[i]);
        }
        return res;
    }
};

时间复杂度:O(n)
空间复杂度:O(n)

判断子序列

题目

leetcode392
在这里插入图片描述

题解

这道题有个很简单的方法:
因为是看 t 中是否含有 s,因此只需要 s 从0开始,如果 s[0] 在 t 中,那么查看 s[1] 是否在 t 中,并且s[1] 在 t 的位置在 s[0] 后面,因此 设 j 是 s 的索引,那么当 t[i] == s[j],j 才加1,这样代码如下:

class Solution {
public:
    bool isSubsequence(string s, string t) {
        if (t.size() == 0 & s.size() == 0) return true;
        else if (t.size() == 0 & s.size() != 0) return false;
        else if (t.size() != 0 && s.size() == 0) return true;
        vector<bool> dp(s.size(), false);//dp[i]数组表示索引为i的s[i]是否在t中
        int j = 0;//s的索引
        for (int i = 0; i < t.size(); i++) {//i是t的索引
            if (t[i] == s[j]) {
                dp[j] = true;
                j++;
            }
        }
        return dp.back();
    }
};

时间复杂度:O(n)
空间复杂度:O(m)

当然,这道题作为编辑距离的入门题目,当然要通过编辑距离来做
五部曲:

  1. dp[i][j] 表示以下标 i-1 为结尾的字符串s,和以下标 j-1 为结尾的字符串t,相同子序列的长度为dp[i][j]。
  2. 递推公式需要考虑 s[i - 1] == t[j - 1] 以及 s[i - 1] == t[j - 1] 两种情况
    如果相等,说明以 t[j - 1] 为结尾的字符串包含了 以 s[i - 1] 为结尾的字符串,因此子序列长度等于不考虑 t[j - 1] 和 s[i - 1] 时的长度 + 1。dp[i][j] = dp[i - 1][j - 1] + 1;
    如果不相等,那么就看 t[j - 1] 删除一个元素 与 s[i - 1] 的匹配情况,即 t[j - 2] 与 s[i - 1] 的匹配情况,由于是找 s 是否在 t 中,因此不需要删除 s,只需要考虑删除 t 即可。dp[i][j] = dp[i][j - 1];
  3. 初始化是 dp[i][0] 表示 s 非空字符串,t 空字符串,那么相同子序列长度是0
    同样的,dp[0][j] 也是0

代码:

class Solution {
public:
    bool isSubsequence(string s, string t) {
        vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
        for (int i = 1; i < s.size() + 1; i++) {
            for (int j = 1; j < t.size() + 1; j++) {
                if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;//如果相等,则+1
                else dp[i][j] = dp[i][j - 1];//如果不等,则t[j-1]删除,也就是说是s[i-1]和t[j-2]对比的结果,即dp[i][j]即是dp[i][j-1]
            }
        }

        return dp[s.size()][t.size()] == s.size();
    }
};

不同的子序列

题目

leetcode115
在这里插入图片描述

题解

五部曲:

  1. dp[i][j]:以 i-1 为结尾的 s 子序列中出现以 j-1 为结尾的 t 的个数为 dp[i][j]
  2. s[i - 1] 与 t[j - 1] 相等时,若用 s[i-1] 去匹配 t[j-1],那么 dp[i][j] = dp[i-1][j-1];
    若不用 s[i-1] 去匹配,那么出现的个数就是和上一个即 s[i-2] 相同,则 dp[i][j] = dp[i-1][j]
    s[i - 1] 与 t[j - 1] 不相等时,那么 s[i-1] 匹配不了,dp[i][j] 只有一部分组成,即不用 s[i - 1] 来匹配,所以 dp[i][j] = dp[i-1][j]
    因此递推公式:
if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
else dp[i][j] = dp[i - 1][j];

这个题中 dp[i][j] 表示方法数,而不是序列长度,因此相等是 就是之前的长度,不相等是 也是之前的长度,只不过 相等的“之前” 和 不相等的“之前” 是不一样的。而若 dp[i][j] 表示的是序列长度的话,那么就会相等就是 长度+1,不相等就是之前的长度
3. dp[i][0] 表示 s 非空字符串, t 空字符串,那么 s 中一定有1个 t,因此 dp[i][0] = 1
dp[0][j] 表示 s 空字符串,t 非空字符串,那么 s 中有0个 t,因此 dp[0][j] = 0
dp[0][0] 是1,空串 s 有1个 空串 t
4. 顺序遍历

代码:

class Solution {
public:
    int numDistinct(string s, string t) {
        vector<vector<unsigned int>> dp(s.size() + 1, vector<unsigned int>(t.size() + 1, 0));
        for (int j = 1; j < t.size() + 1; j++) dp[0][j] = 0;
        for (int i = 0; i < s.size() + 1; i++) dp[i][0] = 1;

        for (int i = 1; i < s.size() + 1; i++) {
            for (int j = 1; j < t.size() + 1; j++) {
                if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                else dp[i][j] = dp[i - 1][j];
            }
        }
        
        return dp[s.size()][t.size()];
    }
};

时间复杂度: O(n * m)
空间复杂度: O(n * m)

两个字符串的删除操作

题目

583
在这里插入图片描述

题解

这道题相比于编辑距离,只要删除操作
五部曲:

  1. dp[i][j]:以 i-1 为结尾的字符串 word1,和以 j-1 位结尾的字符串 word2,想要达到相等,所需要删除元素的最少次数。
  2. word1[i - 1] 与 word2[j - 1] 相等时,需要删除的步数和之前的是一样的,因此 dp[i][j] = dp[i - 1][j - 1];
    word1[i - 1] 与 word2[j - 1] 不相等时,有三种情况:
    情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1
    情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1
    情况三:同时删 word1[i - 1] 和 word2[j - 1],操作的最少次数为 dp[i - 1][j - 1] + 2这个很容易忘记
    因此递推公式:
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] + 2, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
  1. 初始化:dp[i][0] 指的是 word1 非空,变成和 空字符串的word2 相等 需要删除 i 次
    dp[0][j] 指的是 word2 非空,变成和 空字符串的word1 相等 需要删除 j 次
    因此初始化为 dp[i][0] = i; dp[0][j] = j;
    特殊的 dp[0][0] = 0;
  2. 遍历顺序:顺序遍历

代码:

class Solution {
public:
    int minDistance(string word1, string word2) {
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
        for (int i = 1; i < word1.size() + 1; i++) dp[i][0] = i;
        for (int j = 1; j < word2.size() + 1; j++) dp[0][j] = j;

        for (int i = 1; i < word1.size() + 1; i++) {
            for (int j = 1 ; j < word2.size() + 1; 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] + 2, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
            }
        }
        
        return dp[word1.size()][word2.size()];
    }
};

时间复杂度: O(n * m)
空间复杂度: O(n * m)

方法二:也可以通过 最长公共子序列 的方法求出最长公共子序列,那么除了最长公共子序列之外的字符都是必须删除的,那么最后结果就是: word1.size() + word.size() - 2 * 最长公共子序列的长度

编辑距离

题目

leetcode72
在这里插入图片描述

题解

那么会了上一道题 两个字符串的删除操作,这道题无非就是多了一个替换操作
五部曲:

  1. dp[i][j] 表示以下标 i-1 为结尾的字符串 word1,和以下标 j-1 为结尾的字符串 word2,最近编辑距离次数为 dp[i][j]。
  2. word1[i - 1] 与 word2[j - 1] 相等,那么不操作,编辑距离的次数和上一次相等 dp[i][j] = dp[i - 1][j - 1];
    word1[i - 1] 与 word2[j - 1] 不相等,有三个操作:
    操作一:word1 删除一个元素,即dp[i][j] = dp[i - 1][j] + 1;
    操作二:word2 删除一个元素,或者说 word1 增加一个元素,即dp[i][j] = dp[i][j - 1] + 1;
    操作三:替换元素,word1 替换 word1[i - 1],使其与 word2[j - 1] 相同,那么也是需要加一步,比如 “ab” 和 “ac”,将 b 换成 c 就相等了,所以只需要加一步:dp[i][j] = dp[i - 1][j - 1] + 1;
    所以递推公式:
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] + 1, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
  1. 初始化和上一道题一样:dp[i][0] = i; dp[0][j] = j; dp[0][0] = 0;

代码:

class Solution {
public:
    int minDistance(string word1, string word2) {
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1));
        for (int i = 0; i < word1.size() + 1; i++) dp[i][0] = i;
        for (int j = 0; j < word2.size() + 1; j++) dp[0][j] = j;

        for (int i = 1; i < word1.size() + 1; i++) {
            for (int j = 1; j < word2.size() + 1; 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] + 1, min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
            }
        }

        return dp[word1.size()][word2.size()];
    }
};

时间复杂度: O(n * m)
空间复杂度: O(n * m)

回文子串

题目

leetcode647
在这里插入图片描述

题解

如果定义 dp[i] 为 下标 i 结尾的字符串有 dp[i] 个回文串的话,会发现很难找到递归关系。
dp[i] 和 dp[i-1] ,dp[i + 1] 看上去都没啥关系。
由于回文串的特性,如果 “aba” 是回文串的话,“cabac” 就是回文串,因此可设定起始 i,种植为 j 的字符串是否是回文串,这就是 dp[i][j] 的定义
在这里插入图片描述

那么很容易发现 dp[i][j] 和 dp[i + 1][j - 1] 有关
所以递推公式是,如果 s[i] 和 s[j] 相等,那么就看 dp[i + 1][j - 1],如果 dp[i + 1][j - 1] 是true,那么 dp[i][j] 就也是 true
如果 s[i] 和 s[j] 不相等,那么一定不是回文串
由于dp[i][j] 由 dp[i + 1][j - 1] 推出,因此遍历顺序是 外层遍历 i 从下到上,内层遍历 j 从前到后
在赋值时需要注意 i + 1 < j - 1,如果 i + 1 > j - 1,那么 从 i + 1 到 j - 1 的字符串只有1个或2个,直接判断 s[i] 和 s[j] 是否相等即可

代码:

class Solution {
public:
    int countSubstrings(string s) {
        vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), true));//初始化是true或false都一样
        int res = 0;

        for (int i = s.size() - 1; i >= 0; i--) {
            for (int j = i; j < s.size(); j++) {
                if (i + 1 >= j - 1) dp[i][j] = (s[i] == s[j]);//字符子串长度小于等于2的情况
                else dp[i][j] = dp[i + 1][j - 1] && (s[i] == s[j]);//字符子串长度大于2的情况
                if (dp[i][j]) res++;
            }
        }

        return res;
    }
};

时间复杂度:O(n^2)
空间复杂度:O(n^2)

双指针也可以解,思路就是把每个点或每两个点看作中间节点,往外扩,看看是不是回文串,是的话就记录 res,不是就换节点继续尝试

最长回文子序列

题目

leetcode516
在这里插入图片描述

题解

上道题是回文子串,是连续的
这道题是回文子序列,是不连续的
上道题让回文子串的个数,也就是看看每个字串是不是回文的,因此申请 dp 中存的是 bool
这道题让求回文子序列的长度,也就是让求“长度”,因此申请 dp 数组中存的是 int
五部曲:

  1. dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]
  2. s[i] 和 s[j] 相等时,dp[i][j] = dp[i + 1][j - 1] + 2;
    s[i] 和 s[j] 不相等时,那么就是看看 s[i] 和 s[j - 1] 最长子序列的长度,也需要看看 s[i + 1] 和 s[j] 最长子序列的长度,因此dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
    相等时需要看 dp[i + 1][j - 1],需要注意的是当 i == j 时,长度就是1,而不是0+2
  3. 初始化:直接全部初始化为0,方便 + 2,也方便取 max
  4. 遍历顺序:外层遍历 i:从下到上,内层遍历 j:从前到后
  5. 对于" b b b a b ":
    在这里插入图片描述

代码:

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));

        for (int i = s.size() - 1; i >= 0; i--) {
            for (int j = i; j < s.size(); j++) {
                if (s[i] == s[j]) {
                    if (i == j) dp[i][j] = 1;//对角线
                    else 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][s.size() - 1];
    }
};

时间复杂度: O(n^2)
空间复杂度: O(n^2)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值