20261007 190418 动态规划系统课程3

20261007_190418_动态规划系统课程3.md

动态规划系统课程 · 第三讲:背包 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 优化。