一模一样的代码,只把内层循环的方向反过来 —— 每种物品就从「最多选一次」变成「随便选多少次」
| 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 背包的一维滚动数组写法,核心口诀是「外层枚举物品,内层倒序枚举容量」。
今天你会发现:把那个「倒序」改成「正序」,一维数组就自动变成了完全背包。昨天那道题的代码你几乎不用改。 但你必须搞清楚「为什么」,否则考场上你会分不清该用哪个。
昨天的 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) |
两者都是 O(n·W),都是一维数组。你不需要为了「无限件」多付任何代价。 这就是为什么「改一个方向」是标准做法,而不是什么奇技淫巧。
光看代码是记不住的,我们把中间过程打印出来。用一个最小的例子: 只有 1 种物品,花费 w = 2,价值 v = 3,总容量 W = 6。
从 j = 6 一路走到 j = 2,下面是每一步执行完之后的 dp 数组(下标 0~6):
| 这一步 | dp[0] | dp[1] | dp[2] | dp[3] | dp[4] | dp[5] | dp[6] |
|---|---|---|---|---|---|---|---|
| 初始 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| j = 6 | 0 | 0 | 0 | 0 | 0 | 0 | 3 |
| j = 5 | 0 | 0 | 0 | 0 | 0 | 3 | 3 |
| j = 4 | 0 | 0 | 0 | 0 | 3 | 3 | 3 |
| j = 3 | 0 | 0 | 0 | 3 | 3 | 3 | 3 |
| j = 2 | 0 | 0 | 3 | 3 | 3 | 3 | 3 |
最终 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] |
|---|---|---|---|---|---|---|---|
| 初始 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| j = 2 | 0 | 0 | 3 | 0 | 0 | 0 | 0 |
| j = 3 | 0 | 0 | 3 | 3 | 0 | 0 | 0 |
| j = 4 | 0 | 0 | 3 | 3 | 6 | 0 | 0 |
| j = 5 | 0 | 0 | 3 | 3 | 6 | 6 | 0 |
| j = 6 | 0 | 0 | 3 | 3 | 6 | 6 | 9 |
最终 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 改掉,看看方向不同会不会导致结果不同。
背包题的代码都很短,真正让人 WA 的是数据范围。今天两道题都卡在这里。
一维背包的数组长度 = 容量上限 + 1(因为要放下标 0)。
| 题目 | 容量上限 | 数组至少多大 | 用 int 时的内存 |
|---|---|---|---|
| P1616 疯狂的采药 | t ≤ 10⁷ | 10⁷ + 1 | 约 40 MB |
| P1802 5 倍经验日 | x ≤ 10³ | 10³ + 1 | 约 4 KB |
| P1466 集合 | half ≤ 780/2 = 390 | 391 | 约 1.5 KB |
内存只是问题的一半,另一半是数值会不会溢出。见下面。
做法:拿「最大答案」和 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 绰绰有余 |
你算 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。
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 时 dp 里的值最大会到 10¹¹,早就溢出了。这题的内存就是拿 long long 换来的,省不掉。
(顺带一提,如果你真的遇到内存吃紧的情况,思路是「把答案换个表示方法」,而不是把 dp 改成 int。)
看它的数据范围里藏着一句关键的话:1 ≤ m × t ≤ 10⁷。
这句话的意思是:m 和 t 不可能同时很大。t 最大 10⁷ 时 m 只能是 1;m 最大 10⁴ 时 t 最多只有 10³。
而我们的双重循环次数正好就是 m × t ≤ 10⁷ —— 1 千万次操作,对 1 秒的时限来说非常轻松。
这就是为什么这题看着数据很吓人(t 到 10⁷、m 到 10⁴),却只用最朴素的完全背包就能过。 读数据范围时,一定要把每个数字和别的数字连起来看。
普通背包每件物品只有两个选择:拿 或 不拿。今天 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,或加法)。
背包问题有三种常见问法,转移方程长得像但意思完全不同:
| 问法 | dp 的含义 | 转移 | dp[0] 初值 |
|---|---|---|---|
| 最大价值(P1048、P1616) | 最大价值 | dp[j] = max(dp[j], dp[j-w]+v) | 0 |
| 方案数(P1466、P1164) | 方案总数 | dp[j] += dp[j-w] | 1 |
| 可行性(能不能凑出) | true / false | dp[j] |= dp[j-w] | true |
铁律一:dp[0] = 1。 意思是「凑出和 0 有 1 种方案:什么都不选」。
如果你写 dp[0] = 0,那么所有 dp 值都会是 0,因为加法是从 dp[0] 推出去的。
铁律二:加法要倒序。 每个数(或每件物品)只能用一次,所以内层还是倒序。 正序的话方案数会被重复统计。
P1466 就是这个套路的应用:把「划分成两个和相等的集合」变成「从 1~n 里挑出若干个数、让它们的和恰好是总和的一半」。 挑出来的那一堆就是一个集合,剩下的自动就是另一个集合。
今天最后一道题换了个维度:状态要带两个下标 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(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] 才能正确拦住。
三个状态一个都不能少。
把矩阵存进 a[1..n][1..m],dp 同样从 1 开始。这样 dp[0][*]、dp[*][0]
全自动是 0(全局数组默认值),不需要手写任何边界特判。
注意数组要开 [105][105] 而不是 [100][100] —— 下标要用到 n 和 m,多留 5 个位置最稳。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall
实际编译通过(零警告)并跑过官方样例,输出是你看到的真实结果。
题意:山洞里有 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 组随机小数据,和一份「二维完全背包」的独立实现逐一对照,结果零差异。
t m(时间在前),写成 cin >> m >> t 就全错了。dp[t] 而不是 dp[t+1] → 下标 t 越界。cin/cout 时建议加 ios::sync_with_stdio(false),数据量大时更稳(我的代码加了)。题意:有 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。
把「每个好友」看成一件物品,但它的决策有两个:
| 决策 | 花费 | 收益 |
|---|---|---|
| 打输 | 0 | losei |
| 打赢 | usei | wini |
因为每个人只打一次,本质是 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[j] 随 j 增大是单调不减的。 药最多有 x 个,那当然用满最划算,答案就是 dp[x]。
(严格说 dp[j] 表示「最多用 j 个药」,用不满也没关系,它会自动取到最优。)
官方样例 → 输出 1060 完全一致
边界 x = 0(题目里 10% 的数据就是这种):输入 1 0 / 5 10 3 → 输出 25。
(一个药都没有,只能打输拿 5 点经验,5 × 5 = 25,正确。)
另做了 60 组随机小数据,和「2ⁿ 枚举每个人赢/输」的暴力程序逐一对照,结果零差异。
dp[j - use[i]] 就是 dp[j] 本身,这里的顺序要想清楚(我的代码用 a、b 两个临时变量,所以没问题)。题意:把集合 {1, 2, 3, …, n} 划分成两个子集合,要求两个子集合里所有数字的和相等。 问一共有多少种划分方案。交换两个集合的位置算同一种方案。
对于 100% 的数据,1 ≤ n ≤ 39。
n = 39 时总和 39 × 40 / 2 = 780,一半是 390,所以 dp 数组只要开 391 个就够了 —— 非常小。
第一步:把问题翻译一下。
两个集合和相等,那么每个集合的和都必须是总和的一半。设 sum = n(n+1)/2。
总和是奇数时,不可能分成两个整数和相等的集合,直接输出 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 上结果完全一致。
dp[0] 忘了设成 1 → 所有 dp 值都是 0,答案恒为 0。int 算没问题(最大 780),但 dp 里的方案数要 long long(n = 39 时是 15 亿)。j >= 0 或 j >= 1:因为 j - i 必须 ≥ 0,所以条件是 j >= i,写成别的会数组越界。题意:给一个 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),和「枚举每个左上角 + 每个边长 + 逐格检查」的暴力程序 逐一对照,结果零差异。
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(时间在前) |
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 可以被反复使用。
答:一维数组是「滚动」的,同一个 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 件)。
答: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},确实只有一种。)
答:想让 (i, j) 当右下角、边长做到 k,必须同时满足:上方那块能撑起 k−1、左边那块能撑起 k−1、 左上那块能撑起 k−1。三个条件同时成立,所以取最小;再加上自己这一格,边长 = min + 1。
漏掉任何一个,都可能在某些形状下把边长算大。比如左边和上边都很长、但左上角那块有缺口的情形,
只有 dp[i-1][j-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 一直加到无穷(数组爆炸)。
答:把「最大可能的答案」估出来,和 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
栈与队列:STL 容器
DP 先告一段落,明天换一个完全不同的方向:用现成的容器解决问题。
你会学到 std::stack、std::queue、std::priority_queue 的接口,
并用它们写出「括号匹配」和「后缀表达式求值」这两个经典模板。
今天学的背包是一维数组上的技巧,明天的栈和队列是后面 BFS、单调栈、 表达式解析等一大堆内容的地基 —— 接口一定要练到不用查文档就能写出来。