【软考算法】软件设计师下午第四题之动态规划:0-1 背包与最长公共子序列的“填表艺术“

上一篇我们聊了分治法,核心是"拆、治、合"。今天聊动态规划(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,长度 +1

    复制代码
    dp[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] = 0dp[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] = 4dp[4][6] = 3dp[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

七、小结

动态规划在软考第四题里,考法相对固定。记住这套组合拳:

  1. 定义状态dp[i][j] 到底代表什么(前 i 个物品、容量 j;或前 i 个字符、前 j 个字符)。

  2. 状态转移方程 :这是填空的核心,0-1 背包是 max(不选, 选),LCS 是 相等就+1,不等取max

  3. 初始化:第 0 行第 0 列通常是 0。

  4. 填表顺序:一般是从左到右、从上到下,一维背包要倒序。

考场上如果看到二维数组 + 双重循环,先往动态规划上想,基本不会错。


八、下一篇预告

【软考算法】软件设计师下午第四题之贪心算法:活动选择与最小生成树的"局部最优"

如果说动态规划是"瞻前顾后、全局最优",那贪心算法就是"活在当下、局部最优"。下一篇我们聊聊贪心策略什么时候管用,以及 Prim 和 Kruskal 在软考里怎么考。

相关推荐
柒和远方1 小时前
LeetCode 139. 单词拆分 —— 从暴力回溯到 DP 完全背包
javascript·python·算法
从零开始的代码生活_2 小时前
C++ stack、queue 与 priority_queue:容器适配器原理与实战
开发语言·c++·后端·学习·算法
晚笙coding2 小时前
LeetCode 226. 翻转二叉树(Invert Binary Tree)
算法·leetcode·职场和发展
2zcode2 小时前
项目文档:基于MATLAB低采样率ISAR成像的快速稀疏重建算法研究
开发语言·算法·matlab
哈里沃克3 小时前
编译与链接 - 02
算法
巴糖3 小时前
Embedding了解一些
算法
战族狼魂3 小时前
广东备案大模型超百款
人工智能·算法·大模型·大语言模型
千桐科技3 小时前
🚀 qModel 算法模型平台v1.2.0 开源版发布!Python 模型接入链路全面升级,告别“能跑但上不了线”的尴尬
后端·算法·llm
刘沅3 小时前
LeetCode 93.复原IP地址
算法·面试