1.背包问题
背包问题是动态规划中最经典的问题,很多题⽬或多或少都有背包问题的影⼦。它的基本形式是:给 定⼀组物品,每个物品有体积和价值,在不超过背包容量的情况下,选择物品使得总价值最⼤。
背包问题有多种变体,主要包括:
- 01 背包问题 :每种物品只能选或不选(选 0 次或 1 次)。
- 完全背包问题 :每种物品可以选择⽆限次。
- 多重背包问题 :每种物品有数量限制。
- 分组背包问题 :物品被分为若⼲组,每组只能选⼀个物品。
- 混合背包 :以上四种背包问题混在⼀起。
- 多维费⽤的背包问题 :限定条件不⽌有体积,还会有其他因素(⽐如重量)。
除了经典的总价值最⼤问题,还会有: - ⽅案总数。
- 最优⽅案。
- ⽅案可⾏性。
- 输出具体⽅案。
因此,背包问题种类⾮常繁多,题型⾮常丰富。但是,尽管背包有很多变形,都是从 01 背包问题演化
过来的。所以,⼀定要把 01 背包问题学好。
01 背包
1.1 01 背包
题⽬来源: ⽜客⽹
题⽬链接: 【模板】01背包
难度系数: ★★
链接:https://ac.nowcoder.com/acm/problem/226514
来源:牛客网
【题⽬描述】
你有⼀个背包,最多能容纳的体积是 V 。
现在有 n 个物品,第 i 个物品的体积为 v i ,价值为 w i 。
- 求这个背包⾄多能装多⼤价值的物品?
- 若背包恰好装满,求⾄多能装多⼤价值的物品?
【输⼊描述】
第⼀⾏两个整数 n 和 V ,表⽰物品个数和背包体积。
接下来 n ⾏,每⾏两个数 v i 和 w i ,表⽰第 i 个物品的体积和价值。
1 ≤ n , V , v i , w i ≤ 1000
【输出描述】
输出有两⾏,第⼀⾏输出第⼀问的答案,第⼆⾏输出第⼆问的答案,如果⽆解请输出 0 。
【⽰例⼀】
输⼊:
3 5
2 10
4 5
1 4
输出:
14 9
【⽰例⼆】
输⼊:
3 8
12 6
11 8
6 8
输出:
8
0
【解法】
我们先解决第⼀问:
- 状态表⽰:
dp [ i ][ j ] 表⽰:从前 i 个物品中挑选,总体积「不超过」 j ,所有的选法中,能挑选出来的最⼤
价值。
那么 dp [ n ][ v ] 就是我们要的结果。 - 状态转移⽅程:
线性 dp 状态转移⽅程分析⽅式,⼀般都是根据「最后⼀步」的状况,来分情况讨论:
a. 不选第 i 个物品:相当于就是去前 i − 1 个物品中挑选,并且总体积不超过 j 。此时
dp [ i ][ j ] = dp [ i − 1][ j ] ;
b. 选择第 i 个物品:那么我就只能去前 i − 1 个物品中,挑选总体积不超过 j − v [ i ] 的物品。
此时 dp [ i ][ j ] = dp [ i − 1][ j − v [ i ]] + w [ i ] 。但是这种状态不⼀定存在,因此需要特判⼀下。
综上,状态转移⽅程为: dp [ i ][ j ] = max ( dp [ i − 1][ j ], dp [ i − 1][ j − v [ i ]] + w [ i ]) 。 - 初始化:
直接填表,第⼀⾏的 0 不影响结果。 - 填表顺序:
根据「状态转移⽅程」,我们仅需「从上往下」填表即可。 接下来解决第⼆问:
第⼆问仅需修改⼀下初始化以及最终结果即可。 - 初始化:
因为有可能凑不⻬j 体积的物品,因此我们把不合法的状态设置为负⽆穷。这样在取最⼤值的时
候,就不会考虑到这个位置的值。负⽆穷⼀般设置为−0 x 3 f 3 f 3 f 3 f 即可。
然后把dp [0][0] = 0 修改成 0,因为这是⼀个合法的状态,最⼤价值是0 ,也让后续填表是正确
的。 - 返回值:
在最后拿结果的时候,也要判断⼀下最后⼀个位置是不是⼩于 0 ,因为有可能凑不⻬。
不能判断是否等于−0 x 3 f 3 f 3 f 3 f ,因为这个位置的值会被更新,只不过之前的值太⼩,导致更
新后还是⼩于0 的。
【参考代码】
cpp
#include <iostream>
#include <cstring>
using namespace std;
// 常量定义:题目中n/V最大为1000,留10个余量
const int N = 1010;
int main() {
// 1. 变量定义
int n, m; // n=物品数,m=背包最大容量(对应题目中的V)
int v[N], w[N]; // v[i]=第i个物品体积,w[i]=第i个物品价值
int f[N][N]; // 二维dp数组:f[i][j]表示前i个物品、体积≤j的最大价值
// 2. 读取输入
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> v[i] >> w[i];
}
// ===================== 第一问:不要求装满 =====================
// 初始化:二维数组默认值为0(未选物品时价值为0)
// 遍历前i个物品
for (int i = 1; i <= n; i++) {
// 遍历所有可能的背包体积(0到m)
for (int j = 0; j <= m; j++) {
// 情况1:不选第i个物品 → 价值 = 前i-1个物品、体积j的价值
f[i][j] = f[i-1][j];
// 情况2:选第i个物品(前提:背包体积≥物品体积)
if (j >= v[i]) {
f[i][j] = max(f[i][j], f[i-1][j - v[i]] + w[i]);
}
}
}
// 输出第一问结果:前n个物品、体积≤m的最大价值
cout << f[n][m] << endl;
// ===================== 第二问:要求恰好装满 =====================
// 初始化:将数组全部设为极小值(-0x3f3f3f3f),代表"无法装满该体积"
memset(f, -0x3f, sizeof f);
f[0][0] = 0; // 唯一合法初始状态:前0个物品、体积0,价值0(恰好装满)
// 遍历前i个物品(逻辑和第一问一致)
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
// 情况1:不选第i个物品
f[i][j] = f[i-1][j];
// 情况2:选第i个物品(前提:背包体积≥物品体积)
if (j >= v[i]) {
f[i][j] = max(f[i][j], f[i-1][j - v[i]] + w[i]);
}
}
}
// 结果判断:若f[n][m]为极小值,说明无法装满,输出0;否则输出f[n][m]
if (f[n][m] < 0) {
cout << 0 << endl;
} else {
cout << f[n][m] << endl;
}
return 0;
}
1.2 采药
题⽬来源: 洛⾕
题⽬链接: P1048 [NOIP2005 普及组] 采药
难度系数: ★
题目描述
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:"孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。"
如果你是辰辰,你能完成这个任务吗?
输入格式
第一行有 2 个整数 T(1≤T≤1000)和 M(1≤M≤100),用一个空格隔开,T 代表总共能够用来采药的时间,M 代表山洞里的草药的数目。
接下来的 M 行每行包括两个在 1 到 100 之间(包括 1 和 100)的整数,分别表示采摘某株草药的时间和这株草药的价值。
输出格式
输出在规定的时间内可以采到的草药的最大总价值。
输入输出样例
输入 #1复制
70 3
71 100
69 1
1 2
输出 #1复制
3
说明/提示
【数据范围】
- 对于 30% 的数据,M≤10;
- 对于全部的数据,M≤100。
【题目来源】
NOIP 2005 普及组第三题
【解法】
基本 01 背包问题,将时间看成体积,就是标准的不放满的 01 背包问题。不再赘述~
【参考代码】
cpp
#include <iostream>
using namespace std;
const int N = 1010;
int n, m;
int t[N], w[N];
int f[N];
int main()
{
cin >> m >> n;
for(int i = 1; i <= n; i++) cin >> t[i] >> w[i];
for(int i = 1; i <= n; i++)
for(int j = m; j >= t[i]; j--) // 修改遍历顺序
{
f[j] = max(f[j], f[j - t[i]] + w[i]);
}
cout << f[m] << endl;
return 0;
}
1.3 ⼩ A 点菜
题⽬来源: 洛⾕
题⽬链接: P1164 ⼩A点菜
难度系数: ★★
题目背景
uim 神犇拿到了 uoi 的 ra(镭牌)后,立刻拉着基友小 A 到了一家......餐馆,很低端的那种。
uim 指着墙上的价目表(太低级了没有菜单),说:"随便点"。
题目描述
不过 uim 由于买了一些书,口袋里只剩 M 元 (0<M≤10000)。
餐馆虽低端,但是菜品种类不少,有 N 种 (1≤N≤100),第 i 种卖 ai 元 (0<ai≤1000)。由于是很低端的餐馆,所以每种菜只有一份。
小 A 奉行"不把钱吃光不罢休"的原则,所以他点单一定刚好把 uim 身上所有钱花完。他想知道有多少种点菜方法。
由于小 A 肚子太饿,所以最多只能等待 1 秒。
输入格式
第一行两个整数 N 和 M,分别表示菜品种类和 uim 身上的钱数。
第二行 N 个正整数 ai(可能有重复),用空格隔开,分别表示每种菜的价格。
输出格式
一个正整数,表示点菜方案数,保证答案的范围在 [0,231−1] 之内(不超过 C/C++的 int 范围)。
输入输出样例
输入 #1复制
4 4
1 1 2 2
输出 #1复制
3
说明/提示
2020.8.29,增添一组 hack 数据 by @yummy
【解法】
背包问题求⽅案数,稍微修改⼀个状态表⽰,然后根据具体问题分析状态转移⽅程和初始化即可。
1. 状态表⽰:
dp [ i ][ j ] 表⽰:从前 i 个菜中挑选,总价钱恰好等于 j ,此时的总⽅案数。
2. 状态转移⽅程:
针对 a [ i ] 选或者不选,分两种情况讨论:
a. 如果不选a[i] :相当于去前i-1 个菜中挑选,总价钱恰好为 j的⽅案数,此时的⽅案数就
是dp[i-1][j] ;
b. 如果选a[i] :那么应该去前i-1 个菜中挑选,总价值恰好为j-a[i] ,此时的⽅案数就是
dp[i-1][j-a[i]];
因为我们要的是总⽅案数,于是 dp [i ][j ] = dp [i − 1][j ] + dp [i − 1][j − a [i]] 。
注意第⼆个状态可能不存在,要注意判断⼀下 j ≥ a [i] 。
3. 初始化:
dp [0][0] = 1,如果没有物品,想凑成总体积为 0是可⾏的,啥也不选就是⼀种⽅案。当然,这
个状态也是为了让后⾯的值是正确的。 其余位置的值是 0 就不影响填表的正确性。
4. 填表顺序:
从上往下每⼀⾏,每⼀⾏从左往右。
空间优化版本:每⼀⾏从右往左。
【参考代码】
cpp
#include<iostream>
#include<cstring>
using namespace std;
const int V=1010;
int n, m;
int a[110];
int f[V]; // 一维数组:f[j]表示凑出体积j的方案数
int main() {
cin >> n >> m;
for(int i=1; i<=n; i++) cin >> a[i];
f[0] = 1; // 初始化:凑0体积的方案数=1
for(int i=1; i<=n; i++) {
for(int j=m; j>=a[i]; j--) { // 倒序遍历,避免重复选
f[j] += f[j - a[i]];
}
}
cout << f[m] << endl;
return 0;
}
3.1.4 Cow Frisbee Team
题⽬来源: 洛⾕
题⽬链接: P2946 [USACO09MAR] Cow Frisbee Team S
难度系数: ★★★
题目描述
老唐最近迷上了飞盘,约翰想和他一起玩,于是打算从他家的 N 头奶牛中选出一支队伍。
每只奶牛的能力为整数,第 i 头奶牛的能力为 Ri。飞盘队的队员数量不能少于 1、大于 N。一支队伍的总能力就是所有队员能力的总和。
约翰比较迷信,他的幸运数字是 F,所以他要求队伍的总能力必须是 F 的倍数。请帮他算一下,符合这个要求的队伍组合有多少?由于这个数字很大,只要输出答案对 108 取模的值。
输入格式
第一行:两个用空格分开的整数:N 和 F。
第二行到 N+1 行:第 i+1 行有一个整数 Ri,表示第 i 头奶牛的能力。
输出格式
第一行:单个整数,表示方案数对 108 取模的值。
输入输出样例
输入 #1复制
4 5
1
2
8
2
输出 #1复制
3
说明/提示
对于 100% 的数据,1≤N≤2000,1≤F≤1000,1≤Ri≤105。
【解法】
01 背包问题变形。
1. 状态表⽰:
dp [i ][j ] 表⽰:从前 i 头奶⽜中挑选,总和模 f 之后为 j时,⼀共有多少种组合。
那么 dp [n][0] − 1 就是最终结果 。(因为动态规划会把全都不选这种情况也考虑进去,所以要剪
掉)
2. 状态转移⽅程:
对于第 i 头奶⽜选或者不选,可以分为两种情况讨论:
a. 如果不选 a [ i ] :此时的总⽅案数就是去 [1, i − 1] ⾥⾯凑余数正好是 j ,也就是 dp [i − 1][j]
;
b. 如果选 a [ i ] :此时已经有⼀个余数为 a [ i ] % f ,只⽤再去前⾯凑⼀个 j − a [ i ] % f 即可。但
是直接减可能会减出来⼀个负数,我们要把它补正,最终凑的数为 (j − a [i ] % f + f ) % f 。
那么总⽅案数就是 dp [i − 1][(j − a [i ] % f + f ) % f] 。
因为要的总⽅案数,所以状态转移⽅程就是上⾯两种情况的总和。
3. 初始化:
dp[0][0] = 1 :什么也不选的时候,总和是 0 ,余数也是 0 ,属于⼀种⽅案 ,也是为了后续填表
是正确的。
4. 填表顺序:
从上往下每⼀⾏,每⼀⾏从左往右。
【参考代码】
cpp
#include <iostream>
using namespace std;
// 常量定义:
// N:奶牛数量上限(题目N≤2000,留10余量)
// M:F的上限(题目F≤1000,留10余量)
// MOD:取模值(10^8)
const int N = 2010, M = 1010, MOD = 1e8;
int main() {
int n, F; // n=奶牛数,F=幸运数字(避免用m,和模数混淆)
int R[N]; // R[i]存储第i头奶牛的能力值
// dp[i][j]:前i头奶牛中选,总和%F=j的组合数
long long dp[N][M]; // 用long long避免中间结果溢出
// 初始化dp数组为0(避免随机值干扰)
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
dp[i][j] = 0;
}
}
// 1. 读取输入
cin >> n >> F;
for (int i = 1; i <= n; i++) {
cin >> R[i];
// 提前计算能力值的模F结果,减少后续计算量
R[i] = R[i] % F;
}
// 2. 动态规划初始化:选0头奶牛,总和为0,模F=0,仅1种方案
dp[0][0] = 1;
// 3. 状态转移:遍历每头奶牛,计算所有余数的组合数
for (int i = 1; i <= n; i++) {
for (int j = 0; j < F; j++) {
// 情况1:不选第i头奶牛 → 组合数 = 前i-1头凑余数j的数量
dp[i][j] = dp[i-1][j];
// 情况2:选第i头奶牛 → 需前i-1头凑余数 (j - R[i]) mod F
// 修正负数:(j - R[i] + F) % F 确保结果在0~F-1之间
int pre_j = (j - R[i] + F) % F;
dp[i][j] = (dp[i][j] + dp[i-1][pre_j]) % MOD;
}
}
// 4. 计算结果:总组合数 - 选0头的无效方案(确保非负)
long long ans = (dp[n][0] - 1 + MOD) % MOD;
cout << ans << endl;
return 0;
}