ACM 一个月学习计划 · DAY 27

Day 27 · 完全背包 + DP 综合

一模一样的代码,只把内层循环的方向反过来 —— 每种物品就从「最多选一次」变成「随便选多少次」

今日目标:吃透完全背包的「正序枚举容量」,并认识多决策背包、计数型 DP、二维 DP 三种新题型
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1616 / P1802 / P1466 / P1387
← Day 26 · 0/1 背包:为什么要倒序枚举容量

⏱今日时间安排

0 - 40 min搞懂「正序 vs 倒序」的区别(第 1、2 节)—— 今天的命根子
40 - 60 min算清楚数组开多大、什么时候必须用 long long(第 3 节)
60 - 90 min多决策背包 + 计数背包 + 二维 DP 的思路(第 4、5 节)
90 - 180 min做 4 道题,每题限时 25 分钟
180 - 210 min对照本文题解,把没做出来的补掉
💡 昨天内容的衔接

昨天你学会了 0/1 背包的一维滚动数组写法,核心口诀是「外层枚举物品,内层倒序枚举容量」。

今天你会发现:把那个「倒序」改成「正序」,一维数组就自动变成了完全背包。昨天那道题的代码你几乎不用改。 但你必须搞清楚「为什么」,否则考场上你会分不清该用哪个。

1完全背包:把「倒序」改成「正序」

先回忆昨天的 0/1 背包

昨天的 P1048 采药是这样的:有若干种草药,每种最多采一次,给定总时间,求最大价值。

int w[N], v[N];                 // w[i] 花费,v[i] 价值
long long 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]);

今天的问题:同一种药可以无限采

P1616 疯狂的采药把题目改了一句话:「每种草药可以无限制地疯狂采摘」。 也就是说,同一种草药你可以采 0 次、1 次、3 次、100 次……只要总时间不超标。

那要怎么改代码?很多人的第一反应是:「那我在内层再加一层循环,枚举这种草药采几件」—— 那样确实能做,但复杂度变成 O(n·W·W),P1616 的 t 能到 10⁷,直接超时。

🎯 正确的答案只有一句话

把内层循环从「倒序」改成「正序」,别的都不用动。

0/1 背包:for(j = W; j >= w[i]; j--) 倒着走
完全背包:for(j = w[i]; j <= W; j++) 正着走

两个写法并排看

下面左右两段代码,只有箭头方向这一个字符不一样:

0/1 背包(每种最多 1 件) 完全背包(每种无限件)
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]);
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]);
🚫 这是整个背包问题里最容易记混的一处

如果你把两个方向记反了:

· 该用倒序的题写成正序 → 变成了完全背包 → 每种物品被重复使用 → 答案偏大(WA)
· 该用正序的题写成倒序 → 变成了 0/1 背包 → 每种物品只能用一次 → 答案偏小(WA)

所以不要死记,要理解下面第 2 节的手推过程。理解了就再也不会记反。

两者对比一览表

对比项0/1 背包完全背包
每种物品能选几次最多 1 次无限次
内层容量循环方向倒序(W → w[i])正序(w[i] → W)
内层用到的 dp[j-w[i]] 是上一轮(没用过 i)的值本轮(可能已经用过 i)的值
典型题目P1048 采药P1616 疯狂的采药
时间复杂度O(n·W)O(n·W)
✅ 好消息:完全背包并没有比 0/1 背包更慢

两者都是 O(n·W),都是一维数组。你不需要为了「无限件」多付任何代价。 这就是为什么「改一个方向」是标准做法,而不是什么奇技淫巧。

2为什么「正序」就等于「无限件」——把 dp 数组打出来看

光看代码是记不住的,我们把中间过程打印出来。用一个最小的例子: 只有 1 种物品,花费 w = 2,价值 v = 3,总容量 W = 6。

情况一:倒序(0/1 背包)

从 j = 6 一路走到 j = 2,下面是每一步执行完之后的 dp 数组(下标 0~6):

这一步dp[0]dp[1]dp[2]dp[3]dp[4]dp[5]dp[6]
初始0000000
j = 60000003
j = 50000033
j = 40000333
j = 30003333
j = 20033333

最终 dp[6] = 3。 也就是「只装得下 1 件」——完全符合 0/1 背包的定义。

关键在 j = 4 那一步:dp[4] = max(dp[4], dp[2] + 3)。此时 dp[2] 还没被更新(因为倒序, 下标 2 排在 4 后面才轮到),所以读到的还是初始值 0,于是 dp[4] = 3。
每一步都读到「没装过这件物品」的旧值 → 这件物品最多被用 1 次。

情况二:正序(完全背包)

从 j = 2 一路走到 j = 6:

这一步dp[0]dp[1]dp[2]dp[3]dp[4]dp[5]dp[6]
初始0000000
j = 20030000
j = 30033000
j = 40033600
j = 50033660
j = 60033669

最终 dp[6] = 9 = 3 × 3,正好是「装了 3 件」!

看 j = 4 那一步:dp[4] = max(dp[4], dp[2] + 3)。这次 dp[2] 已经被更新过了 (第 j = 2 步时就变成了 3),所以读到的值是 3,于是 dp[4] = 3 + 3 = 6——相当于「又装了一件」。
j = 6 同理:读到的 dp[4] = 6,再加 3 得到 9。

✅ 一句话记住

正序枚举时,dp[j - w[i]] 是本轮刚刚更新过的值,里面可能已经包含了物品 i; 所以「再加一件 i」是允许的 → 无限件。

倒序枚举时,dp[j - w[i]] 还是上一轮的旧值,绝对没有物品 i; 所以「再加一件 i」相当于第一次用它 → 最多 1 件。

💡 上面这两张表是真的跑出来的

我写了个小程序把每一步的 dp 数组打印出来(就是上面表格里的数字), 不是手算的。你也可以自己试:把 w、v、W 改掉,看看方向不同会不会导致结果不同。

3开数组之前先算命:数组多大、要不要 long long

背包题的代码都很短,真正让人 WA 的是数据范围。今天两道题都卡在这里。

第一步:数组开多大?

一维背包的数组长度 = 容量上限 + 1(因为要放下标 0)。

题目容量上限数组至少多大用 int 时的内存
P1616 疯狂的采药t ≤ 10⁷10⁷ + 1约 40 MB
P1802 5 倍经验日x ≤ 10³10³ + 1约 4 KB
P1466 集合half ≤ 780/2 = 390391约 1.5 KB
⚠️ 但是今天 P1616 和 P1802 的 dp 数组都不能用 int

内存只是问题的一半,另一半是数值会不会溢出。见下面。

第二步:会不会溢出?(这一步必须做)

做法:拿「最大答案」和 int 的上限(约 2.147×10⁹)比一比。

题目最大答案怎么估估算值int 够吗
P1616 ai 最小 1、bi 最大 10⁴,时间最多 10⁷ → 全采最划算的那种:10⁷ × 10⁴ 10¹¹ ❌ 必须 long long
P1802 n ≤ 10³、wini ≤ 10⁶ → s ≤ 10³ × 10⁶ = 10⁹;但题目要求输出 5s 5×10⁹ ❌ 必须 long long
P1466 n = 39 时方案数(本题实测) 1512776590 ⚠️ 勉强够,但保险起见用 long long
P1387 边长最大 100 100 ✅ int 绰绰有余
🚨 P1802 这个坑特别阴

你算 s 的时候会想:「n ≤ 1000,每人最多 10⁶ 经验,那 s 最多 10⁹,int 装得下啊」——对。

但题目要你输出的是 5s,也就是 5×10⁹,这就超过 int 上限 2.147×10⁹ 了。 很多人 dp 数组开了 long long,最后却写 printf("%d", dp[x] * 5) 或者 int ans = dp[x] * 5;,一样溢出。

结论:dp 数组、5 倍运算、输出,全程都得是 long long。

第三步:P1616 的内存账

vector<long long> dp(t + 1, 0);      // t = 10^7 时:10^7 × 8 字节 = 80 MB

算法本身是 O(m·t) 次操作,但空间要单独算一笔账:long long 占 8 字节, 而 int 只占 4 字节。t 取到上限 10⁷ 时,光这一个数组就要 80 MB。

⚠️ 这里有一个我不能从题面确认的地方

本题的题面和数据范围里并没有给出内存限制,所以我无法确认 80 MB 一定放得下。 一般竞赛题目的内存限制在 128 MB 或 256 MB 这个量级,80 MB 通常没问题 —— 但这是我基于常见情况的判断,不是题面的保证。你在洛谷提交前可以自己看一眼题目的内存限制。

反正代码是对的:我实测 t = 10⁷ 这组极限数据能正常跑完并输出正确结果。

💡 那能不能改用 int 省一半内存?

不行。用 int 时 dp 里的值最大会到 10¹¹,早就溢出了。这题的内存就是拿 long long 换来的,省不掉。

(顺带一提,如果你真的遇到内存吃紧的情况,思路是「把答案换个表示方法」,而不是把 dp 改成 int。)

🎯 为什么 P1616 的 O(m·t) 不会超时?

看它的数据范围里藏着一句关键的话:1 ≤ m × t ≤ 10⁷。

这句话的意思是:m 和 t 不可能同时很大。t 最大 10⁷ 时 m 只能是 1;m 最大 10⁴ 时 t 最多只有 10³。

而我们的双重循环次数正好就是 m × t ≤ 10⁷ —— 1 千万次操作,对 1 秒的时限来说非常轻松。

这就是为什么这题看着数据很吓人(t 到 10⁷、m 到 10⁴),却只用最朴素的完全背包就能过。 读数据范围时,一定要把每个数字和别的数字连起来看。

4从背包长出来的两种新题型

4.1 多决策背包(P1802 的做法)

普通背包每件物品只有两个选择:拿 或 不拿。今天 P1802 是:

决策花费收益
这个人打输了0 个药losei
这个人打赢了usei 个药wini

注意「打输」也要花 0 个药、也能拿经验(题目里 losei > 0)。所以每个人不管怎样都有收益, 区别只是「要不要花药去拿更多」。

转移就很自然了:

long long a = dp[j] + lose[i];              // 这个人打输:不花药,拿 lose
long long b = -1;
if(j >= use[i])
    b = dp[j - use[i]] + win[i];            // 这个人打赢:花 use 个药,拿 win
dp[j] = max(a, b);                          // 两个决策取最优 —— 这就是「多决策」
⚠️ 两个必须注意的地方

1. 外层容量要倒序。 因为每个人只能被打一次,本质还是 0/1 背包(每个人是一件「最多用一次」的物品)。

2. 药不够的时候也要加 losei。 也就是 j < use[i] 时不能跳过这个人, 他还是会贡献 losei。写成 if/else 只处理一半就错了。

🎯 「多决策」是一个通用套路

以后你还会见到「不选 / 选一半 / 全选」「不买 / 买 1 个 / 买 2 个」这类题。 做法都一样:把每个决策写成一条转移式子,外面套一层 max(或 min,或加法)。

4.2 计数型背包 / 「恰好装满」(P1466 的做法)

背包问题有三种常见问法,转移方程长得像但意思完全不同:

问法dp 的含义转移dp[0] 初值
最大价值(P1048、P1616)最大价值dp[j] = max(dp[j], dp[j-w]+v)0
方案数(P1466、P1164)方案总数dp[j] += dp[j-w]1
可行性(能不能凑出)true / falsedp[j] |= dp[j-w]true
🚫 计数型的两条铁律

铁律一:dp[0] = 1。 意思是「凑出和 0 有 1 种方案:什么都不选」。 如果你写 dp[0] = 0,那么所有 dp 值都会是 0,因为加法是从 dp[0] 推出去的。

铁律二:加法要倒序。 每个数(或每件物品)只能用一次,所以内层还是倒序。 正序的话方案数会被重复统计。

P1466 就是这个套路的应用:把「划分成两个和相等的集合」变成「从 1~n 里挑出若干个数、让它们的和恰好是总和的一半」。 挑出来的那一堆就是一个集合,剩下的自动就是另一个集合。

5二维 DP:从「左、上、左上」推出当前格(P1387)

今天最后一道题换了个维度:状态要带两个下标 dp[i][j],表示网格里的位置。

题意转化

要找「不含 0 的最大正方形」的边长。如果直接枚举左上角和边长再逐格检查,复杂度是 O(n³·面积),n、m ≤ 100 时能过但很笨。

聪明的做法是:让 dp[i][j] 表示「以 (i, j) 这个格子为右下角,能构成的最大全 1 正方形的边长」。

🎯 核心转移式(背下来)

如果 a[i][j] == 0:dp[i][j] = 0(这个格子是 0,不可能当全 1 正方形的右下角)

如果 a[i][j] == 1:dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

答案 = 所有 dp[i][j] 里的最大值。

为什么是「三个方向取最小」

假设你想让 (i, j) 当右下角、边长做到 k。那么它必须同时满足三件事:

三个条件必须同时成立,也就是取它们的最小值;然后加上自己这一格,边长就是 min + 1。

方向状态它代表的那块正方形在哪
上dp[i-1][j]「正上方」那块(宽 k−1,贴着当前格的左侧)
左dp[i][j-1]「左边」那块(高 k−1,贴着当前格的上侧)
左上dp[i-1][j-1]「左上角」那块(完全在内部)
🚨 最经典的错法:min 里只写了两个

写成 min(dp[i-1][j], dp[i][j-1]) + 1,漏掉 dp[i-1][j-1]。

为什么错?考虑这个 2×3 的矩阵(本来是 2 行):

1 1 1
1 0 1

到 (2,3) 时,左边 dp[2][2] = 0、上面 dp[1][3] = 3,只取这两个的 min 是 0,结果 dp[2][3] = 1,还算对。 但换一个形状就会出问题:

1 1 0 1
1 1 1 1
1 1 ? 

这种「左边很大、上面很大,但左上角那块其实缺口」的情形,只有带上 dp[i-1][j-1] 才能正确拦住。 三个状态一个都不能少。

💡 从 (1,1) 开始存,边界自动是 0

把矩阵存进 a[1..n][1..m],dp 同样从 1 开始。这样 dp[0][*]、dp[*][0] 全自动是 0(全局数组默认值),不需要手写任何边界特判。

注意数组要开 [105][105] 而不是 [100][100] —— 下标要用到 n 和 m,多留 5 个位置最稳。

6四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译通过(零警告)并跑过官方样例,输出是你看到的真实结果。

P1616 疯狂的采药 普及- · 完全背包模板

题意:山洞里有 m 种草药,采第 i 种要花 ai 的时间,能得到 bi 的价值。 总共有 t 的时间。每种草药可以无限制地采。问最多能采到多少总价值。

它就是昨天 P1048 采药的改编版,唯一的改动是「每种可以无限采」。

⚠️ 数据范围(原文)

1 ≤ m ≤ 10⁴,1 ≤ t ≤ 10⁷,且 1 ≤ m × t ≤ 10⁷,1 ≤ ai, bi ≤ 10⁴。
另有 30% 的数据保证 m ≤ 10³。

两个关键推论:
① m × t ≤ 10⁷ 说明双重循环最多转 10⁷ 次,O(m·t) 完全够用;
② 最大答案可达 10⁷ × 10⁴ = 10¹¹,必须用 long long。

思路

完全背包模板题。一维滚动数组,外层枚举草药种类,内层正序枚举时间。

注意输入顺序:第一行是 t 在前、m 在后(先时间,后草药种类数),别读反。

样例输入
70 3
71 100
69 1
1 2
样例输出
140

后两种草药一个要 69 时间只值 1,一个要 1 时间值 2。最优解是采第 3 种 70 次: 70 时间、70 × 2 = 140 价值。(第 1 种要 71 时间,超了,一次都采不了。)

完整代码

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

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

    int t, m;
    cin >> t >> m;                       // ★ 第一行是「时间 种类数」

    vector<int> w(m + 1), v(m + 1);
    for(int i = 1; i <= m; i++)
        cin >> w[i] >> v[i];

    // dp[j] = 时间限制为 j 时能采到的最大总价值(用 long long!)
    vector<long long> dp(t + 1, 0);

    for(int i = 1; i <= m; i++){
        // ★ 完全背包:容量正序枚举,j 从小到大
        for(int j = w[i]; j <= t; j++){
            if(dp[j - w[i]] + v[i] > dp[j])
                dp[j] = dp[j - w[i]] + v[i];
        }
    }

    cout << dp[t] << "\n";
    return 0;
}
✅ 实测结果

官方样例 70 3 / 71 100 / 69 1 / 1 2 → 输出 140 完全一致

极限规模 10000000 1 / 1 10000(t = 10⁷,是数据范围的上限)→ 输出 100000000000(10¹¹),约 80 ms 跑完。
这个结果同时也证明了 long long 是必须的:10¹¹ 用 int 早就溢出了。

另做了 60 组随机小数据,和一份「二维完全背包」的独立实现逐一对照,结果零差异。

易错点

  • 内层写成倒序 → 退化成 0/1 背包,每种草药只能采一次 → 答案偏小。
  • dp 用 int → 答案可达 10¹¹,直接溢出成负数或错误值。
  • 第一行读反:题目是 t m(时间在前),写成 cin >> m >> t 就全错了。
  • 数组开成 dp[t] 而不是 dp[t+1] → 下标 t 越界。
  • 这题用 cin/cout 时建议加 ios::sync_with_stdio(false),数据量大时更稳(我的代码加了)。
P1802 5 倍经验日 普及/提高- · 多决策背包

题意:有 n 个好友和 x 个「属性药」。对第 i 个人:打输了得 losei 经验; 想打赢至少要花 usei 个药,赢了得 wini 经验。 药花得不够 usei 就必输,而且那些药也白白浪费。

求最多能拿到的经验 s,然后输出 5s。

⚠️ 数据范围(原文)

0 ≤ n, x ≤ 10³;0 < losei ≤ wini ≤ 10⁶;0 ≤ usei ≤ 10³。

(另有 10% 的数据保证 x = 0。)

关键:s 最大约 10³ × 10⁶ = 10⁹,看着 int 装得下;但题目要输出 5s = 5×10⁹, 超过 int 上限 2.147×10⁹ —— 必须全程 long long。

思路

把「每个好友」看成一件物品,但它的决策有两个:

决策花费收益
打输0losei
打赢useiwini

因为每个人只打一次,本质是 0/1 背包,所以容量倒序枚举。转移时对两个决策取 max。

另外注意 usei = 0 的情况:不花药也能赢,而 wini ≥ losei, 所以公式会自动选「打赢」,不需要特判。

样例输入
6 8
21 52 1
21 70 5
21 48 2
14 38 3
14 36 1
14 36 2
样例输出
1060

6 个人全打输保底 21+21+21+14+14+14 = 105 点。
用 8 个药去赢第 1、2、3 个人(花 1+5+2 = 8 个药),比打输多赚 (52−21)+(70−21)+(48−21) = 31+49+27 = 107 点。
总共 105 + 107 = 212,乘 5 就是 1060。

完整代码

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

int main(){
    int n, x;
    cin >> n >> x;

    vector<int> lose(n + 1), win(n + 1), use(n + 1);
    for(int i = 1; i <= n; i++)
        cin >> lose[i] >> win[i] >> use[i];

    // dp[j] = 药最多用 j 个时,累计获得的最大经验
    vector<long long> dp(x + 1, 0);      // ★ long long

    for(int i = 1; i <= n; i++){
        // 0/1 背包:容量倒序枚举,保证每个人只被考虑一次
        for(int j = x; j >= 0; j--){
            long long a = dp[j] + lose[i];          // 这个人打输了
            long long b = -1;
            if(j >= use[i])                          // 药够,可以打赢
                b = dp[j - use[i]] + win[i];
            dp[j] = (a > b ? a : b);                 // ★ 两个决策取最优
        }
    }

    cout << dp[x] * 5 << "\n";       // ★ 输出 5 倍,必须 long long
    return 0;
}
💡 为什么最后直接输出 dp[x] 就行?

因为「多花药不会有坏处」,所以 dp[j] 随 j 增大是单调不减的。 药最多有 x 个,那当然用满最划算,答案就是 dp[x]。

(严格说 dp[j] 表示「最多用 j 个药」,用不满也没关系,它会自动取到最优。)

✅ 实测结果

官方样例 → 输出 1060 完全一致

边界 x = 0(题目里 10% 的数据就是这种):输入 1 0 / 5 10 3 → 输出 25。 (一个药都没有,只能打输拿 5 点经验,5 × 5 = 25,正确。)

另做了 60 组随机小数据,和「2ⁿ 枚举每个人赢/输」的暴力程序逐一对照,结果零差异。

易错点

  • 只写了「打赢」的分支,药不够时忘了加 losei → 答案偏小。
  • 最后乘 5 时用了 int → 5×10⁹ 溢出。dp 是 long long 也没用,要保证乘出来还是 long long。
  • 容量写成正序 → 一个人被反复打了,答案偏大。
  • 误以为「打输就不打这个人了」。题目里 6 个人是要全打的,打输也有 losei 经验。
  • usei 可以为 0,dp[j - use[i]] 就是 dp[j] 本身,这里的顺序要想清楚(我的代码用 a、b 两个临时变量,所以没问题)。
P1466 [USACO2.2] 集合 Subset Sums 普及/提高- · 计数型 DP

题意:把集合 {1, 2, 3, …, n} 划分成两个子集合,要求两个子集合里所有数字的和相等。 问一共有多少种划分方案。交换两个集合的位置算同一种方案。

⚠️ 数据范围(原文)

对于 100% 的数据,1 ≤ n ≤ 39。

n = 39 时总和 39 × 40 / 2 = 780,一半是 390,所以 dp 数组只要开 391 个就够了 —— 非常小。

思路

第一步:把问题翻译一下。

两个集合和相等,那么每个集合的和都必须是总和的一半。设 sum = n(n+1)/2。

🚨 如果 sum 是奇数,答案是 0

总和是奇数时,不可能分成两个整数和相等的集合,直接输出 0。 这一步不能省:如果你不判断,half = sum / 2 会向下取整, 程序会去数「和为 ⌊sum/2⌋ 的子集」,输出一个毫无意义的错误答案。

第二步:转化成背包。 从 1 ~ n 里挑出若干个数字,让它们的和恰好等于 half —— 这问的是方案数。

dp[j] = 从 {1..n} 中选出若干个数、和恰好为 j 的方案数
转移:  dp[j] += dp[j - i]        // 数字 i 选或不选
初值:  dp[0] = 1                 // 「一个都不选」只有 1 种

每个数字只能用一次,所以内层倒序。

第三步:为什么要除以 2。

dp[half] 数的是「和为 half 的子集有多少个」。假设某个子集是 S,那它的补集就是另一半, S 和补集是同一个划分方案的两种写法。所以每个划分方案在 dp[half] 里被数了两次, 答案要除以 2。

💡 用小例子验证一下

n = 3,sum = 6,half = 3。和为 3 的子集有两个:{3} 和 {1,2}。
所以 dp[3] = 2,除以 2 得 1 —— 而题目原话也正是「{3} 和 {1,2} 是唯一一种分法」。✅

样例输入
7
样例输出
4

n = 7 时 sum = 28,half = 14。和为 14 的子集有 8 个(就是题目列的 4 种划分 × 2 种写法), dp[14] = 8,除以 2 得 4。

完整代码

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

int main(){
    int n;
    cin >> n;

    int sum = n * (n + 1) / 2;
    if(sum % 2 != 0){                 // ★ 总和是奇数,不可能平分
        cout << 0 << "\n";
        return 0;
    }
    int half = sum / 2;

    // dp[j] = 从 {1..n} 中选出若干数、和恰好为 j 的方案数
    vector<long long> dp(half + 1, 0);
    dp[0] = 1;                        // ★ 一个都不选,和为 0,算 1 种

    for(int i = 1; i <= n; i++)
        for(int j = half; j >= i; j--)   // 每个数只能用一次 -> 倒序
            dp[j] += dp[j - i];

    cout << dp[half] / 2 << "\n";     // ★ 两个集合互换算同一种,除以 2
    return 0;
}
✅ 实测结果

官方样例 7 → 输出 4 完全一致

边界测试:
· 1 → 0(sum = 1 是奇数,走特判分支)
· 3 → 1(题目原文里说「{3} 和 {1,2} 是唯一一种分法」)
· 39(数据范围上限)→ 1512776590

因为 n = 39 这组数据我没有官方答案可以对照,所以我又写了一份完全不同的程序 —— 用「折半枚举(meet in the middle)」把 1~39 拆成两半、 枚举所有子集和再用二分配对。两份代码在 n = 7 / 1 / 3 / 39 上结果完全一致。

易错点

  • 忘记除以 2 → 输出恰好是正确答案的两倍。
  • 忘记判断 sum 是奇数 → 会数出一个错误的非零答案。
  • dp[0] 忘了设成 1 → 所有 dp 值都是 0,答案恒为 0。
  • 内层写成正序 → 同一个数字被反复用,方案数暴涨。
  • sum 用 int 算没问题(最大 780),但 dp 里的方案数要 long long(n = 39 时是 15 亿)。
  • 内层循环下界写成 j >= 0 或 j >= 1:因为 j - i 必须 ≥ 0,所以条件是 j >= i,写成别的会数组越界。
P1387 最大正方形 普及/提高- · 二维 DP

题意:给一个 n × m 的、只含 0 和 1 的矩阵,找出一个不包含 0 的最大正方形, 输出它的边长。题目保证矩阵里至少有一个 1。

⚠️ 数据范围(原文)

输入第一行为两个整数 n, m(1 ≤ n, m ≤ 100),接下来 n 行每行 m 个用空格隔开的 0 或 1。

数据很小,所以二维数组开 105 × 105 绰绰有余。答案最大是 100,int 完全够。

思路

让 dp[i][j] = 以 (i, j) 为右下角的最大全 1 正方形的边长。

  • a[i][j] == 0 → dp[i][j] = 0(自己是 0,当不了全 1 正方形的角)
  • a[i][j] == 1 → dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

最后扫一遍所有 dp 值取最大。复杂度 O(n·m) = 10⁴,飞快。

样例输入
4 4
0 1 1 1
1 1 1 0
0 1 1 0
1 1 0 1
样例输出
2

最大的全 1 正方形是左上角那块 2×2((1,2)、(1,3)、(2,2)、(2,3)),边长 2。 没有更大的了,所以输出 2。

完整代码

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

int a[105][105];
int dp[105][105];        // 全局数组,自动全部初始化为 0

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            cin >> a[i][j];

    int ans = 0;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(a[i][j] == 1){
                // 左、上、左上 三个状态取最小,再加 1
                int mn = min(min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]);
                dp[i][j] = mn + 1;
                if(dp[i][j] > ans) ans = dp[i][j];
            }
        }
    }

    cout << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 → 输出 2 完全一致

边界测试 3 3 全 1 矩阵 → 输出 3(整个矩阵就是正方形)。

另做了 60 组随机小数据(n、m ≤ 6),和「枚举每个左上角 + 每个边长 + 逐格检查」的暴力程序 逐一对照,结果零差异。

易错点

  • min 里只写两个状态(漏掉 dp[i-1][j-1])→ 某些形状下边长算大,WA。三个都要写。
  • 输出面积而不是边长:题目要的是边长,别写成 ans * ans。
  • 把 dp 定义成局部数组 int dp[105][105];(在 main 里)→ 不会自动清零,边界值成了随机数。放全局就自动是 0。
  • 数组开成 a[100][100]:下标要用到 n = 100,会越界。开 105 留余量。
  • a[i][j] == 0 时不用手动赋值,全局数组本来就是 0;但如果你为了省事把 dp 清成别的数,这里就得补一句 dp[i][j] = 0;。

✗今日常见错误汇总

#错误写法后果正确写法
1 完全背包内层写成
for(j=W; j>=w[i]; j--)
退化成 0/1 背包,每件只能用一次 → 答案偏小 for(j=w[i]; j<=W; j++)
2 0/1 背包内层写成
for(j=w[i]; j<=W; j++)
升格成完全背包,每件能被用无限次 → 答案偏大 for(j=W; j>=w[i]; j--)
3 dp 数组用 int P1616 答案有 10¹¹、P1802 要输出 5×10⁹
→ 溢出,答案变负数或乱码
long long dp[...]
4 P1802 只在药够时更新 漏掉了「打输也有 losei 经验」→ 答案偏小 两个决策都写,再取 max
5 P1466 忘记 dp[half] / 2 输出正好是答案的 2 倍 最后除以 2
6 P1466 不判断总和是奇数 half 被截断,数出无意义的方案数 if(sum % 2) { cout << 0; return 0; }
7 计数型 DP 的 dp[0] 设成 0 所有 dp 值都推不出来,答案恒为 0 dp[0] = 1;(最值型才是 0)
8 P1387 的 min 只写两个状态 边长算大 → WA 左、上、左上 三个都要进 min
9 数组开成 dp[W] / a[100][100] 下标 W 或 100 越界 → 玄学 RE / WA 开 W+1 / 105,永远留余量
10 P1616 第一行读成 m t 时间和种类数调换,全盘皆错 题目是 t m(时间在前)

✓自测:你真的会了吗

第 1 题:下面这段代码是 0/1 背包还是完全背包?
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]);

答:完全背包。 内层 j 从小到大(正序), 所以 dp[j - w[i]] 读到的是本轮已更新的值,物品 i 可以被反复使用。

第 2 题:为什么完全背包内层用正序就能表示「无限件」?请说出理由,不要背结论。

答:一维数组是「滚动」的,同一个 dp 数组在本次外层循环里被反复覆盖。

· 正序时,算 dp[j] 用到的 dp[j - w[i]] 因为下标更小、已经在本轮被更新过, 它里面很可能已经装了若干个物品 i。于是 dp[j] = dp[j-w[i]] + v[i] 相当于「在已经装了 i 的基础上再装一件 i」 → 允许无限件。
· 倒序时,下标更小的 dp[j - w[i]] 还没轮到,仍是上一轮的旧值, 里面绝不含物品 i,所以只能「第一次装 i」→ 最多 1 件。

用 w=2, v=3, W=6 实测:倒序得到 dp[6] = 3(1 件),正序得到 dp[6] = 9(3 件)。

第 3 题:P1466 里,为什么最后要除以 2?n = 4 时答案是多少?

答:dp[half] 数的是「和为 half 的子集个数」。设子集为 S,它的补集也是和为 half 的子集, 而 {S, 补集} 是同一个划分方案。所以每个方案被数了两次,要除以 2。

n = 4 时 sum = 10,half = 5。和为 5 的子集有 {1,4} 和 {2,3},只有 2 个,除以 2 得 1。 (划分就是 {1,4} 和 {2,3},确实只有一种。)

第 4 题:P1387 的转移式为什么要取三个状态的 min?

答:想让 (i, j) 当右下角、边长做到 k,必须同时满足:上方那块能撑起 k−1、左边那块能撑起 k−1、 左上那块能撑起 k−1。三个条件同时成立,所以取最小;再加上自己这一格,边长 = min + 1。

漏掉任何一个,都可能在某些形状下把边长算大。比如左边和上边都很长、但左上角那块有缺口的情形, 只有 dp[i-1][j-1] 能拦住它。

第 5 题(手写代码):把 0/1 背包改成完全背包,一共要改几个地方?

答:只改内层循环的方向,其它一个字都不用动。

// 0/1 背包
for(int j = W; j >= w[i]; j--)

// 完全背包
for(int j = w[i]; j <= W; j++)

★ 注意连「起止点」也是配套的:倒序从 W 到 w[i],正序从 w[i] 到 W。不要只把 -- 改成 ++ 却忘了调整起点,那样循环会从 W 一直加到无穷(数组爆炸)。

第 6 题:什么时候必须用 long long?给一个判断方法。

答:把「最大可能的答案」估出来,和 int 的上限 2147483647(约 2.147×10⁹)比较。

· P1616:时间上限 10⁷ × 单件价值上限 10⁴ = 10¹¹ > 2.147×10⁹ → 用 long long
· P1802:s ≤ 10⁹ 看着安全,但输出 5s = 5×10⁹ → 用 long long
· P1466:n = 39 时方案数 1512776590,接近但没超 → 保险起见也建议 long long
· P1387:边长最大 100 → int 足够

★ 估算的时候别忘了题目可能让你对答案做运算(比如乘 5)。

▤速查卡(截图存手机)

【0/1 背包 · 每种最多 1 件】
  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]);

【完全背包 · 每种无限件】
  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]);

  // 记法:正序 = 可以反复拿同一个东西;倒序 = 每个只能拿一次

【多决策背包 · 每个物品多个选择】
  dp[j] = max( 决策1, 决策2, ... );
  // 例:dp[j] = max(dp[j] + lose[i], dp[j-use[i]] + win[i]);

【计数型背包 · 方案数】
  dp[0] = 1;                                  // ★ 不能写成 0
  dp[j] += dp[j - w[i]];                      // 内层倒序

【恰好装满的判断】
  目标和是奇数(或除不尽)→ 直接输出 0
  两个集合互换算一种 → 最后答案 / 2

【二维 DP · 最大全 1 正方形】
  if(a[i][j] == 1)
      dp[i][j] = min(min(dp[i-1][j], dp[i][j-1]),
                     dp[i-1][j-1]) + 1;       // ★ 三个都要写
  ans = max(ans, dp[i][j]);                   // 答案是边长,不是面积

【开数组铁律】
  一维 dp 长度 = 容量上限 + 1
  二维 dp 开 [n + 5][m + 5]
  答案估一遍:超过 2.1×10⁹ → long long

→明天预告 · Day 28

栈与队列:STL 容器

DP 先告一段落,明天换一个完全不同的方向:用现成的容器解决问题。 你会学到 std::stack、std::queue、std::priority_queue 的接口, 并用它们写出「括号匹配」和「后缀表达式求值」这两个经典模板。

今天学的背包是一维数组上的技巧,明天的栈和队列是后面 BFS、单调栈、 表达式解析等一大堆内容的地基 —— 接口一定要练到不用查文档就能写出来。