上一篇我们聊了分治法,核心是"拆、治、合"。今天聊动态规划(DP),它的核心就一句话:**"记住过往,避免重复"**。如果说分治法是"重新做一遍",那动态规划就是"抄上一次的作业"。
一、动态规划到底在干嘛?
在考场上,第四题经常问你:"该算法采用了什么策略?"如果你看到代码里有二维数组填表 、从子问题推导父问题 、递归+记忆化这类特征,基本可以确定是动态规划。
动态规划和分治法最大的区别:
| 分治法 | 动态规划 |
|---|---|
| 子问题相互独立 | 子问题重叠(大量重复计算) |
| 递归到底 | 自底向上填表 |
| 没有记忆 | 用数组记住已经算过的结果 |
软考里考 DP,最经典的就是两个案例:0-1 背包 和最长公共子序列(LCS)。这两个题在历年真题里反复出现,而且代码结构高度相似,都是"二维数组 + 双重循环 + 状态转移方程"。
下面直接上代码,把套路焊死。
二、案例一:0-1 背包问题
这是软考下午题里出现频率最高的 DP 题,没有之一。题目描述你肯定见过:
有 n 个物品,第 i 个物品重量为 wi,价值为 vi。背包容量为 C。每个物品要么选要么不选(0-1),求能装下的最大价值。
思路拆解
1. 定义状态 dp[i][j] 表示:前 i 个物品,在背包容量为 j 时,能获得的最大价值。
2. 状态转移方程 对于第 i 个物品,只有两种选择:
-
不选 :
dp[i][j] = dp[i-1][j](容量不变,价值不变) -
选 :
dp[i][j] = dp[i-1][j-w[i]] + v[i](前提是j >= w[i])
取两者最大值:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
3. 初始化 dp[0][j] = 0(没有物品,价值为 0);dp[i][0] = 0(容量为 0,价值为 0)。
完整代码(二维数组版)
cpp
#include <stdio.h>
#define N 100
#define C 1000
int max(int a, int b) {
return a > b ? a : b;
}
/* 0-1 背包:二维 DP */
int knapsack(int w[], int v[], int n, int cap) {
int dp[N][C] = {0}; // 初始化全为 0
int i, j;
for (i = 1; i <= n; i++) {
for (j = 1; j <= cap; j++) {
if (j < w[i]) {
/* 容量不够,只能不选 */
dp[i][j] = dp[i-1][j];
} else {
/* 选或不选,取最大值 */
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
}
}
}
return dp[n][cap];
}
int main() {
int w[] = {0, 2, 3, 4, 5}; // 下标从 1 开始,w[0] 占位
int v[] = {0, 3, 4, 5, 6};
int n = 4;
int cap = 8;
printf("最大价值: %d\n", knapsack(w, v, n, cap));
return 0;
}
空间优化:一维滚动数组
二维数组好理解,但软考真题里有时会给出一维数组的版本,要求你填空。核心思想:每一行只依赖上一行,而且只依赖上一行的左边部分 。所以可以把二维压成一维,倒序遍历防止覆盖。
cpp
/* 0-1 背包:一维滚动数组(空间优化) */
int knapsack_1d(int w[], int v[], int n, int cap) {
int dp[C] = {0};
int i, j;
for (i = 1; i <= n; i++) {
for (j = cap; j >= w[i]; j--) { /* 倒序!防止重复选 */
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[cap];
}
为什么必须倒序? 如果正序遍历,dp[j-w[i]] 可能已经被当前第 i 个物品更新过了,导致一个物品被重复选(变成完全背包)。倒序保证 dp[j-w[i]] 仍然是上一行(i-1)的状态。
关键点
-
时间复杂度:O(n × C),n 是物品数,C 是背包容量。
-
空间复杂度:二维 O(n × C) ,一维 O(C)。
-
状态转移方程是填空的必考点,务必记住
max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])这个形式。
三、案例二:最长公共子序列(LCS)
这也是软考真题里的常客。题目描述:
给定两个字符串 X 和 Y,求它们的最长公共子序列的长度。子序列不要求连续,但要求相对顺序一致。
例如:X = "ABCBDAB",Y = "BDCABA",LCS = "BCBA",长度为 4。
思路拆解
1. 定义状态 dp[i][j] 表示:X 的前 i 个字符 和Y 的前 j 个字符的 LCS 长度。
2. 状态转移方程
-
如果
X[i] == Y[j]:说明当前字符可以纳入 LCS,长度 +1dp[i][j] = dp[i-1][j-1] + 1 -
如果
X[i] != Y[j]:说明当前字符至少有一个不在 LCS 里,取两种情况的最大值dp[i][j] = max(dp[i-1][j], dp[i][j-1])
3. 初始化 dp[0][j] = 0,dp[i][0] = 0(任一字符串为空,LCS 长度为 0)。
完整代码
cpp
#include <stdio.h>
#include <string.h>
#define MAX 100
int max(int a, int b) {
return a > b ? a : b;
}
/* 最长公共子序列 */
int lcs(char X[], char Y[]) {
int m = strlen(X);
int n = strlen(Y);
int dp[MAX][MAX] = {0};
int i, j;
/* 注意:字符串下标从 0 开始,dp 下标从 1 开始 */
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[m][n];
}
int main() {
char X[] = "ABCBDAB";
char Y[] = "BDCABA";
printf("LCS 长度: %d\n", lcs(X, Y));
return 0;
}
关键点
-
下标错位 :字符串数组从 0 开始,但 dp 数组通常从 1 开始,所以比较的是
X[i-1]和Y[j-1]。 -
时间复杂度:O(m × n),m 和 n 分别是两个字符串的长度。
-
空间复杂度:O(m × n)。
-
如果相等,
dp[i][j]来自左上角dp[i-1][j-1] + 1;如果不等,来自上方或左方的较大值。这个方向关系在考场上经常用来让你倒推 LCS 的具体内容。
四、模拟题一(0-1 背包)
【说明】 0-1 背包问题:给定 n 个物品和一个容量为 W 的背包,物品 i 的重量为 wi,价值为 vi。每种物品只有一件,求解将哪些物品装入背包可使总价值最大。
【C代码】
cpp
#include <stdio.h>
#define N 5
#define W 10
int max(int a, int b) { return a > b ? a : b; }
int knapsack(int w[], int v[], int n, int cap) {
int dp[N][W+1] = {0};
int i, j;
for (i = 1; i <= n; i++) {
for (j = 1; j <= cap; j++) {
if ((1)) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = (2);
}
}
}
return (3);
}
int main() {
int w[] = {0, 2, 3, 4, 5};
int v[] = {0, 3, 4, 5, 8};
printf("%d\n", knapsack(w, v, 4, 10));
return 0;
}
【问题1】(9分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(3分) 该算法采用了(4)算法设计策略,其时间复杂度为(5)(用 O 符号表示)。
【问题3】 (3分) 若将代码中的二维数组 dp 优化为一维数组,则内层循环对 j 的遍历顺序应改为(6)。
五、模拟题二(最长公共子序列)
【说明】 最长公共子序列(LCS)问题:给定两个字符串 X 和 Y,求它们的最长公共子序列的长度。若 X[i] == Y[j],则 dp[i][j] = dp[i-1][j-1] + 1;否则 dp[i][j] = max(dp[i-1][j], dp[i][j-1])。
【C代码】
cpp
#include <stdio.h>
#include <string.h>
#define MAX 100
int max(int a, int b) { return a > b ? a : b; }
int lcs(char X[], char Y[]) {
int m = strlen(X);
int n = strlen(Y);
int dp[MAX][MAX] = {0};
int i, j;
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if ((1)) {
dp[i][j] = (2);
} else {
dp[i][j] = (3);
}
}
}
return dp[m][n];
}
int main() {
char X[] = "ABCBDAB";
char Y[] = "BDCABA";
printf("%d\n", lcs(X, Y));
return 0;
}
【问题1】(9分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(3分) 该算法采用了(4)算法设计策略,其时间复杂度为(5)(用 O 符号表示)。
【问题3】 (3分) 若 dp[5][6] = 4,dp[4][6] = 3,dp[5][5] = 4,且 X[4] != Y[5],则 dp[5][6] 的值是由(6)转移而来(填"上方"或"左方")。
六、参考答案
模拟题一
【问题1】
-
(1)
j < w[i]或w[i] > j -
(2)
max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) -
(3)
dp[n][cap]或dp[4][10]
【问题2】
-
(4) 动态规划
-
(5) O(n × W) 或 O(nW)
【问题3】
- (6) 倒序(或"从大到小"、"递减")
模拟题二
【问题1】
-
(1)
X[i-1] == Y[j-1](或X[i-1] == Y[j-1]) -
(2)
dp[i-1][j-1] + 1 -
(3)
max(dp[i-1][j], dp[i][j-1])
【问题2】
-
(4) 动态规划
-
(5) O(m × n) 或 O(mn)(m、n 分别为两字符串长度)
【问题3】
- (6) 左方 (因为
dp[5][5] = 4等于dp[5][6] = 4,说明是从左边dp[5][5]转移而来,而非上方的dp[4][6] = 3)
七、小结
动态规划在软考第四题里,考法相对固定。记住这套组合拳:
-
定义状态 :
dp[i][j]到底代表什么(前 i 个物品、容量 j;或前 i 个字符、前 j 个字符)。 -
状态转移方程 :这是填空的核心,0-1 背包是
max(不选, 选),LCS 是相等就+1,不等取max。 -
初始化:第 0 行第 0 列通常是 0。
-
填表顺序:一般是从左到右、从上到下,一维背包要倒序。
考场上如果看到二维数组 + 双重循环,先往动态规划上想,基本不会错。
八、下一篇预告
【软考算法】软件设计师下午第四题之贪心算法:活动选择与最小生成树的"局部最优"
如果说动态规划是"瞻前顾后、全局最优",那贪心算法就是"活在当下、局部最优"。下一篇我们聊聊贪心策略什么时候管用,以及 Prim 和 Kruskal 在软考里怎么考。