文章目录
动态规划
1、理论
定义
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的,所以动态规划中每一个状态一定是由上一个状态推导出来的。
解题步骤
对于动态规划问题,将其拆解为如下五步曲:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
debug
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
2、基础问题
leetcode 509. 斐波那契数
leetcode 509. 斐波那契数
动态规划五部曲:
这里我们要用一个一维dp数组来保存递归的结果
-
确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i] -
确定递推公式
为什么这是一道非常简单的入门题目呢?
因为题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]; -
dp数组如何初始化
题目中把如何初始化也直接给我们了,如下:
dp[0] = 0;
dp[1] = 1; -
确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的 -
举例推导dp数组
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。
代码如下:
int fib(int n) {
/* 0、特判
* 当你为0、1时直接返回即可
*/
if (n < 2) {
return n;
}
/* 1、dp数组含义
* dp[i]为F(i)的斐波那契函数值
*/
//int* dp = (int*)calloc(n + 1, sizeof(int));
int dp[2] = {0, 1};
/* 2、确定状态转移方程
* dp[i] dp[i - 1] + dp[i - 2]
*/
/* 3、初始化dp数组
* dp[0] = 0, dp[1] = 1
*/
// dp[0] = 0;
// dp[1] = 1;
/* 4、遍历顺序
* 从前向后i = [2。。。n]
*/
for (int i = 2; i <= n; i++) {
int cur = dp[1] + dp[0];
dp[0] = dp[1];
dp[1] = cur;
}
return dp[1];
}
leetcode 70. 爬楼梯
leetcode 70. 爬楼梯
这里需要注意dp[0]的初始化问题,因为dp[i]表示爬到第i层楼顶的方法,且题目中没有说明0层楼的情况,那么不考虑dp[0],直接考虑dp[1]、dp[2]即可,代码如下:
int climbStairs(int n){
/* 0、特判 */
if (n < 3) {
return n;
}
/* 1、dp数组,dp[i]表示第i层共dp[i]中方法 */
//int* dp = (int*)calloc(n + 1, sizeof(int));
int pre = 1, cur = 2;
/* 2、dp数组初始化
* dp[0]不考虑,dp[1] = 1, dp[2] = 2
*/
//dp[1] = 1, dp[2] = 2;
/* 3、遍历顺序:从前向后 */
for (int i = 3; i <= n; i++) {
int sum = pre + cur;
pre = cur;
cur = sum;
}
return cur;
}
leetcode 746. 使用最小花费爬楼梯
leetcode 746. 使用最小花费爬楼梯
本题中递推公式为:
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
初值为:
dp[0] = cost[0];
dp[1] = cost[1];
题意为爬上i阶楼梯需要支付对应的体力值,并且可以选择向上爬1或2个楼梯,那么初始值一开始就支付体力值,最后就不需要支付体力值了;
也可以初始值不支付体力值,最有支付体力值:
dp[0] = 0; // 默认第一步都是不花费体力的
dp[1] = 0;
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
选择初始值支付体力值,代码如下:
int minCostClimbingStairs(int* cost, int costSize) {
int* dp = (int*)calloc(costSize, sizeof(int));
dp[0] = cost[0];
dp[1] = cost[1];
for (int i = 2; i < costSize; i++) {
dp[i] = (int)fmin(dp[i - 1], dp[ i - 2]) + cost[i];
}
return (int)fmin(dp[costSize - 1], dp[costSize - 2]);
}
leetcode 62. 不同路径
leetcode 62. 不同路径
五部曲:
- 1、dp数组含义
dp[i][j]表示从(0, 0)到(i, j)的不同路径数量 - 2、状态转移方程
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
当前的dp由左边和上边而来 - 3、dp初值
dp[0][j] = 1;
dp[i][0] = 1;
第一行只能从左边来,路劲数目只能为1。
第一列只能从上边来,路劲数目只能为1。 - 4、遍历顺序
从左上遍历至右下 - 5、举例
代码如下:
int uniquePaths(int m, int n) {
int dp[m][n];
dp[0][0] = 1;
for (int j = 1; j < n; j++) {
dp[0][j] = dp[0][j - 1];
}
for (int i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0];
}
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];
}
状态压缩:
- 1、dp数组含义
dp[j]表示从(0, 0)到当前行第j列的不同路径数量 - 2、状态转移方程
dp[j] += [j - 1]
当前行的的第j列 = “上一行的第j列(dp[j]: 相当于dp[i - 1][j])” + “当前行的j-1列(dp[j - 1]: 相当于dp[i][j - 1])” - 3、dp初值
dp[j] = 1;
第一行只能从左边来,路径数目只能为1。 - 4、遍历顺序
从左上遍历至右下
代码如下:
int uniquePaths(int m, int n) {
int dp[n];
dp[0] = 1;
for (int j = 1; j < n; j++) {
dp[j] = dp[j - 1];
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[j] += dp[j - 1];
}
}
return dp[n - 1];
}
leetcode 63. 不同路径 II
leetcode 63. 不同路径 II
和 leetcode 62. 不同路径相同,只需要考虑一个障碍即可:
int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize) {
/* 1、dp数组含义:从(0, 0)到(i, j)的路径数 */
int dp[obstacleGridSize][*obstacleGridColSize];
/* 2、状态转移方程:
* if (obstacleGrid[i][j] == 1) {
* dp[i][j] = 0;
* } else {
* dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
* }
*/
/* 3、dp初值
* 第一行第一列路障之前均为1,路障之后均为0
*/
if (obstacleGrid[0][0] == 1) {
dp[0][0] = 0;
} else {
dp[0][0] = 1;
}
for (int j = 1; j < *obstacleGridColSize; j++) {
if (obstacleGrid[0][j] == 1) {
dp[0][j] = 0;
} else {
dp[0][j] = dp[0][j - 1];
}
}
for (int i = 1; i < obstacleGridSize; i++) {
if (obstacleGrid[i][0] == 1) {
dp[i][0] = 0;
} else {
dp[i][0] = dp[i - 1][0];
}
}
/* 4、遍历顺序:左上到右下 */
for (int i = 1; i < obstacleGridSize; i++) {
for (int j = 1; j < *obstacleGridColSize; j++) {
if (obstacleGrid[i][j] == 1) {
dp[i][j] = 0;
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[obstacleGridSize - 1][*obstacleGridColSize - 1];
}
状态压缩:
int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize) {
/* 1、dp数组含义:从(0, 0)到当前行的第j列的路径数 */
int dp[*obstacleGridColSize];
/* 2、状态转移方程:
* if (obstacleGrid[i][j] == 1) {
* dp[j] = 0;
* } else {
* dp[j] += dp[j - 1];
* }
*/
/* 3、dp初值
* 第一行当前列路障之前均为1,路障之后均为0
*/
if (obstacleGrid[0][0] == 1) {
dp[0] = 0;
} else {
dp[0] = 1;
}
for (int j = 1; j < *obstacleGridColSize; j++) {
if (obstacleGrid[0][j] == 1) {
dp[j] = 0;
} else {
dp[j] = dp[j - 1];
}
}
/* 4、遍历顺序:从左到右一层一层遍历 */
for (int i = 1; i < obstacleGridSize; i++) {
for (int j = 0; j < *obstacleGridColSize; j++) {
if (j == 0) { /* 开头 */
if (obstacleGrid[i][j] == 1) {
/* 当前路径上有路障:dp为0 */
dp[j] = 0;
} else {
/* 当前路径上没有路障:跳过 */
continue;
}
} else { /* 非开头 */
if (obstacleGrid[i][j] == 1) {
/* 当前路径上有路障:dp为0 */
dp[j] = 0;
} else {
/* 当前路径上没有路障:计算路径数 */
dp[j] += dp[j - 1];
}
}
}
}
return dp[*obstacleGridColSize - 1];
}
leetcode 343. 整数拆分
leetcode 343. 整数拆分
五部曲:
- 1、确定dp数组含义:dp[i][j]表示i拆分之后的最大乘积
- 2、确定递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
这里的 j 是从1开始的,所以 j 的拆分之前已经做过了,这一步只需要拆分 i - j 即可 - 3、dp初始化:dp初值:dp[2] = 1,dp[0]、dp[1]无意义,pass掉
- 4、遍历顺序:当前dp[i]从之前的状态得来,从前向后遍历
代码如下:
int integerBreak(int n) {
/* 1、dp数组含义:
* dp[i][j]表示i拆分之后的最大乘积
*/
int* dp = (int*)calloc(n + 1, sizeof(int));
/* 3、dp初值:dp[2] = 1 */
dp[2] = 1;
/* 4、遍历顺序:i从小到n */
for (int i = 3; i <= n; i++) {
/* j从 1 到 n - 1 */
for (int j = 1; j <= i - 1; j++) {
/* 2、状态转移方程
* dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
* 因为dp[i]计算多次,取最大;每次取(int)fmax((i - j) * j, dp[i - j] * j)
*/
dp[i] = (int)fmax(dp[i], (int)fmax((i - j) * j, dp[i - j] * j));
}
}
return dp[n];
}
leetcode 96. 不同的二叉搜索树
leetcode 96. 不同的二叉搜索树
状态转移方程:
n = 1个节点的二叉搜索树有1种;
n = 2个节点的二叉搜索树有2种;
n = 3个节点的二叉搜索树有5种;
当1为头结点的时候,其右子树有两个节点,和n为2的时候两棵树的布局一样;
当3为头结点的时候,其左子树有两个节点,和n为2的时候两棵树的布局一样;
当2位头结点的时候,其左右子树都只有一个节点,和n为1的时候只有一棵树的布局一样的。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
那么当有i个节点时,以j为头节点的数量为:
左子树的数量 * 右子树的数量
dp[j - 1] * ddp[i - i]
(0 ~ j - 1)个 (j + 1 ~ i)个
代码如下:
int numTrees(int n) {
/* 1、dp数组含义:1到n组成的二叉搜索树数量 */
int* dp = (int*)calloc(n + 1, sizeof(int));
/* 3、dp初值:0个节点即为空树,也算二叉搜索树 */
dp[0] = 1;
/* 4、遍历顺序:从前向后,后面的dp根据前面的dp而来 */
for (int i = 1; i <= n; i++) {
/* 2、状态转移方程:dp[i]为i个节点组成的数量 */
for (int j = 1; j <= i; j++) {
/* 2、状态转移方程:dp[j-1]所有以j为头节点的左子树数量
* dp[i - j]所有以j为头节点的右子树数量
*/
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
3、背包问题
01背包(二维数组)
参考代码随想录 的讲解
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
背包最大重量为4。
物品为:
项目 | Value | 价值 |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
动规五部曲:
1、确定dp数组以及下标的含义
使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2、确定递推公式
那么可以有两个方向推出来dp[i][j],
- 由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
- 由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、dp数组如何初始化
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
代码如下:
// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。
如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。
而背包问题的物品价值都是正整数,所以初始化为0,就可以了。
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。
如图:
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
4、确定遍历顺序
先遍历物品,然后遍历背包重量的代码。
// 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]; // 这个是为了展现dp数组里元素的变化
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)
例如这样:
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
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]);
}
}
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正左和正上两个方向)所以遍历顺序都可以
5、举例推导dp数组
完整代码:
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() + 1, 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();
}
01背包(一维数组)
参考代码随想录 的讲解
对于背包问题其实状态都是可以压缩的。
在使用二维数组的时候,递推公式: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[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。
dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
动规五部曲分析如下:
1、确定dp数组的定义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2、一维dp数组的递推公式
dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?
dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。
dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j],一个是取dp[j - weight[i]] + value[i],指定是取最大的,毕竟是求最大价值,
所以递归公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
3、一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。
那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4、一维dp数组遍历顺序
代码如下:
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]);
}
}
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
为什么呢?
倒叙遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒叙遍历,就可以保证物品只放入一次呢?
倒叙就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
那么问题又来了,为什么二维dp数组历的时候不用倒叙呢?
因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!
(如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)
再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每次更新dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
for (int j = target; j > 0; --j) {
for (int i = 0; i < numsSize; ++i) {
if (j >= nums[i]) {
dp[j] = dp[j - nums[i]] + nums[i];
}
}
}
从代码中看到每次都是从后向前更新dp[j],每一个dp[j]都被更新了numsSize次,但是每一次都是覆盖上一次的值,由于dp初值为0,这样就导致每一次dp[j]中存放的只是当前nums[i]一个物品了,既背包里只放了一个物品
(这里如果读不懂,就在回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)
所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。
5、举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
完整代码:
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();
}
leetcode 416. 分割等和子集(01背包)
leetcode 416. 分割等和子集
二维数组:
bool canPartition(int* nums, int numsSize){
/* 0、取数组和的一半作为背包的容量target */
int target = 0;
for (int i = 0; i< numsSize; i++) {
target += nums[i];
}
/* 如果和为奇数,则不可能分割成两部分,返回false
* 如果和为偶数,设置target为和的一半
*/
if (target % 2 == 1) {
return false;
} else {
target /= 2;
}
/* 1、dp数组含义:背包容量为j时,物品0到i任意取,可以装载的最大价值为dp[i][j] */
int dp[numsSize][target + 1];
memset(dp, 0, sizeof(dp)); //需要初始化为0,否则里面的值不确定
/* 3、dp数组初值:第一行只有背包容量大于等于nums[0]的才为nums[0],其余为0 */
for (int j = target; j >= nums[0]; j--) {
dp[0][j] = dp[0][j - nums[0]] + nums[0];
}
/* 第一列背包容量为0,获得价值均为0 */
for (int i = 0; i < numsSize; i++) {
dp[i][0] = 0;
}
/* 4、遍历顺序: 外层物品i从前向后,内层背包j从前向后 */
for (int i = 1; i < numsSize; i++) {
for (int j = 1; j <= target; j++) {
/* 2、状态转移方程 :其中物品的体积和价值均为nums[i];
* 不选当前物品i:与上一层物品(0~i-1)相同均为:dp[i - 1][j]
* 选择当前物品i:为:dp[i - 1][j - nums[i]] + nums[i]
* 两者取最大
*/
if (j < nums[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = (int)fmax(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
}
}
}
/* 若最后背包的价值为target,则表示数组可以分割为两部分 */
if (dp[numsSize - 1][target] == target) {
return true;
}
return false;
}
一维数组:
bool canPartition(int* nums, int numsSize) {
/* 0、取数组和的一半作为背包的容量target */
int target = 0;
for (int i = 0; i< numsSize; i++) {
target += nums[i];
}
/* 如果和为奇数,则不可能分割成两部分,返回false
* 如果和为偶数,设置target为和的一半
*/
if (target % 2 == 1) {
return false;
} else {
target /= 2;
}
/* 1、dp数组含义:背包容量为j时,可以装载的最大价值为dp[j]
* 3、dp数组初值:均为0,表示还没有物品
*/
int* dp = (int*)calloc(target + 1, sizeof(int));
/* 4、遍历顺序
* 外层物品i从前向后,内层背包从后向前
* 因为当前dp[j]选哟用到上一层的dp[0~j-1]的值
*/
for (int i = 0; i < numsSize; i++) {
for (int j = target; j >= nums[i]; j--) {
/* 2、状态转移方程
* 物品的体积和价值均为nums[i]
* 不选当前物品i:与上一层物品(0~i-1)相同
* 选择物品i:为 dp[j - nums[i]] + nums[i]
* 两者取最大
*/
dp[j] = (int)fmax(dp[j], dp[j - nums[i]] + nums[i]);
}
}
/* 若最后背包的价值为target,则表示数组可以分割为两部分 */
if (dp[target] == target) {
return true;
}
return false;
}
leetcode 1049. 最后一块石头的重量 II(01背包)
leetcode 1049. 最后一块石头的重量 II
二维dp:
int lastStoneWeightII(int* stones, int stonesSize) {
int sum = 0;
for (int i = 0; i < stonesSize; i++) {
sum += stones[i];
}
int target = sum / 2;
int dp[stonesSize][target + 1];
memset(dp, 0, sizeof(dp));
for (int j = target; j >= stones[0]; j--) {
dp[0][j] = dp[0][j - stones[0]] + stones[0];
}
for (int i = 0; i < stonesSize; i++) {
dp[i][0] = 0;
}
for (int i = 1; i < stonesSize; i++) {
for (int j = 1; j <= target; j++) {
if (j < stones[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = (int)fmax(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);
}
}
}
return sum - 2 * dp[stonesSize - 1][target];
}
一维dp:
int lastStoneWeightII(int* stones, int stonesSize) {
int sum = 0;
for (int i = 0; i < stonesSize; i++) {
sum += stones[i];
}
int target = sum / 2;
int dp[target + 1];
memset(dp, 0, sizeof(dp));
for (int i = 0; i < stonesSize; i++) {
for (int j = target; j >= stones[i]; j--) {
dp[j] = (int)fmax(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[target];
}
leetcode 494. 目标和(01背包)
添加了正负号之后:正元素为Left个、负元素为right个,那么有
left + right = target
left - right = sum
这样left = (sum + target) / 2
即转化为0-1背包问题,背包容量 bagSize = left;
物品为nums数组中的各个元素,各物品的容量和价值均为nums[i];
dp数组为装满背包的所有方法
代码如下:
int findTargetSumWays(int* nums, int numsSize, int target) {
/* 添加了正负号之后:正元素为Left个、负元素为right个,那么有
* left + right = target
* left - right = sum
* 这样left = (sum + target) / 2
* 即转化为0-1背包问题,背包容量 bagSize = left;
* 物品为nums数组中的各个元素,各物品的容量和价值均为nums[i];
* dp数组为装满背包的所有方法
*/
/* 计算数组元素和 */
int sum = 0;
for (int i = 0; i <numsSize; i++) {
sum += nums[i];
}
/* 若和为奇数则不会装满背包,返回0 */
if ((sum + target) % 2 == 1) {
return 0;
}
/* 背包容量 */
int bagSize = (sum + target) / 2;
/* 1、dp含义:dp[j]为装满容量j的背包的方法数量 */
int* dp = (int*)calloc(bagSize + 1, sizeof(int));
/* 3、dp初值:dp[0]为1(表示容量为0的背包被装满的方法数量)
* 其他为0:表示没有装任何物品时的方法数量
*/
dp[0] = 1;
/* 4、遍历顺序:外层物品从低到高、内层背包容量从高到低 */
for (int i = 0; i < numsSize; i++) {
for (int j = bagSize; j >= nums[i]; j--) {
/* 2、状态转移方程
* 物品从0到i时,dp[j]为容量为j的背包被装满的方法总和相加
* 如物品为0时,dp[j]为容量为j的背包被装满的方法为a种
* 如物品为0...numsSize - 1时,dp[j]为容量为j的背包被装满的方法为b种
* 那么dp[j]为a + ... + b
*/
dp[j] += dp[j - nums[i]];
}
}
return dp[bagSize];
}
leetcode 474. 一和零(01背包)
此题为 两个背包的 一维 0-1背包问题
物品为:数组中的每个字符串
物品的重量为:0的个数、1的个数
物品的价值为:可以看做全部都为1
背包0:装有0字符的背包,即dp的一维数据
背包1:装有1字符的背包,即dp的二维数据
状态转移方程:
若这是 1个背包的 一维 0-1背包问题时,转移方程为:
dp[j] = dp[j - weight[i]] + vlaue[i]; //i从0到i
2个背包的 一维 0-1背包问题时,转移方程为:
dp[j][k] = dp[j - weight0[i]][k - weight1[i]] + vlaue[i];
其中weight0[i]、weight1[i]为当前字符串strs[i]中0、1的个数。
代码为:
int findMaxForm(char ** strs, int strsSize, int m, int n) {
/* 1、dp数组含义
* dp[i][j]表示字符串可以从0到i中任意选时,最多有i个0、j个1的最大子集字符串数量
*/
int dp[m + 1][n + 1];
/* 3、dp初值
* 全部为0:表示当前没有选择字符串时,最大子集字符串的数量均为0
*/
memset(dp, 0, sizeof(dp));
/* 4、遍历顺序
* 就是一维dp的0-1背包问题
* 外层物品从前向后,内层两个背包从后向前
*/
for (int i = 0; i < strsSize; i++) {
int zeroNums = 0, oneNums = 0;
int index = 0;
/* 统计当前字符串中0、1的个数 */
while (strs[i][index] != '\0') {
if (strs[i][index] == '0') {
zeroNums++;
} else {
oneNums++;
}
index++;
}
for (int j = m; j >= zeroNums; j--) {
for (int k = n; k >= oneNums; k--) {
/* 2、状态转移方程
* 因为有两个背包,所以j - zeroNums为0的个数、k - oneNums为1的个数
* 选择当前字符串:dp[j - zeroNums][k - oneNums] + 1
* 不选当前字符串:dp[i][j]
*/
dp[j][k] = (int)fmax(dp[j][k], dp[j - zeroNums][k - oneNums] + 1);
}
}
}
return dp[m][n];
}
完全背包(一维数组)
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品可使用无数次,求解将哪些物品装入背包里物品价值总和最大。
背包最大重量为4。
物品为:
项目 | Value | 价值 |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
01背包和完全背包唯一不同就是体现在遍历顺序上
首先在回顾一下01背包的核心代码
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]);
}
}
我们知道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]);
}
}
为什么遍历物品在外层循环,遍历背包容量在内层循环?
01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一位dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!
因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。
// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cout << endl;
}
完整代码:
先遍历物品,在遍历背包
void test_CompletePack() {
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 = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
先遍历背包,再遍历物品
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
leetcode 518. 零钱兑换 II(求组合数)
leetcode 518. 零钱兑换 II
完全背包的两个for循环的先后顺序都是可以的。
但本题就不行了!
因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!
而本题要求凑成总和的组合数,元素之间要求没有顺序。
所以纯完全背包是能凑成总和就行,不用管怎么凑的。
本题是求凑出来的方案个数,且每个方案个数是为组合数。
那么本题,两个for循环的先后顺序可就有说法了。
我们先来看 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。
代码如下:
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
dp[j] += dp[j - coins[i]];
}
}
假设:coins[0] = 1,coins[1] = 5。
那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。
所以这种遍历顺序中dp[j]里计算的是组合数!
如果把两个for交换顺序,代码如下:
for (int j = 0; j <= amount; j++) { // 遍历背包容量
for (int i = 0; i < coins.size(); i++) { // 遍历物品
if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
}
}
背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。
此时dp[j]里算出来的就是排列数!
二维dp:
int change(int amount, int* coins, int coinsSize) {
/* 1、dp含义
* dp[i][j]表示物品0到物品i任意选时,能装满容量为j的背包的方法数
*/
int dp[coinsSize][amount + 1];
/* 3、dp初值
* 当背包容量为0时,dp[i][0] = 1,表示装满背包容量为0的背包只有一种方法就是不放
* 只有物品0时,dp[i][0] = 1,表示装满背包容量为i的背包只有一种方法(能装的下时dp[0][j - coins[0]],装不下时就是0)
*/
memset(dp, 0, sizeof(dp));
for (int i = 0; i < coinsSize; i++) {
dp[i][0] = 1;
}
for (int j = 1; j <= amount; j++) {
if (j < coins[0]) {
dp[0][j] = 0;
} else {
dp[0][j] = dp[0][j - coins[0]];
}
}
/* 4、遍历顺序
* 外层物品从小到大
* 内层背包从小到大
*/
for (int i = 1; i < coinsSize; i++) {
for (int j = 1; j <= amount; j++) {
/* 2、状态转移方程
* 2.1、当背包容量j小于当前物品i时,dp[i][j] = dp[i - 1][j];
* 表示等于物品0到i-1任意取时,装满容量为j的背包的方法数
* 2.2、当背包容量j大于等于当前物品i时,dp[i - 1][j] + dp[i][j - coins[i]];
* 表示等于物品0到i-1任意取时,装满容量为j的背包的方法数 +
* 表示等于物品0到i任意取时,装满容量为j - coins[i]的背包的方法数
*/
if (j < coins[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
}
}
}
// /* 调试 */
// for (int i = 0; i < coinsSize; i++) {
// printf("物品%d: ", i);
// for (int j = 0; j < amount + 1; j++) {
// printf("%d ", dp[i][j]);
// }
// printf("\n");
// }
return dp[coinsSize - 1][amount];
}
一维dp:
int change(int amount, int* coins, int coinsSize) {
/* 1、dp含义
* dp[j]表示物品0到物品i任意选时,能装满容量为j的背包的方法数
*/
int dp[amount + 1];
/* 3、dp初值
* dp[0] = 1,表示物品为0个,容量为0的背包被填满的方法数为1种
* dp[1~amount] = 0,表示物品为0个,容量为j的背包被填满的方法数为0中
*/
memset(dp, 0, sizeof(dp));
dp[0] = 1;
/* 4、遍历顺序
* 外层物品从前向后,
* 内层背包从前向后:因为完全背包中物品可以任意取多次,
* 而0-1背包中物品只能取1次,需要从后向前。
*/
for (int i = 0; i < coinsSize; i++) {
for (int j = coins[i]; j <= amount; j++) {
/* 2、状态转移方程
* 2.1、物品0任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[0]的数量
* 2.2、物品0、1任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[1]的数量 + 2.1的数量
* 2.3、物品0、1、2任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[1]的数量 + 2.2的数量
* dp[j] = dp[j] + dp[j - coins[i]]
* 物品0到i装满背包j的数量 = 物品0到i - 1装满背包j的数量 + 物品0到i装满背包j - coins[i]的数量
*/
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
leetcode 377. 组合总和 Ⅳ(求排列数)
leetcode 377. 组合总和 Ⅳ
一维dp完全背包,求排列数,遍历顺序:外层背包、内层物品,代码如下:
int combinationSum4(int* nums, int numsSize, int target) {
int* dp = (int*)calloc(target + 1, sizeof(int));
dp[0] = 1;
for (int j = 0; j <= target; j++) {
for (int i = 0; i < numsSize; i++) {
if (j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]]) {
dp[j] += dp[j - nums[i]];
}
}
}
return dp[target];
}
leetcode 70. 爬楼梯(求排列数)
leetcode 70. 爬楼梯
背包就是楼顶n、物品就是1、2两个台阶(重量和价值都是1、2),求排列数,一维dp完全背包,外层背包(升序)、内层物品(升序)
int climbStairs(int n) {
int* dp = (int*)calloc(n + 1, sizeof(int));
dp[0] = 1;
int value[2] = {1, 2};
for (int j = 0; j <= n; j++) {
for (int i = 0; i < 2; i++) {
if (j >= value[i]) {
dp[j] += dp[j - value[i]];
}
}
}
return dp[n];
}
leetcode 322. 零钱兑换(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)
leetcode 322. 零钱兑换
背包为总金额,物品为硬币(重量和价值都为coins[i]),求凑齐总金额的最小硬币数,那么组合或者排列均不影响最小硬币的个数,即组合排列均可,即外层物品(升序)内层背包(升序)、外层背包(升序)内层物品(升序)均可。
外层物品,内层背包:
int coinChange(int* coins, int coinsSize, int amount) {
int* dp = (int*)malloc(sizeof(int) * (amount + 1));
for (int j = 0; j < amount + 1; j++) {
dp[j] = INT_MAX;
}
dp[0] = 0;
for (int i = 0; i < coinsSize; i++) {
for (int j = coins[i]; j <= amount; j++) {
if (dp[j - coins[i]] != INT_MAX) {
dp[j] = (int)fmin(dp[j], dp[j - coins[i]] + 1);
}
}
}
if (dp[amount] == INT_MAX) {
return -1;
}
return dp[amount];
}
外层背包,内层物品:
int coinChange(int* coins, int coinsSize, int amount) {
int* dp = (int*)malloc(sizeof(int) * (amount + 1));
for (int j = 0; j < amount + 1; j++) {
dp[j] = INT_MAX;
}
dp[0] = 0;
for (int j = 0; j <= amount; j++) {
for (int i = 0; i < coinsSize; i++) {
if (j >= coins[i] && dp[j - coins[i]] != INT_MAX) {
dp[j] = (int)fmin(dp[j], dp[j - coins[i]] + 1);
}
}
}
if (dp[amount] == INT_MAX) {
return -1;
}
return dp[amount];
}
leetcode 279. 完全平方数(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)
leetcode 279. 完全平方数
背包就是n,物品就是小于n的 ii ,每一个物品的价值和重量均为 ii,求的是装满背包n的最小物品数量,和leetcode 322. 零钱兑换一样。
外层物品、内层背包:
int numSquares(int n) {
int *dp =(int*)malloc(sizeof(int) * (n + 1));
for (int j = 0; j < n + 1; j++) {
dp[j] = INT_MAX;
}
dp[0] = 0;
for (int i = 0; i * i <= n; i++) {
for (int j = i * i; j <= n; j++) {
if (dp[j - i * i] < INT_MAX) {
dp[j] = (int)fmin(dp[j], dp[j - i * i] + 1);
}
}
}
return dp[n];
}
外层背包、内层物品:
int numSquares(int n) {
int *dp =(int*)malloc(sizeof(int) * (n + 1));
for (int j = 0; j < n + 1; j++) {
dp[j] = INT_MAX;
}
dp[0] = 0;
for (int j = 0; j <= n; j++) {
for (int i = 0; i * i <= n; i++) {
if (j >= i * i && dp[j - i * i] < INT_MAX) {
dp[j] = (int)fmin(dp[j], dp[j - i * i] + 1);
}
}
}
return dp[n];
}
leetcode 139. 单词拆分(求背包能否被装满,组合排列均不影响)
leetcode 139. 单词拆分
背包就是字符串s,物品就是每个单词,求单词能否被凑出既背包能否被装满。那么组合排列均可。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
/* 1、dp数组含义
* dp[i]表示从0到i组成的字符串能否被拆分
*/
vector<bool> dp(s.size() + 1, false);
/* 3、dp初值
* dp[0] = true, 其余为false
* dp[0]在题目中没有含义,为了后面计算正确将其赋值为true
*/
dp[0] = true;
/* 4、遍历顺序
* 外层背包从小到大,内层物品从小到大
*/
for (int i = 1; i <= s.size(); i++) { // 遍历背包
for (int j = 0; j < i; j++) { // 遍历物品
string word = s.substr(j, i - j); //substr(起始位置,截取的个数)
/* 2、状态转移方程
* dp[i]若为true,则dp[j]为true、且[j+1 到 i]组成的字符串也在字典列表中。
*/
if (wordSet.find(word) != wordSet.end() && dp[j]) {
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
多重背包
参考 代码随想录 的讲解
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?
每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
例如:
背包最大重量为10。
物品为:
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 2 |
物品1 | 3 | 20 | 3 |
物品2 | 4 | 30 | 2 |
问背包能背的物品最大价值是多少?
和如下情况有区别么?
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 1 |
物品0 | 1 | 15 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品2 | 4 | 30 | 1 |
物品2 | 4 | 30 | 1 |
毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。
这种方式来实现多重背包的代码如下:
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
for (int i = 0; i < nums.size(); i++) {
while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
- 时间复杂度:O(m * n * k) m:物品种类个数,n背包容量,k单类物品数量
也有另一种实现方式,就是把每种商品遍历的个数放在01背包里面在遍历一遍。
代码如下:(详看注释)
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
// 以上为01背包,然后加一个遍历个数
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
// 打印一下dp数组
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
- 时间复杂度:O(m * n * k) m:物品种类个数,n背包容量,k单类物品数量
从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。
4、打家劫舍问题
leetcode 198. 打家劫舍
leetcode 198. 打家劫舍
代码如下:
int rob(int* nums, int numsSize) {
/* 特判:题目 numsSize > 0,当为1时,直接返回nums[0]即可 */
if (numsSize < 2) {
return nums[0];
}
/* 1、dp数组含义
* dp[i]表示从房屋0偷到房屋i能获得的最大金额
*/
int*dp = (int*)calloc(numsSize, sizeof(int));
/* 3、dp初值
* dp[0] = nums[0];表示只有房屋0时可以偷到的金额为最大为nums[0]
* dp[1] = (int)fmax(nums[0], nums[1]);表示有房屋0、1时能偷到的最大金额
*/
dp[0] = nums[0];
dp[1] = (int)fmax(nums[0], nums[1]);
/* 4、遍历顺序
* 房屋从小到大遍历
*/
for (int i = 2; i < numsSize; i++) {
/* 2、递推公式
* dp[i - 2] + nums[i]:表示偷房屋i
* dp[i - 1]:表示不偷房屋i
*/
dp[i] = (int)fmax(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[numsSize - 1];
}
leetcode 213. 打家劫舍 II
leetcode 213. 打家劫舍 II
代码如下:
int robRange(int *nums, int numsSize, int start, int end)
{
/* 特判:题目 numsSize > 0,当为1时,直接返回nums[0]即可 */
if (start == end)
return nums[start];
/* 1、dp数组含义
* dp[i]表示从房屋0偷到房屋i能获得的最大金额
*/
int*dp = (int*)calloc(numsSize, sizeof(int));
/* 3、dp初值
* dp[0] = nums[0];表示只有房屋0时可以偷到的金额为最大为nums[0]
* dp[1] = (int)fmax(nums[0], nums[1]);表示有房屋0、1时能偷到的最大金额
*/
dp[start] = nums[start];
dp[start + 1] = (int)fmax(nums[start], nums[start + 1]);
/* 4、遍历顺序
* 房屋从小到大遍历
*/
for (int i = start + 2; i <= end; i++) {
/* 2、递推公式
* dp[i - 2] + nums[i]:表示偷房屋i
* dp[i - 1]:表示不投房屋i
*/
dp[i] = (int)fmax(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[end];
}
int rob(int *nums, int numsSize)
{
if (numsSize == 0)
return 0;
if (numsSize == 1)
return nums[0];
/* 不考虑房屋numsSize - 1 */
int result1 = robRange(nums, numsSize, 0, numsSize - 2);
/* 不考虑房屋0 */
int result2 = robRange(nums, numsSize, 1, numsSize - 1);
return (int)fmax(result1, result2);
}
leetcode 337. 打家劫舍 III
leetcode 337. 打家劫舍 III
需要使用后序遍历来递归搜索二叉树,因为当前节点的状态需要根据左右孩子的返回值进行判断。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
int *robRange(struct TreeNode *root)
{
int *res = (int*)calloc(2, sizeof(int));
if (!root)
return res;
int *leftValue = (int*)calloc(2, sizeof(int));
int *righttValue = (int*)calloc(2, sizeof(int));
leftValue = robRange(root->left);
righttValue = robRange(root->right);
/* res[0]表示偷当前节点、res[1]表示不偷当前节点 */
res[0] = root->val + leftValue[1] + righttValue[1];
res[1] = (int)fmax(leftValue[0], leftValue[1]) + (int)fmax(righttValue[0], righttValue[1]);
return res;
}
int rob(struct TreeNode *root)
{
int *dp = (int*)calloc(2, sizeof(int));
dp = robRange(root);
return (int)fmax(dp[0], dp[1]);
}
5、股票问题
leetcode 121. 买卖股票的最佳时机(买卖一次)
leetcode 121. 买卖股票的最佳时机
动规五部曲分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?
其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。
dp[i][1] 表示第i天不持有股票所得最多现金
注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态
很多同学把“持有”和“买入”没分区分清楚。
在下面递推公式分析中,我会进一步讲解。
2、确定递推公式
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
- 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
- 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1][0]
同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
这样递归公式我们就分析完了
3、dp数组如何初始化
由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);可以看出
其基础都是要从dp[0][0]和dp[0][1]推导出来。
那么dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];
dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;
5、确定遍历顺序
从递推公式可以看出dp[i]都是有dp[i - 1]推导出来的,那么一定是从前向后遍历。
5、举例推导dp数组
代码如下:
int maxProfit(int* prices, int pricesSize) {
/* 1、dp数组含义
* dp[i][0]表示第i天持有股票所得现金
* dp[i][1]表示第i天不持有股票所得现金
*/
int dp[pricesSize][2];
memset(dp, 0, sizeof(int) * pricesSize * 2);
/* 3、dp初值
* dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
* dp[0][1] = 0; 表示第0天不买入,持有现金0
*/
dp[0][0] = -prices[0];
dp[0][1] = 0;
/* 4、遍历顺序:天数从小到大 */
for (int i = 1; i < pricesSize; i++) {
/* 2、递推公式
* 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
* 今天买入就相当于“-prices[i]”)
* 第i天不持有股票所得现金:dp[i][1] = max(今天不持有股票就相当于前一天不持有所得现金,
* 今天卖出股票就相当于今天股价+昨天持有股票)
* 并且更新最大利润:maxProfit
*/
dp[i][0] = (int)fmax(dp[i - 1][0], -prices[i]);
dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0]);
}
return dp[pricesSize - 1][1];
}
leetcode 122. 买卖股票的最佳时机 II(买卖多次)
leetcode 122. 买卖股票的最佳时机 II
这题比leetcode 121. 买卖股票的最佳时机题区别就是股票可以多次买卖,那么主要体现在递推公式上面,具体代码如下:
int maxProfit(int* prices, int pricesSize) {
/* 1、dp数组含义
* dp[i][0]表示第i天持有股票所得最多现金
* dp[i][1]表示第i天不持有股票所得最多现金
*/
int dp[pricesSize][2];
memset(dp, 0, sizeof(int) * pricesSize * 2);
/* 3、dp初值
* dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
* dp[0][1] = 0; 表示第0天不买入,持有现金0
*/
dp[0][0] = -prices[0];
dp[0][1] = 0;
/* 4、遍历顺序:天数从小到大 */
for (int i = 1; i < pricesSize; i++) {
/* 2、递推公式
* 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
* 今天买入就相当于前一天不持有股票所得现金 - 今天股票价格)
* 第i天不持有股票所得现金:dp[i][1] = max(今天不卖就相当于前一天不持有所得现金,
* 今天卖出就相当于今天股价+昨天持有股票所得现金(这是个负数))
* 并且更新最大利润:maxProfit
*/
dp[i][0] = (int)fmax(dp[i - 1][0], dp[i - 1][1] - prices[i]);
dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0]);
}
return dp[pricesSize - 1][1];
}
leetcode 123. 买卖股票的最佳时机 III(最多买卖两次)
leetcode 123. 买卖股票的最佳时机 III
关键在于至多买卖两次,这意味着可以买卖一次,可以买卖两次,也可以不买卖。
接来下我用动态规划五部曲详细分析一下:
1、确定dp数组以及下标的含义
一天一共就有五个状态,
0. 没有操作
1.第一次买入
2.第一次卖出
3.第二次买入
4.第二次卖出
dp[i][j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金。
2、确定递推公式
需要注意:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区。
达到dp[i][1]状态,有两个具体操作:
操作一:第i天买入股票了,那么dp[i][1] = dp[i-1][0] - prices[i]
操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][1] = dp[i - 1][1]
那么dp[i][1]究竟选 dp[i-1][0] - prices[i],还是dp[i - 1][1]呢?
一定是选最大的,所以 dp[i][1] = max(dp[i-1][0] - prices[i], dp[i - 1][1]);
同理dp[i][2]也有两个操作:
- 操作一:第i天卖出股票了,那么dp[i][2] = dp[i - 1][1] + prices[i]
- 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][2] = dp[i - 1][2]
所以dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])
同理可推出剩下状态部分:
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]); dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
3、dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;
第0天做第一次买入的操作,dp[0][1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0,
从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了。
所以dp[0][2] = 0;
第0天第二次买入操作,初始值应该是多少呢?应该不少同学疑惑,第一次还没买入呢,怎么初始化第二次买入呢?
第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后在买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0][3] = -prices[0];
同理第二次卖出初始化dp[0][4] = 0;
4、确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。
5、举例推导dp数组
代码如下:
int maxProfit(int* prices, int pricesSize){
/* 1、dp数组含义
* 0-无操作、1-第一次买入、2-第一次卖出、3-第二次买入、4-第二次卖出
* dp[i][j]:i表示第i天、j表示上面的五种状态、dp[i][j]表示第i天第j种状态之后所得最大利润
*/
int dp[pricesSize][5];
memset(dp, 0, sizeof(int) * pricesSize * 5);
/* 3、dp初值
* dp[0][0] = 0; 无操作。
* dp[0][1] = -prices[0]; 第0天第一次买入股票之后所得利润。
* dp[0][2] = 0; 第0天第一次卖出股票之后所得最大利润,
* 因为第0天所得利润为0,所以这一天第一次卖出之后利润为负,当做0即可。
* dp[0][3] = -prices[0]; 第0天第二次买入股票之后所得利润,
* 不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
* dp[0][4] = 0; 第0天第二次卖出股票之后所得最大利润,
* 因为第0天所得利润为0,所以这一天第二次卖出之后利润为负,当做0即可。
*/
dp[0][1] = -prices[0];
dp[0][3] = -prices[0];
/* 4、遍历顺序
* 天数从小到大
*/
for (int i = 1; i < pricesSize; i++) {
/* 2、递推公式
* 上面的五种状态中买入和卖出股票并不是一定要当天做的决定;
* 决定可以是之前做的,可以是当前做的。
*/
/**********今天无操作等于上一天无操作**************************/
dp[i][0] = dp[i - 1][0];
/********************今天买了*******************今天没买******/
dp[i][1] = (int)fmax(dp[i - 1][0] - prices[i], dp[i - 1][1]);
/********************今天卖了*******************今天没卖******/
dp[i][2] = (int)fmax(dp[i - 1][1] + prices[i], dp[i - 1][2]);
/********************今天买了*******************今天没买******/
dp[i][3] = (int)fmax(dp[i - 1][2] - prices[i], dp[i - 1][3]);
/********************今天卖了*******************今天没卖******/
dp[i][4] = (int)fmax(dp[i - 1][3] + prices[i], dp[i - 1][4]);
}
return dp[pricesSize - 1][4];
}
leetcode 188. 买卖股票的最佳时机 IV(最多买卖k次)
leetcode 188. 买卖股票的最佳时机 IV
与leetcode 123. 买卖股票的最佳时机 III题类似,这次最多买卖k次,状态由5个(2 * 2 + 1)变为(2 * k + 1)个;其中除0之外奇数就是买入、偶数就是卖出。
参考上一题代码写出:
int maxProfit(int k, int* prices, int pricesSize){
/* 特判 */
if (pricesSize == 0) {
return 0;
}
/* 1、dp数组含义
* 0-无操作、1-第一次买入、2-第一次卖出。。。、第k次买入、第k次卖出
* k为奇数就是买入、偶数就是卖出
* dp[i][j]:i表示第i天、j表示上面的五种状态、dp[i][j]表示第i天第j种状态之后所得最大利润
*/
int dp[pricesSize][2 * k + 1];
memset(dp, 0, sizeof(int) * pricesSize * (2 * k + 1));
/* 3、dp初值
* dp[0][0] = 0; 无操作。
* dp[0][1] = -prices[0]; 第0天第一次买入股票之后所得利润。
* dp[0][2] = 0; 第0天第一次卖出股票之后所得最大利润,
* 因为第0天所得利润为0,所以这一天第一次卖出之后利润为负,当做0即可。
* dp[0][3] = -prices[0]; 第0天第二次买入股票之后所得利润,
* 不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
* dp[0][4] = 0; 第0天第二次卖出股票之后所得最大利润,
* 因为第0天所得利润为0,所以这一天第二次卖出之后利润为负,当做0即可。
* 。。。
* 。。。
* dp[0][k] = -prices[0]、0; k为奇数就是买入(就是-prices[0])、偶数就是卖出(就是0)
*/
for (int i = 0; i < k; i++) {
dp[0][2 * i + 1] = -prices[0];
}
/* 4、遍历顺序
* 天数从小到大
*/
for (int i = 1; i < pricesSize; i++) {
/* 2、递推公式
* 其中dp[i][1],表示的是第i天,第一次买入股票的状态,并不是说一定要第i天买入股票。
*/
/**********今天无操作等于上一天无操作**************************/
dp[i][0] = dp[i - 1][0];
for (int j = 1; j <= 2 * k; j++) {
if (j % 2 == 1) {
/********************今天买了***********************今天没买******/
dp[i][j] = (int)fmax(dp[i - 1][j - 1] - prices[i], dp[i - 1][j]);
} else {
/********************今天卖了***********************今天没卖******/
dp[i][j] = (int)fmax(dp[i - 1][j - 1] + prices[i], dp[i - 1][j]);
}
}
}
return dp[pricesSize - 1][2 * k];
}
leetcode 309. 最佳买卖股票时机含冷冻期(买卖多次含冷冻期)
leetcode 309. 最佳买卖股票时机含冷冻期
动规五部曲,分析如下:
1、确定dp数组以及下标的含义
dp[i][j],第i天状态为j,所剩的最多现金为dp[i][j]。
具体可以区分出如下四个状态:
- 状态一:买入股票状态(今天买入股票,或者是之前就买入了股票然后没有操作)
卖出股票状态,这里就有两种卖出股票状态
- 状态二:两天前就卖出了股票,度过了冷冻期,一直没操作,今天保持卖出股票状态
- 状态三:今天卖出了股票
- 状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!
j 的状态为:
- 0:状态一
- 1:状态二
- 2:状态三
- 3:状态四
2、确定递推公式
达到买入股票状态(状态一)即:dp[i][0],有两个具体操作:
- 操作一:前一天就是持有股票状态(状态一),dp[i][0] = dp[i - 1][0]
操作二:今天买入了,有两种情况
- 前一天是冷冻期(状态四),dp[i - 1][3] - prices[i]
- 前一天是保持卖出股票状态(状态二),dp[i - 1][1] - prices[i]
所以操作二取最大值,即:max(dp[i - 1][3], dp[i - 1][1]) - prices[i]
那么dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]);
达到保持卖出股票状态(状态二)即:dp[i][1],有两个具体操作:
- 操作一:前一天就是状态二
- 操作二:前一天是冷冻期(状态四)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
达到今天就卖出股票状态(状态三),即:dp[i][2] ,只有一个操作:
- 操作一:昨天一定是买入股票状态(状态一),今天卖出
即:dp[i][2] = dp[i - 1][0] + prices[i];
达到冷冻期状态(状态四),即:dp[i][3],只有一个操作:
- 操作一:昨天卖出了股票(状态三)
p[i][3] = dp[i - 1][2];
综上分析,递推代码如下:
dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
dp[i][2] = dp[i - 1][0] + prices[i];
dp[i][3] = dp[i - 1][2];
3、dp数组如何初始化
这里主要讨论一下第0天如何初始化。
如果是持有股票状态(状态一)那么:dp[0][0] = -prices[0],买入股票所省现金为负数。
保持卖出股票状态(状态二),第0天没有卖出dp[0][1]初始化为0就行,
今天卖出了股票(状态三),同样dp[0][2]初始化为0,因为最少收益就是0,绝不会是负数。
同理dp[0][3]也初始为0。
4、确定遍历顺序
从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历。
5、举例推导dp数组
代码如下:
int maxProfit(int* prices, int pricesSize)
{
if (pricesSize == 0)
return 0;
int dp[pricesSize][4];
memset(dp, 0, sizeof(dp));
dp[0][0] = -prices[0];
for (int i = 1; i < pricesSize; i++) {
dp[i][0] = (int)fmax((int)fmax(dp[i - 1][1] - prices[i], dp[i - 1][3] - prices[i]), dp[i - 1][0]);
dp[i][1] = (int)fmax(dp[i - 1][1], dp[i - 1][3]);
dp[i][2] = dp[i - 1][0] + prices[i];
dp[i][3] = dp[i - 1][2];
}
return (int)fmax(dp[pricesSize - 1][3], (int)fmax(dp[pricesSize - 1][2], dp[pricesSize - 1][1]));
}
leetcode 714. 买卖股票的最佳时机含手续费(买卖多次含手续费)
leetcode 714. 买卖股票的最佳时机含手续费
与leetcode 122. 买卖股票的最佳时机 II类似,此题就在卖出时付一个手续费即可
代码如下:
int maxProfit(int* prices, int pricesSize, int fee) {
/* 1、dp数组含义
* dp[i][0]表示第i天持有股票所得最多现金
* dp[i][1]表示第i天不持有股票所得最多现金
*/
int dp[pricesSize][2];
memset(dp, 0, sizeof(dp));
/* 3、dp初值
* dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
* dp[0][1] = 0; 表示第0天不买入,持有现金0
*/
dp[0][0] = -prices[0];
dp[0][1] = 0;
/* 4、遍历顺序:天数从小到大 */
for (int i = 1; i < pricesSize; i++) {
/* 2、递推公式
* 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
* 今天买入就相当于前一天不持有股票所得现金 - 今天股票价格)
* 第i天不持有股票所得现金:dp[i][1] = max(今天不卖就相当于前一天不持有所得现金,
* 今天卖出就相当于今天股价+昨天持有股票所得现金(这是个负数))
* 并且更新最大利润:maxProfit
*/
dp[i][0] = (int)fmax(dp[i - 1][0], dp[i - 1][1] - prices[i]);
dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0] - fee);
}
return dp[pricesSize - 1][1];
}
6、子序列问题
子序列(不连续)
leetcode 300. 最长递增子序列
300. 最长递增子序列
dp五部曲:
1、dp[i]定义
dp[i]表示 i 之前包括 i 的最长上升子序列。
2、状态转移方程
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。
3、dp[i]的初始化
每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是是1.
4、确定遍历顺序
dp[i] 是有0到i-1各个位置的最长升序子序列 推导而来,那么遍历i一定是从前向后遍历。
j其实就是0到i-1,遍历i的循环里外层,遍历j则在内层,代码如下:
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
}
if (dp[i] > result) result = dp[i]; // 取长的子序列
}
5、举例推导dp数组
代码如下:
int lengthOfLIS(int* nums, int numsSize) {
int dp[numsSize + 1];
for (int i = 0; i <= numsSize; i++) {
dp[i] = 1;
}
int res = 0;
for (int i = 1; i < numsSize; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = (int)fmax(dp[i], dp[j] + 1);
}
if (dp[i] > res)
res = dp[i];
}
return res;
}
leetcode 1143. 最长公共子序列
leetcode 1143. 最长公共子序列
与leetcode 718. 最长重复子数组类似,此题不要求子序列连续。
继续动规五部曲分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]
有同学会问:为什么要定义长度为[0, i - 1]的字符串text1,定义为长度为[0, i]的字符串text1不香么?
这样定义是为了后面代码实现方便,如果非要定义为为长度为[0, i]的字符串text1也可以,大家可以试一试!
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]的最长公共子序列,取最大的。
即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
代码如下:
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]);
}
3、dp数组如何初始化
先看看dp[i][0]应该是多少呢?
test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i][0] = 0;
同理dp[0][j]也是0。
其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。
代码:
vector<vector> dp(text1.size() + 1, vector(text2.size() + 1, 0));
4、确定遍历顺序
从递推公式,可以看出,有三个方向可以推出dp[i][j],如图:
那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。
5、举例推导dp数组
以输入:text1 = “abcde”, text2 = “ace” 为例,dp状态如图:
最后红框dp[text1.size()][text2.size()]为最终结果
C语言代码如下:
int longestCommonSubsequence(char * text1, char * text2) {
int len1 = strlen(text1);
int len2 = strlen(text2);
int dp[len1 + 1][len2 + 1];
int res = 0;
memset(dp, 0, sizeof(dp));
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if(text1[i - 1] == text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = (int)fmax(dp[i - 1][j], dp[i][j - 1]);
}
if (dp[i][j] > res) {
res = dp[i][j];
}
}
}
return res;
}
leetcode 1035. 不相交的线
leetcode 1035. 不相交的线
与leetcode 1143. 最长公共子序列一模一样。
代码如下:
int maxUncrossedLines(int* nums1, int nums1Size, int* nums2, int nums2Size) {
/* 1、dp数组含义
* dp[i][j]表示数组1以nums[i - 1]结尾、数组2以nums[j - 1]结尾
* 两数组的最大连线数
*/
int dp[nums1Size + 1][nums2Size + 1];
/* 3、dp数组初值
* dp[i][0] = dp[0][j] = 0;表示没有字符时连接数为0
*/
memset(dp, 0, sizeof(dp));
/* 4、遍历顺序
* 内外层从小到大、且内外层可交换
*/
for (int i = 1; i <= nums1Size; i++) {
for (int j = 1; j <= nums2Size; j++) {
/* 2、状态转移方程 */
if (nums1[i - 1] == nums2[j - 1]) {
/* 当前两字符相等,则dp值为上一dp加一 */
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
/* 当前两字符不等,则dp值为
* 数组1从0到i-2,数组2从0到j-1;
* 数组1从0到i-1,数组2从0到j-2;两者的较大值
*/
dp[i][j] = (int)fmax(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[nums1Size][nums2Size];
}
子序列(连续)
leetcode 674. 最长连续递增序列
leetcode 674. 最长连续递增序列
本题与300. 最长递增子序列类似,本题要求递增子序列连续,那么主要区别在于递推公式dp[i + 1] = dp[i] + 1;
代码如下:
int findLengthOfLCIS(int* nums, int numsSize) {
if (numsSize == 0)
return 0;
int* dp = (int*)calloc(numsSize, sizeof(int));
int result = 1;
dp[0] = 1;
for (int i = 0; i < numsSize - 1; i++) {
dp[i + 1] = 1;
if (nums[i + 1] > nums[i])
dp[i + 1] = dp[i] + 1;
if (dp[i + 1] > result)
result = dp[i + 1];
}
return result;
}
leetcode 718. 最长重复子数组
leetcode 718. 最长重复子数组
代码如下:
int findLength(int* nums1, int nums1Size, int* nums2, int nums2Size) {
/* 1、dp数组含义
* dp[i][j]表示数组1以i-1结尾、数组2以j-1结尾时两数组的最长公共子序列
*/
int dp[nums1Size + 1][nums2Size + 1];
/* 3、dp初值
* dp[0][0] = 0; 本题无意义,因为两数组长度均大于0
* dp[0][j] = 0; 本题无意义,因为两数组长度均大于0
* dp[i][0] = 0; 本题无意义,因为两数组长度均大于0
*/
memset(dp, 0, sizeof(int) * (nums1Size + 1) * (nums2Size + 1));
int res = 0;
/* 4、遍历顺序
* 外层数组1从小到大、内层数组2从小到大
* 内外层是数组1还是数组2均可
*/
for (int i = 1; i <= nums1Size; i++) {
for (int j = 1; j <= nums2Size; j++) {
/* 若当前两数组元素相等,则当前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;
}
if (dp[i][j] > res) {
res = dp[i][j];
}
}
}
return res;
}
leetcode 53. 最大子序和
leetcode 53. 最大子序和
此题要求子序列连续,那么递推公式为:
dp[i] = (int)fmax(dp[i - 1] + nums[i], nums[i]);
代码如下:
int maxSubArray(int* nums, int numsSize) {
/* 1、dp数组含义
* dp[i]表示从0到i的字符串中的最大子序和
*/
int* dp = (int*)calloc(numsSize, sizeof(int));
/* 3、dp初值
* dp[0] = nums[0];
*/
dp[0] = nums[0];
int res = INT_MIN;
/* 4、遍历顺序:从前向后 */
for (int i = 1; i < numsSize; i++) {
/* 2、状态转移方程
* 前一个dp + 当前nums[i] 和 当前nums[i]比较
* 加上当前元素大就继续加,小就重新开始,这样保证是连续的子序列
*/
dp[i] = (int)fmax(dp[i - 1] + nums[i], nums[i]);
if (dp[i] > res) {
res = dp[i];
}
}
return res;
}
因为当前dp[i]至于dp[i-1]有关,所以只需要保存另个dp值即可,进行状态压缩:
int maxSubArray(int* nums, int numsSize) {
/* 1、若数组为空,返回0 */
//if (numsSize == 0) {
// return 0;
//}
/* 2、确定base case */
int dpCur = nums[0], dpPre = nums[0];
/* 3、定义子序列的最大和 */
int res = dpCur;
/* 4、确定状态转移方程
* 状态:以当前节点结束的最大子序列和
* 选择:每个节点
* 状态转移方程:dp[i] = dp[i] > dp[i - 1] + nums[i] ? dp[i] : dp[i - 1] + nums[i]
*/
for (int i = 1; i < numsSize; i++) {
/* 4.1、计算当前节点的dp值 */
dpCur = nums[i] > (dpPre + nums[i]) ? nums[i] : (dpPre + nums[i]);
/* 4.2、更新上以节点的dp值 */
dpPre = dpCur;
/* 4.3、更新最大子序列和 */
if (res < dpCur) {
res = dpCur;
}
}
/* 5、返回最大子序列的和 */
return res;
}
编辑距离
leetcode 392. 判断子序列
leetcode 392. 判断子序列
动态规划五部曲分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。
注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。
有同学问了,为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?
用i来表示也可以!
但我统一以下标i-1为结尾的字符串来计算,这样在下面的递归公式中会容易理解一些,如果还有疑惑,可以继续往下看。
2、确定递推公式
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:
- if (s[i - 1] == t[j - 1])
t中找到了一个字符在s中也出现了 - if (s[i - 1] != t[j - 1])
相当于t要删除元素,继续匹配
if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1
if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];
3、dp数组如何初始化
从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。
这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。
因为这样的定义在dp二维矩阵中可以留出初始化的区间,如图:
如果要是定义的dp[i][j]是以下标i为结尾的字符串s和以下标j为结尾的字符串t,初始化就比较麻烦了。
这里dp[i][0]和dp[0][j]是没有含义的,仅仅是为了给递推公式做前期铺垫,所以初始化为0。
其实这里只初始化dp[i][0]就够了,但一起初始化也方便,所以就一起操作了,代码如下:
vector<vector> dp(s.size() + 1, vector(t.size() + 1, 0));
4、确定遍历顺序
同理从从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右
如图所示:
5、举例推导dp数组
以示例一为例,输入:s = “abc”, t = “ahbgdc”,dp状态转移图如下:
dp[i][j]表示以下标i-1为结尾的字符串s和以下标j-1为结尾的字符串t 相同子序列的长度,所以如果dp[s.size()][t.size()] 与 字符串s的长度相同说明:s与t的最长相同子序列就是s,那么s 就是 t 的子序列。
图中dp[s.size()][t.size()] = 3, 而s.size() 也为3。所以s是t 的子序列,返回true。
代码如下:
bool isSubsequence(char * s, char * t) {
int sLen = strlen(s);
int tLen = strlen(t);
if (sLen == 0)
return true;
if (tLen == 0)
return false;
int dp[sLen + 1][tLen + 1];
memset(dp, 0, sizeof(dp));
for (int i = 1; i <= sLen; i++) {
for (int j = 1; j <= tLen; 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];
}
}
}
if (dp[sLen][tLen] == sLen)
return true;
return false;
}
leetcode 115. 不同的子序列
leetcode 115. 不同的子序列
动规五部曲分析如下:
1、确定dp数组(dp table)以及下标的含义
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] 不相等
当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。
一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。
一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]。
这里可能有同学不明白了,为什么还要考虑 不用s[i - 1]来匹配,都相同了指定要匹配啊。
例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。
当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。
所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配,即:dp[i - 1][j]
所以递推公式为:dp[i][j] = dp[i - 1][j];
3、dp数组如何初始化
从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][0] 和dp[0][j]是一定要初始化的。
每次当初始化的时候,都要回顾一下dp[i][j]的定义,不要凭感觉初始化。
dp[i][0]表示什么呢?
dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。
那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。
再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。
那么dp[0][j]一定都是0,s如论如何也变成不了t。
最后就要看一个特殊位置了,即:dp[0][0] 应该是多少。
dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。
初始化分析完毕,代码如下:
vector<vector<long long>> dp(s.size() + 1, vector<long long>(t.size() + 1));
for (int i = 0; i <= s.size(); i++) dp[i][0] = 1;
for (int j = 1; j <= t.size(); j++) dp[0][j] = 0; // 其实这行代码可以和dp数组初始化的时候放在一起,但我为了凸显初始化的逻辑,所以还是加上了。
4、确定遍历顺序
从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j]都是根据左上方和正上方推出来的。
所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。
代码如下:
for (int i = 1; i <= s.size(); i++) {
for (int j = 1; j <= t.size(); j++) {
if (s[i - 1] == t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
5、举例推导dp数组
以s:“baegg”,t:"bag"为例,推导dp数组状态如下:
代码如下:
int numDistinct(char *s, char *t) {
int sLen = strlen(s);
int tLen = strlen(t);
/* 1、dp数组含义
* dp[i][j]表示s以i-1结尾,t以j-1结尾时,s中出现t的次数
*/
int dp[sLen + 1][tLen + 1];
memset(dp, 0, sizeof(dp));
/* 3、dp数组初值
* 从状态转移方程中可以看出dp[i][0]和dp[0][j]都要初始化
* dp[0][0] = 1; 空字符串s中出现空字符串t的次数为1次
* dp[i][0] = 1; 字符串s中出现空字符串的次数为1次
* dp[0][j] = 0; 空字符串s中出现字符串t的次数为0次
*/
for (int i = 0; i <= sLen; i++) {
dp[i][0] = 1;
}
/* 4、遍历顺序
* 从状态转移方程可以看出当前dp从左上和正上而来
* 那么外层s从上到下、内层t从左到右
* 且内外层顺序可调换
*/
for (int i = 1; i <= sLen; i++) {
for (int j = 1; j <= tLen; j++) {
/* 2、状态转移方程 */
if (s[i - 1] == t[j - 1]) {
/* 当前两元素相等时
* 使用当前s[i - 1]时dp[i][j] = dp[i - 1][j - 1]
* 不使用当前s[i - 1]时dp[i][j] = dp[i - 1][j]
* 若当前元素s[i - 1]和s[i - 2]相等,那么都可以和t[j - 1]匹配
*/
if (dp[i - 1][j - 1] < INT_MAX - dp[i - 1][j]) {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
}
} else {
/* 当前两元素不相等时
* 相当于不使用当前元素s[i - 1],即使用s[0 ~ i-2]和t[0 ~ j-1]匹配
*/
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[sLen][tLen];
}
leetcode 583. 两个字符串的删除操作
leetcode 583. 两个字符串的删除操作
动规五部曲,分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。
这里dp数组的定义有点点绕,大家要撸清思路。
2、确定递推公式
- 当word1[i - 1] 与 word2[j - 1]相同的时候
- 当word1[i - 1] 与 word2[j - 1]不相同的时候
当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
那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1});
3、dp数组如何初始化
从递推公式中,可以看出来,dp[i][0] 和 dp[0][j]是一定要初始化的。
dp[i][0]:word2为空字符串,以i-1为结尾的字符串word2要删除多少个元素,才能和word1相同呢,很明显dp[i][0] = i。
dp[0][j]的话同理,所以代码如下:
vector<vector> dp(word1.size() + 1, vector(word2.size() + 1));
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
4、确定遍历顺序
从递推公式 dp[i][j] = min(dp[i - 1][j - 1] + 2, min(dp[i - 1][j], dp[i][j - 1]) + 1); 和dp[i][j] = dp[i - 1][j - 1]可以看出dp[i][j]都是根据左上方、正上方、正左方推出来的。
所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。
5、举例推导dp数组
以word1:“sea”,word2:"eat"为例,推导dp数组状态图如下:
代码如下:
int minDistance(char * word1, char * word2) {
int len1 = strlen(word1);
int len2 = strlen(word2);
/* 1、dp数组含义
* dp[i][j]表示s以i-1结尾、t以j-1结尾时
* 两串达到相等时最少要删除的元素的个数
*/
int dp[len1 + 1][len2 + 1];
memset(dp, 0, sizeof(int) * (len1 + 1) * (len2 + 1));
/* 3、dp数组初值
* 从状态转移方程可以看出dp[i][j]
* 从左上、正上、正左而来
* 那么dp[i][0] = i表示串t为空,串s需要删除i个元素才能与t相等
* 那么dp[0][j] = j表示串s为空,串t需要删除j个元素才能与s相等
*/
for (int i = 0; i <= len1; i++) {
dp[i][0] = i;
}
for (int j = 0; j <= len2; j++) {
dp[0][j] = j;
}
/* 4、遍历顺序
* 从状态转移方程可以看出dp[i][j]
* 从左上、正上、正左而来
* 所以外层s从上到下、内层t从左到右
* 且内外层顺序可交换
*/
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
/* 2、状态转移方程 */
if (word1[i - 1] == word2[j - 1]) {
/* 当前两元素相等时
* 两串中的元素都不需要删除,删除次数与上一次相同
*/
dp[i][j] = dp[i - 1][j - 1];
} else {
/* 当前两元素不等时,当前两串s[0 ~ i-1]、t[0 ~ j-1]
* 有三种删除方法:
* 1、删除s[i-1]: dp[i - 1][j] + 1
* 2、删除t[j-1]: dp[i][j - 1] + 1
* 3、删除s[i-1]和t[j-1]:dp[i - 1][j - 1] + 2
* 三者取最小值
*/
dp[i][j] = (int)fmin(dp[i - 1][j - 1] + 2, (int)fmin(dp[i - 1][j], dp[i][j - 1]) + 1);
}
}
}
return dp[len1][len2];
}
leetcode 72. 编辑距离
1. 确定dp数组(dp table)以及下标的含义
dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。
这里在强调一下:为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?
用i来表示也可以! 但我统一以下标i-1为结尾的字符串,在下面的递归公式中会容易理解一点。
2. 确定递推公式
在确定递推公式的时候,首先要考虑清楚编辑的几种操作,整理如下:
if (word1[i - 1] == word2[j - 1])
不操作
if (word1[i - 1] != word2[j - 1])
增
删
换
也就是如上4种情况。
if (word1[i - 1] == word2[j - 1])
那么说明不用任何编辑,dp[i][j]
就应该是 dp[i - 1][j - 1]
,即dp[i][j] = dp[i - 1][j - 1];
此时可能有同学有点不明白,为啥要即dp[i][j] = dp[i - 1][j - 1]
呢?
那么就在回顾上面讲过的dp[i][j]
的定义,word1[i - 1]
与 word2[j - 1]
相等了,那么就不用编辑了,以下标i-2为结尾的字符串word1和以下标j-2为结尾的字符串word2
的最近编辑距离dp[i - 1][j - 1]
就是 dp[i][j]
了。
在下面的讲解中,如果哪里看不懂,就回想一下dp[i][j]
的定义,就明白了。
在整个动规的过程中,最为关键就是正确理解dp[i][j]
的定义!
if (word1[i - 1] != word2[j - 1])
,此时就需要编辑了,如何编辑呢?
操作一:word1增加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 加上一个增加元素的操作。
即 dp[i][j] = dp[i - 1][j] + 1;
操作二:word2添加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个增加元素的操作。
即 dp[i][j] = dp[i][j - 1] + 1;
这里有同学发现了,怎么都是添加元素,删除元素去哪了。
word2添加一个元素,相当于word1删除一个元素,例如 word1 = "ad" ,word2 = "a"
,word1
删除元素'd'
,word2
添加一个元素'd'
,变成word1="a", word2="ad"
, 最终的操作数是一样! dp数组如下图所示意的:
a a d
+-----+-----+ +-----+-----+-----+
| 0 | 1 | | 0 | 1 | 2 |
+-----+-----+ ===> +-----+-----+-----+
a | 1 | 0 | a | 1 | 0 | 1 |
+-----+-----+ +-----+-----+-----+
d | 2 | 1 |
+-----+-----+
操作三:替换元素,word1
替换word1[i - 1]
,使其与word2[j - 1]
相同,此时不用增加元素,那么以下标i-2
为结尾的word1
与 j-2
为结尾的word2
的最近编辑距离 加上一个替换元素的操作。
即 dp[i][j] = dp[i - 1][j - 1] + 1;
综上,当 if (word1[i - 1] != word2[j - 1])
时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], 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], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
3. dp数组如何初始化
再回顾一下dp[i][j]的定义:
dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。
那么dp[i][0] 和 dp[0][j] 表示什么呢?
dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。
那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;
同理dp[0][j] = j;
所以C++代码如下:
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
4. 确定遍历顺序
从如下四个递推公式:
dp[i][j] = dp[i - 1][j - 1]
dp[i][j] = dp[i - 1][j - 1] + 1
dp[i][j] = dp[i][j - 1] + 1
dp[i][j] = dp[i - 1][j] + 1
可以看出dp[i][j]是依赖左方,上方和左上方元素的,如图:
所以在dp矩阵中一定是从左到右从上到下去遍历。
代码如下:
for (int i = 1; i <= word1.size(); i++) {
for (int j = 1; j <= word2.size(); j++) {
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
else {
dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
}
}
5. 举例推导dp数组
以示例1为例,输入:word1 = "horse", word2 = "ros"
为例,dp矩阵状态图如下:
代码入下:
int minDistance(char * word1, char * word2) {
int len1 = strlen(word1);
int len2 = strlen(word2);
/* 1、dp数组含义
* dp[i][j]表示串word1以i-1结尾、串Word2以j-1结尾
* word1转换为word2的次数
*/
int dp[len1 + 1][len2 + 1];
memset(dp, 0, sizeof(int) * (len1 + 1) * (len2 + 1));
/* 3、dp数组初值
* dp[i][0] = i;表示串word2为空,word1转换为空的次数为word1的长度
* dp[0][j] = j;表示串word1为空,word1转换为word2的次数为word2的长度
*/
for (int i = 0; i <= len1; i++) {
dp[i][0] = i;
}
for (int j = 0; j <= len2; j++) {
dp[0][j] = j;
}
/* 4、遍历顺序
* 从状态转移方程来看dp[i][j]从正上、左上、正左而来
* 所以外层word1从上到下、内层Word2从左到右
*/
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
/* 2、状态转移方程 */
if (word1[i - 1] == word2[j - 1]) {
/* 当前两元素相等
* 相等就不需要插入、删除、替换的操作
* 和上一个状态一样
*/
dp[i][j] = dp[i - 1][j - 1];
} else {
/* 当前两元素不相等
* 插入(增加)word1[i - 1],将 word1[i - 1]增加一个元素变成word2[j - 1]:dp[i - 1][j] + 1
* 增加(删除word1[i - 1])word2[j - 1],将 word2[j - 1]添加成word1[i - 1]:dp[i][j - 1] + 1
* 替换word1[i - 1],将word1[i - 1]替换为word2[j - 1]: dp[i - 1][j - 1] + 1
* 三种取最小值
*/
dp[i][j] = (int)fmin(dp[i - 1][j - 1], (int)fmin(dp[i - 1][j], dp[i][j - 1])) + 1;
}
}
}
return dp[len1][len2];
}
回文子序列
leetcode 647. 回文子串
- 确定dp数组(dp table)以及下标的含义
布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
- 确定递推公式
在确定递推公式时,就要分析如下几种情况。
整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。
当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。
当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况
- 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
- 情况二:下标i 与 j相差为1,例如aa,也是文子串
- 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。
以上三种情况分析完了,那么递归公式如下:
if (s[i] == s[j]) {
if (j - i <= 1) { // 情况一 和 情况二
result++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1]) { // 情况三
result++;
dp[i][j] = true;
}
}
result就是统计回文子串的数量。
注意这里我没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]初始化的时候,就初始为false。
- dp数组如何初始化
dp[i][j]可以初始化为true么? 当然不行,怎能刚开始就全都匹配上了。
所以dp[i][j]初始化为false。
- 确定遍历顺序
遍历顺序可有有点讲究了。
首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。
dp[i + 1][j - 1] 在 dp[i][j]的左下角,如图:
如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。
所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的。
有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。
代码如下:
for (int i = s.size() - 1; i >= 0; i--) { // 注意遍历顺序
for (int j = i; j < s.size(); j++) {
if (s[i] == s[j]) {
if (j - i <= 1) { // 情况一 和 情况二
result++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1]) { // 情况三
result++;
dp[i][j] = true;
}
}
}
}
- 举例推导dp数组
举例,输入:“aaa”,dp[i][j]状态如下:
图中有6个true,所以就是有6个回文子串。
注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分。
代码如下:
int countSubstrings(char * s) {
int len = strlen(s);
/* 1、dp数组含义
* dp[i][j]表示s[i~j]是否为回文子串
*/
bool dp[len][len];
/* 3、dp初值
* dp[i][j]全为flase,因为还开始计算
*/
memset(dp, 0, sizeof(dp));
/* 回文子串个数 */
int res = 0;
/* 4、遍历顺序
* 从状态转移方程来看,当前dp从右下而来
* 外层i从下到上、内层j从左到右
*/
for (int i = len - 1; i >= 0; i--) {
for (int j = i; j < len; j++) {
/* 2、状态转移方程 */
if (s[i] == s[j]) { /* 当前元素相等 */
if (j - i <= 1) {
/* 元素之间相差一位、或0位 */
res++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1] == true) {
/* 元素之间相差多位,查看左右各向里移动一位的dp */
res++;
dp[i][j] = true;
}
} else { /* 当前元素不等 */
dp[i][j] = false;
}
}
}
return res;
}
- 时间复杂度:O(n^2)
- 空间复杂度:O(n^2)
此题也可使用双指针求解:
首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。
在遍历中心点的时候,要注意中心点有两种情况。
一个元素可以作为中心点,两个元素也可以作为中心点。
那么有人同学问了,三个元素还可以做中心点呢。其实三个元素就可以由一个元素左右添加元素得到,四个元素则可以由两个元素左右添加元素得到。
所以我们在计算的时候,要注意一个元素为中心点和两个元素为中心点的情况。
这两种情况可以放在一起计算,但分别计算思路更清晰,我倾向于分别计算,代码如下:
int extend(char* s, int left, int right, int sLen) {
int res = 0;
while (left >= 0 && right < sLen && s[left] == s[right]) {
res++;
left--;
right++;
}
return res;
}
int countSubstrings(char * s) {
int len = strlen(s);
int result = 0;
for (int i = 0; i < len; i++) {
result += extend(s, i, i, len);
result += extend(s, i, i + 1, len);
}
return result;
}
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
leetcode 516. 最长回文子序列
516. 最长回文子序列
动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]。
- 确定递推公式
在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。
如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;
如图:
如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。
加入s[j]的回文子序列长度为dp[i + 1][j]。
加入s[i]的回文子序列长度为dp[i][j - 1]。
那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
代码如下:
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]);
}
- dp数组如何初始化
首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。
所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。
其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
- 确定遍历顺序
从递推公式dp[i][j] = dp[i + 1][j - 1] + 2 和 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 可以看出,dp[i][j]是依赖于dp[i + 1][j - 1] 和 dp[i + 1][j],
也就是从矩阵的角度来说,dp[i][j] 下一行的数据。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的。
递推公式:dp[i][j] = dp[i + 1][j - 1] + 2,dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 分别对应着下图中的红色箭头方向,如图:
代码如下:
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.size(); j++) {
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
- 举例推导dp数组
输入s:“cbbd” 为例,dp数组状态如图:
红色框即:dp[0][s.size() - 1]; 为最终结果。
代码如下:
int longestPalindromeSubseq(char * s) {
int len = strlen(s);
int dp[len][len];
memset(dp, 0, sizeof(dp));
for (int i = 0; i < len; i++) {
dp[i][i] = 1;
}
for (int i = len - 2; i >= 0; i--) {
for (int j = i + 1; j < len; j++) {
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = (int)fmax(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][len - 1];
}