动规基础
背包问题
打家劫舍
股票问题
子序列问题
动态规划(DP):有很多重叠子问题,每一个状态一定是由上一个状态推导出来的
贪心:没有状态推导,和上一个状态没有关系,而是从局部直接选最优的
动规五步曲:
确定dp数组(dp table)以及下标的含义
确定递推公式(容斥原理)
dp数组如何初始化
确定遍历顺序
举例推导dp数组(用于检验)
一:递推问题
1.1. 如何求解递推问题
正向递推:(递推:一个算法 递归:程序实现的方式,不是算法)
正向递推(慢):n------->1------->递归
逆向递推(快):1------->n------->循环
解决效率过差:
1. 递归+记忆化
2. 改成逆向递推求解
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[n]:第n个月的兔子总数是dp[n]
确定递推公式(容斥原理)
容斥原理 :dp[n]全集包括:成年兔 + 幼年兔
dp[n] = dp[n - 1] + dp[n - 2];
dp数组如何初始化
dp[1] = 1;
dp[2] = 2;
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #include <stdlib.h> // 正向递推:递归过程+记忆化(提高运行效率) #define MAX_N 100 int arr[MAX_N + 1] = {0}; int func1(int n) { if (n <= 2) { return n; } if (arr[n]) { return arr[n]; } arr[n] = func1(n - 1) + func1(n - 2); return arr[n]; } // 逆向递推 int func2(int n) { int *dp = malloc(sizeof(int) * (n + 1)); dp[1] = 1; dp[2] = 2; for (int i = 3; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } int ret = dp[n]; free(dp); return ret; } int main(int argc, char *argv[]) { int n; scanf("%d", &n); printf("%d\n", func1(n)); printf("%d\n", func2(n)); return 0; }
1.2. 容斥原理的基本思想
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:用前 i 种钱币,凑足 j 元钱的方法总数
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:没有使用第i种钱币 + 使用第i种钱币
没有使用第i种钱币:dp[i - 1][j]
使用第i种钱币:dp[i][j - value[i]]
第一部分:给第i种钱币先留出一个空,用前i种钱币凑够(j - value[i])钱
第二部分:最后一个空用第i种钱币
dp[i][j] = dp[i - 1][j] + dp[i][j - value[i]];
dp数组如何初始化
1)
//初始化 for (int i = 1; i <= m; i++) { dp[i][0] = 1; } for (int j = 1; j <= n; j++) { if (w[1] <= j && (j % w[1] == 0)) { dp[1][j] = 1; } else { dp[1][j] = 0; } }
2)
//初始化 memset(dp[0], 0, sizeof(int) * n); //将第0行初始化为0 for (int i = 1; i <= m; i++) { dp[i][0] = 1; //初始化第i行第0列为1 }
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
//1)动态规划 #include <stdio.h> #define MAX_N 10000 #define MAX_M 20 int w[MAX_M + 1]; int dp[MAX_M + 1][MAX_N + 1]; int main(int argc, char *argv[]) { int m, n; //m种面额的钱币凑足n元钱 scanf("%d%d", &m, &n); for (int i = 1; i <= m; i++) { scanf("%d", w + i); } //初始化 for (int i = 1; i <= m; i++) { dp[i][0] = 1; } for (int j = 1; j <= n; j++) { if (w[1] <= j && (j % w[1] == 0)) { dp[1][j] = 1; } else { dp[1][j] = 0; } } for (int i = 2; i <= m; i++) { for (int j = 1; j <= n; j++) { dp[i][j] = dp[i - 1][j]; if (j < w[i]) { continue; } dp[i][j] += dp[i][j - w[i]]; dp[i][j] %= 9973; } } printf("%d\n", dp[m][n]); return 0; } //2)动态规划 #include <stdio.h> #include <string.h> #define MAX_N 10000 #define MAX_M 20 int w[MAX_M + 5]; int dp[MAX_M + 5][MAX_N + 5]; int main(int argc, char *argv[]) { int m, n; //m种面额的钱币凑足n元钱 scanf("%d%d", &m, &n); for (int i = 1; i <= m; i++) { scanf("%d", w + i); } memset(dp[0], 0, sizeof(int) * n); //将第0行初始化为0 for (int i = 1; i <= m; i++) { dp[i][0] = 1; //初始化第i行第0列为1 for (int j = 1; j <= n; j++) { dp[i][j] = dp[i - 1][j]; if (j < w[i]) { continue; } dp[i][j] += dp[i][j - w[i]]; dp[i][j] %= 9973; } } printf("%d\n", dp[m][n]); return 0; } //3)回溯
1.3. 随堂练习1:爬楼梯
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[n]:走到第n阶台阶的方法总数
确定递推公式(容斥原理)
容斥原理:dp[n]全集包括:最后跨2步到达第n阶台阶 + 最后跨3步到达第n阶台阶
dp[n] = dp[n - 2] + dp[n - 3];
dp数组如何初始化
dp[1] = 0;
dp[2] = 1;
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define MAX_N 500 int dp[MAX_N + 1]; int func(int n) { dp[0] = 1; dp[1] = 0; dp[2] = 1; for (int i = 3; i <= n; i++) { dp[i] = dp[i - 2] + dp[i - 3]; } return dp[n]; } int main(int argc, char *argv[]) { int n; scanf("%d", &n); printf("%d\n", func(n)); return 0; }
💖1.4. 随堂练习1:墙壁涂色
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[n][i][j]代表前n块墙壁,在不考虑头尾成环的前提下,第1块涂颜色i,第n块涂颜色j的方法总数
此时 i 可以等于 j ,最后统计答案时去除相等的情况
确定递推公式(容斥原理)
容斥原理:dp[n][i][j]全集包括:第1块涂颜色i,第n-1块涂颜色k(k != j),第n块涂颜色j
dp[n][i][j] = dp[n-1][i][k](k != j)的累加
dp数组如何初始化
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
二:递推-课后实战题
2.1. 数的划分
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:将数字 i 分成 j 份的方法总数
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:拆分方案中有1 + 拆分方案中没有1
拆分方案中有1:留下最后一个位置放1,dp[i - 1][j - 1]
拆分方案中没有1:将所有方案中的 j 个数都减1,得到另外一个数(i - j)分成 j 份的结果,将i - j的所有方案列出来,每个数都加上1,就是拆分方 案中没有1的结果,所以拆分方案中没有1的方法总数 == 将数字 i - j分成 j 份的方法总数 ,即dp[i - j][j]
dp[i][j] = dp[i - 1][j - 1] + dp[i - j][j];
dp数组如何初始化
int dp[MAX_N + 1][MAX_K + 1] = {0}; for (int i = 1; i <= n; i++) { dp[i][1] = 1; }
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
//动态规划 #include <stdio.h> #define MAX_N 200 #define MAX_K 6 #define min(a, b) ((a) > (b) ? (b) : (a)) int dp[MAX_N + 1][MAX_K + 1] = {0}; int main(int argc, char *argv[]) { int n, k; scanf("%d%d", &n, &k); dp[0][0] = 1; for (int i = 1; i <= n; i++) { dp[i][1] = 1; for (int j = 2; j <= min(i, k); j++) { dp[i][j] = dp[i - 1][j - 1] + dp[i - j][j]; } } printf("%d\n", dp[n][k]); return 0; } //回溯
2.2. 数的计算
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:以i作为开头的合法的数列个数
确定递推公式(容斥原理)
容斥原理:dp[i]全集包括:
以i作为结尾(不扩展)+ i后面接i/2 + i后面接i/2-1 ...... + i后面接1
1 dp[i/2] dp[i/2-1] dp[1]
dp[i] = dp[j]的累加(j <= i/2)+ 1
dp数组如何初始化
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define MAX_N 1000 int dp[MAX_N + 1] = {0}; int main(int argc, char *argv[]) { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) { dp[i] = 1; for (int j = 1; j <= i / 2; j++) { dp[i] += dp[j]; } } printf("%d\n", dp[n]); return 0; }
2.3. 神经网络
2.4. 栈
题目描述:1 <= n <=18 的合法出栈序列一共有多少种
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[n]:1—n的合法出栈序列方案数
确定递推公式(容斥原理)
容斥原理:dp[n]全集包括:出栈序列末尾是1的方案数 + 出栈序列末尾是2的方案数 + 出栈序列末尾是3的方案数 + ...... + 出栈序列末尾是n的方案数
小于x的数不断入栈出栈---》x入栈---》大于x的数不断入栈出栈---》x出栈
第一部分:小于x的数 第二部分:大于x的数 第三部分:x
所以出栈序列末尾是x的方案数 = dp[x - 1] * dp[n - x]
dp[n] = dp[x - 1] * dp[n - x]的累加(x == 1; x <= n; x++)
dp数组如何初始化
int dp[MAX_N + 1] = {0};
dp[0] = 1;
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define MAX_N 18 int dp[MAX_N + 1] = {0}; int main(int argc, char *argv[]) { int n; scanf("%d", &n); dp[0] = 1; for (int i = 1; i <= n; i++) { for (int j = 1; j <= i; j++) { dp[i] += dp[j - 1] * dp[i - j]; } } printf("%d\n", dp[n]); return 0; }
2.5. 循环
2.6. 传球游戏
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[j][i]:传了j轮球,球在第i个人手里的方法总数
确定递推公式(容斥原理)
容斥原理:dp[j][i]全集包括:
倒数第二轮时球在第i-1个人手里 + 倒数第二轮时球在第 i+1个人手里
dp[j - 1][i - 1] dp[j - 1][i + 1]
dp[j][i] = dp[j - 1][i - 1] + dp[j - 1][i + 1]
dp数组如何初始化
确定遍历顺序
从前往后
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define MAX_N 30 #define MAX_M 30 int dp[MAX_N + 1][MAX_N + 1] = {0}; int main(int argc, char *argv[]) { int n, m; scanf("%d%d", &n, &m); dp[0][1] = 1; for (int j = 1; j <= m; j++) { for (int i = 2; i <= n - 1; i++) { dp[j][i] = dp[j - 1][i + 1] + dp[j - 1][i - 1]; } //单独处理边界 dp[j][1] = dp[j - 1][2] + dp[j - 1][n]; dp[j][n] = dp[j - 1][1] + dp[j - 1][n - 1]; } printf("%d\n", dp[m][1]); return 0; }
2.7. Hanoi 双塔问题
动规五步曲:
确定dp数组(dp table)以及下标的含义
确定递推公式(容斥原理)
dp数组如何初始化
确定遍历顺序
举例推导dp数组(用于检验)
三:动态规划
3.1. 数字三角形
第一种解法:
思考-1:如果由下向上走的话,很容易获得最后一行站在每个点上能够获得的最大值,由此得到倒数第二行的每个点的最大值,依次递推。
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:代表从底边走到(i,j)点所能获得的最大值
确定递推公式(容斥原理)
dp[i][j] = max[dp[i + 1][j]), dp[i + 1][j + 1] + val[i][j]
dp数组如何初始化
for (int i = 1; i <= n; i++) //dp数组初始化 dp[n][i] = val[n][i];
确定遍历顺序
从下往上,从左往右
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 1000 int val[MAX_N + 1][MAX_N + 1]; int dp[MAX_N + 1][MAX_N + 1]; int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) //得到一个三角形数塔 { for (int j = 1; j <= i; j++) scanf("%d", &val[i][j]); } for (int i = 1; i <= n; i++) //dp数组初始化 dp[n][i] = val[n][i]; for (int i = n - 1; i >= 1; i--) { for (int j = 1; j <= i; j++) dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + val[i][j]; } printf("%d\n", dp[1][1]); return 0; }
第二种解法:
思考-2:如果由上向下走的话,很容易获得第一行站在每个点上能够获得的最大值,由此得到第二行的每个点的最大值,依次递推。
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:代表从第一个点走到(i,j)点所能获得的最大值
确定递推公式(容斥原理)
dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + val[i][j];
//临界点单独处理
dp[i][1] = dp[i - 1][1] + val[i][1];
dp[i][i] = dp[i - 1][i - 1] + val[i][i];
dp数组如何初始化
dp[1][1] = val[1][1]; //dp数组初始化
确定遍历顺序
从上往下,从左往右
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 1000 int val[MAX_N + 1][MAX_N + 1]; int dp[MAX_N + 1][MAX_N + 1]; int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) //得到一个三角形数塔 { for (int j = 1; j <= i; j++) scanf("%d", &val[i][j]); } dp[1][1] = val[1][1]; //dp数组初始化 for (int i = 2; i <= n; i++) { for (int j = 2; j < i; j++) dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + val[i][j]; //临界点单独处理 dp[i][1] = dp[i - 1][1] + val[i][1]; dp[i][i] = dp[i - 1][i - 1] + val[i][i]; } int ans = dp[n][1]; for (int i = 2; i <= n; i++) { if (dp[n][i] > ans) ans = dp[n][i]; } printf("%d\n",ans); return 0; }
3.2. 全面剖析:数字三角形问题
阶段:每层就是一个阶段
状态:dp[i][j]就是一个状态
无后效性:dp[i][j]的值确定后,不能被修改
决策:动规方程中的max部分就是一种决策
3.3. DP经典问题
1)最长上升子序列
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:代表以i位为结尾的上升子序列最长长度
确定递推公式(容斥原理)
dp[i] = max(dp[j]) + 1; {0 < j && j < i && val[j] < val[i]}
dp数组如何初始化
int dp[MAX_N + 1] = {0};
val[0] = INT32_MIN;
确定遍历顺序
从左往右
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #include <inttypes.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 1000000 int val[MAX_N + 1]; int dp[MAX_N + 1] = {0}; int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) scanf("%d", val + i); val[0] = INT32_MIN; int ans = 0; for (int i = 1; i <= n; i++) { for (int j = 0; j < i; j++) { if (val[j] < val[i]) dp[i] = max(dp[i], dp[j] + 1); } ans = max(ans, dp[i]); } printf("%d\n", ans); return 0; }
拓展:输出最长上升子序列
#include <stdio.h> #include <inttypes.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 1000000 int val[MAX_N + 1]; int dp[MAX_N + 1] = {0}; int pre[MAX_N + 1] = {0}; //pre[i] = j表示i位置的值接在j位置的值后面 int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) scanf("%d", val + i); val[0] = INT32_MIN; int ans = 0, ind = 0; for (int i = 1; i <= n; i++) { for (int j = 0; j < i; j++) { if (val[j] >= val[i]) continue; if (dp[j] + 1 > dp[i]) { dp[i] = dp[j] + 1; pre[i] = j; } } if (dp[i] > ans) { ans = dp[i]; ind = i; } } printf("%d\n", ans); while (ind) { printf("%d->", ind); ind = pre[ind]; } printf("\n"); return 0; }
2)最长公共子序列
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:代表A串长度为 i 位,B串长度为 j 位的最长公共子序列长度
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:i,j 匹配 + i,j 不匹配
i,j 匹配:dp[i - 1][j - 1] + 1
i,j 不匹配:dp[i][j - 1] 或者 dp[i - 1][j]
dp[i][j] = max((dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1] + (A[i] == B[i]))
dp数组如何初始化
确定遍历顺序
从左往右
举例推导dp数组(用于检验)
代码实现:
3)切割回文
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:代表A串长度为 i 位,B串长度为 j 位的最长公共子序列长度
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:i,j 匹配 + i,j 不匹配
i,j 匹配:dp[i - 1][j - 1] + 1
i,j 不匹配:dp[i][j - 1] 或者 dp[i - 1][j]
dp[i][j] = max((dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1] + (A[i] == B[i]))
dp数组如何初始化
确定遍历顺序
从左往右
举例推导dp数组(用于检验)
代码实现:
代码随想录
1. 斐波那契数
动规五部曲:
- 确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
- 确定递推公式(容斥原理)
容斥原理:dp[i]全集包括:第i - 1个数的值 + 第i - 2个数的值
状态转移方程 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
代码实现:
// 1)逆向递推 时间复杂度:O(n) 空间复杂度:O(n) int fib(int n) { if (n <= 1) { return n; } int dp[n + 1]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } // 2)逆向递推 时间复杂度:O(n) 空间复杂度:O(1) int fib(int n) { if (n <= 1) { return n; } int dp[2]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) { int sum = dp[0] + dp[1]; dp[0] = dp[1]; dp[1] = sum; } return dp[1]; } // 3)正向递推 递归 + 记忆化解法 #define MAX_N 30 int arr[MAX_N + 1] = {0}; // 优化:记忆化(防止大量重复运算,加快运行效率) int fib(int n) { if (n <= 1) { return n; } if (arr[n]) { return arr[n]; } arr[n] = fib(n - 1) + fib(n - 2); return arr[n]; }
2. 爬楼梯
动规五步曲:
- 确定dp数组以及下标的含义
dp[i]:爬到第i层楼梯,有dp[i]种方法
- 确定递推公式(容斥原理)
从dp[i]的定义可以看出,dp[i]可以有两个方向推出来
- 首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶就是dp[i]了
- 还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶就是dp[i]了
那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!
容斥原理:dp[i]全集包括:从第i-1台阶到达 + 从第i-2个台阶到达
所以dp[i] = dp[i - 1] + dp[i - 2]
- dp数组如何初始化
dp[1] = 1; dp[2] = 2;
- 确定遍历顺序
从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
- 举例推导dp数组(检验)
举例当n为5的时候,dp table(dp数组)应该是这样的
代码实现:
// 逆向递推 时间复杂度:O(n) 空间复杂度:O(n) int climbStairs(int n) { if (n <= 2) { return n; } int dp[n + 1]; dp[1] = 1; // 上一层台阶 dp[2] = 2; // 上两层台阶 for (int i = 3; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } // 逆向递推 时间复杂度:O(n) 空间复杂度:O(1) int climbStairs(int n) { if (n <= 2) { return n; } int dp[2]; dp[0] = 1; // 上一层台阶 dp[1] = 2; // 上两层台阶 for (int i = 3; i <= n; i++) { int sum = dp[0] + dp[1]; dp[0] = dp[1]; dp[1] = sum; } return dp[1]; } // 正向递推 #define MAX_N 45 int arr[MAX_N + 1] = {0}; // 优化:记忆化(防止大量重复运算,加快运行效率) int climbStairs(int n) { if (n <= 2) { return n; } if (arr[n]) { return arr[n]; } arr[n] = climbStairs(n - 1) + climbStairs(n - 2); return arr[n]; }
3. 使用最小花费爬楼梯
动规五步曲:
- 确定dp数组以及下标的含义
dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]
- 确定递推公式(容斥原理)
容斥原理:dp[i]全集包括:从第i-1台阶到达 + 从第i-2个台阶到达
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]
那么究竟是选dp[i-1]还是dp[i-2]呢?
一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
- dp数组如何初始化
dp[0] = 0; dp[1] = 0;
- 确定遍历顺序
因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组
- 举例推导dp数组(检验)
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化
如下:
代码实现:
// 1) 逆向递推 时间复杂度O(n) 空间复杂度O(n) #define min(a, b) ((a) > (b) ? (b) : (a)) int minCostClimbingStairs(int *cost, int costSize) { int dp[costSize + 1]; dp[0] = 0; dp[1] = 0; for (int i = 2; i <= costSize; i++) { dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]); } return dp[costSize]; } // 2) 逆向递推 时间复杂度O(n) 空间复杂度O(1) #define min(a, b) ((a) > (b) ? (b) : (a)) int minCostClimbingStairs(int *cost, int costSize) { int dp[2]; dp[0] = 0; dp[1] = 0; for (int i = 2; i <= costSize; i++) { int temp = min(dp[1] + cost[i - 1], dp[0] + cost[i - 2]); dp[0] = dp[1]; dp[1] = temp; } return dp[1]; } // 正向递推 // 递归 + 记忆化 #define min(a, b) ((a) > (b) ? (b) : (a)) int arr[1001] = {0}; // 记忆化 int minCostClimbingStairs(int *cost, int costSize) { if (costSize <= 1) { return 0; } if (costSize == 2) { return min(cost[0], cost[1]); } if (arr[costSize]) { return arr[costSize]; } arr[costSize] = min((minCostClimbingStairs(cost, costSize - 1) + cost[costSize - 1]), (minCostClimbingStairs(cost, costSize - 2) + cost[costSize - 2])); return arr[costSize]; }
4. 不同路径
深搜:机器人每次只能向下或者向右移动一步,那么其实机器人走过的路径可以抽象为一颗二叉树,而叶子节点就是终点!——超时
int dfs(int i, int j, int m, int n) { if (i > m || j > n) { // 越界了 return 0; } if (i == m && j == n) { // 找到一种方法,相当于找到了叶子节点 return 1; } // 从右边走有多少条路径 int right = uniquePathsHelper(i + 1, j, m, n); // 从下边走有多少条路径 int down = uniquePathsHelper(i, j + 1, m, n); // 返回总的路径 return right + down; } int uniquePaths(int m, int n) { return dfs(1, 1, m, n); }
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0,0)出发,到 (i,j) 有dp[i][j]条不同的路径
- 确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:从dp[i - 1][j]往下走到达 + 从dp[i][j - 1]往右走到达
想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]
此时在回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理
那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来
- dp数组的初始化
p[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理
// 最左一列 for (int i = 0; i < m; i++) { dp[i][0] = 1; } // 最上一行 for (int j = 0; j < n; j++) { dp[0][j] = 1; }
- 确定遍历顺序
递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了
- 举例推导dp数组
代码实现:
// 时间复杂度:O(m * n) 空间复杂度:O(m * n) int uniquePaths(int m, int n) { int dp[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]; }
一维滚动dp数组:
// 行滚动 时间复杂度O(m * n) 空间复杂度:O(n) int uniquePaths(int m, int n) { int dp[n]; for (int j = 0; j < n; 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]; } // 列滚动 时间复杂度O(m * n) 空间复杂度:O(m) int uniquePaths(int m, int n) { int dp[m]; for (int i = 0; i < m; i++) { dp[i] = 1; } // 只能先遍历列,再遍历行 for (int j = 1; j < n; j++) { for (int i = 1; i < m; i++) { dp[i] += dp[i - 1]; } } return dp[m - 1]; }
5. 不同路径 II
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0,0)出发,到 (i, j) 有dp[i][j]条不同的路径
- 确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:从dp[i - 1][j]往下走到达 + 从dp[i][j - 1]往右走到达
想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]
此时再回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理
那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来
(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)
// 当(i, j)没有障碍的时候,再推导dp[i][j] if (obstacleGrid[i][j] == 0) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; }
- dp数组如何初始化
如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0
下标(0, j)的初始化情况同理
// 最左一列 for (int i = 0; i < obstacleGridSize && obstacleGrid[i][0] == 0; i++) { dp[i][0] = 1; } // 最上一行 for (int j = 0; j < obstacleGridColSize && obstacleGrid[0][j] == 0; j++) { dp[0][j] = 1; }
- 确定遍历顺序
从左到右一层一层遍历
- 举例推导dp数组(检验)
拿示例1来举例如题:
对应的dp table 如图:
代码实现:
int uniquePathsWithObstacles(int **obstacleGrid, int obstacleGridSize, int *obstacleGridColSize) { int dp[obstacleGridSize][*obstacleGridColSize]; memset(dp, 0, sizeof(int) * obstacleGridSize * (*obstacleGridColSize)); // 将二维dp数组初始化为0 // 最左一列 for (int i = 0; i < obstacleGridSize && obstacleGrid[i][0] == 0; i++) { dp[i][0] = 1; } // 最上一行 for (int j = 0; j < *obstacleGridColSize && obstacleGrid[0][j] == 0; j++) { dp[0][j] = 1; } // 也可以先遍历列,再遍历行 for (int i = 1; i < obstacleGridSize; i++) { for (int j = 1; j < *obstacleGridColSize; j++) { if (obstacleGrid[i][j] == 1) { continue; } dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } return dp[obstacleGridSize - 1][*obstacleGridColSize - 1]; }
6. 😭整数拆分
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[i] :分拆数字i,可以得到的最大乘积为dp[i]
- 确定递推公式(容斥原理)
容斥原理:dp[i]全集包括:两个数相乘得到 + 三个数及三个数以上相乘得到
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)); {1 <= j < i - 1}
j是从1开始遍历,拆分j的情况,在遍历j的过程中都计算过了
- dp数组如何初始化
dp[2] = 1;
- 确定遍历顺序
dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]
枚举j的时候,是从1开始的。i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来
for (int i = 3; i <= n ; i++) { for (int j = 1; j < i - 1; j++) { dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)); } }
- 举例推导dp数组(检验)
代码实现:
// 时间复杂度:O(n^2) 空间复杂度:O(n) #define max(a, b) ((a) > (b) ? (a) : (b)) int integerBreak(int n) { int dp[n + 1]; dp[2] = 1; // 初始化 for (int i = 3; i <= n; i++) { for (int j = 1; j < i - 1; j++) { dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)); } } return dp[n]; }
7. 😭不同的二叉搜索树
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[i] :结点为i的二叉搜索树的个数
- 确定递推公式(容斥原理)
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]
- dp数组如何初始化
dp[0] = 1;
- 确定遍历顺序
从前往后遍历
- 举例推导dp数组(检验)
背包问题:
组合不强调元素之间的顺序,排列强调元素之间的顺序
如果求组合数必须外层for循环遍历物品,内层for遍历背包(组合物)
如果求排列数必须外层for遍历背包,内层for循环遍历物品
8. 😎0/1背包
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:表示从下标为[0-i]的物品里任意取,背包承重为 j 时所获得的最大价值
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]);
dp数组如何初始化
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果
如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了
最后初始化代码如下:初始化只能倒序遍历(防止物品被重复加入多次!)
int dp[n + 1][V + 1]; // 初始化 memset(dp, 0, sizeof(dp)); // 注意:只能倒序遍历(如果正序遍历,那么物品0就会被重复加入多次!) for (int j = V; j >= v[0]; j--) { dp[0][j] = dp[0][j - weight[0]] + value[0]; }
确定遍历顺序
先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以
// 先遍历物品、再遍历重量 for (int i = 1; i < n; i++) { // 物品 for (int j = 1; j <= V; j++) { // 背包 if (j < v[i]) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]); } } }
// 先遍历背包、再遍历物品 for (int j = 1; j <= V; j++) { // 背包 for (int i = 1; i < n; i++) { // 物品 if (j < v[i]) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]); } } }
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #include <string.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int main() { int V, n; scanf("%d%d", &V, &n); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } int dp[n + 1][V + 1]; memset(dp, 0, sizeof(int) * (n + 1) * (V + 1)); // 初始化 注意:初始化只能倒序遍历(如果正序遍历,那么物品0就会被重复加入多次!) for (int j = V; j >= v[0]; j--) { dp[0][j] = dp[0][j - v[0]] + w[0]; } // 先遍历物品、再遍历背包 // for (int i = 1; i < n; i++) { // 物品 // for (int j = 1; j <= V; j++) { // 背包 // if (j < v[i]) { // dp[i][j] = dp[i - 1][j]; // } else { // dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]); // } // } // } // 先遍历背包、再遍历物品 for (int j = 1; j <= V; j++) { // 背包 for (int i = 1; i < n; i++) { // 物品 if (j < v[i]) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]); } } } printf("%d\n", dp[n - 1][V]); return 0; }
二维滚动数组代码实现:(只能先遍历物品、再遍历背包)
#include <stdio.h> #include <string.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int main() { int V, n; scanf("%d%d", &V, &n); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } int dp[2][V + 1]; memset(dp, 0, sizeof(int) * 2 * (V + 1)); // 初始化 注意:初始化只能倒序遍历(如果正序遍历,那么物品0就会被重复加入多次!) for (int j = V; j >= v[0]; j--) { dp[0][j] = dp[0][j - v[0]] + w[0]; } // 只能先遍历物品、再遍历背包 for (int i = 1; i < n; i++) { // 物品 for (int j = 1; j <= V; j++) { // 背包 int ind = i % 2, rind = (i - 1) % 2; if (j < v[i]) { dp[ind][j] = dp[rind][j]; } else { dp[ind][j] = max(dp[rind][j], dp[rind][j - v[i]] + w[i]); } } } printf("%d\n", dp[(n - 1) % 2][V]); return 0; }
一维dp数组(滚动数组)
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:容量为 j 的背包所背的最大物品价值
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
- 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就可以了
- 确定遍历顺序
只能先遍历物品,再遍历背包,且背包必须倒序遍历
因为一维dp的写法,背包容量一定要倒序遍历,如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品
- 举例推导dp数组(用于检验)
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int dp[MAX_V + 1] = {0}; int main() { int V, n; scanf("%d%d", &V, &n); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } // 只能先遍历物品、再遍历背包 for (int i = 0; i < n; i++) { // 物品 for (int j = V; j >= v[i]; j--) { // 背包 注意:只能倒序遍历(保证物品i只被放入一次) dp[j] = max(dp[j], dp[j - v[i]] + w[i]); } } printf("%d\n", dp[V]); return 0; }
暴力解法:回溯 时间复杂度:2^n
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 10 #define MAX_V 100 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int max_value = 0; void DFS(int n, int startind, int V, int value_sum, int v_sum) { // value_sum:价值 v_sum:体积 if (v_sum > V) { return; } max_value = max(max_value, value_sum); for (int i = startind; i < n; i++) { DFS(n, i + 1, V, value_sum + w[i], v_sum + v[i]); } } int main() { int V, n; scanf("%d%d", &V, &n); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } DFS(n, 0, V, 0, 0); printf("%d\n", max_value); return 0; }
9. 👌完全背包
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:代表前 i 件物品,背包承重为 j 时所获得的最大价值
确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案选第 i 件物品
dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]);
dp数组如何初始化
初始化只能正序遍历(防止物品只被加入1次!)
int dp[n + 1][V + 1]; memset(dp, 0, sizeof(int) * (n + 1) * (V + 1)); // 初始化 注意:初始化只能正序遍历(如果倒序遍历,那么物品0就只会被加入1次!) for (int j = 0; j <= V; j++) { if (j >= v[0]) { dp[0][j] = dp[0][j - v[0]] + w[0]; } }
确定遍历顺序
先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以
举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #include <string.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int main() { int n, V; scanf("%d%d", &n, &V); // n: 物品数 V: 最大承重 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } int dp[n + 1][V + 1]; memset(dp, 0, sizeof(int) * (n + 1) * (V + 1)); // 初始化 注意:初始化只能正序遍历(如果倒序遍历,那么物品0就只会被加入1次!) for (int j = 0; j <= V; j++) { if (j >= v[0]) { dp[0][j] = dp[0][j - v[0]] + w[0]; } } // 先遍历物品、再遍历背包 for (int i = 1; i < n; i++) { // 物品 for (int j = 1; j <= V; j++) { // 背包 if (j < v[i]) { dp[i][j] = dp[i - 1][j]; } else { dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]); } } } // 先遍历背包、再遍历物品 // for (int j = 1; j <= V; j++) { // 背包 // for (int i = 1; i < n; i++) { // 物品 // if (j < v[i]) { // dp[i][j] = dp[i - 1][j]; // } else { // dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]); // } // } // } printf("%d\n", dp[n - 1][V]); return 0; }
二维滚动数组代码实现:(只能先遍历物品、再遍历背包)
#include <stdio.h> #include <string.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int main() { int n, V; scanf("%d%d", &n, &V); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } int dp[2][V + 1]; memset(dp, 0, sizeof(int) * 2 * (V + 1)); // 初始化 注意:初始化只能正序遍历(如果倒序遍历,那么物品0就只会加入1次!) for (int j = 0; j <= V; j++) { if (j >= v[0]) { dp[0][j] = dp[0][j - v[0]] + w[0]; } } // 只能先遍历物品、再遍历背包 for (int i = 1; i < n; i++) { // 物品 for (int j = 1; j <= V; j++) { // 背包 int ind = i % 2, rind = (i - 1) % 2; if (j < v[i]) { dp[ind][j] = dp[rind][j]; } else { dp[ind][j] = max(dp[rind][j], dp[ind][j - v[i]] + w[i]); } } } printf("%d\n", dp[(n - 1) % 2][V]); return 0; }
一维dp数组(滚动数组)
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:容量为 j 的背包所背的最大物品价值
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
- 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就可以了。
- 确定遍历顺序
先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以(背包必须正序遍历)
因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。只要保证下标j之前的dp[j]都是经过计算的就可以了
遍历物品在外层循环,遍历背包容量在内层循环,状态如图:
遍历背包容量在外层循环,遍历物品在内层循环,状态如图:
- 举例推导dp数组(用于检验)
代码实现:
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 100 #define MAX_V 10000 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int dp[MAX_V + 1] = {0}; int main() { int n, V; scanf("%d%d", &n, &V); // n: 物品数 V: 最大承重 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } // 先遍历物品、再遍历背包 // for (int i = 0; i < n; i++) { // 物品 // for (int j = 0; j <= V; j++) { // 背包 注意:只能正序遍历(保证物品i可以被放入多次) // if (j >= v[i]) { // dp[j] = max(dp[j], dp[j - v[i]] + w[i]); // } // } // } // 先遍历背包、再遍历物品 for (int j = 0; j <= V; j++) { // 背包 注意:只能正序遍历(保证物品i可以被放入多次) for (int i = 0; i < n; i++) { // 物品 if (j >= v[i]) { dp[j] = max(dp[j], dp[j - v[i]] + w[i]); } } } printf("%d\n", dp[V]); return 0; }
暴力解法:回溯 时间复杂度:2^n
#include <stdio.h> #define max(a, b) ((a) > (b) ? (a) : (b)) #define MAX_N 10 #define MAX_V 100 int v[MAX_N + 1], w[MAX_N + 1]; // 数组v: 体积, 数组w: 价值 int max_value = 0; void DFS(int n, int startind, int V, int value_sum, int v_sum) { // value_sum:价值 v_sum:体积 if (v_sum > V) { return; } max_value = max(max_value, value_sum); for (int i = startind; i < n; i++) { DFS(n, i, V, value_sum + w[i], v_sum + v[i]); } } int main() { int V, n; scanf("%d%d", &V, &n); // V: 最大承重 n: 物品数 for (int i = 0; i < n; i++) { scanf("%d%d", v + i, w + i); } DFS(n, 0, V, 0, 0); printf("%d\n", max_value); return 0; }
10. 多重背包
转换成01背包问题
11. ✌分割等和子集
背包的体积为sum / 2
背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
背包如何正好装满,说明找到了总和为 sum / 2 的子集
背包中每一个元素是不可重复放入
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
01背包中,dp[j] 表示:容量为j的背包,所背的物品价值可以最大为dp[j]
dp[j](一维滚动dp数组):背包总容量是j,最大可以凑成 j 的子集总和
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 个数 + 拆分方案中选第 i 个数
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
- dp数组如何初始化
从dp[j]的定义来看,首先dp[0]一定是0
如果如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了
本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了
int dp[10001] ={0};
- 确定遍历顺序
一维dp数组,物品遍历的for循环只能放在外层,遍历背包的for循环只能放在内层,且内层for循环倒序遍历!
- 举例推导dp数组(检验)
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int dp[10001] = {0}; bool canPartition(int *nums, int numsSize) { int sum = 0; for (int i = 0; i < numsSize; i++) { sum += nums[i]; } if (sum % 2 == 1) { return false; } int target = sum / 2; // 01背包 for(int i = 0; i < numsSize; i++) { // 物品 for(int j = target; j >= nums[i]; j--) { // 背包 每一个背包一定是不可重复放入,所以从大到小遍历 dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); } } // 集合中的元素正好可以凑成总和target if (dp[target] == target) { return true; } return false; }
12. ✌最后一块石头的重量 II
解题思路:
尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小(化解成01背包问题)
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j] :表示容量(其实就是重量)为j的背包,最多可以背dp[j]这么重的石头
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[j] = max(dp[j], dp[j - stones[i] + stones[i]);
- dp数组如何初始化
因为重量都不会是负数,所以dp[j]都初始化为0就可以了,不用考虑负无穷
int dp[15001] = {0};
- 确定遍历顺序
一维dp数组,物品遍历的for循环只能放在外层,遍历背包的for循环只能放在内层,且内层for循环倒序遍历!
- 举例推导dp数组(检验)
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int dp[15001] = {0}; int lastStoneWeightII(int *stones, int stonesSize) { int sum = 0; for (int i = 0; i < stonesSize; i++) { sum += stones[i]; } int target = sum / 2; for (int i = 0; i < stonesSize; i++) { // 遍历物品 for (int j = target; j >= stones[i]; j--) { // 倒序遍历背包 dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]); } } return sum - dp[target] - dp[target]; }
13. 👌目标和!
解题思路:组合问题
假设加法的总和为x,那么减法对应的总和就是sum - x
所以我们要求的是 x - (sum - x) = S
x = (S + sum) / 2
此时问题就转化为,装满容量为x背包,有几种方法
if ((S + sum) % 2 == 1) { //此时没有方案 return 0; }
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少
本题则是装满有几种方法。其实这就是一个组合问题了
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j] :表示填满 j (包括j) 这么大容积的包,有dp[j]种方法
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
组合问题:dp[j] += dp[j - nums[i]];
- dp数组如何初始化
从递归公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递归结果将都是0
dp[0] = 1,理论上也很好解释,装满容量为0的背包,有1种方法,就是装0件物品
dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来
int dp[bagSize + 1]; memset(dp, 0, sizeof(dp)); dp[0] = 1;
- 确定遍历顺序
一维dp数组,物品遍历的for循环只能放在外层,遍历背包的for循环只能放在内层,且内层for循环倒序遍历!
- 举例推导dp数组(检验)
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
代码实现:
int findTargetSumWays(int *nums, int numsSize, int target) { int sum = 0; for (int i = 0; i < numsSize; i++) { sum += nums[i]; } if (target > sum) { // 此时没有方案 return 0; } if ((target + sum) % 2 == 1) { // 此时没有方案 return 0; } int bagSize = (target + sum) / 2; if (bagSize < 0) { return 0; } int dp[bagSize + 1]; memset(dp, 0, sizeof(dp)); dp[0] = 1; // 01背包 for (int i = 0; i < numsSize; i++) { // 物品 for (int j = bagSize; j >= nums[i]; j--) { // 倒序遍历背包 dp[j] += dp[j - nums[i]]; } } return dp[bagSize]; }
回溯:
int num; void DFS(int *nums, int numsSize, int S, int sum, int cur) { if (cur == numsSize) { if (sum == S) { num++; } return; } DFS(nums, numsSize, S, sum + nums[cur], cur + 1); DFS(nums, numsSize, S, sum - nums[cur], cur + 1); } int findTargetSumWays(int *nums, int numsSize, int target) { num = 0; DFS(nums, numsSize, target, 0, 0); return num; }
14. 👌一和零!
解题思路:
本题其实是01背包问题,只不过这个背包有两个维度,一个是m,一个是n,而不同长度的字符串就是不同大小的待装物品
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
- 确定递推公式(容斥原理)
容斥原理:dp[i][j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
- dp数组如何初始化
int dp[m + 1][n + 1]; memset(dp, 0, sizeof(int) * (m + 1) * (n + 1));
- 确定遍历顺序
一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
- 举例推导dp数组(检验)
以输入:["10","0001","111001","1","0"],m = 3,n = 3为例
最后dp数组的状态如下所示:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int findMaxForm(char **strs, int strsSize, int m, int n) { int dp[m + 1][n + 1]; memset(dp, 0, sizeof(int) * (m + 1) * (n + 1)); // 初始化为0 // 01背包 for (int c = 0; c < strsSize; c++) { // 遍历物品 int oneNum = 0, zeroNum = 0; char *p = strs[c]; while (*p != '\0') { if (*p == '0') { zeroNum++; } else { oneNum++; } p++; } 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]; }
15. 👌零钱兑换 II
分析:完全背包问题
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j] :凑成总金额 j 的货币组合数为dp[j]
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
组合问题:dp[j] += dp[j - nums[i]];
- dp数组如何初始化
int dp[amount + 1]; memset(dp, 0, sizeof(int) * (amount + 1)); dp[0] = 1;
- 确定遍历顺序
完全背包组合问题:只能外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)
两个for循环必须都正序遍历
- 举例推导dp数组(检验)
输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:
代码实现:
int change(int amount, int *coins, int coinsSize) { int dp[amount + 1]; // 初始化 memset(dp, 0, sizeof(int) * (amount + 1)); dp[0] = 1; // 完全背包组合问题,只能先遍历物品,再遍历背包,并且两个for循环必须都正序遍历 for (int i = 0; i < coinsSize; i++) { // 遍历物品 for (int j = coins[i]; j <= amount; j++) { // 遍历背包 dp[j] += dp[j - coins[i]]; } } return dp[amount]; }
16. 👌改编爬楼梯
改为:每次可以爬 1 - m 个台阶。问有多少种不同的方法可以爬到楼顶呢?
分析:
1阶,2阶......m阶就是物品,楼顶就是背包
每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶
问跳到楼顶有几种方法其实就是问装满背包有几种方法
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:爬到有j个台阶的楼顶,有dp[j]种方法
- 确定递推公式(容斥原理)
dp[j] += dp[i - j];
- dp数组如何初始化
int dp[bagSize + 1]; memset(dp, 0, sizeof(dp)); dp[0] = 1;
- 确定遍历顺序
完全背包排列问题:只能外层for循环遍历物品,内层for循环遍历背包
两个for循环都必须正序遍历
- 举例推导dp数组(检验)
代码实现:
int climbStairs(int n) { int dp[n + 1]; // 初始化 memset(dp, 0, sizeof(int) * (n + 1)); dp[0] = 1; // 完全背包排列问题,只能先遍历背包,再遍历物品,并且两个for循环必须都正序遍历 for (int i = 1; i <= n; i++) { // 遍历背包 for (int j = 1; j <= m; j++) { // 遍历物品 if (i - j >= 0) { dp[i] += dp[i - j]; } } } return dp[n]; }
17. 零钱兑换
分析:完全背包问题
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
- dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
所以下标非0的元素都是应该是最大值
int dp[amount + 1]; for (int i = 1; i < amount + 1; i++) { dp[i] = INT32_MAX; } dp[0] = 0;
- 确定遍历顺序
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数
先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以
- 举例推导dp数组(检验)
以输入:coins = [1, 2, 5], amount = 5为例
代码实现:
#define min(a, b) ((a) > (b) ? (b) : (a)) int coinChange(int* coins, int coinsSize, int amount) { int dp[amount + 1]; // 初始化 for (int i = 0; i < amount + 1; i++) { dp[i] = INT32_MAX; } dp[0] = 0; // 01背包 -----先遍历物品,再遍历背包 // for (int i = 0; i < coinsSize; i++) { // 遍历物品 // for (int j = coins[i]; j <= amount; j++) { // 遍历背包 // if (dp[j - coins[i]] != INT32_MAX) { // 如果dp[j - coins[i]]是初始值则跳过 // dp[j] = min(dp[j - coins[i]] + 1, dp[j]); // } // } // } // 01背包 -----先遍历背包,再遍历物品 for (int j = 0; j <= amount; j++) { // 遍历背包 for (int i = 0; i < coinsSize; i++) { // 遍历物品 if (j >= coins[i] && dp[j - coins[i]] != INT32_MAX) { // 如果dp[j - coins[i]]是初始值则跳过 dp[j] = min(dp[j - coins[i]] + 1, dp[j]); } } } if (dp[amount] == INT32_MAX) { return -1; } return dp[amount]; }
18. 完全平方数
分析:完全背包问题 完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:和为i的完全平方数的最少数量为dp[j]
- 确定递推公式(容斥原理)
容斥原理:dp[j]全集包括:拆分方案中不选第 i 件物品 + 拆分方案中选第 i 件物品
dp[j] = min(dp[j - i * i] + 1, dp[j]);
- dp数组如何初始化
dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0;
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - i * i] + 1, dp[j])比较的过程中被初始值覆盖
所以下标非0的元素都是应该是最大值
int dp[n + 1]; for (int i = 1; i < n + 1; i++) { dp[i] = INT32_MAX; } dp[0] = 0;
- 确定遍历顺序
本题求和为n的完全平方数的最少数量,那么有顺序和没有顺序都可以
先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以
- 举例推导dp数组(检验)
代码实现:
#define min(a, b) ((a) > (b) ? (b) : (a)) int numSquares(int n) { int dp[n + 1]; // 初始化 for (int i = i; i < n + 1; i++) { dp[i] = INT32_MAX; } dp[0] = 0; // 01背包 先遍历背包,再遍历物品 for (int j = 0; j <= n; j++) { // 遍历背包 for (int i = 1; i * i <= j; i++) { // 遍历物品 dp[j] = min(dp[j - i * i] + 1, dp[j]); } } // 01背包 先遍历物品,再遍历背包 // for (int i = 1; i * i <= n; i++) { // 遍历物品 // for (int j = 1; j <= n; j++) { // 遍历背包 // if (j - i * i >= 0 && dp[j - i * i] != INT_MAX) { // dp[j] = min(dp[j - i * i ] + 1, dp[j]); // } // } // } return dp[n]; }
19. 单词拆分
分析:完全背包问题
单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。
拆分时可以重复使用字典中的单词,说明就是一个完全背包!
动规五步曲:
- 确定dp数组(dp table)以及下标的含义
dp[j]:字符串长度为 j,如果dp[j]为true,表示可以拆分为一个或多个在字典中出现的单词
- 确定递推公式(容斥原理)
如果确定dp[i] 是true,且 [i, j] 这个区间的子串出现在字典里
那么dp[j]也一定是true(j < i)
所以递推公式是 if([i, j] 这个区间的子串出现在字典里 && dp[i]是true)
那么 dp[j] = true
- dp数组如何初始化
从递归公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0]一定要为true,否则递归下去后面都都是false了。
那么dp[0]有没有意义呢?
dp[0]表示如果字符串为空的话,说明出现在字典里
但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式
下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词
int dp[n + 1]; for (int i = 1; i < n + 1; i++) { dp[i] = INT32_MAX; } dp[0] = 0;
- 确定遍历顺序
本题最终要求的是是否都出现过,所以对出现单词集合里的元素是组合还是排列,并不在意
那么本题使用求排列的方式,还是求组合的方式都行
所以先遍历物品,再遍历背包 或者 先遍历背包,再遍历物品 都可以
(难点) 如果要是外层for循环遍历物品,内层for遍历背包,就需要把所有的子串都预先放在一个容器里
- 举例推导dp数组(检验)
以输入: s = "leetcode", wordDict = ["leet", "code"]为例,dp状态如图:
代码实现:
bool wordBreak(char *s, char **wordDict, int wordDictSize) { int sLen = strlen(s); bool dp[sLen + 1]; memset(dp, false, sizeof(dp)); dp[0] = true; for (int i = 1; i <= sLen; i++) { // 遍历背包 for (int j = 0; j < wordDictSize; j++) { // 遍历物品 int len = strlen(wordDict[j]); int k = i - len; // 分割点是由i和字典单词长度决定的 if (k < 0) { continue; } if (dp[k] && !strncmp(s + k, wordDict[j], len)) { dp[i] = true; } } } return dp[sLen]; }
20. 😎打家劫舍(考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i])
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]
确定递推公式(容斥原理)
决定dp[i]的因素就是第i房间偷还是不偷
如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱
如果不偷第i房间,那么dp[i] = dp[i - 1],即考虑i-1房(注意这里是考虑,并不是一定要偷i-1房)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
dp数组如何初始化
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]
dp[0] = nums[0]; dp[1] = max(nums[0], nums[1]);
确定遍历顺序
dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!
for (int i = 2; i < numsSize; i++) { dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]); }
举例推导dp数组(用于检验)
以示例二,输入[2,7,9,3,1]为例
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int rob(int *nums, int numsSize) { // 特殊处理 if (numsSize == 0) { return 0; } int dp[numsSize]; // 初始化 dp[0] = nums[0]; dp[1] = max(nums[0], nums[1]); for (int i = 2; i < numsSize; i++) { dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]); } return dp[numsSize - 1]; }
21. 😎打家劫舍II
对于一个数组,成环的话主要有如下三种情况:
情况一:考虑不包含首尾元素
情况二:考虑包含首元素,不包含尾元素
情况三:考虑包含尾元素,不包含首元素
注意这里用的是"考虑",例如情况三,虽然是考虑包含尾元素,但不一定要选尾部元素!对于情况三,取nums[1] 和 nums[3]就是最大的
而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int robRange(int *nums, int numsSize, int start, int end) { if (start == end) { return nums[start]; } int dp[numsSize]; dp[start] = nums[start]; dp[start + 1] = max(nums[start], nums[start + 1]); for (int i = start + 2; i <= end; i++) { dp[i] = max(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]; } int result1 = robRange(nums, numsSize, 0, numsSize - 2); // 情况二 int result2 = robRange(nums, numsSize, 1, numsSize - 1); // 情况三 return max(result1, result2); }
22. 😭打家劫舍 III
对于树的话,首先就要想到遍历方式,前中后序(深度优先搜索)还是层序遍历(广度优先搜索)
本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算
与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢
如果抢了当前节点,两个孩子就不是动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”)
递归:
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) { // 跳过root->left,相当于不考虑左孩子了 val1 += rob(root->left->left) + rob(root->left->right); } if (root->right) { // 跳过root->right,相当于不考虑右孩子了 val1 += rob(root->right->left) + rob(root->right->right); } // 不偷父节点 int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子 return max(val1, val2); }
23. 😎买卖股票的最佳时机
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][0]:表示第 i 天持有股票所得现金
一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数
dp[i][1] 表示第i天不持有股票所得现金
注意这里说的是“持有”和“不持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态
确定递推公式(容斥原理)
如果第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]);
dp数组如何初始化
dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能由前一天推出来,所以dp[0][0] = -prices[0];
dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;
dp[0][0] = -prices[0]; dp[0][1] = 0;
确定遍历顺序
从递推公式可以看出dp[i]都是有dp[i - 1]推导出来的,那么一定是从前向后遍历
举例推导dp数组(用于检验)
以示例1,输入:[7,1,5,3,6,4]为例,dp数组状态如下:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int *prices, int pricesSize) { int dp[pricesSize][2]; // 初始化 dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < pricesSize; i++) { dp[i][0] = max(dp[i - 1][0], -prices[i]); dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]); } return dp[pricesSize - 1][1]; }
二维滚动数组:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int *prices, int pricesSize) { int dp[2][2]; // 初始化 dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < pricesSize; i++) { int ind = i % 2, rind = (i - 1) % 2; dp[ind][0] = max(dp[rind][0], -prices[i]); dp[ind][1] = max(dp[rind][1], prices[i] + dp[rind][0]); } return dp[(pricesSize - 1) % 2][1]; }
暴力:超时 时间复杂度:O(n^2) 空间复杂度:O(1)
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int* prices, int pricesSize) { int result = 0; for (int i = 0; i < pricesSize; i++) { for (int j = i + 1; j < pricesSize; j++) { result = max(result, prices[j] - prices[i]); } } return result; }
贪心:因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润
#define max(a, b) ((a) > (b) ? (a) : (b)) #define min(a, b) ((a) > (b) ? (b) : (a)) int maxProfit(int *prices, int pricesSize) { int low = INT32_MAX; int result = 0; for (int i = 0; i < pricesSize; i++) { low = min(low, prices[i]); result = max(result, prices[i] - low); } return result; }
24. 😎买卖股票的最佳时机 II
因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润
那么第i天持有股票即dp[i][0],如果是第i天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 即:dp[i - 1][1] - prices[i]
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int *prices, int pricesSize) { int dp[2][2]; // 初始化 dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < pricesSize; i++) { int ind = i % 2, rind = (i - 1) % 2; dp[ind][0] = max(dp[rind][0], dp[rind][1] - prices[i]); dp[ind][1] = max(dp[rind][1], prices[i] + dp[rind][0]); } return dp[(pricesSize - 1) % 2][1]; }
25. 😭买卖股票的最佳时机 III
动规五步曲:
确定dp数组(dp table)以及下标的含义
一天一共就有五个状态
- 0. 没有操作(没有意义)
1. 第一次买入
2. 第一次卖出
3. 第二次买入
4. 第二次卖出
dp[i][j]中 i 表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金
确定递推公式(容斥原理)
需要注意:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票
达到dp[i][1]状态,有两个具体操作:
操作一:第i天买入股票了,那么dp[i][1] = -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(-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]);
dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;
第0天做第一次买入的操作,dp[0][1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0
从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了
所以dp[0][2] = 0;
第0天第二次买入操作,初始值应该是多少呢?
不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0][3] = -prices[0];
同理第二次卖出初始化dp[0][4] = 0;
dp[0][0] = 0; dp[0][1] = -prices[0]; dp[0][2] = 0; dp[0][3] = -prices[0]; dp[0][4] = 0;
确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值
- 举例推导dp数组(用于检验)
以输入[1,2,3,4,5]为例
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int* prices, int pricesSize) { int dp[pricesSize][5]; // 初始化 dp[0][0] = 0; dp[0][1] = -prices[0]; dp[0][2] = 0; dp[0][3] = -prices[0]; dp[0][4] = 0; for (int i = 1; i < pricesSize; i++) { dp[i][0] = dp[i - 1][0]; dp[i][1] = max(dp[i - 1][1], -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]); dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]); } return dp[pricesSize - 1][4]; }
26. 😭买卖股票的最佳时机IV
动规五步曲:
确定dp数组(dp table)以及下标的含义
使用二维数组 dp[i][j] :第i天的状态为j,所剩下的最大现金是dp[i][j]
j的状态表示为:
0 表示不操作(没有意义)
1 第一次买入
2 第一次卖出
3 第二次买入
4 第二次卖出
.....
规律:除了0以外,偶数就是卖出,奇数就是买入
题目要求是至多有K笔交易,那么 j 的范围就定义为 2 * k + 1
确定递推公式(容斥原理)
需要注意: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] = max(dp[i - 1][0] - prices[i], dp[i - 1][0]);
同理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][i] + prices[i], dp[i][2])
同理可以类比剩下的状态,代码如下:
// 除了0以外,偶数就是卖出,奇数就是买入 for (int j = 0; j < 2 * k - 1; j += 2) { dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]); dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]); }
dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;
第0天做第一次买入的操作,dp[0][1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0
从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了
所以dp[0][2] = 0;
第0天第二次买入操作,初始值应该是多少呢?
不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0][3] = -prices[0];
同理第二次卖出初始化dp[0][4] = 0;
可以推出dp[0][j]当j为奇数的时候都初始化为 -prices[0]
memset(dp, 0, sizeof(int) * pricesSize * (2 * k + 1)); for (int j = 1; j < 2 * k; j += 2) { dp[0][j] = -prices[0]; }
确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值
- 举例推导dp数组(用于检验)
以输入[1,2,3,4,5],k=2为例
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int k, int* prices, int pricesSize) { int dp[pricesSize][2 * k + 1]; // 初始化 memset(dp, 0, sizeof(int) * pricesSize * (2 * k + 1)); for (int j = 1; j < 2 * k; j += 2) { dp[0][j] = -prices[0]; } for (int i = 1;i < pricesSize; i++) { for (int j = 0; j < 2 * k - 1; j += 2) { dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]); dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]); } } return dp[pricesSize - 1][2 * k]; }
27. 😭最佳买卖股票时机含冷冻期
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j],第i天状态为j,所剩的最多现金为dp[i][j]
j的状态为:
0:持有股票后的最多现金
1:保持卖出股的最多现金
2:当天卖出股的最多现金
3:当天冷冻期的最多现金
确定递推公式(容斥原理)
达到持有股票dp[1][0]状态,有两个具体操作:
操作一:前一天就是持有股票状态,dp[i][0] = dp[i - 1][0]
操作二:今天买入了,1)前一天是冷冻期 dp[i][0] = dp[i - 1][3] - prices[i]
2)前一天是保持卖出股 dp[i][0] = dp[i - 1][1] - prices[i]
dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]));
达到保持卖出股的最多现金dp[i][1] 状态,有两个操作:
操作一:前一天就是保持卖出股状态,即:dp[i][1] = dp[i - 1][1]
操作二:前一天冷冻期状态,即:dp[i][1] = dp[i - 1][3]
dp[i][1] = max(dp[i - 1][1], dp[i - 1][2]);
达到当天卖出股的最多现金dp[i][2]状态,只有一个操作,即:前一天是持有股票状态
dp[i][2] = dp[i - 1][0] + prices[i];
达到当天冷冻期的最多现金dp[i][3] 状态,只有一个操作,即:前一天是当天卖出股状态
dp[i][3] = dp[i - 1][2];
dp数组如何初始化
dp[0][0] = -prices[0]; dp[0][1] = 0; dp[0][2] = 0; dp[0][3] = 0;
确定遍历顺序
从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历
举例推导dp数组(用于检验)
以 [1,2,3,0,2] 为例,dp数组如下:
代码实现:
28. 😎买卖股票的最佳时机含手续费
dp[i][0] 表示第i天持有股票所得最多现金。dp[i][1] 表示第i天不持有股票所得最多现金
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:dp[i - 1][1] - prices[i]
所以:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
再来看看如果第i天不持有股票即dp[i][1]的情况, 依然可以由两个状态推出来
第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金,注意这里需要有手续费了即:dp[i - 1][0] + prices[i] - fee
所以:dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee);
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxProfit(int *prices, int pricesSize, int fee) { int dp[pricesSize][2]; // 初始化 dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < pricesSize; i++) { dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]); dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0] - fee); } return dp[pricesSize - 1][1]; }
29. 最长递增子序列 🌹
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:以下标i为结尾的数组的最长上升子序列的长度
注意这里的定义,一定是以下标 i 为结尾,并不是一定以下标0为起始位置
确定递推公式(容斥原理)
位置 i 的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值
if (nums[i] > nums[j]) { dp[i] = max(dp[i], dp[j] + 1); }
dp数组如何初始化
每一个i,对应的dp[i](以下标i为结尾的数组的最长上升子序列的长度)起始大小至少都是1
确定遍历顺序
dp[i] 是由0到i-1各个位置的最长升序子序列 推导而来,那么遍历i一定是从前向后遍历。
j其实就是0到i-1,遍历i的循环里外层,遍历j则在内层,代码如下:
for (int i = 1; i < numsSize; i++) { for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { dp[i] = max(dp[i], dp[j] + 1); } } result = max(result, dp[i]); // 取最长的子序列 }
举例推导dp数组(用于检验)
输入:[0,1,0,3,2],dp数组的变化如下:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int lengthOfLIS(int *nums, int numsSize) { if (numsSize <= 1) { // 特殊情况 return numsSize; } int dp[numsSize]; for (int i = 0; i < numsSize; i++) { // 初始化 dp[i] = 1; } int result = 0; for (int i = 1; i < numsSize; i++) { for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) { dp[i] = max(dp[i], dp[j] + 1); } } result = max(result, dp[i]); // 取最长的子序列 } return result; }
30. 最长连续递增序列 🌹
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:以下标i为结尾的数组的连续递增的子序列长度为dp[i]
注意这里的定义,一定是以下标 i 为结尾,并不是一定以下标0为起始位置
确定递推公式(容斥原理)
如果 nums[i] > nums[i - 1],那么以 i 为结尾的数组的连续递增的子序列长度 一定等于 以i - 1为结尾的数组的连续递增的子序列长度 + 1
即:dp[i] = dp[i - 1] + 1;
因为本题要求连续递增子序列,所以就只要比较nums[i]与nums[i - 1],而不用去比较nums[j]与nums[i] (j是 [0,i - 1])
既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i] 和 nums[i - 1]
dp数组如何初始化
以下标i为结尾的数组的连续递增的子序列长度最少也应该是1,即就是nums[i]这一个元素。
所以dp[i]应该初始1
for (int i = 0; i < numsSize; i++) { // 初始化 dp[i] = 1; }
确定遍历顺序
从递推公式上可以看出, dp[i]依赖dp[i - 1],所以一定是从前向后遍历
for (int i = 1; i < numsSize; i++) { if (nums[i] > nums[i - 1]) { dp[i] = dp[i - 1] + 1; // 递推公式 } if (dp[i] > result) { result = dp[i]; } }
举例推导dp数组(用于检验)
已输入nums = [1,3,5,4,7]为例,dp数组状态如下:
代码实现:
int findLengthOfLCIS(int *nums, int numsSize) { if (numsSize == 0) { // 特殊处理 return 0; } int result = 1; int dp[numsSize]; for (int i = 0; i < numsSize; i++) { // 初始化 dp[i] = 1; } for (int i = 1; i < numsSize; i++) { if (nums[i] > nums[i - 1]) { dp[i] = dp[i - 1] + 1; // 递推公式 } if (dp[i] > result) { result = dp[i]; } } return result; }
31. 最长重复子数组
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j] :以下标i - 1为结尾的A 和 以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]
注意这里的定义,最长重复子数组一定是以下标i - 1为结尾的A 和 以下标j - 1为结尾的B,A、B并不一定以下标 0 为起始位置
那dp[0][0]是什么含义呢?总不能是以下标-1为结尾的A数组吧
其实dp[i][j]的定义也就决定着,我们在遍历dp[i][j]的时候 i 和 j 都要从1开始
确定递推公式(容斥原理)
根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来
即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1;
根据递推公式可以看出,遍历i 和 j 要从1开始!
dp数组如何初始化
根据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
举个例子A[0]如果和B[0]相同的话,dp[1][1] = dp[0][0] + 1,只有dp[0][0]初始为0,正好符合递推公式逐步累加起来
确定遍历顺序
外层for循环遍历A,内层for循环遍历B 或者 外层for循环遍历B,内层for循环遍历A
同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i][j]的最大值记录下来
举例推导dp数组(用于检验)
拿示例1中,A: [1,2,3,2,1],B: [3,2,1,4,7]为例,画一个dp数组的状态变化,如下:
代码实现:
int findLength(int *nums1, int nums1Size, int *nums2, int nums2Size) { int dp[nums1Size + 1][nums2Size + 1]; memset(dp, 0, sizeof(int) * (nums1Size + 1) * (nums2Size + 1)); int result = 0; for (int i = 1; i <= nums1Size; i++) { for (int j = 1; j <= nums2Size; j++) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } if (dp[i][j] > result) { result = dp[i][j]; } } } return result; }
32. 😎最长公共子序列 (dp[i][j]:长度为[0, i - 1]的字符串text1、与长度为[0, j - 1]的字符串text2的最长公共子序列)
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:长度为[0, i - 1]的字符串text1、与长度为[0, j - 1]的字符串text2的最长公共子序列
确定递推公式(容斥原理)
主要就是两大情况: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]); }
dp数组如何初始化
先看看dp[i][0]应该是多少呢?
test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i][0] = 0;
同理dp[0][j]也是0
其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0
确定遍历顺序
从递推公式,可以看出,有三个方向可以推出dp[i][j],如图:
那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵
举例推导dp数组(用于检验)
以输入:text1 = "abcde", text2 = "ace" 为例,dp状态如图:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int longestCommonSubsequence(char *text1, char *text2) { int len1 = strlen(text1), len2 = strlen(text2); int dp[len1 + 1][len2 + 1]; 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] = max(dp[i - 1][j], dp[i][j - 1]); } } } return dp[len1][len2]; }
33. 😎不相交的线
分析:最长公共子序列问题
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxUncrossedLines(int *nums1, int nums1Size, int *nums2, int nums2Size) { int dp[nums1Size + 1][nums2Size + 1]; memset(dp, 0, sizeof(dp)); for (int i = 1; i <= nums1Size; i++) { for (int j = 1; j <= nums2Size; j++) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); } } } return dp[nums1Size][nums2Size]; }
34. 😎最大子数组和 (dp[i]:以 i 结尾的最大连续子序列和为dp[i])
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i]:以 i 结尾的最大连续子序列和为dp[i]
确定递推公式(容斥原理)
dp[i]只有两个方向可以推出来:
dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
nums[i],即:从头开始计算当前连续子序列和
一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);
dp数组如何初始化
从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础
dp[0]应该是多少呢?
更具dp[i]的定义,很明显dp[0]为nums[0] 即dp[0] = nums[0]
确定遍历顺序
递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历
举例推导dp数组(用于检验)
以示例一为例,输入:nums = [-2,1,-3,4,-1,2,1,-5,4],对应的dp状态如下:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) int maxSubArray(int *nums, int numsSize) { if (numsSize == 0) { // 特殊情况 return 0; } int dp[numsSize]; dp[0] = nums[0]; int result = dp[0]; for (int i = 1; i < numsSize; i++) { dp[i] = max(dp[i - 1] + nums[i], nums[i]); // 状态转移方程 result = max(result, dp[i]); // result 保存dp[i]的最大值 } return result; }
35. 😎判断子序列
分析:编辑距离的入门题目,只需要计算删除的情况,不用考虑增加和替换的情况
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:下标为[0, i - 1]的字符串s 和 下标为[0, j - 1]的字符串t,相同子序列的长度
注意这里是判断s是否为t的子序列,即t的长度是大于等于s的
确定递推公式(容斥原理)
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:
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];
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][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右
如图所示:
举例推导dp数组(用于检验)
以示例一为例,输入:s = "abc", t = "ahbgdc",dp状态转移图如下:
代码实现:
bool isSubsequence(char *s, char *t) { int len_s = strlen(s), len_t = strlen(t); int dp[len_s + 1][len_t + 1]; memset(dp, 0, sizeof(dp)); for (int i = 1; i <= len_s; i++) { for (int j = 1; j <= len_t; 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[len_s][len_t] == len_s) { return true; } return false; }
转换成最长公共子序列问题:
#define max(a, b) ((a) > (b) ? (a) : (b)) int longestCommonSubsequence(char *text1, char *text2) { int len1 = strlen(text1), len2 = strlen(text2); int dp[len1 + 1][len2 + 1]; 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] = max(dp[i - 1][j], dp[i][j - 1]); } } } return dp[len1][len2]; } bool isSubsequence(char* s, char* t) { int len_s = strlen(s), len_t = strlen(t); if (longestCommonSubsequence(s, t) == len_s) { return true; } return false; }
36. 不同的子序列 (dp[i][j]:下标为[0, i - 1]的字符串s子序列中出现下标为[0, j - 1]的字符串t的个数)
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:下标为[0, i - 1]的字符串s子序列中出现下标为[0, j - 1]的字符串t的个数
确定递推公式(容斥原理)
这一类问题,基本是要分析两种情况
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: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];
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][0] 表示:[0, i - 1]的s可以随便删除元素,出现空字符串的个数。那么dp[i][0]一定都是1,因为也就是把[0, i - 1]的s,删除所有元素,出现空字符串的个数就是1
dp[0][j]:空字符串s可以随便删除元素,出现[0, j - 1]字符串t的个数,那么dp[0][j]一定都是0,s如论如何也变成不了 t
特殊位置dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t
确定遍历顺序
从递推公式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]可以根据之前计算出来的数值进行计算
举例推导dp数组(用于检验)
以s:"baegg",t:"bag"为例,推导dp数组状态如下:
代码实现:
int numDistinct(char *s, char *t) { int len_s = strlen(s), len_t = strlen(t); int dp[len_s + 1][len_t + 1]; // 初始化 for (int i = 0; i <= len_s; i++) { dp[i][0] = 1; } for (int j = 1; j <= len_t; j++) { dp[0][j] = 0; } for (int i = 1; i <= len_s; i++) { for (int j = 1; j <= len_t; 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[len_s][len_t]; }
37. 👌两个字符串的删除操作
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:下标为[0, i - 1]的字符串word1 和 下标为[0, j - 1]的字符串word2,想要达到相等,所需要删除元素的最少次数
确定递推公式(容斥原理)
- 当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, min(dp[i - 1][j], dp[i][j - 1]) + 1);
dp数组如何初始化
从递推公式中,可以看出来,dp[i][0] 和 dp[0][j]是一定要初始化的
dp[i][0]:word2为空字符串,以i-1为结尾的字符串word2要删除多少个元素,才能和word1相同呢,很明显dp[i][0] = i
dp[0][j]同理:dp[i][0] = j
确定遍历顺序
从递推公式 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]可以根据之前计算出来的数值进行计算
举例推导dp数组(用于检验)
以word1:"sea",word2:"eat"为例,推导dp数组状态图如下:
代码实现:
#define min(a, b) ((a) > (b) ? (b) : (a)) int minDistance(char *word1, char *word2) { int len1 = strlen(word1), len2 = strlen(word2); int dp[len1 + 1][len2 + 1]; // 初始化 for (int i = 0; i <= len1; i++) { dp[i][0] = i; } for (int j = 0; j <= len2; j++) { dp[0][j] = j; } for (int i = 1; i <= len1; i++) { for (int j = 1; j <= len2; 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], dp[i][j - 1]) + 1); } } } return dp[len1][len2]; }
38. 编辑距离
动规五步曲:
确定dp数组(dp table)以及下标的含义
dp[i][j]:下标为[0, i - 1]的字符串word1 和 下标为[0, j - 1]的字符串word2的最少编辑距离
确定递推公式(容斥原理)
在确定递推公式的时候,首先要考虑清楚编辑的几种操作,整理如下:
if (word1[i - 1] == word2[j - 1])
不操作
if (word1[i - 1] != word2[j - 1])
增
删
换
也就是如上四种情况
if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];
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",word2添加一个元素d,也就是相当于word1删除一个元素d,操作数是一样!
- 操作三:替换元素,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(min(dp[i - 1][j - 1], dp[i - 1][j]), dp[i][j - 1]) + 1;
dp数组如何初始化
for (int i = 0; i <= len1; i++) { dp[i][0] = i; } for (int j = 0; j <= len2; j++) { dp[0][j] = j; }
确定遍历顺序
遍历顺序一定是从左到右、从上到下,确保dp[i][j]可以根据之前计算出来的数值进行计算
举例推导dp数组(用于检验)
以示例1,输入:word1 = "horse", word2 = "ros"为例,dp矩阵状态图如下:
代码实现:
#define min(a, b) ((a) > (b) ? (b) : (a)) int minDistance(char* word1, char* word2) { int len1 = strlen(word1), len2 = strlen(word2); int dp[len1 + 1][len2 + 1]; // 初始化 for (int i = 0; i <= len1; i++) { dp[i][0] = i; } for (int j = 0; j <= len2; j++) { dp[0][j] = j; } for (int i = 1; i <= len1; i++) { for (int j = 1; j <= len2; j++) { if (word1[i - 1] == word2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = min(min(dp[i - 1][j - 1], dp[i - 1][j]), dp[i][j - 1]) + 1; } } } return dp[len1][len2]; }
39. 😎回文子串
动规五步曲:
确定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]初始化为false
确定遍历顺序
从递推公式中可以看出一定要从下到上,从左到右遍历
举例推导dp数组(用于检验)
举例,输入:"aaa",dp[i][j]状态如下:
注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分
代码实现:
int countSubstrings(char *s) { int len = strlen(s); int dp[len][len]; memset(dp, 0, sizeof(dp)); int result = 0; for (int i = len - 1; i >= 0; i--) { // 从下到上 for (int j = i; j < len; j++) { // 从左到右 if (s[i] == s[j]) { if (j - i <= 1) { // 情况一 和 情况二 result++; dp[i][j] = 1; } else if (dp[i + 1][j - 1]) { // 情况三 result++; dp[i][j] = 1; } } } } return result; }
40. 😎最长回文子序列
回文子串要求必须是连续的,回文子序列可不连续!
动规五步曲:
确定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]);
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]才不会被初始值覆盖
确定遍历顺序
从递推公式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 - 1]
也就是从矩阵的角度来说,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]) 分别对应着下图中的红色箭头方向,如图:
举例推导dp数组(用于检验)
输入s:"cbbd" 为例,dp数组状态如图:
代码实现:
#define max(a, b) ((a) > (b) ? (a) : (b)) 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 - 1; 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] = max(dp[i + 1][j], dp[i][j - 1]); } } } return dp[0][len - 1]; }
41. 戳气球
代码实现:
int maxCoins(int *nums, int numsSize) { /* 定义points数组用于存放扩展后的nums数组, * 其中points头尾为1,表示边界 */ int points[numsSize + 2]; points[0] = points[numsSize + 1] = 1; for (int i = 1; i <= numsSize; i++) { points[i] = nums[i - 1]; } /* dp数组含义:dp[i][j]表示在开区间(i, j)之间, * 戳气球获得的最高分数,初始化为0。 */ int dp[numsSize + 2][numsSize + 2]; memset(dp, 0, sizeof(dp)); /* 方向:从下往上、从左往右 */ for (int i = numsSize; i >= 0; i--) { for (int j = i + 1; j < numsSize + 2; j++) { /* 在开区间(i, j)之间依次便利选取最大的分数 */ for (int k = i + 1; k < j; k++) { /* 状态:即区间边界i,j的值 * 状态转移方程:当前区间(i,j)的dp值为区间(i,k) + (k,j) + p[i]*[k]*p[j]的值 */ int res = dp[i][k] + dp[k][j] + points[i] * points[k] * points[j]; if (dp[i][j] < res) { dp[i][j] = res; } } } } /* 结果就为开区间(0, numsSiae + 1)之间的dp值 */ return dp[0][numsSize + 1]; }