问题描述
有n种物品,它们有各自的体积,价值和一定的数量。现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?
算法思想
在解决问题之前,为描述方便,首先定义一些变量:vi表示第 i 种物品的价值,wi表示第 i 种物品的体积或重量,si表示第i种物品的数量。定义V(i,j):当前背包容量 j,前 i 个物品最佳组合对应的价值。
多重背包问题其实可以转换为01背包来解决,那么如何转换呢?有一种朴素思想:对于同一种物品,如果我们把不同数量的该物品看作一个新品种,那么这种物品不就化成了很多个新品种但每种只有1个,这不就变成了01背包了吗。
不妨设第i件物品有s个,我们可以把相同种类的物品进行合并。比如我拿出两件合并出一个新的物品,我拿出三件合并出另一个新的物品。依此类推,我拿出s个合并出一个新的物品。基于这种思想,我们可以把第i件的s个物品转换为s种体积价值各不相同的物品,然后再用01背包的思想,求出最优解!
于是可以列出状态转移方程:
- j<w(i) V(i,j)=V(i-1,j)
- j>=w(i) V(i,j)=max{V(i-1,j),V(i-1,j-s*w(i))+s*v(i)}(s*w(i)<=j)
解释:对于第i种物品,我们有容量j。容量j小于w(i)时,这种物品肯定放不下,所以容量j装i种物品的最大价值就等于容量j装i-1种物品的最大价值,即V(i,j)=V(i-1,j);容量j大于等于w(i)时,对于第i种物品我们可以选择s件(不超过容量j),也可以选择不拿,所以要在这两种之间取一个最大值。
那么怎么实现呢?我们只需要在遍历第i种物品的时候,多加入一个for循环,遍历s个物品并且在动态转移方程中进行合并操作!需要注意的是,我们合并出的物品体积,一定不能超过当前背包的总体积(j),不然合并是没有意义的。下面给出用一维数组优化过的核心代码:
for (int i = 1; i <= n; i++)
{
int w, v, s; //体积,价值,数量
cin >> w >> v >> s;
for (int j = m; j >= w; j--)
{
for (int k = 1; k <= s && k * w <= j; k++)
dp[j] = max(dp[j], dp[j - k * w] + k * v);
}
}
二进制优化
朴素思想是非常耗时的,所以需要优化。二进制优化就是一种比较常见的优化方案。
在朴素解法中我们需要把每一种物品,按个数1~s,分为不同类,形成新体积和价值的种类。这种做法虽然剪枝优化过(k*w[i]<=j),但复杂度仍然很高。问题的关键在于怎么分。我们可不可以在分的时候换一种算法,不再是从1分到s,并且也可以表示出1到s,产生同样的效果!答案肯定是有的,就是用二进制思想优化,我们下面讲解这种思想。
我们知道,任意的1~n的整数,我们都可以通过二进制的思想表示出来。即一个数字,我们可以按照二进制来分解为1 + 2 + 4 + 8 …… +2^n + 余数
比如7可以写成1+2+4,所以7以内的数都可以用1,2,4中的某几个的加和来表示。
通过上述原理,我们可以把第i种物品的s件,按二进制思想分为1,2,4…到剩余。每种数量都看作一个新品种。这样内层的复杂度就能从s降到 log 2 s \log_{2}{s} log2s。后面的做法就和01背包完全相同了。
代码实现:
样例输入
4 5
1 2 3
2 4 1
3 4 3
4 5 2
样例输出
10
#include <iostream>
using namespace std;
const int MAX = 100001;
int dp[MAX];
int w[MAX]; //体积
int v[MAX]; //价值
int main()
{
int n, m; // n种物品,容量m
int cnt = 0; //用于新种类计数
cin >> n >> m;
for (int i = 0; i < n; i++)
{
int a, b, s; //体积,价值,数量
cin >> a >> b >> s;
for (int j = 1; j <= s; j *= 2)
{
w[++cnt] = j * a;
v[cnt] = j * b;
s -= j;
}
if (s > 0) //如果s有剩余,自立为新品种
{
w[++cnt] = s * a;
v[cnt] = s * b;
}
}
// 01背包的做法
for (int i = 1; i <= cnt; i++)
{
for (int j = m; j >= w[i]; j--)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
cout << dp[m];
return 0;
}
单调队列优化
优化思想
先来回顾一下朴素思想的核心代码:
for (int i = 1; i <= n; i++)
{
int w, v, s; //体积,价值,数量
cin >> w >> v >> s;
for (int j = m; j >= w; j--)
{
for (int k = 1; k <= s && k * w <= j; k++)
dp[j] = max(dp[j], dp[j - k * w] + k * v);
}
}
有三重for循环,i枚举的是物品种类数,j枚举的是背包容量,k枚举的是每种物品的数量。仔细观察最内层循环我们会发现:这个循环的作用其实是在0,1,2,…,s个第i种物品寻找一种取法,使得容量为j时价值最大。他的遍历过程是从后往前的,大致如下:
注意:上图中的j-w,j-2w,…指的不是最终该种取法的价值,只是需要用到之前的状态。假如我取t件该种物品,那么我需要为这t件物品预留t*w的空间,剩下的j-t*w的空间就是我们要用到的之前的状态。
由于中间一层循环的j是递减遍历的,那么当下一次容量为j-1时,上图的整个遍历过程可以预想到会向左平移一格。依此类推,当j递减到j-w时不就和最开始的重合了吗?所以根据第i个物品的体积(或重量)w,我们可以将整个dp数组划分成w个等价类,分别以0,1,2,…,w-1为开始元素。对于每个等价类内部求最大值我们就可以运用从队首到队尾递减的单调队列,时刻维护这个队列保持队首为最大值,时间复杂度就可以降至O(n*m)。
代码如下:
样例输入
4 5
1 2 3
2 4 1
3 4 3
4 5 2
样例输出
10
#include <iostream>
#include <cstring>
using namespace std;
const int MAX = 100001;
int dp[MAX], g[MAX], q[MAX]; // g数组用来复制dp,q数组用来形成单调队列,存放g中元素的下标
int main()
{
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
{
memcpy(g, dp, sizeof(dp)); //备份dp数组
int w, v, s; //体积,价值,数量
cin >> w >> v >> s;
for (int j = 0; j < w; j++)
{
int head = 0, tail = -1;
for (int k = j; k <= m; k += w)
{
//利用单调队列滑动窗口模板,窗口大小为s*w,保持队首元素为最大
while (head <= tail && k - s * w > q[head])
head++; //队首元素不在窗口内,则踢出队列
if (head <= tail)
dp[k] = max(g[k], g[q[head]] + (k - q[head]) / w * v); //状态转移
//如果队尾元素小于g[k],则从队尾出队
while (head <= tail && g[k] >= g[q[tail]] + (k - q[tail]) / w * v)
tail--;
// g[k]入队
q[++tail] = k;
}
}
}
cout << dp[m] << endl;
return 0;
}
代码细节讲解
g数组用来复制dp,q数组用来形成单调队列,存放g中元素的下标
(1)
while (head <= tail && k - s * w > q[head])
head++;
q[head]
指队首元素的下标,同样也是体积。对于当前种类的物品,他的数量最多有s个,体积为w,那么我要用到的最早的状态也只是全取时k-s*w
,所以要保证队首元素要在这个范围内。
(2)
if (head <= tail)
dp[k] = max(g[k], g[q[head]] + (k - q[head]) / w * v);
这就是状态转移方程了。g数组保存dp数组上一个状态时的数据,因为dp在不断更新会把之前的数据覆盖掉,所以用g来保存。g[k]
就是不拿的情况,q[head]
就是单调队列的队首,也就是最大的一个。g[q[head]]
就是这种最大的取法要用到的之前的状态,(k - q[head]) / w
表示取了几个物品,乘上v就是取的这几个物品的价值。
(3)
while (head <= tail && g[k] >= g[q[tail]] + (k - q[tail]) / w * v)
tail--;
q[++tail] = k;
这是对单调队列的维护。g[k]
要从队尾入队,但是我要保证整个队列是递减的,也就是g[k]入队后是最下的。所以我用while循环先从队尾开始把比g[k]
大的都去掉。然后g[k]入队。