本章面向经典问题,给出记忆化写法和循环迭代写法,对比转换
介绍DP的相关专业术语
本章的内容,相对比较简单

动态规划相关术语《进阶指南》

学习动态规划的阶段,容易产生这样的现象: 看题没思路,看题解这么简单,抄TMD,下一题,怎么还是不会。循环往复。

所以在学习的时候,切莫呆板的去背模板,抄题解。

正所谓,模板不是用来背的,题解也不是用来Ctrl-c Ctrl-v的。

  1. // 解决dp问题的基本路径
  2. 问题(形式化的表述)
  3. |
  4. v
  5. 方案的初始状态
  6. |
  7. v
  8. 中间状态
  9. |
  10. v
  11. 中间状态
  12. |
  13. v
  14. 目标(可行方案 / 最优解)
  1. // 从搜索出发,看搜索推导的时候,有哪些状态未涉及到,哪些可以成为代表信息作为新的状态
  2. // 这是最核心的思路来源
  3. 1.定义状态(存什么,哪些是全局变量,哪些是参数,哪些是局部变量)
  4. 2.简化状态(用最少的值去代表一个状态)
  5. 3.推导(怎么从一个状态搜到下一个状态)

我们之前探讨过的很多算法,都是利用问题的可划分性以及子问题之间的相似性来进行归纳,降低求解的复杂度,动态规划也不例外。动态规划把原问题视作若干个重叠子问题的逐层递进,每个子问题的求解过程都构成一个“阶段”。在完成前一个阶段的计算后,动态规划才会执行下一阶段的计算。

从“状态空间”入手对“问题与状态空间”之间的类比,有了更深入的认识。

  1. 递推和递归是两种遍历状态空间的基本方法
  2. 搜索算法,处理指数级别等非多项式复杂度的问题
  3. 动态规划算法,针对满足特定条件的一类问题,对各状态维度进行分阶段、有顺序、无重复、决策性的遍历求解

动态规划,把原问题视作若干个重叠子问题的逐层递进,每个子问题的求解过程都构成一个“阶段”。

在完成前一个阶段的计算后,动态规划才会执行下一阶段的计算。

为了保证这些计算能够按顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响(如果没有先后,谁推导谁就会混乱。这个名词叫做“无后效性”)。

本质上,动态规划对状态空间的遍历,构成了一张有向无环图,遍历顺序就是该有向无环图的一个拓扑序(有向无环图中的node,对应问题中的“状态”。图中的edge对应状态之间的“转移”,转移的选取就是动态规划中的“决策”)。

动态规划用于求最优解的问题时,下一阶段的最优解应该能够由前面各阶段子问题的最优解导出。这个条件被称为”最优子结构性质”(当子结构之间都可以用代表元素和最优解来推导时,称问题满足最优子结构性质)。

在阶段计算完成时,动态规划只会在每个状态上保留与最终解集相关的部分代表信息,这些代表信息应该具有可重复的求解过程,并能够导出后续阶段的代表信息。这样一来,动态规划对状态的抽象和子问题的重叠递进才能够起到优化作用。

1、状态、阶段、决策是构成动态规划算法的三要素
2、子问题重叠性、无后效性、最优子结构是问题能用动态规划求解的三个基本条件

动态规划算法把相同的计算过程作用于各阶段的同类子问题,就好像把一个固定的公式的格式相同的若干输入数据上运行。定义出了动态规划的计算过程,就可以编程实现了,这个计算过程被称为“状态转移方程”。

如何把问题形式化为状态空间,进一步抽象出动态规划的“状态表示”和“阶段划分”,是一件考查智力而非套路的事情。对状态的设计、子结构的挖掘,是核心要点。

Understanding dynamic programming is a milestone in every competitive programmer`s career. While the basic idea is simple, the challenge is how to apply dynamic programming to different problems.

The dynamic programming algorithm is based on a recursive function that goes through all possibilities how to form the sum, like a brute force algorithm.

However, the dynamic programming algorithm is efficient because it uses memoization and calculates the answer to each subproblem only once.

Dynamic Programming is a technique that combines the correctness of complete search and the efficiency of greedy algorithms. Dynamic Programming can be applied if the problem can be divided into overlapping subproblems that can be solved independently. There are two uses for Dynamic Programming:

  1. Finding an optimal solution: We want to find a solution that is as large as possible or as small as possible.
  2. Counting the number of solutions: We want to calculate the total number of possible solutions.

DP问题的描述框架

  1. 问题描述
  2. 状态表示
  3. 阶段划分
  4. 转移方程
  5. 边界(初始条件)
  6. 目标(答案)

image.png

具有线性 “阶段” 划分的动态规划算法被称为线性DP
经典例题:最长上升子序列(LIS)、最长公共子序列(LCS)、数字三角形(IOI1994)

容易发现,无论状态表示是一维还是多维,DP算法在这些问题上都体现为“作用在线性空间上的递推”——DP的阶段沿着各个维度线性增长,从一个或多个“边界点”开始有方向地向整个状态空间转移、扩展。最后,每个状态上都保留了以自身为“目标”的子问题的最优解。

这几个问题也是线性DP中最简单的一类,在这类问题中,需要计算的对象表现出明显的维度以及有序性,每个状态的求解直接构成一个阶段,这使得DP的状态表示就是阶段的表示。

因此,我们只需要在每个维度上各取一个坐标值作为DP的状态,自然就可以描绘出“已求解部分”在状态空间中的轮廓特征,该轮廓的进展就是阶段的推移。

另外,每个状态的求解,显然只与之前阶段的最有解有关,“最优子结构”性质也得以验证。接下来,我们按顺序依次循环每个维度,根据问题要求,递推求解的具体实现过程也就顺理成章了。

下图,形象的描述了线性DP的模样。
image.png

LIS问题

递归实现, 以子序列长度作为参数设计函数

  1. // 序列{3, 1, 2, 8, 2, 5, 9}
  2. 8 为结尾的上升子序列,有
  3. {1, 2, 8}
  4. {3, 8}
  5. {2, 8}
  6. {1, 8}
  7. {8}
  8. 在这些子序列中,共同的特点是,结尾都是8
  9. 代表元素是 8(注意理解这句话,代表元素是8
  10. 最优解是{1, 2, 8},长度len = 3

image.png

  1. // TLE
  2. #include <bits/stdc++.h>
  3. using namespace std;
  4. const int N = 1e3 + 10;
  5. int a[N], n;
  6. int k[N], ret; // 用一个k[N]来存下标,不是直接存具体数值 B[k[N]]
  7. // 而是存有关联的信息,下标
  8. // 这是常见的“简化”
  9. void dfs(int len){ // 传递的参数len,是最长子序列的长度
  10. if (len > ret) ret = len;
  11. for (int i = k[len] + 1; i <= n; i++) // 枚举下一个位置是谁
  12. if (a[i] > a[k[len]]){ // 满足更长一点的条件,就加上
  13. k[len + 1] = i;
  14. dfs(len + 1);
  15. }
  16. }
  17. int main(){
  18. cin >> n;
  19. for (int i = 1; i <= n; i++) cin >> a[i];
  20. k[0] = 0, a[0] = -2e9;
  21. dfs(0);
  22. cout << ret << '\n';
  23. return 0;
  24. }
  25. // 这个搜索,和我们平时的dfs的编写,有些不同
  26. // 平时的dfs,框架是问题的边界,求解子问题
  27. // 这个函数,并没有显性的写出来边界,但是在for循环的时候,如果到了最后一个数,就不会进入for循环
  28. // 也就实现了终止钻下去的过程
  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1e3 + 10;
  4. int a[N], n;
  5. int ret; // 我们发现这个K[]数组是多余,因为每次只用最后一个数
  6. // 那么,这最后一个数,就作为函数参数吧
  7. void dfs(int len, int tail){
  8. if (len > ret) ret = len;
  9. for (int i = tail + 1; i <= n; i++)
  10. if (a[i] > a[tail]){
  11. dfs(len + 1, i);
  12. }
  13. }
  14. int main(){
  15. cin >> n;
  16. for (int i = 1; i <= n; i++) cin >> a[i];
  17. a[0] = -2e9;
  18. dfs(0, 0);
  19. cout << ret << '\n';
  20. return 0;
  21. }

递归实现, 子集枚举, 选或者不选

  1. int numList[] = {5, 2, 7, 3, 4, 6};
  2. // solution is finally set of 0s and 1s..pick or leave.
  3. // 0 1 0 1 1 1
  4. int m = 6;
  5. // called with LIS(0, m)
  6. int LIS(int i, int prev)
  7. {
  8. if(i == m)
  9. return 0;
  10. int choice1 = LIS(i+1, prev); // LEAVE
  11. int choice2 = 0;
  12. if(prev == m || numList[prev] <= numList[i])
  13. choice2 = LIS(i+1, i) + 1;
  14. return max(choice1, choice2);
  15. }
  16. LIS(0, m); // 需要分析一下,为甚调用的时候prev,传的是m
  17. // 传-1行不行

循环实现, 刷表法, 填表法

image.png
image.png

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1e3 + 10;
  4. int a[N], n;
  5. int dp[N], ret;
  6. int main(){
  7. cin >> n;
  8. for (int i = 1; i <= n; i++) cin >> a[i];
  9. // 刷表法
  10. a[0] = -2e9, dp[0] = 0;
  11. for (int i = 0; i < n; i++) // 枚举以i为结尾
  12. for (int j = i + 1; j <= n; j++) // 枚举下一个数
  13. if (a[j] > a[i]) dp[j] = max(dp[j], dp[i] + 1);
  14. for (int i = 1; i <= n; i++) ret = max(ret, dp[i]);
  15. cout << ret << '\n';
  16. return 0;
  17. }
  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1e3 + 10;
  4. int a[N], n;
  5. int dp[N], ret;
  6. int main(){
  7. cin >> n;
  8. for (int i = 1; i <= n; i++) cin >> a[i];
  9. // 填表法
  10. a[0] = -2e9, dp[0] = 0;
  11. for (int i = 1; i <= n; i++) // 枚举以i为结尾
  12. for (int j = 0; j < i; j++) // 枚举从哪个数来
  13. if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1);
  14. for (int i = 1; i <= n; i++) ret = max(ret, dp[i]);
  15. cout << ret << '\n';
  16. return 0;
  17. }
  18. // 刷表法,更符合人的正常思考
  19. // 常用的填表法,当前状态,从哪些状态转移过来
  20. // 逆向思维,而已

打印方案

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1e3 + 10;
  4. int a[N], n;
  5. int dp[N], ret, tail;
  6. int pre[N]; // 记录方案的核心要点是:记录决策
  7. void print(int u){
  8. if (u == 0) return ;
  9. print(pre[u]);
  10. printf("%d ", a[u]);
  11. }
  12. int main(){
  13. cin >> n;
  14. for (int i = 1; i <= n; i++) cin >> a[i];
  15. // 填表法
  16. a[0] = -2e9, dp[0] = 0;
  17. for (int i = 1; i <= n; i++) // 枚举以i为结尾
  18. for (int j = 0; j < i; j++) // 枚举从哪个数来
  19. if (a[j] < a[i]){
  20. if (dp[i] < dp[j] + 1){
  21. dp[i] = dp[j] + 1;
  22. pre[i] = j;
  23. }
  24. }
  25. for (int i = 1; i <= n; i++){
  26. if (ret < dp[i]) ret = dp[i], tail = i;
  27. }
  28. cout << ret << '\n';
  29. print(tail); // 找到LIS的最后一个字符,然后开始递归打印方案
  30. puts("");
  31. return 0;
  32. }

LCS问题

递归实现

  1. string str1 = "abcdefgzh";
  2. string str2 = "ackghefhlmn";
  3. // abcdefgzh
  4. // 101011001
  5. // ackghefhlmn
  6. // 11000111000
  7. // acefh
  8. // called with LCS(0, 0)
  9. int LCS(int i, int j)
  10. {
  11. if(i == sz(str1) || j == sz(str2))
  12. return 0;
  13. if(str1[i] == str2[j])
  14. return 1 + LCS(i+1, j+1);
  15. int choice1 = LCS(i+1, j);
  16. int choice2 = LCS(i, j+1);
  17. return max(choice1, choice2);
  18. }

循环实现, O(n^4)

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. char A[N], B[N];
  5. int n, m, dp[N][N]; // dp[i][j]表示以A[i] B[j]作为结尾的两个子串,最长公共子序列的长度
  6. int main(){
  7. cin >> n >> m;
  8. scanf("%s%s", A + 1, B + 1);
  9. // 这种枚举方式,是知道具体公共的位置,O(n^4) ,超时
  10. // 同时,我们也并不需要知道具体是哪个位置公共的
  11. for (int i = 0; i < n; i++) // 枚举A取到了i位置, B取到了j位置
  12. for (int j = 0; j < m; j++){
  13. if (A[i] != B[j]) continue; // 当A[i] == B[j],就符合公共条件,可以看下一个位置
  14. for (int ni = i + 1; ni <= n; ni++) // 两重循环枚举下一个可能位置是什么
  15. for (int nj = j + 1; nj <= m; nj++)
  16. if (A[ni] == B[nj]) dp[ni][nj] = max(dp[ni][nj], dp[i][j] + 1);
  17. }
  18. int ret = 0;
  19. for (int i = 1; i <= n; i++)
  20. for (int j = 1; j <= m; j++)
  21. ret = max(ret, dp[i][j]);
  22. cout << ret << '\n';
  23. return 0;
  24. }

循环实现, O(n^2)

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. char A[N], B[N];
  5. int n, m, dp[N][N]; // dp[i][j]表示以A[i] B[j]作为结尾的两个子串,最长公共子序列的长度
  6. int main(){
  7. cin >> n >> m;
  8. scanf("%s%s", A + 1, B + 1);
  9. for (int i = 1; i <= n; i++) dp[i][0] = 0; // B串0个字符,公共长度就是0
  10. for (int j = 1; j <= m; j++) dp[0][j] = 0;
  11. for (int i = 1; i <= n; i++)
  12. for (int j = 1; j <= m; j++){
  13. // dp[i-1][j] 以A[i-1] B[j]为结尾的两个子串,最长公共子序列长度
  14. // dp[i][j-1] 以A[i] B[j-1]为结尾的两个子串,最长公共子序列长度
  15. // 并不需要子序列的结尾就是i,或者就是j
  16. dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
  17. if (A[i] == B[j]) dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1);
  18. }
  19. cout << dp[n][m] << '\n';
  20. return 0;
  21. }

打印方案

  1. // 打印方案
  2. #include <bits/stdc++.h>
  3. using namespace std;
  4. const int N = 1010;
  5. char A[N], B[N];
  6. int n, m, dp[N][N]; // dp[i][j]表示以A[i] B[j]作为结尾的两个子串,最长公共子序列的长度
  7. int pre[N][N]; // 核心:记录如何转移的, 记决策
  8. void print(int i, int j){
  9. if (i == 0 || j == 0) return ;
  10. if (pre[i][j] == 3){
  11. print(i - 1, j - 1);
  12. printf("%c", A[i]);
  13. }
  14. else if (pre[i][j] == 1) print(i - 1, j);
  15. else if (pre[i][j] == 2) print(i, j - 1);
  16. }
  17. int main(){
  18. cin >> n >> m;
  19. scanf("%s%s", A + 1, B + 1);
  20. for (int i = 1; i <= n; i++) dp[i][0] = 0; // B串0个字符,公共长度就是0
  21. for (int j = 1; j <= m; j++) dp[0][j] = 0;
  22. for (int i = 1; i <= n; i++)
  23. for (int j = 1; j <= m; j++){
  24. if (dp[i - 1][j] > dp[i][j - 1]){
  25. dp[i][j] = dp[i - 1][j];
  26. pre[i][j] = 1;
  27. }
  28. else{
  29. dp[i][j] = dp[i][j - 1];
  30. pre[i][j] = 2;
  31. }
  32. if (A[i] == B[j]){
  33. if (dp[i - 1][j - 1] + 1 > dp[i][j]){
  34. dp[i][j] = dp[i - 1][j - 1] + 1;
  35. pre[i][j] = 3;
  36. }
  37. }
  38. }
  39. cout << dp[n][m] << '\n';
  40. print(n, m);
  41. puts("");
  42. return 0;
  43. }

数字三角形问题

递归实现, TLE

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. int a[N][N], dp[N][N], f[N][N];
  5. int n;
  6. int solve(int x, int y)
  7. {
  8. // 问题的边界
  9. if (x == n) return a[x][y];
  10. // 求解子问题
  11. int u = solve(x + 1, y);
  12. int v = solve(x + 1, y + 1);
  13. if (u > v) return a[x][y] + u;
  14. else return a[x][y] + v;
  15. }
  16. int main()
  17. {
  18. cin >> n;
  19. for (int i = 1; i <= n; i++){
  20. for (int j = 1; j <= i; j++)
  21. cin >> a[i][j];
  22. }
  23. cout << solve(1, 1) << '\n';
  24. return 0;
  25. }

递归实现, 记忆化

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. int mem[N][N];
  5. int a[N][N], dp[N][N], f[N][N];
  6. int n;
  7. int solve(int x, int y)
  8. {
  9. //问题的边界
  10. if (x == n) return a[x][y];
  11. int &ret = mem[x][y];
  12. if (ret != -1) return ret;
  13. //求解子问题
  14. int u = solve(x + 1, y);
  15. int v = solve(x + 1, y + 1);
  16. if (u > v) return ret = a[x][y] + u;
  17. else return ret = a[x][y] + v;
  18. }
  19. int main()
  20. {
  21. cin >> n;
  22. for (int i = 1; i <= n; i++){
  23. for (int j = 1; j <= i; j++)
  24. cin >> a[i][j];
  25. }
  26. //递归+记忆化
  27. memset(mem, -1, sizeof mem);
  28. cout << solve(1, 1) << '\n';
  29. return 0;
  30. }

循环实现, 从上往下

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. int mem[N][N];
  5. int a[N][N], dp[N][N], f[N][N];
  6. int n;
  7. void recur01()
  8. {
  9. dp[1][1] = a[1][1];
  10. for (int i = 2; i <= n; i++)
  11. for (int j = 1; j <= i; j++)
  12. dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + a[i][j];
  13. int maxn = -2e9;
  14. for (int j = 1; j <= n; j++)
  15. maxn = max(maxn, dp[n][j]);
  16. cout << maxn << '\n';
  17. }
  18. int main()
  19. {
  20. cin >> n;
  21. for (int i = 1; i <= n; i++){
  22. for (int j = 1; j <= i; j++)
  23. cin >> a[i][j];
  24. }
  25. recur01();
  26. return 0;
  27. }

循环实现, 从下往上

  1. #include <bits/stdc++.h>
  2. using namespace std;
  3. const int N = 1010;
  4. int mem[N][N];
  5. int a[N][N], dp[N][N], f[N][N];
  6. int n;
  7. void recur02()
  8. {
  9. for (int j = 1; j <= n; j++) f[n][j] = a[n][j];
  10. for (int i = n - 1; i >= 1; i--)
  11. for (int j = i; j >= 1; j--){
  12. f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1]);
  13. }
  14. cout << f[1][1] << '\n';
  15. }
  16. int main()
  17. {
  18. cin >> n;
  19. for (int i = 1; i <= n; i++){
  20. for (int j = 1; j <= i; j++)
  21. cin >> a[i][j];
  22. }
  23. recur02();
  24. return 0;
  25. }

例题:898. 数字三角形

  1. acwing上的数据更丰富一些,需要对dp进行初始化成负无穷,每一行0 ~ i+1都要初始化

01背包问题(Subset Sum)

递归实现

  1. // 题面:W代表重量,P代表价值
  2. // W: 10, 4 , 20, 5, 7
  3. // P: 10, 15, 3 , 1, 4
  4. // knapsack size = 12
  5. // 0 1 0 0 1
  6. const int MAX = 5;
  7. int n = 5;
  8. int weights[MAX] = {10, 4, 20, 5, 7};
  9. int benfit[MAX] = {10, 15, 3, 1, 4};
  10. int knapsack(int i, int reminder) // aka 0/1 knapsack
  11. {
  12. if(i == n)
  13. return 0;
  14. int choice1 = knapsack(i+1, reminder);
  15. int choice2 = 0;
  16. if(reminder >= weights[i])
  17. choice2 = knapsack(i+1, reminder - weights[i]) + benfit[i];
  18. return max(choice1, choice2);
  19. }
  20. // called with knapsack(0, intialWeight)

循环实现, 求解最大值/最小值

  1. #include <iostream>
  2. #include <algorithm>
  3. using namespace std;
  4. const int N = 1010;
  5. int n, m;
  6. int v[N], w[N];
  7. int f[N][N];
  8. int main()
  9. {
  10. cin >> n >> m;
  11. for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
  12. for (int i = 1; i <= n; i++)
  13. for (int j = 0; j <= m; j++)
  14. {
  15. f[i][j] = f[i - 1][j]; // 右边集合为空,不包含i
  16. if (j >= v[i]) f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
  17. }
  18. cout << f[n][m] << endl;
  19. return 0;
  20. }

循环实现, 剩余最小空间, 求解最大值最小值(提供了多种代码)

例题,1295:装箱问题

  1. 【描述】
  2. 有一个箱子容量为 V(正整数,0 v 20000),同时有n个物品(0< n 30),
  3. 每个物品有一个体积(正整数)。
  4. n个物品中,任取若干个装入箱内,使箱子的剩余空间为最小。
  5. 【输入】
  6. 第一行是一个整数V,表示箱子容量。
  7. 第二行是一个整数n,表示物品数。
  8. 接下来n行,每行一个正整数(不超过10000),分别表示这n个物品的各自体积。
  9. 【输出】
  10. 一个整数,表示箱子剩余空间。
  11. 【输入样例】
  12. 24
  13. 6
  14. 8
  15. 3
  16. 12
  17. 7
  18. 9
  19. 7
  20. 【输出样例】
  21. 0
  1. // 递归实现,时间复杂度O(2^n)
  2. // dfs(u, m)表示当前第u个物品,总共占用m体积,返回的是实际使用的体积
  3. // called with dfs(1, 0)
  4. #include <bits/stdc++.h>
  5. using namespace std;
  6. const int N = 20010, M = 35;
  7. int w[M];
  8. int n, V;
  9. int dfs(int u, int m){
  10. if (u > n) return 0;
  11. int c1 = 0, c2 = 0;
  12. c1 = dfs(u + 1, m);
  13. if (m + w[u] <= V) c2 = dfs(u + 1, m + w[u]) + w[u];
  14. return max(c1, c2);
  15. }
  16. int main()
  17. {
  18. cin >> V >> n;
  19. for (int i = 1; i <= n; i++) cin >> w[i];
  20. cout << V - dfs(1, 0) << '\n';
  21. return 0;
  22. }
  23. // 这个方法应该是不能过的,但是题目的数据比较小,还是过了
  1. // 还是递归实现,遍历的顺序发生调整,从大到小
  2. // 主函数调用dfs(n, v)
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. const int N = 20010, M = 35;
  6. int w[M];
  7. int n, V;
  8. int dfs(int u, int m){
  9. if (u == 0) return 0;
  10. int c1 = 0, c2 = 0;
  11. c1 = dfs(u - 1, m);
  12. if (m >= w[u]) c2 = dfs(u - 1, m - w[u]) + w[u];
  13. return max(c1, c2);
  14. }
  15. int main()
  16. {
  17. cin >> V >> n;
  18. for (int i = 1; i <= n; i++) cin >> w[i];
  19. cout << V - dfs(n, V) << '\n';
  20. return 0;
  21. }
  22. // 上面两个程序的本质是一样的,为了熟练使用搜索,这些是很好的练习角度
  23. // 更好的是,在这种方法上,加上记忆化,基本是万能流
  1. // 加上记忆化,执行时间在ybt平台上,最后一个测试点是33ms,降到了3ms(似乎比循环还要快一点)
  2. #include <bits/stdc++.h>
  3. using namespace std;
  4. const int N = 20010, M = 35;
  5. int w[M], mem[M][N];
  6. int n, V;
  7. int dfs(int u, int m){
  8. if (u == 0) return 0;
  9. int &ret = mem[u][m];
  10. if (ret != -1) return ret;
  11. int c1 = -1, c2 = -1;
  12. c1 = dfs(u - 1, m);
  13. if (m >= w[u]) c2 = dfs(u - 1, m - w[u]) + w[u];
  14. return ret = max(c1, c2);
  15. }
  16. int main()
  17. {
  18. cin >> V >> n;
  19. for (int i = 1; i <= n; i++) cin >> w[i];
  20. memset(mem, -1, sizeof mem);
  21. cout << V - dfs(n, V) << '\n';
  22. return 0;
  23. }
  1. // 循环实现
  2. // 时间复杂度O(n^2),空间复杂度O(n^2)
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. const int N = 20010, M = 35;
  6. int dp[M][N], w[M]; // dp[i][j]表示前i个物品使用空间j的情况下,实际放了多少体积的东西
  7. int n, V;
  8. int main()
  9. {
  10. cin >> V >> n;
  11. for (int i = 1; i <= n; i++) cin >> w[i];
  12. for (int i = 1; i <= n; i++)
  13. for (int j = 0; j <= V; j++){
  14. dp[i][j] = max(dp[i][j], dp[i - 1][j]);
  15. if (j >= w[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + w[i]);
  16. }
  17. cout << V - dp[n][V] << '\n';
  18. return 0;
  19. }
  1. // 循环时间,优化空间复杂度
  2. // 时间复杂度O(n^2),空间复杂度O(n)
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. const int N = 20010, M = 35;
  6. int dp[N], w[M];
  7. int n, V;
  8. int main()
  9. {
  10. cin >> V >> n;
  11. for (int i = 1; i <= n; i++) cin >> w[i];
  12. for (int i = 1; i <= n; i++)
  13. for (int j = V; j >= w[i]; j--)
  14. dp[j] = max(dp[j], dp[j - w[i]] + w[i]);
  15. cout << V - dp[V] << '\n';
  16. return 0;
  17. }
  1. // 总结
  2. // 这道题目,我们用了多种方法,把一个简单问题复杂化
  3. // 通过简单问题的简单背景,弱化非主要目标,集中注意力到问题的本质
  4. // 复杂化,多种角度的解决本质问题,从而提高对本质的理解
  5. // 日后实际工作中,遇到的问题,基本都是复杂的问题,但拨开非主要目标
  6. // 暴露出来的核心问题,就是本质,就是我们很熟悉的本质
  7. // 然后,就可以多角度,随心所欲的搞
  8. // 其中,拨开非主要目标的这个过程,往往需要较好的阅读理解能力

循环实现, 求解可行性

image.png
image.png
image.png

  1. // 时间复杂度O(n^2),空间复杂度O(n^2)
  2. possible[0][0] = true;
  3. for (int k = 1; k <= n; k++) {
  4. for (int x = 0; x <= W; x++) {
  5. if (x-w[k] >= 0) possible[x][k] |= possible[x-w[k]][k-1];
  6. possible[x][k] |= possible[x][k-1];
  7. }
  8. }
  9. // 时间复杂度O(n^2),空间复杂度O(n)
  10. // trick是枚举x的时候,从大往小枚举
  11. possible[0] = true;
  12. for (int k = 1; k <= n; k++) {
  13. for (int x = W; x >= 0; x--) {
  14. if (possible[x]) possible[x+w[k]] = true;
  15. }
  16. }

image.png

edit distance编辑距离

aka, Levenshtein distance莱文斯坦距离(aka, also known as)

image.png
image.png
image.png
image.png

  1. 字符串A("xyzab")和字符串B("axyzc"),问至少经过多少步操作可以把A变成B
  2. 我们还是从两个字符串的最后一个字符来考察即'b''c'。显然二者不相同,那么我们有以下三种处理办法:
  3. (1)增加:在A末尾增加一个'c',那么A变成了"xyzabc"B仍然是"axyzc",由于此时末尾字符相同了,那么就变成了比较"xyzab""axyz"的距离,即d(xyzab,axyzc) = d(xyzab,axyz) + 1
  4. 可以写成d(i,j) = d(i,j-1) + 1。表示下次比较的字符串B的长度减少了1,而加1表示当前进行了一次字符的操作
  5. (2)删除:删除A末尾的字符'b',考察A剩下的部分与B的距离。即d(xyzab,axyzc) = d(xyza,axyzc) + 1
  6. 可以写成d(i,j) = d(i-1,j) + 1。表示下次比较的字符串A的长度减少了1
  7. (3)替换:把A末尾的字符替换成'c',这样就与B的末尾字符一样了,那么接下来就要考察出了末尾'c'部分的字符,即d(xyzab,axyzc) = d(xyza,axyz) + 1
  8. 写成d(i,j) = d(i-1,j-1) + 1表示字符串AB的长度均减少了1
  9. 参考:https://www.jianshu.com/p/12e9b9a9a350

例题,【例9.20】编辑距离

  1. // 示例代码
  2. // 编辑距离的问题,和LCS很像很像
  3. // 放在一块进行对比学习,更有帮助
  4. #include <bits/stdc++.h>
  5. using namespace std;
  6. const int N = 2010;
  7. char a[N], b[N];
  8. int n, m;
  9. int dp[N][N];
  10. int main()
  11. {
  12. scanf("%s%s", a + 1, b + 1);
  13. n = strlen(a + 1), m = strlen(b + 1);
  14. dp[0][0] = 0;
  15. for (int i = 1; i <= n; i++) dp[i][0] = i;
  16. for (int j = 1; j <= m; j++) dp[0][j] = j;
  17. for (int i = 1; i <= n; i++)
  18. for (int j = 1; j <= m; j++){
  19. dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + 1;
  20. if (a[i] == b[j]) dp[i][j] = min(dp[i][j], dp[i-1][j-1]);
  21. else dp[i][j] = min(dp[i][j], dp[i-1][j-1] + 1);
  22. }
  23. cout << dp[n][m] << '\n';
  24. return 0;
  25. }