2026-10 的文章

返回全部文章

20261007 190853 动态规划系统课程7

动态规划系统课程 · 第七讲:状态压缩 DP

这一讲进入动态规划中非常重要的一类:

状态压缩 DP(Bitmask DP)

前面我们学习 DP 时,状态通常比较简单:

dp[i]
dp[i][j]
dp[l][r]
dp[u][0]

但有一类问题会遇到一个困难:

状态不仅需要记录“当前在哪里”,还需要记录“哪些东西已经使用过”。

例如:

已经访问:
1、3、5

当前在:
3

如果直接记录集合:

{1,3,5}

程序处理起来很麻烦。

状态压缩 DP 的核心思想就是:

使用二进制的每一位表示一个元素是否被选择。


一、什么是状态压缩?

假设有 5 个城市:

1 2 3 4 5

我们使用一个二进制数表示访问状态。

规定:

第 0 位 → 城市 1
第 1 位 → 城市 2
第 2 位 → 城市 3
第 3 位 → 城市 4
第 4 位 → 城市 5

例如:

00101

表示:

城市 1:访问
城市 2:没有访问
城市 3:访问
城市 4:没有访问
城市 5:没有访问

也就是:

{1, 3}

二、为什么二进制特别适合表示集合?

因为二进制每一位只有:

0
1

正好可以表示:

0 → 没有选择
1 → 已经选择

所以:

集合

可以变成:

二进制整数

例如:

集合 {1,3,4}

对应:

1101

三、状态压缩最重要的三个操作

假设:

int mask;

1. 判断第 i 个元素是否选择

使用:

mask & (1 << i)

例如:

if (mask & (1 << i)) {
    // i 已经选择
}

2. 把 i 加入集合

使用:

mask | (1 << i)

例如:

int newMask = mask | (1 << i);

3. 从集合中删除 i

使用:

mask ^ (1 << i)

前提是:

i 原来一定在集合中。

更通用的写法:

mask & ~(1 << i)

四、最重要的操作:枚举集合中的元素

如果:

mask

表示已经选择的集合,那么我们经常需要:

枚举所有已经选择的元素。

最简单的写法:

for (int i = 0; i < n; i++) {

    if (mask & (1 << i)) {

        // i 在集合中
    }
}

五、一个非常重要的概念:状态数量

如果有 n 个元素。

每个元素有两种状态:

选择
不选择

所以一共有:

$$
2^n
$$

种状态。

例如:

n 状态数
5 32
10 1024
15 32768
20 1048576
25 33554432

因此状态压缩 DP 通常适用于:

$$
n\le 20
$$

左右的问题。

具体上限取决于转移复杂度和内存。


六、第一道经典题:TSP

我们开始状态压缩 DP 最经典的问题:

旅行商问题

有 n 个城市。

城市之间有距离:

dist[i][j]

要求:

从城市 0 出发,每个城市恰好访问一次,最后回到城市 0,求最短路线长度。

例如:

      2
   ↗     ↘
  0       3
   ↖     ↙
      1

七、为什么普通 DP 不够?

如果只写:

dp[i]

表示:

当前在城市 i。

这是不够的。

因为:

当前都在城市 3

可能存在两种情况:

0 → 1 → 2 → 3

或者:

0 → 2 → 1 → 3

虽然当前都是:

3

但已经访问的城市集合不同。

所以状态必须同时记录:

  1. 已访问哪些城市
  2. 当前在哪里

于是:

$$
\boxed{dp[S][i]}
$$


八、TSP 状态定义

定义:

$$
dp[S][i]
$$

表示:

已经访问的城市集合为 S,当前位于城市 i 时的最短路径长度。

其中:

S

是一个二进制集合。


九、初始化

从城市 0 出发。

一开始只访问了城市 0:

00001

所以:

$$
dp[1][0]=0
$$

如果从 0 开始编号:

dp[1 << 0][0] = 0;

十、状态转移

假设:

dp[S][i]

表示:

已经访问集合 S,现在在 i。

下一步去:

j

要求:

j 还没有访问。

判断:

if (!(S & (1 << j)))

然后:

S
↓
S ∪ {j}

新的集合:

newS = S | (1 << j);

路径增加:

$$
dist[i][j]
$$

所以:

$$
dp[S\cup{j}][j]
=
\min
\left(
dp[S\cup{j}][j],
dp[S][i]+dist[i][j]
\right)
$$


十一、最终答案

当所有城市都访问完成:

S = (1 << n) - 1

例如 n=4:

1111

表示:

所有城市都访问了

此时我们在某个城市 i。

但题目要求:

最后回到城市 0。

因此:

$$
answer
=
\min_i
\left(
dp[all][i]+dist[i][0]
\right)
$$

注意:

不要忘记最后回到起点。


十二、完整 C++ 实现

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<vector<long long>> dist(
        n,
        vector<long long>(n)
    );

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            cin >> dist[i][j];
        }
    }

    const long long INF = 4e18;

    int total = 1 << n;

    vector<vector<long long>> dp(
        total,
        vector<long long>(n, INF)
    );

    // 从城市 0 出发
    dp[1][0] = 0;

    for (int mask = 0; mask < total; mask++) {

        for (int i = 0; i < n; i++) {

            if (dp[mask][i] == INF)
                continue;

            // 尝试去城市 j
            for (int j = 0; j < n; j++) {

                // j 已经访问
                if (mask & (1 << j))
                    continue;

                int newMask =
                    mask | (1 << j);

                dp[newMask][j] =
                    min(
                        dp[newMask][j],
                        dp[mask][i]
                        + dist[i][j]
                    );
            }
        }
    }

    int all = (1 << n) - 1;

    long long answer = INF;

    // 最后回到 0
    for (int i = 0; i < n; i++) {

        answer = min(
            answer,
            dp[all][i] + dist[i][0]
        );
    }

    cout << answer << '\n';

    return 0;
}

十三、一定要理解 mask

这是状态压缩 DP 最重要的基础。

假设:

n = 4

那么:

mask

可能是:

0000
0001
0010
0011
0100
...
1111

其中:

1111

表示:

4 个城市全部访问。

而:

0101

表示:

第 0 个和第 2 个城市已经访问。


十四、位运算详细解释

假设:

int mask = 5;

二进制:

0101

判断城市 0

mask & (1 << 0)

也就是:

0101
&
0001
----
0001

结果非零。

所以:

城市 0 已访问。


判断城市 1

mask & (1 << 1)

即:

0101
&
0010
----
0000

结果为 0。

所以:

城市 1 没有访问。


十五、加入一个城市

假设:

mask = 0101

现在访问城市 1。

城市 1 对应:

0010

使用:

mask | (1 << 1)

得到:

0101
|
0010
----
0111

于是:

0101

变成:

0111

十六、第二道经典题:集合覆盖 DP

现在把 TSP 稍微抽象一下。

假设有:

n 个任务

每个任务可能被某种操作覆盖。

我们希望:

用最少的操作覆盖所有任务。

如果 mask 表示:

当前已经覆盖的任务集合。

那么可以定义:

$$
dp[mask]
$$

表示:

覆盖集合 mask 所需要的最小代价。

这就是最基础的:

集合 DP


十七、一个简单模型

假设有 4 个任务:

1 2 3 4

有三个操作:

操作 A:覆盖 1、2
操作 B:覆盖 2、3
操作 C:覆盖 3、4

每个操作有一个费用。

例如:

A:5
B:2
C:4

目标:

覆盖全部任务,最小费用。


十八、状态定义

$$
dp[mask]
$$

表示:

当前已经覆盖 mask 中的任务所需要的最小费用。

初始:

$$
dp[0]=0
$$

因为:

什么任务都没有覆盖时,费用是 0。


十九、状态转移

假设当前:

mask

选择一个操作,它覆盖:

cover

新的覆盖集合:

$$
newMask=mask\ |\ cover
$$

那么:

$$
dp[newMask]
=
\min(
dp[newMask],
dp[mask]+cost
)
$$


二十、完整 C++ 代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<int> cover(m);
    vector<long long> cost(m);

    for (int i = 0; i < m; i++) {

        int k;
        cin >> k;

        int mask = 0;

        for (int j = 0; j < k; j++) {

            int x;
            cin >> x;

            // 任务编号从 1 开始
            --x;

            mask |= (1 << x);
        }

        cover[i] = mask;

        cin >> cost[i];
    }

    const long long INF = 4e18;

    int total = 1 << n;

    vector<long long> dp(
        total,
        INF
    );

    dp[0] = 0;

    for (int mask = 0; mask < total; mask++) {

        if (dp[mask] == INF)
            continue;

        for (int i = 0; i < m; i++) {

            int newMask =
                mask | cover[i];

            dp[newMask] =
                min(
                    dp[newMask],
                    dp[mask] + cost[i]
                );
        }
    }

    int all = (1 << n) - 1;

    if (dp[all] == INF)
        cout << -1 << '\n';
    else
        cout << dp[all] << '\n';

    return 0;
}

二十一、这道题和 TSP 有什么关系?

TSP:

dp[mask][i]

集合覆盖:

dp[mask]

共同点:

都使用 mask 描述“已经完成了什么”。

区别:

TSP 还需要知道:

当前位置 i

所以:

$$
dp[mask][i]
$$

而集合覆盖只关心:

已经覆盖哪些任务

所以:

$$
dp[mask]
$$


二十二、第三道经典题:状压 DP 的“选数”问题

现在看一个更加基础的模型。

给定 n 个数:

a1, a2, ..., an

要求选择若干个数。

如果选择状态由:

mask

表示,那么:

mask

就是一个集合。

例如:

mask = 10110

表示选择:

2、3、5

二十三、为什么不直接使用普通 DFS?

当然可以。

例如:

void dfs(int pos) {

    // 不选
    dfs(pos + 1);

    // 选
    dfs(pos + 1);
}

这实际上是在枚举所有子集。

但是:

DFS 只能告诉你当前走到了哪里。

如果不同的路径最终产生相同的状态,就会重复计算。

DP 的作用就是:

把相同状态合并。

这就是状态压缩 DP 的价值。


二十四、一个非常重要的思想:状态合并

假设有:

1 → 2
1 → 3

最终可能产生同一个状态:

mask = 10101

如果这个状态之后的计算完全相同:

那么前面的过程就没有必要重复计算。

只需要保存:

dp[10101]

即可。

这就是 DP 的核心思想之一:

相同状态只计算一次。


二十五、状态压缩 DP 的复杂度

最常见:

$$
2^n
$$

个状态。

如果每个状态枚举 n 个选择:

$$
O(n2^n)
$$

如果再加一层:

$$
O(n^2 2^n)
$$

因此:

n = 20

时:

$$
2^{20}=1,048,576
$$

大约一百万个状态。

这是可以接受的。

但:

n = 30

时:

$$
2^{30}\approx10.7亿
$$

通常就非常困难了。


二十六、状态压缩 DP 的常见状态形式

以后会经常看到:

形式一

$$
dp[mask]
$$

表示:

集合 mask 的最优答案。


形式二

$$
dp[mask][i]
$$

表示:

已经完成 mask,当前位于 i。

TSP 就属于这一类。


形式三

$$
dp[mask][i][j]
$$

表示:

集合 mask + 两个额外状态。

这种情况状态更多,需要特别注意复杂度。


二十七、状态压缩 DP 最重要的思维

以后看到:

n 很小,通常 n ≤ 20

并且题目出现:

  • 每个元素选/不选
  • 已经使用了哪些元素
  • 已经访问哪些城市
  • 已经完成哪些任务
  • 集合
  • 子集
  • 每个元素只能使用一次

马上想到:

二进制状态压缩


二十八、四种常用位运算

建议学生直接背下来,但更重要的是理解。

假设第 i 位表示元素 i。

判断

mask & (1 << i)

加入

mask | (1 << i)

删除

mask & ~(1 << i)

翻转

mask ^ (1 << i)

二十九、如何枚举所有子集?

如果有 n 个元素:

for (int mask = 0; mask < (1 << n); mask++) {

    // mask 就是一个子集
}

这是最基础的子集枚举。


三十、如何枚举 mask 的所有子集?

这是状态压缩 DP 中非常重要的技巧。

例如:

mask = 10110

我们想枚举:

10110
10100
10010
10000
00110
00100
00010
00000

可以使用:

for (int sub = mask; sub; sub = (sub - 1) & mask) {

    // sub 是 mask 的一个非空子集
}

这个技巧非常重要。


三十一、为什么 (sub - 1) & mask 可以枚举子集?

例如:

mask = 10110

假设:

sub = 10100

减一:

10011

再:

10011
&
10110
-----
10010

得到:

10010

仍然是 mask 的子集。

不断重复:

sub = (sub - 1) & mask

最终就会遍历所有子集。

复杂度:

$$
O(3^n)
$$

这会在后面的高级状态压缩 DP 中非常重要。


三十二、状态压缩 DP 与普通 DP 的区别

普通 DP:

状态数量通常是 n
n²
n³

状态压缩 DP:

状态数量通常是 2^n

所以它的核心交换关系是:

用指数级状态数量,换取对“小规模集合问题”的高效精确求解。


三十三、目前的 DP 知识地图

到这里已经进入一个比较完整的体系:

动态规划
│
├── ① 线性 DP
│   ├── LIS
│   ├── LCS
│   └── 编辑距离
│
├── ② 背包 DP
│   ├── 01 背包
│   ├── 完全背包
│   ├── 多重背包
│   └── 分组背包
│
├── ③ 区间 DP
│   ├── 石子合并
│   ├── 回文
│   └── 矩阵链
│
├── ④ 树形 DP
│   ├── 最大独立集
│   ├── 没有上司的舞会
│   └── 树上最大路径
│
├── ⑤ DAG DP
│   ├── 最长路
│   ├── 最短路
│   └── 路径计数
│
└── ⑥ 状态压缩 DP
    ├── 子集 DP
    ├── TSP
    ├── 集合覆盖
    └── 位运算

三十四、本讲最重要的三个公式

判断元素是否在集合中

$$
\boxed{
mask\ \&\ (1<<i)
}
$$

加入元素

$$
\boxed{
mask|(1<<i)
}
$$

状压 DP 最经典状态

$$
\boxed{
dp[mask][i]
}
$$

它表示:

完成集合 mask,当前处于状态 i 时的最优答案。


三十五、一个非常重要的判断口诀

以后做竞赛题时看到:

n 很小
+
每个元素有选/不选
+
还要记录已经选择了哪些

马上想到:

二进制 → 集合 → 状态压缩 DP

如果进一步出现:

当前在哪里

那么马上想到:

dp[mask][i]

如果题目出现:

每个城市访问一次

马上想到:

TSP / 状态压缩 DP


三十六、下一讲:数位 DP

下一讲我们继续进入一个非常有特色的 DP:

第八讲:数位 DP

数位 DP 专门解决这类问题:

统计 [L,R] 范围内满足某种数字条件的整数个数。

例如:

  • 1 ~ 10^18 中有多少个数字不包含 4?
  • 1 ~ 10^18 中有多少个数字相邻两位不同?
  • 1 ~ N 中有多少个数字的数位和等于 S?
  • L ~ R 中有多少个数字满足某种数位限制?

届时会重点学习:

$$
\boxed{dp[pos][state][limit][lead]}
$$

这几个状态参数分别是什么意思,以及为什么数位 DP 经常使用:

答案(1 ~ R)
-
答案(1 ~ L-1)

来解决 [L,R] 的问题。

到那时,我们会把动态规划进一步推进到:

线性 DP → 背包 → 区间 → 树形 → DAG → 状态压缩 → 数位 DP → 计数 DP → 概率 DP → DP 优化

这套体系基本就覆盖了中学信息学竞赛中最重要的动态规划框架。

20261007 190739 动态规划系统课程6

动态规划系统课程 · 第六讲:DAG DP

这一讲我们把前面学过的 DP 再提升一个层次。

前面:

  • 线性 DP:dp[i]
  • 背包 DP:dp[j]
  • 区间 DP:dp[l][r]
  • 树形 DP:dp[u][...]

今天学习:

DAG 上的动态规划

DAG 是:

Directed Acyclic Graph,有向无环图。

这一讲真正要掌握的不是某一道题,而是一个非常重要的思想:

只要状态之间形成无环的依赖关系,就可以按照依赖顺序进行动态规划。


一、什么是有向图?

例如:

1 → 2

表示:

1 可以到达 2

如果:

1 → 2
2 → 3

那么:

1 → 2 → 3

二、什么是环?

如果:

1 → 2
2 → 3
3 → 1

就形成:

1 → 2
↑     ↓
└── 3

这叫:

有向环

而如果一个有向图:

不存在任何有向环

那么叫:

DAG


三、为什么 DAG 可以做 DP?

这是本讲最重要的问题。

假设:

1 → 2 → 4
 \       ↑
  → 3 ───┘

如果我们定义:

$$
dp[u]
$$

表示:

从起点到节点 u 的最优答案。

那么计算:

dp[4]

之前必须先知道:

dp[2]
dp[3]

而计算:

dp[2]

之前又必须知道:

dp[1]

于是出现:

dp[1]
 ↓
dp[2]
 ↓
dp[4]

dp[1]
 ↓
dp[3]
 ↓
dp[4]

这就是一个依赖关系。

只要按照:

先计算依赖,再计算当前状态

就可以完成 DP。


四、拓扑排序

DAG 有一个非常重要的性质:

一定存在拓扑序。

所谓拓扑序,就是把所有节点排列成一个序列,使得:

如果 u → v,那么 u 一定出现在 v 前面。

例如:

1 → 2
1 → 3
2 → 4
3 → 4

一个拓扑序:

1 2 3 4

也可能是:

1 3 2 4

只要满足所有边的方向即可。


五、DAG DP 的基本套路

最典型的形式:

dp[start] = 初始值;

按照拓扑序:

    for (u 的所有出边 u -> v) {

        dp[v] = 根据 dp[u] 更新;
    }

例如求最长路:

$$
dp[v]
=
\max(dp[v],dp[u]+w)
$$


六、第一道经典题:DAG 最长路

我们来看最基础的问题。


题目

给定一个 DAG,每条边有一个权值。

求:

从节点 1 出发,到所有节点的最长路径长度。

例如:

       2
   1 ───→ 2
   │       \
  3│        \4
   ↓         ↓
   3 ──5──→ 4

边:

1 → 2,权值 2
1 → 3,权值 3
2 → 4,权值 4
3 → 4,权值 5

那么:

1 → 2 → 4

长度:

$$
2+4=6
$$

而:

1 → 3 → 4

长度:

$$
3+5=8
$$

所以到 4 的最长路是:

$$
8
$$


七、状态定义

定义:

$$
dp[v]
$$

表示:

从起点 1 到节点 v 的最长路径长度。

初始:

$$
dp[1]=0
$$

其他节点:

dp[v] = -∞

因为一开始还没有到达它们。


八、状态转移

如果存在边:

$$
u\rightarrow v
$$

权值为:

$$
w
$$

那么:

如果能够到达 u,就可以通过这条边到达 v。

所以:

$$
dp[v]
=
\max(dp[v],dp[u]+w)
$$

这就是 DAG 最长路的核心转移。


九、为什么必须按照拓扑序?

假设:

u → v

那么:

dp[v]

依赖:

dp[u]

所以:

u
↓
v

必须先处理 u。

拓扑排序刚好保证:

所有前驱节点都在当前节点之前。


十、完整 C++ 代码

#include <bits/stdc++.h>
using namespace std;

struct Edge {
    int to;
    long long w;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<vector<Edge>> graph(n + 1);

    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        long long w;

        cin >> u >> v >> w;

        graph[u].push_back({v, w});

        indegree[v]++;
    }

    // 拓扑排序
    queue<int> q;

    for (int i = 1; i <= n; i++) {
        if (indegree[i] == 0) {
            q.push(i);
        }
    }

    vector<int> topo;

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        topo.push_back(u);

        for (auto e : graph[u]) {

            indegree[e.to]--;

            if (indegree[e.to] == 0) {
                q.push(e.to);
            }
        }
    }

    // DP
    const long long NEG_INF = -(1LL << 60);

    vector<long long> dp(
        n + 1,
        NEG_INF
    );

    dp[1] = 0;

    for (int u : topo) {

        if (dp[u] == NEG_INF)
            continue;

        for (auto e : graph[u]) {

            int v = e.to;

            dp[v] = max(
                dp[v],
                dp[u] + e.w
            );
        }
    }

    for (int i = 1; i <= n; i++) {

        if (dp[i] == NEG_INF)
            cout << "INF";
        else
            cout << dp[i];

        cout << '\n';
    }

    return 0;
}

十一、拓扑排序到底做了什么?

这段代码:

while (!q.empty()) {

    int u = q.front();
    q.pop();

    topo.push_back(u);

    for (auto e : graph[u]) {

        indegree[e.to]--;

        if (indegree[e.to] == 0) {
            q.push(e.to);
        }
    }
}

做的是:

不断寻找当前没有前驱的节点。

例如:

1 → 2
1 → 3
2 → 4
3 → 4

开始:

indegree:
1 = 0
2 = 1
3 = 1
4 = 2

所以先处理:

1

删除 1 的出边以后:

2 = 0
3 = 0

于是:

2、3

进入队列。

最后:

4

入队。

得到拓扑序:

1 2 3 4

十二、第二道经典题:DAG 路径计数

刚才求的是:

最长路径。

现在改成:

从起点 1 到终点 n,一共有多少条不同路径?


十三、状态设计

定义:

$$
dp[v]
$$

表示:

从 1 到 v 的路径数量。

初始:

$$
dp[1]=1
$$

为什么不是:

$$
dp[1]=0
$$

因为:

从 1 到 1 有一条“空路径”。

这是计数 DP 中非常重要的初始化思想。


十四、状态转移

假设:

u → v

那么从 1 到 u 的每一条路径,都可以通过这条边变成:

1 → ... → u → v

因此:

$$
dp[v]+=dp[u]
$$

这就是 DAG 路径计数。


十五、例子

      2
    ↗   ↘
1 →       → 4
    ↘   ↗
      3

从 1 到 4:

1 → 2 → 4
1 → 3 → 4

一共:

$$
2
$$

条。


十六、完整 C++ 实现

#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007LL;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<vector<int>> graph(n + 1);

    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {

        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);

        indegree[v]++;
    }

    // 拓扑排序
    queue<int> q;

    for (int i = 1; i <= n; i++) {

        if (indegree[i] == 0) {
            q.push(i);
        }
    }

    vector<int> topo;

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        topo.push_back(u);

        for (int v : graph[u]) {

            indegree[v]--;

            if (indegree[v] == 0) {
                q.push(v);
            }
        }
    }

    // DP
    vector<long long> dp(n + 1, 0);

    dp[1] = 1;

    for (int u : topo) {

        for (int v : graph[u]) {

            dp[v] += dp[u];

            if (dp[v] >= MOD) {
                dp[v] -= MOD;
            }
        }
    }

    cout << dp[n] << '\n';

    return 0;
}

十七、为什么这里用 +?

这是一个非常重要的 DP 思维。

如果两个不同的状态都能够转移到 v:

u → v
w → v

那么:

从 u 来的方案
+
从 w 来的方案

都是不同方案。

因此:

$$
dp[v]=dp[v]+dp[u]
$$

也就是:

dp[v] += dp[u];

十八、第三道经典题:DAG 最长路——关键路径

现在来看一个非常重要的实际模型:

一个项目由多个任务组成,有些任务必须在另一些任务完成后才能开始。

例如:

任务 A
  ↓
任务 B
  ↓
任务 D

或者:

任务 A ─→ B ─→ D
       ↘
         C ─→ D

这本质上就是一个 DAG。


十九、关键路径思想

假设每条边表示一个任务,边权表示任务耗时。

我们希望:

整个项目最早什么时候可以完成?

实际上就是:

求 DAG 上的最长路径

因为一个任务链上的所有任务必须依次完成。

如果存在:

A → B → C

耗时:

3 + 5 + 7

那么这条链至少需要:

$$
15
$$

个时间单位。

如果存在另一条链:

A → D → C

需要:

$$
4+8=12
$$

那么整个项目需要:

$$
\max(15,12)=15
$$


二十、DAG DP 的一个重要变化

到这里可以发现:

DAG DP 本身不是一种固定的“题型”。

它更像是一种:

DP 计算框架

只要有:

状态
↓
状态之间的有向依赖
↓
不存在环

就可以:

拓扑排序
↓
按照拓扑序 DP

二十一、DAG DP 和树形 DP 的关系

现在把两个知识点放在一起。

树形 DP

        1
       / \
      2   3
     /
    4

从根到叶子:

1 → 2 → 4
 \→ 3

本质上也是一个 DAG。


DAG DP

1 → 2 → 5
 \→ 3 → 5
  \→ 4 ─┘

只不过:

一个节点可能有多个前驱。

树中通常:

一个节点只有一个父亲。

DAG 更一般。


二十二、树形 DP和DAG DP最大的区别

树:

        1
      / | \
     2  3  4

每个节点通常只有一个父亲。

所以我们可以:

dfs(u, father)

自然地计算。

而 DAG:

1 → 3
2 → 3

节点 3 有两个前驱。

因此通常需要:

拓扑排序

来保证:

1、2
 ↓
3

在计算 3 时,所有前驱都已经完成。


二十三、DFS 能不能做 DAG DP?

当然可以。

这是一个非常值得掌握的问题。

例如求从 u 出发的最长路径:

$$
dp[u]
=
\max_{u\rightarrow v}
(w(u,v)+dp[v])
$$

可以使用记忆化 DFS:

long long dfs(int u) {

    if (vis[u])
        return dp[u];

    vis[u] = true;

    for (auto e : graph[u]) {

        dp[u] = max(
            dp[u],
            e.w + dfs(e.to)
        );
    }

    return dp[u];
}

这就是:

记忆化搜索 + DAG DP


二十四、两种 DAG DP 方法

因此 DAG DP 通常有两种实现。

方法一:拓扑排序

拓扑排序
↓
按照拓扑序
↓
DP

特点:

  • 迭代
  • 不需要递归
  • 很适合竞赛

方法二:记忆化 DFS

dfs(u)
↓
dfs(所有后继)
↓
计算 dp[u]

特点:

  • 写法直观
  • 状态转移容易理解
  • 本质仍然是 DP

二十五、两种方法的核心区别

例如:

1 → 2
1 → 3
2 → 4
3 → 4

拓扑排序:

1
↓
2、3
↓
4

记忆化 DFS:

dfs(1)
 ├── dfs(2)
 │     └── dfs(4)
 │
 └── dfs(3)
       └── dfs(4)

注意:

dfs(4)

第二次不会重新计算,因为:

if (vis[u])
    return dp[u];

这就是“记忆化”。


二十六、一个非常重要的统一理解

现在我们已经学了:

线性 DP
区间 DP
树形 DP
DAG DP

其实可以统一理解成:

先建立状态之间的依赖关系,再按照依赖顺序计算。

例如线性 DP:

dp[1] → dp[2] → dp[3] → dp[4]

区间 DP:

短区间 → 长区间

树形 DP:

子节点 → 父节点

DAG DP:

前驱节点 → 后继节点

二十七、这是动态规划真正的本质

我们可以把动态规划理解成:

             状态
              │
              ↓
        状态之间的依赖
              │
              ↓
       没有循环依赖
              │
              ↓
        按正确顺序计算
              │
              ↓
         得到最终答案

所以:

DP 并不是“背转移方程”。

真正需要培养的是:

建立状态 → 找依赖 → 确定计算顺序 → 转移


二十八、到目前为止的完整知识体系

动态规划
│
├── ① 线性 DP
│   ├── LIS
│   ├── LCS
│   └── 编辑距离
│
├── ② 背包 DP
│   ├── 01 背包
│   ├── 完全背包
│   ├── 多重背包
│   └── 分组背包
│
├── ③ 区间 DP
│   ├── 石子合并
│   ├── 最长回文子序列
│   └── 矩阵链乘法
│
├── ④ 树形 DP
│   ├── 最大独立集
│   ├── 没有上司的舞会
│   └── 树上最大路径
│
└── ⑤ DAG DP
    ├── DAG 最长路
    ├── DAG 路径计数
    └── 关键路径

二十九、本讲必须掌握的三个公式

DAG 最长路

$$
\boxed{
dp[v]=\max(dp[v],dp[u]+w)
}
$$


DAG 路径计数

$$
\boxed{
dp[v]+=dp[u]
}
$$


DAG 最短路

如果以后把问题改成最短路:

$$
\boxed{
dp[v]=\min(dp[v],dp[u]+w)
}
$$

所以你会发现:

最长、最短、计数,本质上只是 DP 运算不同。


三十、课后训练

建议按以下顺序训练。

Level 1

  1. DAG 最长路
  2. DAG 最短路
  3. DAG 路径数量

Level 2

  1. DAG 最大权路径
  2. DAG 路径方案数 + 模
  3. 关键路径

Level 3

  1. DAG 上最长路径并恢复具体路径
  2. DAG 上路径数量并恢复一条方案
  3. 多起点 DAG DP
  4. DAG 上状态带多个维度的 DP

下一讲:状态压缩 DP

接下来进入 DP 中非常经典、也非常容易让学生“突然开窍”的一类:

第七讲:状态压缩 DP

我们会从一个非常经典的问题开始:

旅行商问题 TSP

例如有:

城市 1
城市 2
城市 3
城市 4
...
城市 n

要求从某个城市出发:

每个城市恰好访问一次,最后回到起点,求最小总距离。

普通的:

dp[i]

显然不够。

因为我们不仅需要知道:

当前在哪里

还必须知道:

哪些城市已经访问过。

于是产生一个非常重要的状态:

$$
\boxed{dp[S][i]}
$$

其中:

  • S:一个二进制集合,表示已经访问过哪些节点
  • i:当前位于哪个节点

这将正式进入:

Bitmask + DP

也是中学生信息学竞赛中非常重要的一块内容。

20261007 190655 动态规划系统课程5

动态规划系统课程 · 第五讲:树形 DP

这一讲开始进入一个非常重要的 DP 分支:树形 DP。

前面我们学习了:

  • 线性 DP:dp[i]
  • 背包 DP:dp[j]
  • 区间 DP:dp[l][r]

今天学习:

dp[u][...]

其中 u 是树上的一个节点。


一、什么是树形 DP?

先看一棵树:

              1
           /  |  \
          2   3   4
             / \
            5   6

如果题目要求我们:

在这棵树上选择一些节点,使得满足某种条件,并求最大值/最小值/方案数。

通常可以考虑:

树形 DP

树形 DP 的核心思想其实并不神秘:

把一个节点的子树看成一个独立的小问题,然后利用子树的答案计算当前节点的答案。


二、为什么树特别适合 DP?

因为树具有一个非常重要的性质:

从一个节点向下看,它的每棵子树之间互不影响。

例如:

              1
           /  |  \
          2   3   4
         / \
        5   6

节点 1 的三个子树:

以 2 为根的子树
以 3 为根的子树
以 4 为根的子树

彼此之间没有边连接。

所以:

                1
          /     |     \
       子树2   子树3   子树4

我们可以分别计算:

dp[2]
dp[3]
dp[4]

然后再计算:

dp[1]

这就是树形 DP。


三、树形 DP 与 DFS 的关系

这是初学者最容易混淆的地方。

一定要区分:

DFS

是一种:

遍历树的方法。

DP

是一种:

保存并利用子问题答案的方法。

两者可以结合:

DFS
 ↓
先计算儿子
 ↓
得到儿子的 DP
 ↓
计算当前节点 DP

所以树形 DP 最常见的代码结构就是:

void dfs(int u, int fa) {

    for (int v : tree[u]) {

        if (v == fa)
            continue;

        dfs(v, u);
    }

    // 根据所有儿子的 dp
    // 计算 u 的 dp
}

注意:

先 DFS 儿子,再计算父亲。

这实际上就是树上的一种“后序遍历”。


四、第一道经典题:树的最大独立集

我们从最经典的树形 DP 开始。


题目

给定一棵树。

要求选择尽可能多的节点,使得:

任意一条边的两个端点不能同时被选择。

例如:

        1
       / \
      2   3
     / \
    4   5

如果选择:

1

那么:

2、3

不能选择。

如果不选择 1,那么:

2、3

就有机会选择。


五、什么叫独立集?

一个节点集合,如果满足:

任意两个被选择的节点之间没有直接的边。

那么这个集合叫:

独立集

目标是:

求最大独立集的大小。


六、最关键的问题:状态怎么设计?

对于节点 u,有两种情况:

情况 1:选择 u

dp[u][1]

情况 2:不选择 u

dp[u][0]

定义:

$$
dp[u][0]
$$

表示:

在 u 的子树中,不选择 u 时能够选择的最大节点数。

定义:

$$
dp[u][1]
$$

表示:

在 u 的子树中,选择 u 时能够选择的最大节点数。

这两个状态非常重要。


七、如果选择 u,会发生什么?

假设:

        u
       / \
      v   w

如果选择 u:

选择 u

那么它的直接儿子:

v
w

都不能选择。

因此:

$$
dp[u][1]
=
1+\sum dp[v][0]
$$

注意:

孙子节点仍然可以选择。

例如:

        u
       /
      v
     /
    x

选择 u:

u ✓
v ✗
x ✓

所以不能简单地把整棵子树都放弃。


八、如果不选择 u 呢?

如果不选择 u:

u ✗

那么儿子:

v

可以:

  • 选择
  • 不选择

因此:

$$
dp[u][0]
=
\sum
\max(dp[v][0],dp[v][1])
$$


九、得到完整转移

对于每一个儿子 v:

如果选择 u:

$$
dp[u][1]
=
1+\sum dp[v][0]
$$

如果不选择 u:

$$
dp[u][0]
=
\sum \max(dp[v][0],dp[v][1])
$$

这就是树形 DP 最经典的状态转移之一。


十、为什么必须先计算儿子?

因为:

dp[u][0]

需要:

dp[v][0]
dp[v][1]

所以必须:

先算 v
再算 u

DFS 正好可以完成这个过程。


十一、完整 C++ 代码

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

vector<int> tree[MAXN];

int dp[MAXN][2];

void dfs(int u, int father) {

    // 选择 u
    dp[u][1] = 1;

    // 不选择 u
    dp[u][0] = 0;

    for (int v : tree[u]) {

        if (v == father)
            continue;

        // 先计算子树
        dfs(v, u);

        // 不选择 u
        // v 可以选,也可以不选
        dp[u][0] += max(
            dp[v][0],
            dp[v][1]
        );

        // 选择 u
        // v 一定不能选
        dp[u][1] += dp[v][0];
    }
}

int main() {

    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    for (int i = 1; i < n; i++) {

        int u, v;
        cin >> u >> v;

        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    dfs(1, 0);

    cout << max(
        dp[1][0],
        dp[1][1]
    ) << '\n';

    return 0;
}

十二、手算一个例子

考虑:

        1
       / \
      2   3
     / \
    4   5

从叶子开始。


节点 4

没有孩子。

选择:

$$
dp[4][1]=1
$$

不选择:

$$
dp[4][0]=0
$$

所以:

dp[4] = {0, 1}

节点 5 同理:

dp[5] = {0, 1}

节点 2

节点 2 的孩子:

4、5

如果不选择 2:

$$
dp[2][0]
=
\max(0,1)+\max(0,1)
=2
$$

如果选择 2:

$$
dp[2][1]
=
1+0+0
=1
$$

因此:

dp[2] = {2, 1}

节点 3

节点 3 是叶子:

dp[3] = {0,1}

节点 1

如果不选择 1:

$$
dp[1][0]
=
\max(2,1)+\max(0,1)
$$

所以:

$$
dp[1][0]=2+1=3
$$

如果选择 1:

$$
dp[1][1]
=
1+dp[2][0]+dp[3][0]
$$

得到:

$$
1+2+0=3
$$

最终:

$$
\boxed{3}
$$


十三、树形 DP 的第一个核心模型

刚才这个问题可以总结成:

dp[u][0] = 不选 u
dp[u][1] = 选择 u

这是树形 DP 中非常重要的一种:

“选 / 不选”模型

以后看到:

  • 节点选取
  • 相邻节点不能同时选
  • 选某节点会影响相邻节点
  • 最大独立集
  • 没有上司的舞会

首先考虑:

dp[u][0]
dp[u][1]

十四、第二道经典题:没有上司的舞会

这道题和刚才的最大独立集本质完全相同,但是题意更加贴近实际。


题目

公司有 n 名员工。

公司关系是一棵树:

老板
 |
 ├── 员工
 │    ├── 下属
 │    └── 下属

每个员工有一个快乐值:

happy[i]

如果某个员工参加舞会,那么他的直接上司不能参加。

要求:

求所有参加员工的快乐值总和最大是多少。


十五、状态定义

仍然是:

$$
dp[u][0]
$$

表示:

u 不参加舞会时,u 子树的最大快乐值。

$$
dp[u][1]
$$

表示:

u 参加舞会时,u 子树的最大快乐值。


十六、转移

如果 u 参加:

u ✓

那么所有直接下属不能参加:

$$
dp[u][1]
=
happy[u]
+
\sum dp[v][0]
$$

如果 u 不参加:

u ✗

那么每个下属可以自由选择:

$$
dp[u][0]
=
\sum
\max(dp[v][0],dp[v][1])
$$

是不是和上一道题一模一样?

是的。


十七、完整 C++ 实现

这里稍微增加一个重要知识点:

树的根不一定是 1。

所以我们需要找到真正的根节点。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

vector<int> tree[MAXN];

long long happy[MAXN];

long long dp[MAXN][2];

void dfs(int u) {

    // u 参加
    dp[u][1] = happy[u];

    // u 不参加
    dp[u][0] = 0;

    for (int v : tree[u]) {

        dfs(v);

        // u 不参加
        dp[u][0] += max(
            dp[v][0],
            dp[v][1]
        );

        // u 参加
        dp[u][1] += dp[v][0];
    }
}

int main() {

    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    for (int i = 1; i <= n; i++) {
        cin >> happy[i];
    }

    vector<int> hasParent(n + 1, 0);

    for (int i = 1; i < n; i++) {

        int boss, employee;

        cin >> boss >> employee;

        tree[boss].push_back(employee);

        hasParent[employee] = 1;
    }

    int root = 1;

    for (int i = 1; i <= n; i++) {

        if (!hasParent[i]) {
            root = i;
            break;
        }
    }

    dfs(root);

    cout << max(
        dp[root][0],
        dp[root][1]
    ) << '\n';

    return 0;
}

十八、这道题告诉我们一个非常重要的事情

树形 DP 不一定是在:

无根树

上直接做。

很多题目实际上给的是:

父亲 → 儿子

这样的有向关系。

这时候可以直接从根开始 DFS。


十九、第三道题:树的最大路径和

现在我们学习一个不同类型的树形 DP。

给定一棵带权树:

        1
       / \
      2   3
     / \
    4   5

每个节点有一个权值。

求:

树中任意两个节点之间路径上的最大权值和。

这道题非常经典,因为它告诉我们:

树形 DP 不一定只有“选 / 不选”。


二十、先思考:路径是什么?

假设路径经过节点 u:

        x
        |
        v
        |
        u
        |
        w
        |
        y

一条经过 u 的路径最多可以:

从一个方向进入 u,再从另一个方向离开 u。

所以在 u 这里,最多连接两条向下的路径。

这就给了我们状态设计的灵感。


二十一、定义状态

定义:

$$
dp[u]
$$

表示:

从节点 u 出发,向它的子树方向走,能够得到的最大路径和。

也就是:

从 u 开始
只能向下面走

二十二、如何计算 dp[u]?

假设 v 是 u 的一个儿子。

如果我们从 u 走向 v:

u → v → ...

可以得到:

$$
dp[v]
$$

因此:

$$
dp[u]
=
w[u]
+
\max(0,\max dp[v])
$$

为什么是:

max(0, ...)

因为如果所有子树贡献都是负数:

还不如不走。


二十三、但是最终答案不一定只有一条向下路径

例如:

        u
       / \
      v   w

最大路径可能是:

v
 \
  u
   \
    w

也就是:

最长向下路径1
       +
u
       +
最长向下路径2

因此我们需要维护:

u 的两个最大的正向子树贡献。

假设最大的两个贡献是:

best1
best2

那么经过 u 的最大路径:

$$
best1+w[u]+best2
$$


二十四、完整 C++ 实现

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

vector<int> tree[MAXN];

long long w[MAXN];

long long dp[MAXN];

long long answer;

void dfs(int u, int father) {

    // 最大的两个子树贡献
    long long best1 = 0;
    long long best2 = 0;

    for (int v : tree[u]) {

        if (v == father)
            continue;

        dfs(v, u);

        long long value = dp[v];

        if (value > best1) {

            best2 = best1;
            best1 = value;

        } else if (value > best2) {

            best2 = value;
        }
    }

    // 从 u 向下走的一条最大路径
    dp[u] = w[u] + best1;

    // 一条经过 u 的完整路径
    answer = max(
        answer,
        w[u] + best1 + best2
    );
}

int main() {

    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    for (int i = 1; i <= n; i++) {
        cin >> w[i];
    }

    for (int i = 1; i < n; i++) {

        int u, v;

        cin >> u >> v;

        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    answer = LLONG_MIN;

    dfs(1, 0);

    cout << answer << '\n';

    return 0;
}

二十五、这道题和前两道有什么不同?

前两道:

dp[u][0]
dp[u][1]

是两个状态。

第三道:

dp[u]

只有一个主要状态。

但我们在转移时维护:

best1
best2

因此可以看到:

树形 DP 没有固定的状态形式。

并不是看到树就一定:

dp[u][2]

真正应该做的是:

根据题目的结构设计状态。


二十六、树形 DP 的标准模板

对于普通的无根树:

void dfs(int u, int father) {

    // 初始化 dp[u]

    for (int v : tree[u]) {

        if (v == father)
            continue;

        dfs(v, u);

        // 利用 dp[v]
        // 更新 dp[u]
    }
}

这个模板一定要掌握。


二十七、为什么需要 father?

因为普通树的邻接表是双向存储的:

tree[u].push_back(v);
tree[v].push_back(u);

例如:

1 —— 2

那么:

tree[1] = {2}
tree[2] = {1}

如果:

dfs(1, 0);

进入 2:

dfs(2, 1);

此时 2 又看到:

1

如果没有判断:

if (v == father)
    continue;

就会:

1 → 2 → 1 → 2 → 1 ...

无限递归。

所以:

dfs(u, father)

中的 father 非常重要。


二十八、树形 DP 的本质

现在我们可以把树形 DP 总结成一句话:

先通过 DFS 解决所有子树,再利用子树答案计算当前节点答案。

也就是:

                  当前节点 u
                       ↑
             ┌─────────┼─────────┐
             ↑         ↑         ↑
           dp[v1]    dp[v2]    dp[v3]
             ↑         ↑         ↑
            子树       子树       子树

这实际上就是:

后序遍历 + 动态规划


二十九、树形 DP 最容易犯的 6 个错误

错误 1:没有定义状态含义

错误:

dp[u][0]
dp[u][1]

直接开始写。

正确做法:

先用中文明确说明 dp[u][0] 和 dp[u][1] 分别表示什么。


错误 2:没有先 DFS 儿子

错误:

先算 u
再算 v

正确:

v
↓
dp[v]
↓
u
↓
dp[u]

错误 3:无根树没有排除父节点

必须:

if (v == father)
    continue;

错误 4:把 DFS 和 DP 混为一谈

DFS:

决定访问顺序。

DP:

保存子问题答案。

两者不是同一个概念。


错误 5:认为树形 DP 只能求最大值

实际上可以求:

  • 最大值
  • 最小值
  • 方案数
  • 是否可行
  • 最大路径
  • 最小费用
  • 某种计数

错误 6:递归深度过大

如果:

1 - 2 - 3 - 4 - ... - 100000

是一条链。

递归 DFS 可能出现栈深度问题。

竞赛中如果 n 非常大,需要考虑:

  • 非递归 DFS
  • 调整栈空间
  • 根据题目环境决定实现方式

初学阶段先掌握递归版本。


三十、四种 DP 放在一起比较

现在我们已经学习:

DP 类型 核心状态 核心问题
线性 DP dp[i] 前面的状态如何转移
背包 DP dp[j] 物品如何选择
区间 DP dp[l][r] 大区间如何拆
树形 DP dp[u][...] 子树如何合并

可以把它们理解为:

线性 DP
    ↓
沿着序列解决问题

背包 DP
    ↓
沿着物品 + 容量解决问题

区间 DP
    ↓
沿着区间长度解决问题

树形 DP
    ↓
沿着树的后序结构解决问题

三十一、一个非常重要的提升:树形 DP其实是“有向无环结构上的 DP”

把树从根节点开始看:

        1
      / | \
     2  3  4
    / \
   5   6

如果规定所有边都从父亲指向孩子:

1 → 2
1 → 3
1 → 4
2 → 5
2 → 6

那么实际上形成了一个:

有向无环图

所以树形 DP 和后面要学习的:

DAG DP

存在非常密切的联系。


三十二、动态规划知识地图更新

目前已经完成:

动态规划
│
├── ① 线性 DP
│    ├── LIS
│    ├── LCS
│    └── 编辑距离
│
├── ② 背包 DP
│    ├── 01 背包
│    ├── 完全背包
│    ├── 多重背包
│    └── 分组背包
│
├── ③ 区间 DP
│    ├── 石子合并
│    ├── 回文
│    └── 矩阵链
│
└── ④ 树形 DP
     ├── 最大独立集
     ├── 没有上司的舞会
     └── 树的最大路径和

三十三、本讲最重要的三个模板

模板 1:树上“选 / 不选”

dp[u][1] = 选择 u 的最优值;
dp[u][0] = 不选择 u 的最优值;

for (int v : tree[u]) {
    if (v == father)
        continue;

    dfs(v, u);

    dp[u][1] += dp[v][0];

    dp[u][0] += max(
        dp[v][0],
        dp[v][1]
    );
}

模板 2:树上的子树贡献

dp[u] = 初始值;

for (int v : tree[u]) {
    if (v == father)
        continue;

    dfs(v, u);

    dp[u] = 根据 dp[v]
            更新 dp[u];
}

模板 3:树上的“两个最大贡献”

long long best1 = 0;
long long best2 = 0;

for (int v : tree[u]) {

    if (v == father)
        continue;

    dfs(v, u);

    if (dp[v] > best1) {
        best2 = best1;
        best1 = dp[v];
    }
    else if (dp[v] > best2) {
        best2 = dp[v];
    }
}

这个技巧在:

树的直径、最大路径、树上路径问题

中非常常见。


三十四、课后训练

建议按下面顺序练习:

Level 1

  1. 树的最大独立集
  2. 没有上司的舞会
  3. 树的最大路径和

Level 2

  1. 树的最小点覆盖
  2. 树的最小支配集
  3. 树上最大权独立集

Level 3

  1. 树上背包
  2. 有依赖的背包
  3. 树上路径统计
  4. 换根 DP

其中 换根 DP 非常重要,它会成为后面学习高级树形 DP 的关键。


下一讲:DAG DP

下一阶段我们将学习一个非常自然的内容:

第六讲:DAG 上的动态规划

重点解决:

什么是 DAG?
为什么拓扑排序可以做 DP?
DFS DP 和拓扑 DP 有什么区别?

然后通过三道经典问题学习:

  1. 有向无环图最长路
  2. DAG 上的路径计数
  3. 关键路径 / 最长路径

并进一步理解一个非常重要的统一观点:

动态规划的本质,不是某一种固定代码,而是在一个“状态图”上按照依赖关系计算答案。

这句话理解之后,线性 DP、区间 DP、树形 DP、DAG DP 会开始真正串成一个整体。

20261007 190544 动态规划系统课程4

动态规划系统课程 · 第四讲:区间 DP

学习目标: 掌握 dp[l][r] 这种二维状态,理解“区间 DP 为什么按照长度递增计算”,并通过 石子合并、最长回文子序列、矩阵链乘法 三道经典题建立完整的区间 DP 思维。


一、什么是区间 DP?

前面学习的 DP:

线性 DP

通常是:

dp[i]

表示处理到第 i 个位置时的最优答案。

背包 DP

通常是:

dp[j]

表示容量为 j 时的最优答案。

而区间 DP 的核心状态通常是:

dp[l][r]

表示:

区间 [l,r] 内的某个问题的最优解。

例如:

1 2 3 4 5 6 7
    └─────┘
      [3,7]

dp[3][7] 就表示区间 [3,7] 的答案。


二、为什么区间 DP 会出现?

区间 DP 通常出现在这样的题目中:

给定一段序列,每次对其中一部分进行操作,要求最终整个区间的最优结果。

典型特征:

  • 对一个连续区间进行操作。
  • 大区间可以拆成若干小区间。
  • 小区间的答案可以帮助计算大区间。
  • 经常出现“最后一次合并”“最后一次切割”“最后一次操作”。

因此遇到:

区间 + 合并/分割/消除 + 最优

就应该警惕:

区间 DP


三、区间 DP 最重要的状态

最常见:

$$
dp[l][r]
$$

表示:

区间 [l,r] 的最优答案。

但是仅仅写出这个还不够。

真正困难的是:

如何把 [l,r] 拆成更小的区间?

最经典的方法是:

[l, r]

↓

[l, k] + [k+1, r]

也就是枚举一个分割点 k。


四、区间 DP 的核心转移结构

假设:

dp[l][r]

可以由:

dp[l][k]

和:

dp[k+1][r]

组合得到。

那么:

$$
dp[l][r]
=
\operatorname{opt}
\left(
dp[l][k]+dp[k+1][r]+\text{cost}
\right)
$$

其中:

$$
l\le k<r
$$

如果求最小值:

dp[l][r] = min(...)

如果求最大值:

dp[l][r] = max(...)

五、为什么必须按照区间长度计算?

这是区间 DP 最重要的代码结构之一。

假设:

dp[l][r]

依赖:

dp[l][k]
dp[k+1][r]

那么两个子区间:

[l,k]
[k+1,r]

都比:

[l,r]

短。

所以必须:

先计算短区间,再计算长区间。

通常写成:

for (int len = 2; len <= n; len++) {
    for (int l = 1; l + len - 1 <= n; l++) {
        int r = l + len - 1;

        ...
    }
}

这就是:

区间 DP 的标准枚举顺序


六、第一道经典题:石子合并

这是学习区间 DP 最经典的入门题之一。


题目描述

有 n 堆石子排成一排:

a1 a2 a3 ... an

每次只能合并相邻的两堆石子。

合并两堆石子的代价为:

两堆石子的石子总数。

求将所有石子合并成一堆的最小总代价。


示例

4
1 3 5 2

一种合并方法:

1 3 5 2

1+3 = 4
4 5 2

4+5 = 9
9 2

9+2 = 11
11

总费用:

$$
4+9+11=24
$$

但是不同的合并顺序可能得到不同的总代价。

我们需要求最小值。


七、石子合并为什么适合区间 DP?

观察最终过程:

[1, n]

最终一定是由某两个区间合并得到:

[1, k]
[k+1, n]

也就是说:

整个区间
    ↓
左区间 + 右区间

因此自然想到:

$$
dp[l][r]
$$


八、状态定义

定义:

$$
dp[l][r]
$$

表示:

将第 l 堆到第 r 堆石子合并成一堆的最小代价。


九、如何进行状态转移?

假设最后一次合并:

[l, k]

和:

[k+1, r]

那么:

第一步

先把:

[l,k]

合并成一堆。

代价:

$$
dp[l][k]
$$

第二步

把:

[k+1,r]

合并成一堆。

代价:

$$
dp[k+1][r]
$$

第三步

最后合并这两堆。

这次的代价是多少?

就是整个区间的石子总数:

$$
sum[l][r]
$$

因此:

$$
dp[l][r]
=
\min_{l\le k<r}
\left(
dp[l][k]+dp[k+1][r]+sum[l][r]
\right)
$$

这就是石子合并的核心转移。


十、为什么需要前缀和?

每次计算:

$$
sum[l][r]
$$

如果都重新累加:

a[l] + a[l+1] + ... + a[r]

会浪费大量时间。

所以使用前缀和:

$$
prefix[i]=a_1+a_2+\cdots+a_i
$$

那么:

$$
sum[l][r]
=
prefix[r]-prefix[l-1]
$$

于是区间和可以:

$$
O(1)
$$

计算。


十一、石子合并的初始化

当:

l == r

表示只有一堆石子。

不需要合并。

因此:

$$
dp[l][l]=0
$$

这也是非常典型的区间 DP 初始化:

长度为 1 的区间通常是最基础状态。


十二、完整 C++ 代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<long long> a(n + 1);
    vector<long long> prefix(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        prefix[i] = prefix[i - 1] + a[i];
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1; l + len - 1 <= n; l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            long long sum =
                prefix[r] - prefix[l - 1];

            for (int k = l; k < r; k++) {

                dp[l][r] = min(
                    dp[l][r],
                    dp[l][k]
                    + dp[k + 1][r]
                    + sum
                );
            }
        }
    }

    cout << dp[1][n] << '\n';

    return 0;
}

十三、代码最重要的三层循环

一定要理解这三层循环:

for (int len = 2; len <= n; len++)

表示:

枚举区间长度。


for (int l = 1; l + len - 1 <= n; l++)

表示:

枚举左端点。


for (int k = l; k < r; k++)

表示:

枚举最后一次合并的位置。

所以可以把代码翻译成一句话:

枚举区间长度 → 枚举区间位置 → 枚举最后一次分割点。

这是区间 DP 最重要的代码框架。


十四、复杂度分析

有:

  • len:(O(n))
  • l:(O(n))
  • k:(O(n))

因此:

$$
O(n^3)
$$

时间复杂度。

空间:

$$
O(n^2)
$$


十五、第二道经典题:最长回文子序列

现在学习一种完全不同的区间 DP。


题目

给定一个字符串:

bbbab

求它的最长回文子序列长度。

例如:

bbbb

就是一个回文子序列。

答案:

4

注意:

子序列不要求连续。


十六、什么是回文?

例如:

aba
abba
abcba

正着读和反着读一样。

而:

abc

不是回文。


十七、状态设计

定义:

$$
dp[l][r]
$$

表示:

字符串区间 [l,r] 内的最长回文子序列长度。


十八、观察区间两端

考虑:

s[l]

和:

s[r]

有两种情况。


情况一:两端字符相同

如果:

$$
s[l]=s[r]
$$

那么这两个字符可以作为回文的两端。

因此:

$$
dp[l][r]
=
dp[l+1][r-1]+2
$$

例如:

b .... b

中间部分如果有最长回文长度 x:

b + 中间回文 + b

长度就是:

$$
x+2
$$


十九、情况二:两端字符不同

例如:

a .... b

这两个字符不可能同时作为当前回文的两端。

那么至少有一个端点不能使用。

我们考虑两种情况:

去掉左边

或者:

去掉右边

所以:

$$
dp[l][r]
=
\max(
dp[l+1][r],
dp[l][r-1]
)
$$


二十、完整状态转移

因此:

$$
dp[l][r]
=
\begin{cases}
dp[l+1][r-1]+2,&s_l=s_r\
\max(dp[l+1][r],dp[l][r-1]),&s_l\ne s_r
\end{cases}
$$


二十一、初始化

当:

l == r

只有一个字符。

一个字符本身就是回文。

所以:

$$
dp[l][l]=1
$$


二十二、为什么仍然要按照长度递增?

因为:

dp[l][r]

依赖:

dp[l+1][r-1]
dp[l+1][r]
dp[l][r-1]

这些区间都更短。

所以仍然是:

for (len = 1; len <= n; len++)

二十三、C++ 完整实现

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    cin >> s;

    int n = s.size();

    vector<vector<int>> dp(
        n,
        vector<int>(n, 0)
    );

    // 长度为 1
    for (int i = 0; i < n; i++) {
        dp[i][i] = 1;
    }

    // 枚举区间长度
    for (int len = 2; len <= n; len++) {

        for (int l = 0; l + len - 1 < n; l++) {

            int r = l + len - 1;

            if (s[l] == s[r]) {

                if (len == 2) {
                    dp[l][r] = 2;
                } else {
                    dp[l][r] =
                        dp[l + 1][r - 1] + 2;
                }

            } else {

                dp[l][r] = max(
                    dp[l + 1][r],
                    dp[l][r - 1]
                );
            }
        }
    }

    cout << dp[0][n - 1] << '\n';

    return 0;
}

二十四、代码可以进一步简化吗?

可以。

如果:

s[l] == s[r]

而区间长度为 2:

aa

内部为空。

我们可以定义:

$$
dp[l+1][r-1]=0
$$

或者直接使用二维数组边界处理。

为了让初学者更容易理解,上面的代码专门处理了:

if (len == 2)

后面熟练以后,可以写得更简洁。


二十五、第三道经典题:矩阵链乘法

这是区间 DP 中非常经典的综合题。


题目

有三个矩阵:

$$
A_1,A_2,A_3
$$

它们的维度分别是:

A1:10 × 30
A2:30 × 5
A3:5 × 60

需要计算:

$$
A_1A_2A_3
$$

矩阵乘法满足结合律:

$$
(A_1A_2)A_3
=
A_1(A_2A_3)
$$

但是不同的计算顺序,乘法次数不同。

求最少需要多少次标量乘法。


二十六、为什么计算顺序很重要?

先算:

$$
(A_1A_2)A_3
$$

计算:

$$
A_1A_2
$$

需要:

$$
10\times30\times5=1500
$$

结果矩阵:

$$
10\times5
$$

然后:

$$
(10\times5)(5\times60)
$$

需要:

$$
10\times5\times60=3000
$$

总共:

$$
4500
$$


另一种:

$$
A_1(A_2A_3)
$$

先计算:

$$
A_2A_3
$$

需要:

$$
30\times5\times60=9000
$$

结果:

$$
30\times60
$$

再计算:

$$
(10\times30)(30\times60)
$$

需要:

$$
10\times30\times60=18000
$$

总共:

$$
27000
$$

因此第一种明显更优。


二十七、状态设计

定义:

$$
dp[l][r]
$$

表示:

计算矩阵 (A_lA_{l+1}\cdots A_r) 所需的最少标量乘法次数。


二十八、如何转移?

最后一次计算一定存在一个分割点:

$$
k
$$

把:

$$
A_l\cdots A_r
$$

拆成:

$$
(A_l\cdots A_k)
(A_{k+1}\cdots A_r)
$$

左边:

$$
dp[l][k]
$$

右边:

$$
dp[k+1][r]
$$

最后把两个结果矩阵相乘。

假设矩阵维度数组为:

p

其中:

$$
A_i
$$

的维度为:

$$
p_{i-1}\times p_i
$$

那么最后一次乘法需要:

$$
p_{l-1}\times p_k\times p_r
$$

次标量乘法。

因此:

$$
dp[l][r]
=
\min_{l\le k<r}
\left(
dp[l][k]
+
dp[k+1][r]
+
p_{l-1}p_kp_r
\right)
$$


二十九、初始化

只有一个矩阵:

A[i]

不需要进行矩阵乘法。

所以:

$$
dp[i][i]=0
$$


三十、C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    // A[i] 的维度为 p[i-1] × p[i]
    vector<long long> p(n + 1);

    for (int i = 0; i <= n; i++) {
        cin >> p[i];
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1;
             l + len - 1 <= n;
             l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            for (int k = l; k < r; k++) {

                long long cost =
                    dp[l][k]
                    + dp[k + 1][r]
                    + p[l - 1] * p[k] * p[r];

                dp[l][r] =
                    min(dp[l][r], cost);
            }
        }
    }

    cout << dp[1][n] << '\n';

    return 0;
}

三十一、三道区间 DP 的共同结构

现在把三个问题放到一起。

问题 状态 分割/决策 求什么
石子合并 dp[l][r] 最后一次合并位置 k 最小代价
最长回文子序列 dp[l][r] 是否使用左右端点 最大长度
矩阵链乘法 dp[l][r] 最后一次乘法位置 k 最小代价

虽然题目完全不同,但核心思想高度相似:

把一个大区间的问题拆成更小的区间问题。


三十二、区间 DP 的核心套路

以后看到一个问题,可以按照下面的流程思考。

第一步:确定区间

问:

当前问题是不是针对连续的一段?

如果是,考虑:

$$
dp[l][r]
$$


第二步:确定状态含义

例如:

dp[l][r] = 区间 [l,r] 的最小代价

或者:

dp[l][r] = 区间 [l,r] 的最长回文长度

一定要把这句话说完整。


第三步:寻找最后一次操作

这是区间 DP 最重要的思维。

问:

假设整个区间最终已经解决,那么最后一步是什么?

例如石子合并:

最后一次合并

矩阵链:

最后一次矩阵乘法

然后找到:

k

将大区间拆成:

[l,k]
[k+1,r]

三十三、“最后一步”为什么这么重要?

这是 DP 中非常经典的思维方式。

假设:

[1,10]

最终完成。

我们不知道整个过程到底是什么。

但是我们可以问:

最后一步发生了什么?

假设最后一步把:

[1,k]

和:

[k+1,10]

合并。

那么在最后一步发生之前:

[1,k]

已经是一个完整答案。

[k+1,10]

也已经是一个完整答案。

于是:

$$
大问题
=
小问题1
+
小问题2
+
最后一步代价
$$

这正是动态规划的核心思想。


三十四、区间 DP 的标准模板

以后遇到典型区间 DP,可以先写:

for (int len = 2; len <= n; len++) {

    for (int l = 1;
         l + len - 1 <= n;
         l++) {

        int r = l + len - 1;

        // 初始化 dp[l][r]

        for (int k = l; k < r; k++) {

            // dp[l][r]
            // 根据 dp[l][k]
            // 和 dp[k+1][r]
            // 进行转移
        }
    }
}

这个模板建议熟练到可以脱离提示写出来。


三十五、为什么不能这样写?

很多初学者会写:

for (int l = 1; l <= n; l++) {
    for (int r = l; r <= n; r++) {
        ...
    }
}

这种写法并不一定错误,但很容易出现:

dp[l][r] 依赖的更短区间还没有计算。

例如:

dp[1][5]

可能需要:

dp[2][4]

如果计算顺序不正确,就可能使用尚未得到的状态。

因此初学阶段最推荐:

len → l → k

也就是:

按照区间长度递增。


三十六、区间 DP 的复杂度

典型区间 DP:

区间长度:O(n)
左端点:O(n)
分割点:O(n)

因此:

$$
O(n^3)
$$

空间:

$$
O(n^2)
$$

这也是为什么区间 DP 通常适用于:

n ≤ 几百

左右的数据范围。

如果:

n = 10^5

还需要进一步寻找特殊结构和优化方法。


三十七、一个非常重要的“DP 五问法”

从今天开始,遇到任何 DP 题,都建议学生强迫自己回答这五个问题:

① 状态是什么?

例如:

$$
dp[l][r]
$$

② 状态表示什么?

一定要用完整中文说出来。

③ 初始状态是什么?

例如:

$$
dp[i][i]=0
$$

或者:

$$
dp[i][i]=1
$$

④ 当前状态从哪里来?

也就是:

转移方程是什么?

⑤ 按什么顺序计算?

例如:

len 从小到大

如果这五个问题都能够回答,DP 基本就已经入门了。


三十八、三类 DP 的思维变化

到目前为止,我们已经学习了三大类 DP。

第一类:线性 DP

核心:

dp[i]

典型问题:

  • 最长上升子序列
  • 最大子段和
  • 打家劫舍
  • 字符串问题

核心思维:

当前状态从前面的哪些状态转移而来?


第二类:背包 DP

核心:

dp[j]

典型问题:

  • 01 背包
  • 完全背包
  • 多重背包
  • 分组背包

核心思维:

当前物品能不能选择?可以选择几次?


第三类:区间 DP

核心:

dp[l][r]

典型问题:

  • 石子合并
  • 矩阵链乘法
  • 回文问题

核心思维:

大区间的最后一步是什么?如何拆成小区间?


三十九、阶段性知识地图

                    动态规划
                       │
       ┌───────────────┼────────────────┐
       │               │                │
     线性DP           背包DP          区间DP
       │               │                │
    dp[i]             dp[j]          dp[l][r]
       │               │                │
       ↓               ↓                ↓
    LIS             01背包          石子合并
    LCS             完全背包        回文子序列
    编辑距离         多重背包        矩阵链乘法
                    分组背包

现在我们已经完成了动态规划的第一阶段基础框架。


四十、课后必须完成的训练

建议不要只做“看懂”,而要进行手算 → 写转移 → 写代码 → 调试四步训练。

题目 1:石子合并

输入:

4
1 3 5 2

要求:

  1. 手动画出 dp[l][r]。
  2. 计算 dp[1][2]。
  3. 计算 dp[2][4]。
  4. 计算 dp[1][4]。
  5. 写 C++。

题目 2:最长回文子序列

输入:

bbbab

要求:

  1. 写出 dp[l][r] 的定义。
  2. 写出字符相同时的转移。
  3. 写出字符不同时的转移。
  4. 手算最终答案。
  5. 修改程序,使其输出一个最长回文子序列。

题目 3:矩阵链乘法

矩阵:

A1:10 × 30
A2:30 × 5
A3:5 × 60

要求:

  1. 计算 (A1A2)A3 的代价。
  2. 计算 A1(A2A3) 的代价。
  3. 写出 dp[1][3]。
  4. 写 C++ 程序。

四十一、下一讲:树形 DP

接下来进入动态规划中一个非常重要的分支:

第五讲:树形 DP

我们会从最基础的树开始:

        1
      / | \
     2  3  4
       / \
      5   6

学习为什么树形 DP 通常需要:

dfs(u, father)

以及:

$$
dp[u][0]
$$

$$
dp[u][1]
$$

这样的状态。

重点学习三道经典题:

  1. 树的最大独立集 / 没有上司的舞会
  2. 树上背包
  3. 树的直径相关 DP

并重点解决一个学生经常遇到的问题:

为什么树形 DP 可以使用 DFS?DFS 和 DP 到底是什么关系?

随后我们再进入:

DAG 上的 DP → 状态压缩 DP → 数位 DP → 概率 DP → DP 优化。

这样最终可以建立一套比较完整的中学生竞赛动态规划知识体系。

20261007 190418 动态规划系统课程3

动态规划系统课程 · 第三讲:背包 DP

本讲目标: 从“暴力枚举”出发,彻底理解背包问题为什么可以使用动态规划,并掌握 01 背包 → 完全背包 → 多重背包 → 分组背包 → 背包计数。

这一讲非常重要。对于中学生信息学竞赛来说,背包 DP 是必须熟练掌握的核心专题。


一、什么是背包问题?

先看一个生活中的问题。

你有一个容量为:

10 kg

的背包。

现在有若干物品:

物品 重量 价值
1 2 6
2 2 3
3 6 5
4 5 4

每件物品最多选择一次。

问题:

怎样选择物品,使总重量不超过 10 kg,并且总价值最大?

这就是最经典的:

01 背包问题


二、为什么叫“01 背包”?

因为对于每件物品:

0:不选

1:选

只有两个选择。

例如有 3 件物品:

物品1:0 / 1
物品2:0 / 1
物品3:0 / 1

总共有:

$$
2^3=8
$$

种选择。

如果有 n 件物品:

$$
2^n
$$

种选择。


三、最直接的暴力方法

假设:

n = 3

可以枚举:

000
001
010
011
100
101
110
111

例如:

011

表示:

不选物品1
选择物品2
选择物品3

这种方法的时间复杂度:

$$
O(2^n)
$$

当:

n = 10

还可以。

但是:

n = 50

就已经非常困难了。

因为:

$$
2^{50}\approx 1.12\times10^{15}
$$

显然不能直接枚举。


四、动态规划如何解决?

我们观察:

很多不同的选择方案,会产生相同的“当前状态”。

例如:

已经考虑前 i 件物品
背包剩余容量为 j

后面的决策只与这个状态有关,而不需要知道我们是通过什么具体方式到达这个状态的。

这就是动态规划能够发挥作用的地方。


五、01 背包:二维 DP

先不要急着优化成一维。

定义:

$$
dp[i][j]
$$

表示:

只考虑前 i 件物品,背包容量不超过 j 时能够获得的最大价值。

这个状态定义非常重要。


六、01 背包的状态转移

考虑第 i 件物品。

它有两种情况。


情况 1:不选第 i 件物品

那么:

$$
dp[i][j]=dp[i-1][j]
$$

因为没有选择第 i 件,所以答案与前 i-1 件物品一样。


情况 2:选择第 i 件物品

如果选择它,那么必须满足:

$$
w_i\le j
$$

剩余容量:

$$
j-w_i
$$

前面最多可以获得:

$$
dp[i-1][j-w_i]
$$

再加上当前物品的价值:

$$
v_i
$$

所以:

$$
dp[i][j]
=
dp[i-1][j-w_i]+v_i
$$


七、完整转移方程

因此:

$$
dp[i][j]
=
\begin{cases}
dp[i-1][j], & j<w_i\
\max(dp[i-1][j],dp[i-1][j-w_i]+v_i), & j\ge w_i
\end{cases}
$$

这就是最经典的:

01 背包状态转移方程


八、第一道题:经典 01 背包

题目

有 n 件物品和一个容量为 W 的背包。

第 i 件物品:

重量:w[i]
价值:v[i]

每件物品最多选择一次。

求最大价值。


输入

4 10
2 6
2 3
6 5
5 4

分析

物品:

物品1:重量2,价值6
物品2:重量2,价值3
物品3:重量6,价值5
物品4:重量5,价值4

最优选择:

物品1 + 物品3

总重量:

$$
2+6=8
$$

总价值:

$$
6+5=11
$$

所以答案:

11

九、二维 DP C++ 实现

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    vector<vector<int>> dp(
        n + 1,
        vector<int>(W + 1, 0)
    );

    for (int i = 1; i <= n; i++) {

        for (int j = 0; j <= W; j++) {

            // 不选第 i 件物品
            dp[i][j] = dp[i - 1][j];

            // 选择第 i 件物品
            if (j >= w[i]) {
                dp[i][j] = max(
                    dp[i][j],
                    dp[i - 1][j - w[i]] + v[i]
                );
            }
        }
    }

    cout << dp[n][W] << '\n';

    return 0;
}

十、为什么可以优化成一维?

观察二维转移:

$$
dp[i][j]
$$

实际上只依赖:

$$
dp[i-1][j]
$$

和:

$$
dp[i-1][j-w_i]
$$

也就是说:

当前第 i 层只需要上一层的数据。

因此理论上没有必要保存整个二维数组。

可以压缩成:

dp[j]

表示:

当前已经处理的物品,在容量不超过 j 时的最大价值。

于是:

$$
dp[j]
=
\max(dp[j],dp[j-w_i]+v_i)
$$

但是这里出现了一个非常关键的问题:

循环应该从哪里开始?


十一、为什么 01 背包必须倒序?

代码:

for (int j = W; j >= w[i]; j--)

为什么?

假设当前物品:

重量 = 2
价值 = 3

容量:

W = 6

如果我们正序:

j = 2
j = 3
j = 4
j = 5
j = 6

那么:

dp[2]

可能已经使用了当前物品。

当计算:

dp[4]

时:

dp[4] = dp[2] + 3

这里的 dp[2] 已经包含当前物品。

于是相当于:

当前物品 + 当前物品

也就是:

同一件物品被使用了两次。

这就不是 01 背包了。


十二、倒序为什么可以避免重复?

如果:

for (int j = W; j >= w[i]; j--)

那么计算:

dp[j]

时:

dp[j-w[i]]

还没有被当前物品更新。

它仍然代表:

只使用前 i-1 件物品的结果。

所以:

dp[j-w[i]] + v[i]

恰好表示:

在前 i-1 件物品的基础上,再选择第 i 件。

这保证了当前物品最多使用一次。


十三、01 背包的一维代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        int w, v;
        cin >> w >> v;

        for (int j = W; j >= w; j--) {

            dp[j] = max(
                dp[j],
                dp[j - w] + v
            );
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

十四、学生必须真正理解的一句话

以后看到:

for (int j = W; j >= w; j--)

不要只记:

“01 背包就是倒序。”

而要理解:

倒序是为了让当前物品不能使用自己刚刚更新出来的状态。

这才是背包 DP 的本质。


十五、第二道题:完全背包

现在改变一个条件。

每种物品可以无限次使用。

例如:

物品1:重量2,价值3

那么可以:

选择 0 件
选择 1 件
选择 2 件
选择 3 件
...

这就是:

完全背包


十六、完全背包状态

仍然定义:

$$
dp[j]
$$

表示:

容量不超过 j 时的最大价值。

转移仍然是:

$$
dp[j]
=
\max(dp[j],dp[j-w_i]+v_i)
$$

但是:

循环方向改变了!

完全背包:

for (int j = w[i]; j <= W; j++)

也就是:

从小到大。


十七、为什么完全背包必须正序?

还是:

重量 = 2
价值 = 3

容量:

W = 6

正序:

j = 2

得到:

dp[2] = 3

然后:

j = 4

可以使用刚刚更新的:

dp[2]

于是:

dp[4] = dp[2] + 3
      = 6

这正好表示:

选择两件物品

继续:

dp[6] = dp[4] + 3
      = 9

表示:

选择三件物品

因此:

正序更新,恰好允许当前物品重复使用。


十八、01 背包与完全背包对比

01 背包 完全背包
每件物品 最多一次 无限次
转移 dp[j-w]+v dp[j-w]+v
循环方向 从大到小 从小到大
核心原因 防止重复使用 允许重复使用

记忆口诀:

01 倒序,完全正序。

但真正理解:

倒序 = 不允许使用当前层刚更新的数据。

正序 = 允许使用当前层刚更新的数据。


十九、第二道题:完全背包 C++ 实现

题目

有 n 种物品,每种物品可以无限次选择。

求容量不超过 W 时的最大价值。


C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        int w, v;
        cin >> w >> v;

        for (int j = w; j <= W; j++) {

            dp[j] = max(
                dp[j],
                dp[j - w] + v
            );
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

二十、第三道题:多重背包

现在继续改变条件。

每种物品不能无限使用,但是有数量限制。

例如:

物品1:
重量 = 2
价值 = 3
数量 = 4

那么可以选择:

0 件
1 件
2 件
3 件
4 件

这就是:

多重背包


二十一、最直接的多重背包 DP

定义:

dp[j]

仍然表示:

容量不超过 j 时的最大价值。

对于物品:

重量 w
价值 v
数量 c

我们枚举使用多少件:

$$
k=0,1,2,\ldots,c
$$

如果选择 k 件:

总重量:

$$
k\times w
$$

总价值:

$$
k\times v
$$

因此:

$$
dp[j]
=
\max_{0\le k\le c}
{dp[j-k w]+kv}
$$


二十二、多重背包的朴素实现

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        int w, v, c;
        cin >> w >> v >> c;

        for (int j = W; j >= w; j--) {

            for (int k = 1;
                 k <= c && k * w <= j;
                 k++) {

                dp[j] = max(
                    dp[j],
                    dp[j - k * w] + k * v
                );
            }
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

二十三、这个算法有什么问题?

假设:

W = 100000
c = 100000

那么:

j

循环一次。

内部还有:

k

循环。

复杂度可能达到:

$$
O(nWc)
$$

非常慢。

所以我们需要优化。


二十四、多重背包优化:二进制拆分

这是竞赛中必须掌握的技巧。

假设:

c = 13

我们不需要把 13 件物品一个一个处理。

可以拆成:

1
2
4
6

因为:

$$
1+2+4+6=13
$$

于是原来的:

13 件相同物品

变成:

4 个 01 背包物品

它们分别表示:

1 件
2 件
4 件
6 件

二十五、为什么二进制拆分可以表示 0~13?

使用:

1
2
4
6

可以组合出:

0
1
2
3
4
5
6
7
8
9
10
11
12
13

例如:

9 = 1 + 2 + 6
11 = 1 + 4 + 6
13 = 1 + 2 + 4 + 6

因此可以覆盖所有可能的选择数量。


二十六、二进制拆分后的物品

原始物品:

重量 = w
价值 = v
数量 = c

拆成若干个新的 01 物品。

例如:

数量 1:
重量 = w
价值 = v

数量 2:
重量 = 2w
价值 = 2v

数量 4:
重量 = 4w
价值 = 4v

剩余部分:

重量 = 6w
价值 = 6v

然后直接使用:

01 背包

因为每个拆出来的“组”最多使用一次。


二十七、多重背包二进制优化 C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<pair<int, int>> items;

    for (int i = 1; i <= n; i++) {

        int w, v, c;
        cin >> w >> v >> c;

        int k = 1;

        while (c > 0) {

            int take = min(k, c);

            items.push_back({
                w * take,
                v * take
            });

            c -= take;
            k *= 2;
        }
    }

    vector<int> dp(W + 1, 0);

    // 转化后的物品全部按照 01 背包处理
    for (auto [w, v] : items) {

        for (int j = W; j >= w; j--) {

            dp[j] = max(
                dp[j],
                dp[j - w] + v
            );
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

二十八、三种背包终于可以统一理解了

现在把三种背包放到一起。

01 背包

每件物品:

0 或 1 次
for (j = W; j >= w; j--)

完全背包

每件物品:

0、1、2、3、... 次
for (j = w; j <= W; j++)

多重背包

每件物品:

0 ~ c 次

可以:

枚举数量

或者:

二进制拆分

二十九、第四道题:分组背包

继续增加一个限制。

假设有若干组物品:

第 1 组:
物品 A
物品 B
物品 C

第 2 组:
物品 D
物品 E

第 3 组:
物品 F
物品 G

要求:

每组最多选择一个物品。

这就是:

分组背包


三十、分组背包状态

定义:

$$
dp[j]
$$

表示:

处理完当前这些组以后,容量不超过 j 时的最大价值。

处理某一组时:

枚举这一组选择哪个物品。

状态转移:

$$
dp[j]
=
\max(dp[j],dp[j-w]+v)
$$

但是必须保证:

不能在同一组中选择两个物品。

因此容量仍然需要:

从大到小

三十一、分组背包完整 C++ 代码

假设输入格式:

n W
每组:
k
w1 v1
w2 v2
...

代码:

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> dp(W + 1, 0);

    for (int group = 1; group <= n; group++) {

        int k;
        cin >> k;

        vector<pair<int, int>> items(k);

        for (int i = 0; i < k; i++) {
            cin >> items[i].first
                >> items[i].second;
        }

        // 处理这一组
        for (int j = W; j >= 0; j--) {

            for (auto [w, v] : items) {

                if (j >= w) {
                    dp[j] = max(
                        dp[j],
                        dp[j - w] + v
                    );
                }
            }
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

三十二、为什么分组背包也要倒序?

因为一组里面:

只能选择一个。

如果正序更新,就可能发生:

选择本组物品 A
↓
使用刚刚更新的 dp
↓
再选择本组物品 B

最终:

同一组选择了两个物品。

所以必须倒序。


三十三、第五道题:背包方案数

前面的背包都是:

求最大价值。

但 DP 不仅可以求最大值。

还可以:

求方案数量。

例如:

有:

1 2 3

三种物品,每种最多选择一次。

问:

凑出容量 4 有多少种选择方案?

可以选择:

1 + 3

或者:

1 + 2

所以有 2 种。


三十四、状态设计发生了变化

以前:

dp[j] = 最大价值

现在:

dp[j] = 凑出容量 j 的方案数

这就是非常重要的思想:

同样的状态维度,可以存储完全不同的东西。


三十五、01 背包求方案数

初始化:

dp[0] = 1;

为什么?

因为:

什么都不选,凑出 0,存在 1 种方案。

转移:

dp[j] += dp[j - w];

注意:

不是 max

而是:

+

因为我们在统计方案数量。


三十六、C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, W;
    cin >> n >> W;

    vector<int> dp(W + 1, 0);

    // 什么都不选,凑出 0 有一种方案
    dp[0] = 1;

    for (int i = 1; i <= n; i++) {

        int w;
        cin >> w;

        // 01 背包必须倒序
        for (int j = W; j >= w; j--) {

            dp[j] += dp[j - w];
        }
    }

    cout << dp[W] << '\n';

    return 0;
}

三十七、这里出现了一个非常重要的 DP 思想

请仔细比较:

求最大价值

dp[j] = max(
    dp[j],
    dp[j - w] + v
);

求方案数量

dp[j] += dp[j - w];

也就是说:

DP 的本质不是某个固定公式,而是“状态 + 状态之间的组合关系”。

不同的问题可能分别使用:

max
min
+
乘法
布尔 OR
布尔 AND

等等。


三十八、背包 DP 的统一模型

现在我们可以把很多背包问题统一起来。

设:

物品 i
重量:w[i]
价值:v[i]

核心状态:

dp[j]

然后根据“物品可以使用多少次”决定转移方式。

                 背包 DP
                    │
       ┌────────────┼────────────┐
       │            │            │
      01          完全         多重
       │            │            │
    最多1次       无限次       最多c次
       │            │            │
     倒序          正序        二进制拆分

三十九、背包 DP 最重要的判断题

以后看到题目,先不要写代码。

问:

问题 1

每个物品最多使用几次?

一次 → 01 背包
无限 → 完全背包
有限次 → 多重背包

问题 2

是每组最多选一个吗?

如果是:

分组背包

问题 3

求什么?

最大价值
最小费用
方案数量
是否可行

这会决定 DP 数组里保存什么。


四十、背包 DP 的“状态定义三原则”

以后设计背包 DP 时,可以使用这三个问题。

原则 1:容量是什么?

例如:

dp[j]

中的 j 通常表示:

当前背包容量。


原则 2:已经处理了什么?

二维 DP:

dp[i][j]

中:

i

通常表示:

已经考虑前 i 件物品。


原则 3:dp 中存的是什么?

可能是:

最大价值
最小费用
方案数
是否存在方案

所以不要看到:

dp[j]

就默认它一定是“最大价值”。


四十一、背包 DP 高频错误

错误 1:01 背包写成正序

错误:

for (int j = w; j <= W; j++)

这会允许物品重复使用。


错误 2:完全背包写成倒序

如果题目允许物品无限使用:

for (int j = W; j >= w; j--)

反而会限制物品最多使用一次。


错误 3:方案数初始化为 0

错误:

vector<long long> dp(W + 1, 0);

然后没有:

dp[0] = 1;

那么所有状态都可能一直是 0。

正确:

dp[0] = 1;

因为:

空集合是一种合法的初始方案。


错误 4:把“容量恰好为 j”和“不超过 j”混淆

例如:

dp[j]

可能表示:

容量不超过 j。

也可能表示:

恰好使用 j 的容量。

这两个定义不一样,初始化也可能不同。

一定要先明确。


四十二、一个非常重要的思维训练

假设:

W = 10

有一个物品:

w = 3
v = 5

问:

01 背包中,这个物品最多能贡献多少价值?

答案:

5

因为最多一次。

完全背包呢?

可以:

3 + 3 + 3

所以最大可以贡献:

15

多重背包,数量为 2?

最多:

3 + 3

价值:

10

这个例子非常适合帮助学生理解:

循环方向实际上是在控制“当前物品能不能再次进入状态”。


四十三、背包 DP 学习到这里,应该达到什么水平?

学生现在至少应该能够看到:

每件物品最多一次

马上想到:

for (j = W; j >= w; j--)

看到:

每件物品无限次

马上想到:

for (j = w; j <= W; j++)

看到:

每件物品最多 c 次

想到:

多重背包

然后根据数据范围决定:

朴素枚举

还是:

二进制拆分

四十四、今天的知识地图

                    背包 DP
                       │
        ┌──────────────┼──────────────┐
        │              │              │
      01背包         完全背包        多重背包
        │              │              │
      0/1次          无限次          0~c次
        │              │              │
      倒序            正序         二进制拆分
        │              │              │
        └──────────────┼──────────────┘
                       │
                 分组背包
                       │
                  每组最多一个
                       │
                       ↓
                 背包方案计数
                       │
              dp[j] 表示方案数
                       │
                       ↓
                 更复杂背包
                       │
        ┌──────────────┼──────────────┐
        │              │              │
      二维费用       混合背包       树上背包

四十五、本讲必须掌握的 8 个结论

请让学生尝试脱离代码回答:

① 01 背包为什么叫 01?

因为每件物品只能:

0 次或 1 次

② 01 背包为什么倒序?

因为防止当前物品重复使用。

③ 完全背包为什么正序?

因为允许当前物品重复进入后续状态。

④ 多重背包是什么?

每件物品有数量上限。

⑤ 多重背包为什么可以二进制拆分?

因为若干个特殊数量的“组”可以组合出 0 ~ c 中的任意数量。

⑥ dp[0] = 1 在方案计数中是什么意思?

不选择任何物品,是凑出 0 的一种方案。

⑦ 背包 DP 的 dp[j] 一定代表最大价值吗?

不一定。

它可以表示:

最大价值
最小费用
方案数
是否可行

⑧ 学背包最重要的是什么?

不是背代码,而是:

弄清楚状态表示什么,以及当前物品是否可以使用已经更新过的状态。


四十六、课后训练题

建议按照以下顺序练习。

Level 1:必须独立完成

练习 1

容量 W,每件物品只能选择一次,求最大价值。

要求:

  • 写出二维 DP。
  • 再优化成一维 DP。
  • 解释为什么倒序。

练习 2

每件物品可以无限选择,求最大价值。

要求:

  • 写出状态定义。
  • 写出转移方程。
  • 解释为什么正序。

练习 3

每种物品有数量限制 c[i]。

要求:

  • 先写朴素多重背包。
  • 再使用二进制拆分。
  • 比较两种算法复杂度。

四十七、Level 2:开始综合训练

完成:

01 背包求最大价值
01 背包求方案数
01 背包判断是否可行
完全背包求最大价值
完全背包求方案数
多重背包
分组背包
二维费用背包

这一阶段非常重要,因为学生会开始发现:

“背包”其实不是一道题,而是一整套状态设计模型。


四十八、下一阶段:区间 DP

下一讲我们进入:

第四讲:区间 DP

这是 DP 难度的一次明显提升。

重点学习:

dp[l][r]

为什么区间 DP 通常需要:

for (int len = 2; len <= n; len++)

以及如何从:

一个区间

拆成:

左区间 + 右区间

重点讲解三类经典问题:

  1. 石子合并
  2. 最长回文子序列
  3. 矩阵链乘法

并进一步学习:

  • 区间 DP 的“最后一次操作”思想
  • 为什么必须按照区间长度递增计算
  • 前缀和如何优化区间求和
  • 如何从区间 DP 推导更复杂的竞赛题
  • 如何判断一道题是不是区间 DP

等学生掌握这些内容之后,再进入 树形 DP、DAG DP、状态压缩 DP、数位 DP,最后再学习 DP 优化。

20261007 190334 动态规划系统课程1

从 0 开始学习动态规划:中学生系统掌握 DP

适合对象: 有 C++ 基础、正在学习算法与信息学竞赛的中学生
目标: 从“完全不知道什么是动态规划”,逐步达到能够独立分析、设计状态、写出 DP,并进一步解决较复杂的竞赛题。

动态规划(Dynamic Programming,简称 DP)是中学信息学竞赛中最重要、也最容易“感觉会了但遇到新题不会”的算法之一。

真正掌握 DP,不是背几十个模板,而是掌握下面这句话:

把一个大问题分解成许多规模更小的相同类型的问题,用状态保存已经计算过的结果,再按照状态转移关系逐步求出答案。


一、先建立动态规划的整体知识体系

我们先把动态规划进行分类。

动态规划 DP
│
├── 1. 线性 DP
│   ├── 最长上升子序列 LIS
│   ├── 最大子段和
│   └── 不同走法 / 爬楼梯
│
├── 2. 背包 DP
│   ├── 01 背包
│   ├── 完全背包
│   └── 多重背包
│
├── 3. 网格 DP
│   ├── 网格走法
│   ├── 最小路径和
│   └── 带障碍物路径
│
├── 4. 区间 DP
│   ├── 石子合并
│   ├── 回文串
│   └── 矩阵链乘法
│
├── 5. 树形 DP
│   ├── 树上最大独立集
│   ├── 树的最大路径
│   └── 树上背包
│
├── 6. 状态压缩 DP
│   ├── 集合状态
│   ├── 旅行商问题
│   └── 棋盘放置
│
├── 7. DAG / 拓扑 DP
│   ├── DAG 最长路
│   ├── DAG 最短路
│   └── 计数 DP
│
└── 8. 数位 DP
    ├── 统计数字
    ├── 数字和
    └── 相邻数字限制

对于中学生,我建议按照:

线性 DP → 网格 DP → 背包 DP → 区间 DP → DAG DP → 树形 DP → 状态压缩 DP → 数位 DP

这个顺序学习。


二、第一关:什么是动态规划?

2.1 一个最简单的问题

假设有楼梯:

        ┌───┐
        │ 4 │
    ┌───┤   │
    │ 3 │   │
┌───┤   │   │
│ 2 │   │   │
│   │   │   │
└───┴───┴───┴──
  1   2   3   4

每次可以走:

  • 1 阶
  • 2 阶

问:

走到第 n 阶有多少种不同的方法?

例如:

n = 1

只有:

1

所以:

f[1] = 1

n = 2

两种:

1 + 1
2

所以:

f[2] = 2

n = 3

最后一步有两种可能:

从第 2 阶走 1 步
从第 1 阶走 2 步

所以:

f[3] = f[2] + f[1]

于是:

f[3] = 2 + 1 = 3

继续:

f[4] = f[3] + f[2]

即:

f[4] = 3 + 2 = 5

三、动态规划最重要的 5 个问题

以后遇到 DP 题,不要马上写代码。

先问自己:

问题 1:状态是什么?

例如:

dp[i]

可能表示:

到达第 i 个位置时的最优答案。


问题 2:最后一步是什么?

这是 DP 中非常重要的思维。

例如爬楼梯:

到达第 i 阶:

第 i 阶
  ↑
 / \
i-1 i-2

因此:

dp[i] = dp[i-1] + dp[i-2]

问题 3:状态如何转移?

也就是:

当前状态由哪些以前的状态得到?

例如:

dp[i] = dp[i - 1] + dp[i - 2];

问题 4:初始状态是什么?

例如:

dp[1] = 1;
dp[2] = 2;

问题 5:最终答案在哪里?

例如:

cout << dp[n];

四、动态规划的核心公式

可以把 DP 总结成:

状态
 ↓
状态转移
 ↓
初始状态
 ↓
计算顺序
 ↓
最终答案

也就是:

dp[当前] = 根据若干 dp[之前] 计算

第一类:线性 DP

线性 DP 是最适合入门的 DP。

典型特征:

状态通常按照 1 → 2 → 3 → ... → n 的顺序计算。


题目 1:爬楼梯

题目

有 n 阶楼梯,每次可以爬:

  • 1 阶
  • 2 阶

问到达第 n 阶有多少种不同方法。


1. 状态设计

定义:

dp[i]

表示:

到达第 i 阶的方法数量。


2. 状态转移

到达第 i 阶,最后一步只有两种情况:

i-1 → i

i-2 → i

所以:

dp[i] = dp[i-1] + dp[i-2]

3. 初始值

dp[1] = 1
dp[2] = 2

4. C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<long long> dp(n + 1);

    if (n >= 1)
        dp[1] = 1;

    if (n >= 2)
        dp[2] = 2;

    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    cout << dp[n] << endl;

    return 0;
}

题目 2:最大子段和

题目

给定:

-2 1 -3 4 -1 2 1 -5 4

找出一个连续子数组,使它的和最大。

答案:

4 + (-1) + 2 + 1 = 6

所以答案是:

6

1. 状态设计

定义:

dp[i]

表示:

必须以第 i 个元素结尾的最大连续子段和。

这是一个非常重要的 DP 技巧:

状态定义必须足够精确。


2. 考虑第 i 个元素

假设当前:

a[i]

有两种选择。

情况一

自己重新开始:

a[i]

情况二

接在前面的最大子段后面:

dp[i-1] + a[i]

因此:

dp[i] = max(a[i], dp[i-1] + a[i])

3. C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<long long> a(n + 1);
    vector<long long> dp(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    dp[1] = a[1];

    long long answer = dp[1];

    for (int i = 2; i <= n; i++) {
        dp[i] = max(a[i], dp[i - 1] + a[i]);
        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

题目 3:最长上升子序列 LIS

题目

给出:

1 5 2 4 3 7

求最长严格上升子序列长度。

例如:

1 2 3 7

长度为:

4

注意:

子序列不要求连续。


1. 状态设计

定义:

dp[i]

表示:

以 a[i] 结尾的最长上升子序列长度。


2. 状态转移

如果:

a[j] < a[i]

那么:

dp[i] = max(dp[i], dp[j] + 1)

其中:

j < i

3. C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> a(n + 1);
    vector<int> dp(n + 1, 1);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    int answer = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j < i; j++) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }

        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

复杂度:

时间:O(n²)
空间:O(n)

第二类:网格 DP

网格 DP 是中学生非常容易理解的一类 DP。

例如:

┌───┬───┬───┬───┐
│   │   │   │   │
├───┼───┼───┼───┤
│   │   │   │   │
├───┼───┼───┼───┤
│   │   │   │   │
└───┴───┴───┴───┘

典型特征:

状态通常是 dp[i][j]。


题目 1:网格走法

只能:

向右 →
向下 ↓

问从左上角走到右下角有多少种方法。


状态

dp[i][j]

表示:

从 (1,1) 走到 (i,j) 的方案数。

到达 (i,j):

左边 → 当前
上边 ↓ 当前

所以:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, 0)
    );

    dp[1][1] = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (i == 1 && j == 1)
                continue;

            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

题目 2:最小路径和

每个格子有一个数字,只能向右或向下。

求从左上角到右下角的最小数字和。

例如:

1 3 1
1 5 1
4 2 1

最佳路线:

1 → 3 → 1 → 1 → 1

答案:

7

状态

dp[i][j]

表示:

到达 (i,j) 的最小路径和。

转移:

dp[i][j]
=
min(dp[i-1][j], dp[i][j-1])
+ a[i][j]

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<long long>> a(
        n + 1,
        vector<long long>(m + 1)
    );

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
        }
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, INF)
    );

    dp[1][1] = a[1][1];

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (i == 1 && j == 1)
                continue;

            dp[i][j] = min(
                dp[i - 1][j],
                dp[i][j - 1]
            ) + a[i][j];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

题目 3:带障碍物的网格

有些格子不能走。

例如:

S . .
. # .
. . T

其中:

S:起点
T:终点
#:障碍

求方案数。


状态

仍然是:

dp[i][j]

但是:

如果是障碍:
dp[i][j] = 0

否则:

dp[i][j]
=
dp[i-1][j]
+
dp[i][j-1]

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;

    vector<string> grid(n + 1);

    for (int i = 1; i <= n; i++) {
        string s;
        cin >> s;
        grid[i] = " " + s;
    }

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(m + 1, 0)
    );

    dp[1][1] = 1;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {

            if (grid[i][j] == '#') {
                dp[i][j] = 0;
                continue;
            }

            if (i == 1 && j == 1)
                continue;

            dp[i][j] =
                dp[i - 1][j] +
                dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;

    return 0;
}

第三类:背包 DP

背包 DP 是信息学竞赛中最重要的 DP 类型之一。

核心思想:

有限资源 + 选择物品 + 求最优值/方案数。


题目 1:01 背包

有 n 个物品。

每个物品:

重量 w[i]
价值 v[i]

背包容量为:

W

每个物品:

最多只能选择一次。

求最大价值。


状态

dp[j]

表示:

容量不超过 j 时能够获得的最大价值。


转移

对于物品 i:

不选

dp[j]

选择

dp[j-w[i]] + v[i]

所以:

dp[j]
=
max(dp[j], dp[j-w[i]] + v[i])

最关键的问题:为什么 j 要倒序?

for (int j = W; j >= w[i]; j--)

因为如果正序:

j = w[i] → W

当前物品可能被重复使用。

倒序可以保证:

当前物品只使用一次。

这是 01 背包最重要的代码特征。


C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        for (int j = W; j >= w[i]; j--) {

            dp[j] = max(
                dp[j],
                dp[j - w[i]] + v[i]
            );
        }
    }

    cout << dp[W] << endl;

    return 0;
}

题目 2:完全背包

与 01 背包不同:

每个物品可以无限次选择。

例如:

物品:
重量 价值

2    3
3    4
4    5

容量:

7

状态转移

仍然:

dp[j]
=
max(dp[j], dp[j-w[i]] + v[i])

但是循环方向改变:

for (int j = w[i]; j <= W; j++)

也就是:

完全背包正序。


C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {
        for (int j = w[i]; j <= W; j++) {

            dp[j] = max(
                dp[j],
                dp[j - w[i]] + v[i]
            );
        }
    }

    cout << dp[W] << endl;

    return 0;
}

题目 3:多重背包

每种物品有数量限制:

重量 w
价值 v
最多有 c 件

例如:

物品1:重量2,价值3,最多4件
物品2:重量3,价值5,最多2件

最直观的方法

枚举当前物品选择:

0 件
1 件
2 件
...
c 件

状态:

dp[j]

转移

dp[j]
=
max(
    dp[j],
    dp[j-k*w] + k*v
)

其中:

0 <= k <= c

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, W;
    cin >> n >> W;

    vector<int> w(n + 1);
    vector<int> v(n + 1);
    vector<int> c(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i] >> c[i];
    }

    vector<int> dp(W + 1, 0);

    for (int i = 1; i <= n; i++) {

        for (int j = W; j >= 0; j--) {

            for (int k = 1;
                 k <= c[i] && k * w[i] <= j;
                 k++) {

                dp[j] = max(
                    dp[j],
                    dp[j - k * w[i]] + k * v[i]
                );
            }
        }
    }

    cout << dp[W] << endl;

    return 0;
}

三种背包必须记住

类型 每件物品使用次数 j 的方向
01 背包 1 次 从大到小
完全背包 无限 从小到大
多重背包 有限制 通常需要额外处理

最重要的口诀:

01 背包倒序,完全背包正序。


第四类:区间 DP

区间 DP 是难度明显上升的一类。

核心特点:

状态表示一个区间 [l,r]。

因此状态通常:

dp[l][r]

题目 1:石子合并

有一排石子:

a1 a2 a3 ... an

每次只能合并相邻的两堆。

合并费用:

两堆石子的总数量

求把所有石子合成一堆的最小费用。


为什么是区间 DP?

因为最后一定是:

[l, r]

整个区间合并成一堆。

假设最后一次合并的位置是:

k

那么:

[l,k]

和:

[k+1,r]

分别已经合并好了。

因此:

dp[l][r]
=
min(
    dp[l][k]
    +
    dp[k+1][r]
    +
    sum(l,r)
)

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<long long> a(n + 1);
    vector<long long> prefix(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        prefix[i] = prefix[i - 1] + a[i];
    }

    auto sum = [&](int l, int r) {
        return prefix[r] - prefix[l - 1];
    };

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1; l + len - 1 <= n; l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            for (int k = l; k < r; k++) {

                dp[l][r] = min(
                    dp[l][r],
                    dp[l][k]
                    + dp[k + 1][r]
                    + sum(l, r)
                );
            }
        }
    }

    cout << dp[1][n] << endl;

    return 0;
}

题目 2:最长回文子序列

例如:

bbbab

最长回文子序列:

bbbb

答案:

4

状态

dp[l][r]

表示:

字符串 [l,r] 的最长回文子序列长度。


情况 1

如果:

s[l] == s[r]

那么:

dp[l][r]
=
dp[l+1][r-1] + 2

情况 2

如果:

s[l] != s[r]

那么至少舍弃一个端点:

dp[l][r]
=
max(
    dp[l+1][r],
    dp[l][r-1]
)

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    cin >> s;

    int n = s.size();

    vector<vector<int>> dp(
        n,
        vector<int>(n, 0)
    );

    for (int i = 0; i < n; i++) {
        dp[i][i] = 1;
    }

    for (int len = 2; len <= n; len++) {

        for (int l = 0; l + len - 1 < n; l++) {

            int r = l + len - 1;

            if (s[l] == s[r]) {

                if (len == 2)
                    dp[l][r] = 2;
                else
                    dp[l][r] = dp[l + 1][r - 1] + 2;

            } else {

                dp[l][r] = max(
                    dp[l + 1][r],
                    dp[l][r - 1]
                );
            }
        }
    }

    cout << dp[0][n - 1] << endl;

    return 0;
}

题目 3:矩阵链乘法

给出:

A1 × A2 × A3 × ... × An

矩阵乘法满足结合律:

(A1 × A2) × A3

和:

A1 × (A2 × A3)

结果相同。

但是计算次数可能不同。

求最少的乘法次数。


状态

dp[l][r]

表示:

计算矩阵 l 到 r 的最小乘法次数。

最后一次分割:

l ... k | k+1 ... r

所以:

dp[l][r]
=
min(
    dp[l][k]
    +
    dp[k+1][r]
    +
    cost
)

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<long long> p(n + 1);

    for (int i = 0; i <= n; i++) {
        cin >> p[i];
    }

    const long long INF = 4e18;

    vector<vector<long long>> dp(
        n + 1,
        vector<long long>(n + 1, 0)
    );

    for (int len = 2; len <= n; len++) {

        for (int l = 1; l + len - 1 <= n; l++) {

            int r = l + len - 1;

            dp[l][r] = INF;

            for (int k = l; k < r; k++) {

                long long cost =
                    dp[l][k]
                    + dp[k + 1][r]
                    + p[l - 1] * p[k] * p[r];

                dp[l][r] = min(
                    dp[l][r],
                    cost
                );
            }
        }
    }

    cout << dp[1][n] << endl;

    return 0;
}

第五类:DAG / 拓扑 DP

DAG:

Directed Acyclic Graph
有向无环图。

如果一个问题可以表示成:

状态 A → 状态 B

并且不存在环,就可以考虑 DP。


题目 1:DAG 最长路径

给定一个 DAG。

求从 1 出发能够达到的最长路径。


状态

dp[v]

表示:

到达节点 v 的最长路径长度。

对于边:

u → v

有:

dp[v] = max(dp[v], dp[u] + w)

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

struct Edge {
    int to;
    int weight;
};

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<Edge>> graph(n + 1);
    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;

        graph[u].push_back({v, w});
        indegree[v]++;
    }

    queue<int> q;

    for (int i = 1; i <= n; i++) {
        if (indegree[i] == 0)
            q.push(i);
    }

    vector<int> dp(n + 1, 0);

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        for (auto e : graph[u]) {

            int v = e.to;

            dp[v] = max(
                dp[v],
                dp[u] + e.weight
            );

            indegree[v]--;

            if (indegree[v] == 0)
                q.push(v);
        }
    }

    int answer = 0;

    for (int i = 1; i <= n; i++) {
        answer = max(answer, dp[i]);
    }

    cout << answer << endl;

    return 0;
}

题目 2:DAG 路径计数

从节点 1 到节点 n 有多少条路径?

状态:

dp[v]

表示:

从 1 到 v 的路径数量。

对于:

u → v

有:

dp[v] += dp[u]

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

const long long MOD = 1000000007;

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<int>> graph(n + 1);
    vector<int> indegree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        indegree[v]++;
    }

    queue<int> q;

    for (int i = 1; i <= n; i++) {
        if (indegree[i] == 0)
            q.push(i);
    }

    vector<long long> dp(n + 1, 0);

    dp[1] = 1;

    while (!q.empty()) {

        int u = q.front();
        q.pop();

        for (int v : graph[u]) {

            dp[v] += dp[u];
            dp[v] %= MOD;

            indegree[v]--;

            if (indegree[v] == 0)
                q.push(v);
        }
    }

    cout << dp[n] << endl;

    return 0;
}

题目 3:DAG 最短路

状态:

dp[v]

表示:

从起点到 v 的最短距离。

对于边:

u → v

:

dp[v]
=
min(dp[v], dp[u] + w)

这与 DAG 最长路的区别只有:

max

变成:

min

第六类:树形 DP

树形 DP 是信息学竞赛中非常重要的一类。

假设:

        1
       / \
      2   3
     / \
    4   5

每个节点都有自己的状态。


题目 1:树上最大独立集

要求:

选择尽可能多的节点,但是父子节点不能同时选择。

例如:

        1
       / \
      2   3
     / \
    4   5

可以选择:

1 4 5

也可以:

2 3

状态

定义:

dp[u][0]
dp[u][1]

含义:

dp[u][0]:
不选择 u 时,子树最大答案

dp[u][1]:
选择 u 时,子树最大答案

转移

如果选择 u:

dp[u][1]
=
1
+
所有 dp[v][0]

因为:

选择父节点 u,就不能选择孩子 v。

如果不选择 u:

dp[u][0]
=
sum(max(dp[v][0], dp[v][1]))

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

vector<vector<int>> graph;
vector<vector<int>> dp;

void dfs(int u, int parent) {

    dp[u][0] = 0;
    dp[u][1] = 1;

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        dfs(v, u);

        dp[u][0] += max(
            dp[v][0],
            dp[v][1]
        );

        dp[u][1] += dp[v][0];
    }
}

int main() {
    int n;
    cin >> n;

    graph.resize(n + 1);
    dp.resize(n + 1, vector<int>(2));

    for (int i = 1; i < n; i++) {

        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    dfs(1, 0);

    cout << max(
        dp[1][0],
        dp[1][1]
    ) << endl;

    return 0;
}

题目 2:树的最大路径和

每个节点有一个权值:

        5
       / \
      2   8
         / \
        3   4

求树中任意两个节点之间路径上的最大权值和。


核心思想

对于节点 u:

记录:

从 u 向下面走能够得到的最大路径。

然后考虑:

左边最长 + u + 右边最长

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

vector<vector<int>> graph;
vector<long long> value;
long long answer = LLONG_MIN;

long long dfs(int u, int parent) {

    long long best1 = 0;
    long long best2 = 0;

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        long long child = dfs(v, u);

        if (child > best1) {
            best2 = best1;
            best1 = child;
        }
        else if (child > best2) {
            best2 = child;
        }
    }

    answer = max(
        answer,
        value[u] + best1 + best2
    );

    return value[u] + best1;
}

int main() {
    int n;
    cin >> n;

    value.resize(n + 1);
    graph.resize(n + 1);

    for (int i = 1; i <= n; i++) {
        cin >> value[i];
    }

    for (int i = 1; i < n; i++) {

        int u, v;
        cin >> u >> v;

        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    dfs(1, 0);

    cout << answer << endl;

    return 0;
}

题目 3:树上选择问题

要求:

选择一些节点,父子节点不能同时选择,并且每个节点具有价值,求最大价值。

这实际上就是题目 1 的加权版本。

状态仍然:

dp[u][0]
dp[u][1]

但是:

dp[u][1] = value[u]

然后:

dp[u][1] += dp[v][0]

第七类:状态压缩 DP

这是从中级进入高级的重要一步。

核心思想:

用一个整数的二进制位表示一个集合。

例如:

5 = 0101

可以表示:

第 0 个元素选了
第 2 个元素选了

题目 1:旅行商问题 TSP

有 n 个城市。

从城市 0 出发:

每个城市访问恰好一次,最后回到 0。

求最小费用。


状态

dp[mask][i]

表示:

已经访问了 mask 中的城市,并且当前位于城市 i 时的最小费用。

例如:

mask = 01011

表示访问:

0、1、3

转移

从:

i

前往:

j

:

newMask = mask | (1 << j)

然后:

dp[newMask][j]
=
min(
    dp[newMask][j],
    dp[mask][i] + cost[i][j]
)

C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<vector<int>> cost(
        n,
        vector<int>(n)
    );

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            cin >> cost[i][j];
        }
    }

    const long long INF = 4e18;

    int total = 1 << n;

    vector<vector<long long>> dp(
        total,
        vector<long long>(n, INF)
    );

    dp[1][0] = 0;

    for (int mask = 0; mask < total; mask++) {

        for (int u = 0; u < n; u++) {

            if (dp[mask][u] == INF)
                continue;

            if (!(mask & (1 << u)))
                continue;

            for (int v = 0; v < n; v++) {

                if (mask & (1 << v))
                    continue;

                int newMask =
                    mask | (1 << v);

                dp[newMask][v] = min(
                    dp[newMask][v],
                    dp[mask][u] + cost[u][v]
                );
            }
        }
    }

    long long answer = INF;

    int full = (1 << n) - 1;

    for (int i = 1; i < n; i++) {

        answer = min(
            answer,
            dp[full][i] + cost[i][0]
        );
    }

    cout << answer << endl;

    return 0;
}

复杂度:

O(2^n × n²)

因此通常:

n ≤ 20

左右才比较适合。


题目 2:集合选取

有 n 个元素,每个元素有价值。

要求选择一个集合,使:

某些元素之间不能同时出现。

这类题目经常可以转化为:

mask

表示当前已经选择了哪些元素。


题目 3:棋盘状态压缩 DP

例如:

在一个 n × m 棋盘上放置棋子,要求任意两个棋子不能相邻。

可以使用:

mask

表示一行中哪些位置放了棋子。

例如:

01010

表示:

第 2、4 个位置放棋子

然后:

dp[i][mask]

表示:

前 i 行,第 i 行状态为 mask 时的最大方案数。

这是状态压缩 DP 在棋盘问题中的典型形式。


第八类:数位 DP

数位 DP 是中学生 DP 中相对高级的一类。

典型问题:

统计 1 ~ N 中满足某种数字条件的整数数量。

例如:

1 ~ N 中有多少个数字不包含数字 7?


为什么普通枚举不行?

如果:

N = 10^18

显然不能:

for (long long i = 1; i <= N; i++)

所以需要:

按数字位进行 DP。


数位 DP 的典型状态

常见状态:

dp[pos][state][limit]

其中:

pos

表示处理到第几位。

state

表示当前已经记录的信息。

limit

表示:

当前前缀是否受到 N 的限制。


题目 1:统计不含 7 的数字

例如:

N = 100

统计:

1 ~ 100

中不包含数字 7 的数字。


C++ 完整代码

#include <bits/stdc++.h>
using namespace std;

string s;

long long memo[20][2];
bool vis[20][2];

long long dfs(
    int pos,
    bool tight
) {
    if (pos == (int)s.size()) {
        return 1;
    }

    if (!tight && vis[pos][0]) {
        return memo[pos][0];
    }

    long long ans = 0;

    int limit = tight
        ? s[pos] - '0'
        : 9;

    for (int digit = 0; digit <= limit; digit++) {

        if (digit == 7)
            continue;

        bool nextTight =
            tight && (digit == limit);

        ans += dfs(
            pos + 1,
            nextTight
        );
    }

    if (!tight) {
        vis[pos][0] = true;
        memo[pos][0] = ans;
    }

    return ans;
}

int main() {
    cin >> s;

    cout << dfs(0, true) << endl;

    return 0;
}

这里需要特别注意:

leading zero

前导零的处理。

在更复杂的数位 DP 中通常还需要:

started

表示:

真正的数字是否已经开始。


题目 2:统计数字和

求:

1 ~ N

中所有数字的数位和。

例如:

1 + 2 + ... + 9 + 10 + 11 + ...

这类题目可以使用:

pos
sum
tight

进行状态设计。


题目 3:不出现连续相同数字

例如统计:

1 ~ N

中满足:

不存在连续两个相同数字

的数字数量。

状态可以设计成:

dp[pos][last][tight]

其中:

pos

当前位。

last

上一位数字。

tight

是否受到 N 限制。

转移时:

if digit == last
    不允许

这就是数位 DP 的典型思想。


九、动态规划中最重要的“状态设计”

学习 DP,真正的难点不是代码。

而是:

如何定义 dp。

下面给出一个非常重要的“状态设计表”。

问题类型 常见状态
爬楼梯 dp[i]
最大子段和 dp[i]
LIS dp[i]
网格 dp[i][j]
01 背包 dp[j]
区间 DP dp[l][r]
树形 DP dp[u][state]
DAG DP dp[u]
状态压缩 dp[mask][state]
数位 DP dp[pos][state][limit]

十、遇到一道新 DP 题,按照这个流程思考

以后给学生做题,我建议强制执行下面的流程。

Step 1:判断是不是 DP

问:

一个大问题能不能拆成很多相同类型的小问题?

如果可以,继续。


Step 2:定义状态

问:

dp[...] 到底表示什么?

必须用一句完整中文说出来。

例如:

dp[i] 表示以第 i 个元素结尾的最长上升子序列长度。

而不是:

dp 是答案。

这是非常重要的区别。


Step 3:寻找最后一步

这是最强的 DP 思维之一。

例如:

dp[i]

问:

到达 i 之前最后在哪里?

例如爬楼梯:

i-1 → i
i-2 → i

LIS:

j → i

区间 DP:

[l,k] + [k+1,r]

树形 DP:

父节点 ← 子节点

Step 4:写转移方程

例如:

dp[i] = dp[i-1] + dp[i-2]

或者:

dp[i] = max(dp[i], dp[j] + 1)

或者:

dp[l][r]
=
min(
    dp[l][k]
    + dp[k+1][r]
    + cost
)

Step 5:确定初始化

例如:

dp[1] = 1;

或者:

dp[i][i] = 1;

初始化错误是 DP 最常见的错误之一。


Step 6:确定计算顺序

这是非常重要的一步。

例如:

线性 DP

i = 1 → n

网格 DP

左上 → 右下

区间 DP

区间长度从小到大

DAG DP

拓扑序

树形 DP

后序 DFS

数位 DP

从高位到低位

十一、DP 的三个核心组成部分

可以把任何 DP 题强行写成:

          状态
           ↓
      dp[i] 是什么?
           ↓
       状态转移
           ↓
   dp 如何从以前状态得到?
           ↓
        初始状态
           ↓
       从哪里开始?
           ↓
        计算顺序
           ↓
       最终答案

十二、最容易犯的 10 个错误

错误 1:不知道 dp 表示什么

错误:

dp[i] = ...

却不知道 dp[i] 的具体含义。


错误 2:状态定义不完整

例如 LIS:

错误:

dp[i] = 前 i 个元素的答案

更好的定义:

dp[i] = 以 a[i] 结尾的最长上升子序列长度

错误 3:初始化错误

例如:

vector<int> dp(n);

默认全是:

0

但有些 DP 的初始值必须是:

1

或者:

-INF

或者:

INF

错误 4:01 背包循环方向错误

错误:

for (int j = w[i]; j <= W; j++)

这会让物品重复使用。

01 背包:

for (int j = W; j >= w[i]; j--)

错误 5:区间 DP 顺序错误

不能随便:

for (l)
    for (r)

通常需要:

for (len = 1; len <= n; len++)

保证:

dp[l][k]
dp[k+1][r]

已经计算。


错误 6:忘记考虑“最后一步”

这是最常见的思维障碍。

遇到 DP:

先想最后一步。


错误 7:把 DP 当成暴力搜索

暴力:

枚举所有可能

DP:

相同子问题只计算一次

错误 8:DP 数组开太大

例如:

int dp[100000][100000];

直接爆内存。

需要考虑:

状态压缩
滚动数组
一维 DP

错误 9:没有考虑数据范围

如果:

n = 100000

通常:

O(n²)

可能不行。

如果:

n = 20

那么:

O(2^n n²)

反而可能可以。

所以:

先看数据范围,再决定算法。


错误 10:背模板

这是最危险的。

不要记:

LIS = 这个模板
背包 = 那个模板

而应该记:

为什么这样定义状态?
为什么这样转移?
为什么循环顺序是这样?

十三、中学生 DP 学习路线

建议分成 5 个阶段。


第一阶段:DP 入门

目标:

能看懂简单 DP。

学习:

1. 什么是状态
2. 什么是状态转移
3. 初始化
4. 计算顺序

练习:

爬楼梯
斐波那契
走方格
最大子段和

第二阶段:线性 DP + 网格 DP

学习:

dp[i]

dp[i][j]

重点题型:

LIS
最长公共子序列 LCS
最大子段和
网格路径
最小路径

重点掌握:

“最后一步”分析法。


第三阶段:背包 DP

这是非常重要的阶段。

必须掌握:

01 背包
完全背包
多重背包
分组背包
二维费用背包
背包求方案数
背包求具体方案

重点理解:

为什么 01 背包倒序?

为什么完全背包正序?

第四阶段:高级 DP

学习:

区间 DP
树形 DP
DAG DP
状态压缩 DP
数位 DP

这个阶段开始真正进入竞赛级 DP。


第五阶段:综合 DP

最后学习:

DP + 二分
DP + 贪心
DP + 图论
DP + 数据结构
DP + 单调队列
DP + 线段树
DP 优化

例如:

斜率优化 DP
四边形不等式优化
单调队列优化
分治优化
树上背包

十四、DP 难度阶梯

可以把 DP 学习分成下面几个等级:

Level 0
│
├── 斐波那契
├── 爬楼梯
└── 简单网格
        ↓
Level 1
│
├── 最大子段和
├── LIS
└── LCS
        ↓
Level 2
│
├── 01 背包
├── 完全背包
└── 多重背包
        ↓
Level 3
│
├── 区间 DP
├── DAG DP
└── 树形 DP
        ↓
Level 4
│
├── 状态压缩 DP
├── 数位 DP
└── 树上背包
        ↓
Level 5
│
├── DP 优化
├── 单调队列优化
├── 斜率优化
└── 综合 DP

十五、学生必须掌握的 DP 模板

模板 1:一维 DP

vector<long long> dp(n + 1);

for (int i = 1; i <= n; i++) {

    // 根据以前的状态计算 dp[i]

}

模板 2:二维 DP

vector<vector<long long>> dp(
    n + 1,
    vector<long long>(m + 1)
);

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {

        // 状态转移

    }
}

模板 3:01 背包

for (int i = 1; i <= n; i++) {

    for (int j = W; j >= w[i]; j--) {

        dp[j] = max(
            dp[j],
            dp[j - w[i]] + v[i]
        );
    }
}

模板 4:完全背包

for (int i = 1; i <= n; i++) {

    for (int j = w[i]; j <= W; j++) {

        dp[j] = max(
            dp[j],
            dp[j - w[i]] + v[i]
        );
    }
}

模板 5:区间 DP

for (int len = 2; len <= n; len++) {

    for (int l = 1; l + len - 1 <= n; l++) {

        int r = l + len - 1;

        for (int k = l; k < r; k++) {

            dp[l][r] = min(
                dp[l][r],
                dp[l][k]
                + dp[k + 1][r]
                + cost
            );
        }
    }
}

模板 6:树形 DP

void dfs(int u, int parent) {

    for (int v : graph[u]) {

        if (v == parent)
            continue;

        dfs(v, u);

        // dp[u] 根据 dp[v] 转移
    }
}

模板 7:状态压缩 DP

for (int mask = 0; mask < (1 << n); mask++) {

    for (int i = 0; i < n; i++) {

        if (mask & (1 << i)) {

            // 状态转移

        }
    }
}

十六、真正掌握 DP 的标准

学生学完之后,不应该只是能够写:

dp[i] = ...

而应该能够面对一道陌生题,按照下面的顺序说出来:

① 我认为这是一个 DP 问题。

② 我定义 dp[...] 表示……

③ 考虑最后一步,当前状态可以由……转移过来。

④ 所以状态转移方程是……

⑤ 初始状态是……

⑥ 根据依赖关系,计算顺序应该是……

⑦ 状态数量是……

⑧ 每个状态转移需要……时间。

⑨ 因此总时间复杂度是……。

⑩ 最终答案是 dp[...]。

如果一个中学生能够完整说出这十步,才可以认为他真正开始掌握动态规划。


十七、建议的系统课程安排

如果把动态规划真正教成一门课程,我建议安排 12 个阶段:

阶段 内容 目标
01 DP 基本思想 理解什么是 DP
02 一维线性 DP 掌握 dp[i]
03 二维/网格 DP 掌握 dp[i][j]
04 LIS/LCS 掌握序列 DP
05 01 背包 掌握选择模型
06 完全/多重背包 掌握背包变形
07 区间 DP 掌握 dp[l][r]
08 DAG DP 掌握图上的 DP
09 树形 DP 掌握 DFS + DP
10 状态压缩 DP 掌握集合状态
11 数位 DP 掌握数字限制
12 DP 优化 进入竞赛提高阶段

十八、最重要的一张思维导图

最后把整个动态规划体系压缩成一张图:

                         动态规划
                            │
              ┌─────────────┴─────────────┐
              │                           │
           状态设计                    状态转移
              │                           │
       ┌──────┼──────┐             ┌──────┼──────┐
       │      │      │             │      │      │
      一维    二维   集合          加法    最大值   最小值
       │      │      │
      dp[i] dp[i][j] dp[mask]
       │      │      │
       ├──────┼──────┼──────────────────┐
       │      │      │                  │
      线性    网格   背包             状态压缩
       │      │      │                  │
       │      │   01/完全/多重           │
       │      │                         │
       └──────┴──────────┬──────────────┘
                          │
                     更高级的结构
                          │
             ┌────────────┼────────────┐
             │            │            │
           区间 DP      树形 DP      DAG DP
             │            │            │
             └────────────┼────────────┘
                          │
                     高级 DP
                          │
                 ┌────────┴────────┐
                 │                 │
              数位 DP          DP 优化
                                   │
                     ┌─────────────┼─────────────┐
                     │             │             │
                  单调队列       斜率优化      分治优化

十九、给中学生的最终口诀

学习 DP 时,可以让学生背下面这段口诀:

先判断,后定义;
找状态,想含义;
看最后,找转移;
定初值,排顺序;
算复杂,查边界;
写代码,再验证。

而面对一道真正的 DP 题,最应该反复问自己的只有三个问题:

① dp 到底表示什么?

② 当前状态从哪里来?

③ 为什么所有情况都被考虑到了?

只要这三个问题能够回答清楚,代码实际上只是把数学关系翻译成 C++。

如果按照中学生信息学奥赛的标准继续深入,下一步最值得系统学习的是 “线性 DP → LIS/LCS → 01 背包 → 完全背包 → 多重背包 → 区间 DP”,并通过由易到难的题目训练“状态定义”和“状态转移”能力,而不是继续单纯增加模板数量。

20261007 190323 动态规划系统课程2

动态规划系统课程 · 第二讲:线性 DP 与序列 DP

课程定位:中学生信息学竞赛 / C++ 算法训练

上一讲我们学习了动态规划的基本思想,以及线性 DP、网格 DP、背包 DP、区间 DP、树形 DP 等主要类型。

这一讲开始真正进入系统训练阶段。我们不再只是记忆转移方程,而是训练学生从题目描述出发,独立完成状态设计、转移推导、代码实现和复杂度分析。

本讲重点是:

  • 深入掌握线性 DP 的思考方法。

  • 学会解决最长上升子序列问题。

  • 学会解决最长公共子序列问题。

  • 学会用动态规划解决字符串编辑距离问题。

  • 理解朴素 DP 如何优化,以及为什么优化成立。

一、本讲的学习目标

完成本讲后,学生应当能够:

  1. 判断一道序列问题是否适合使用动态规划。

  2. 准确解释 dp[i] 和 dp[i][j] 的含义。

  3. 从“最后一个元素”或“最后一个字符”推导状态转移。

  4. 独立写出三道典型题目的 C++ 完整代码。

  5. 分析时间复杂度与空间复杂度。

建议先掌握 C++ 的数组、vector、循环、函数和 max()、min() 等基本知识。

二、先理解线性 DP 的本质

线性 DP 中,状态通常按照某个线性顺序进行计算,例如:

dp(1),dp(2),…,dp(n)dp(1),dp(2),\ldots,dp(n)dp(1),dp(2),…,dp(n)

但是,线性 DP 并不意味着转移只能来自前一个状态。

例如:

  • 爬楼梯:可能来自前两个状态。

  • 最大子段和:来自前一个状态或重新开始。

  • 最长上升子序列:可能来自前面任意一个满足条件的位置。

  • 字符串匹配:需要同时考虑两个序列的前缀。

因此,真正需要掌握的是:当前状态依赖哪些已经计算过的状态。

1. 以最长上升子序列为例

给定序列:

a=(3,1,2,5,4)a=(3,1,2,5,4)a=(3,1,2,5,4)

要求找到一个尽可能长的严格上升子序列。

例如:

1,2,51,2,51,2,5

或者:

1,2,41,2,41,2,4

它们的长度都是 3。

注意两个概念:

  • 子数组(子段):元素必须连续。

  • 子序列:元素不必连续,但相对顺序不能改变。

动态规划首先要把题目中的“最长”转化成一个精确的状态。

定义:

dp(i)=以 ai 结尾的最长上升子序列长度dp(i)=\text{以 }a_i\text{ 结尾的最长上升子序列长度}dp(i)=以 ai 结尾的最长上升子序列长度

这里的“以 aia_iai 结尾”非常关键。

如果只说 dp[i] 是前 i 个元素的最长上升子序列长度,后续的转移就会不同。

2. 如何推导转移?

假设当前元素为 aia_iai。

如果前面有一个元素 aja_jaj,满足:

j<i,aj<aij<i,\qquad a_j<a_ij<i,aj<ai

那么,以 aja_jaj 结尾的上升子序列,可以接上 aia_iai。

因此:

dp(i)=max⁡j<i, aj<ai{dp(j)+1}dp(i)=\max_{j<i,\ a_j<a_i}{dp(j)+1}dp(i)=j<i, aj<aimax{dp(j)+1}

如果没有任何符合条件的前驱,aia_iai 自己就构成长度为 1 的子序列。

所以完整的转移是:

dp(i)=max⁡(1,max⁡j<iaj<ai(dp(j)+1))dp(i)=\max\left(1,\max_{\substack{j<i\a_j<a_i}}(dp(j)+1)\right)dp(i)=max1,j<iaj<aimax(dp(j)+1)

这就是从自然语言推导状态转移方程的过程。

三、例题一:最长上升子序列 LIS

入门进阶

时间复杂度 O(n²)

题目描述

给定长度为 nnn 的整数序列 a1,a2,…,ana_1,a_2,\ldots,a_na1,a2,…,an,求最长严格上升子序列的长度。

输入示例

6
3 1 2 5 4 7

输出示例

4

一种最长上升子序列是:

1,2,4,71,2,4,71,2,4,7

长度为 4。

1. 第一步:设计状态

定义:

dp(i)=以 ai 结尾的最长上升子序列长度dp(i)=\text{以 }a_i\text{ 结尾的最长上升子序列长度}dp(i)=以 ai 结尾的最长上升子序列长度

为什么不直接定义成整个序列的答案?

因为我们希望在计算当前位置时,能够利用前面已经求出的结果。限制“以 aia_iai 结尾”,就明确了哪些前驱状态可以接到当前元素后面。

2. 第二步:初始化

每个元素至少可以独立组成一个长度为 1 的子序列。

因此:

dp(i)=1dp(i)=1dp(i)=1

注意,这里的初始化不是随意设置的,而是根据状态的具体含义推导出来的。

3. 第三步:推导转移方程

对每个位置 iii,枚举它前面的所有位置 jjj。

如果:

aj<aia_j<a_iaj<ai

那么可以把 aia_iai 接到以 aja_jaj 结尾的上升子序列后面:

dp(i)=max⁡(dp(i),dp(j)+1)dp(i)=\max(dp(i),dp(j)+1)dp(i)=max(dp(i),dp(j)+1)

最后,答案不是一定等于 dp[n],因为最长上升子序列不一定以最后一个元素结尾。

正确答案是:

ans=max⁡1≤i≤ndp(i)\text{ans}=\max_{1\le i\le n}dp(i)ans=1≤i≤nmaxdp(i)

4. 手工模拟

对于序列:

(3,1,2,5,4,7)(3,1,2,5,4,7)(3,1,2,5,4,7)

iii aia_iai dp(i)dp(i)dp(i) 原因
1 3 1 单独选择 3
2 1 1 前面没有更小元素
3 2 2 1 → 2
4 5 3 1 → 2 → 5
5 4 3 1 → 2 → 4
6 7 4 可以接在 5 或 4 后面

最终答案为 4。

5. C++ 完整实现

C++

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<int> a(n);
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }

    vector<int> dp(n, 1);
    int ans = 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        ans = max(ans, dp[i]);
    }

    cout << ans << '\n';
    return 0;
}

复杂度分析

  • 时间复杂度:O(n2)O(n^2)O(n2),因为每个位置都枚举了此前的位置。

  • 空间复杂度:O(n)O(n)O(n),用于保存序列和 DP 状态。

6. 进阶:如何优化到 O(n log n)?

当 nnn 很大时,O(n2)O(n^2)O(n2) 可能无法通过竞赛中的时间限制。

可以维护一个数组 tails:

tails[k] 表示长度为 k+1 的上升子序列中,可能达到的最小结尾值。

遍历每个元素 x:

  • 如果 x 大于 tails 的最后一个元素,就把它追加到末尾。

  • 否则,用 lower_bound 找到第一个大于等于 x 的元素,并替换它。

为什么可以这样做?

对于相同长度的上升子序列,结尾越小,未来越容易接上更大的元素。因此,我们只需维护每种长度对应的最小结尾值。

注意:tails 并不一定就是某个真实子序列;它维护的是各长度的最优结尾。

C++

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<int> tails;

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;

        auto it = lower_bound(
            tails.begin(), tails.end(), x
        );

        if (it == tails.end()) {
            tails.push_back(x);
        } else {
            *it = x;
        }
    }

    cout << tails.size() << '\n';
    return 0;
}

复杂度:

  • 时间:O(nlog⁡n)O(n\log n)O(nlogn)。

  • 空间:O(n)O(n)O(n)。

教学重点: 先让学生真正理解 O(n2)O(n^2)O(n2) 的状态转移,再学习优化。否则很容易只会背 lower_bound,却无法解释它为什么正确。

四、例题二:最长公共子序列 LCS

核心考点

二维线性 DP

LIS 只处理一个序列,而 LCS 同时处理两个序列。

题目描述

给定两个字符串 A 和 B,求它们的最长公共子序列长度。

例如:

A = ABCBDAB
B = BDCABA

一个公共子序列是:

BCBA

长度为 4,因此答案是 4。

这里仍然不要求字符连续,但字符顺序必须保持不变。

1. 状态设计

定义:

dp(i)(j)dp(i)(j)dp(i)(j)

表示:

字符串 A 的前 i 个字符与字符串 B 的前 j 个字符之间,最长公共子序列的长度。

例如:

dp(3)(4)dp(3)(4)dp(3)(4)

表示 A 的前三个字符和 B 的前四个字符之间的 LCS 长度。

为什么这里需要二维状态?

因为问题同时取决于两个字符串已经处理到哪里。

2. 初始化

如果任意一个字符串为空,就不存在非空公共子序列:

dp(0)(j)=0dp(0)(j)=0dp(0)(j)=0

dp(i)(0)=0dp(i)(0)=0dp(i)(0)=0

使用 vector 初始化为 0,可以自然完成这个过程。

3. 推导状态转移

我们比较两个字符串的最后一个字符。

情况一:最后一个字符相同

如果:

Ai=BjA_i=B_jAi=Bj

那么可以将这个字符接到前面两个前缀的公共子序列后面:

dp(i)(j)=dp(i−1)(j−1)+1dp(i)(j)=dp(i-1)(j-1)+1dp(i)(j)=dp(i−1)(j−1)+1

例如:

A 前缀:ABC
B 前缀:ADC

最后一个字符都是 C,因此可以在前面相应的 LCS 长度上加 1。

情况二:最后一个字符不同

如果:

Ai≠BjA_i\ne B_jAi=Bj

那么一个最长公共子序列不可能同时把这两个不同的末尾字符都作为同一个匹配字符。

我们分别考虑:

  • 不使用 A 的最后一个字符。

  • 不使用 B 的最后一个字符。

于是:

dp(i)(j)=max⁡(dp(i−1)(j),dp(i)(j−1))dp(i)(j)=\max(dp(i-1)(j),dp(i)(j-1))dp(i)(j)=max(dp(i−1)(j),dp(i)(j−1))

合并两种情况,得到完整转移:

dp(i)(j)={dp(i−1)(j−1)+1,Ai=Bjmax⁡(dp(i−1)(j),dp(i)(j−1)),Ai≠Bjdp(i)(j)= \begin{cases} dp(i-1)(j-1)+1,& A_i=B_j\ \max(dp(i-1)(j),dp(i)(j-1)),& A_i\ne B_j \end{cases}dp(i)(j)={dp(i−1)(j−1)+1,max(dp(i−1)(j),dp(i)(j−1)),Ai=BjAi=Bj

4. C++ 完整实现

C++

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string a, b;
    cin >> a >> b;

    int n = a.size();
    int m = b.size();

    vector<vector<int>> dp(
        n + 1, vector<int>(m + 1, 0)
    );

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i - 1] == b[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = max(
                    dp[i - 1][j],
                    dp[i][j - 1]
                );
            }
        }
    }

    cout << dp[n][m] << '\n';
    return 0;
}

注意:C++ 字符串下标从 0 开始,所以第 i 个字符对应 a[i - 1]。

5. 复杂度分析

设两个字符串长度分别为 nnn 和 mmm。

  • 时间复杂度:O(nm)O(nm)O(nm)。

  • 空间复杂度:O(nm)O(nm)O(nm)。

6. 进阶:如何恢复具体的公共子序列?

有时题目不只要求长度,还要求输出一个最长公共子序列。

可以从 dp[n][m] 开始倒推:

  1. 如果 a[i-1] == b[j-1],记录这个字符,然后令 i--, j--。

  2. 否则,如果 dp[i-1][j] >= dp[i][j-1],令 i--。

  3. 否则令 j--。

  4. 一直倒推到某个下标为 0。

最后将记录的字符逆序输出。

这说明 DP 不仅能计算答案,也能保存足够的信息来还原解。

五、例题三:字符串编辑距离 Edit Distance

综合应用

二维 DP

这道题进一步训练学生:当当前字符不同的时候,不一定只是在两个已有状态中取最大值,也可能需要比较多种操作的代价。

题目描述

给定两个字符串 A 和 B。

允许进行三种操作:

  1. 插入一个字符。

  2. 删除一个字符。

  3. 将一个字符替换为另一个字符。

每次操作的代价都是 1。

求把 A 变成 B 的最少操作次数。

例如:

A = horse
B = ros

答案是 3。

一种转换方式是:

horse
rorse    替换 h 为 r
rose     删除 r
ros      删除 e

共进行 3 次操作。

1. 状态设计

定义:

dp(i)(j)dp(i)(j)dp(i)(j)

表示:

将 A 的前 i 个字符转换为 B 的前 j 个字符所需的最少操作次数。

注意,这里求的是最小代价,不是最长长度。

2. 初始化

如果目标字符串为空:

dp(i)(0)=idp(i)(0)=idp(i)(0)=i

因为必须删除 A 的全部 i 个字符。

如果原字符串为空:

dp(0)(j)=jdp(0)(j)=jdp(0)(j)=j

因为必须插入 B 的全部 j 个字符。

这一步体现了一个原则:

初始化应当直接从状态的实际含义推导,而不是机械套用统一模板。

3. 推导状态转移

比较 A 的最后一个字符和 B 的最后一个字符。

情况一:两个字符相同

如果:

Ai=BjA_i=B_jAi=Bj

就不需要对这个字符额外操作:

dp(i)(j)=dp(i−1)(j−1)dp(i)(j)=dp(i-1)(j-1)dp(i)(j)=dp(i−1)(j−1)

情况二:两个字符不同

我们有三种选择。

替换

将 A 的最后一个字符替换成 B 的最后一个字符。

dp(i−1)(j−1)+1dp(i-1)(j-1)+1dp(i−1)(j−1)+1

删除

删除 A 的最后一个字符。

dp(i−1)(j)+1dp(i-1)(j)+1dp(i−1)(j)+1

插入

在 A 的末尾插入 B 的最后一个字符。

dp(i)(j−1)+1dp(i)(j-1)+1dp(i)(j−1)+1

因此:

dp(i)(j)=1+min⁡{dp(i−1)(j−1)dp(i−1)(j)dp(i)(j−1)dp(i)(j)=1+\min \begin{cases} dp(i-1)(j-1)\ dp(i-1)(j)\ dp(i)(j-1) \end{cases}dp(i)(j)=1+min⎩⎨⎧dp(i−1)(j−1)dp(i−1)(j)dp(i)(j−1)

这里的三个候选状态分别对应替换、删除和插入,不能遗漏其中任何一种。

4. C++ 完整实现

C++

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string a, b;
    cin >> a >> b;

    int n = a.size();
    int m = b.size();

    vector<vector<int>> dp(
        n + 1, vector<int>(m + 1, 0)
    );

    for (int i = 0; i <= n; i++) {
        dp[i][0] = i;
    }

    for (int j = 0; j <= m; j++) {
        dp[0][j] = j;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i - 1] == b[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = 1 + min({
                    dp[i - 1][j - 1],
                    dp[i - 1][j],
                    dp[i][j - 1]
                });
            }
        }
    }

    cout << dp[n][m] << '\n';
    return 0;
}

5. 复杂度分析

  • 时间复杂度:O(nm)O(nm)O(nm)。

  • 空间复杂度:O(nm)O(nm)O(nm)。

6. 这道题教会我们什么?

LCS 和编辑距离的状态都是:

dp(i)(j)dp(i)(j)dp(i)(j)

但状态含义和转移完全不同。

  • LCS 求最长长度,因此常使用 max()。

  • 编辑距离求最小操作数,因此常使用 min()。

  • LCS 在字符相同时增加长度。

  • 编辑距离在字符相同时不增加操作次数。

不能因为两道题都是二维 DP,就直接套用同一套转移方程。

六、三道题的横向比较

这是本讲最重要的总结之一。

比较内容 LIS LCS 编辑距离
处理对象 一个序列 两个序列 两个字符串
状态 dp[i] dp[i][j] dp[i][j]
状态含义 以 i 结尾的最长上升子序列 两个前缀的最长公共子序列 两个前缀的最小转换代价
主要转移 枚举前驱 比较末尾字符 枚举三种操作
优化目标 最大值 最大值 最小值
朴素时间复杂度 O(n2)O(n^2)O(n2) O(nm)O(nm)O(nm) O(nm)O(nm)O(nm)

这三道题分别训练三种思维:

  1. LIS: 从前面所有合法状态中寻找最优前驱。

  2. LCS: 通过两个序列的末尾关系进行分类讨论。

  3. 编辑距离: 将当前操作拆成若干种可能,取最优代价。

如果学生能理解这三种思维,后面学习背包 DP、区间 DP 时就会更容易。

七、课后练习:检查是否真正掌握

建议先独立完成,不要立即查看代码。

练习 1:LIS 的状态转移

序列为 2、5、3、4。按照 O(n²) 的方法,最终的最长上升子序列长度是多少?

2

3

4

练习 2:LCS 的初始化

对于两个字符串的最长公共子序列,dp(i)(0) 应该是多少?

0

i

1

练习 3:编辑距离

把空字符串转换成长度为 4 的字符串,最少需要多少次插入操作?

0 次

3 次

4 次

检查答案

八、下一讲学什么?

下一讲建议进入 第三讲:背包 DP 系统精讲。

背包 DP 是中学生信息学竞赛最值得投入时间的专题之一,后续将按照以下顺序展开:

  1. 从暴力枚举理解 01 背包。

  2. 推导二维状态转移,再优化成一维数组。

  3. 彻底理解为什么 01 背包必须倒序遍历。

  4. 完全背包:为什么循环方向变成正序。

  5. 多重背包:二进制拆分优化。

  6. 分组背包、二维费用背包与方案计数。

  7. 用 C++ 完整实现,并通过手工模拟、边界测试和复杂度分析检验程序。

本讲的核心要求: 不要只记住三个题目的代码。请尝试不看答案,独立写出每道题的状态定义、初始化和状态转移方程。能解释“为什么”,才算真正学会动态规划。