题意描述:
有 N 种物品和一个容量是 V 的背包。
第 i 种物品最多有 si 件,每件体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。
输入格式
第一行两个整数,N,V (0<N≤1000, 0<V≤20000),用空格隔开,分别表示物品种数和背包容积。
接下来有 N 行,每行三个整数 vi,wi,si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。
输出格式
输出一个整数,表示最大价值。
多重背包的问题根据数据范围的大小划分为了三个难度层次,分别对应三种解法。对应的数据范围分别是:
暴力求解
0<N,V≤100
0<vi,wi,si≤100
二进制优化
0<N≤1000
0<V≤2000
0<vi,wi,si≤2000
单调队列优化
0<N≤1000
0<V≤20000
0<vi,wi,si≤20000
示例:
4 5
1 2 3
2 4 1
3 4 3
4 5 2
10
解题思路:
Alice: 这是一道题拆成了三道题 ?这题这么难
Bob: 丰俭由人,
Alice: 多重背包和 01 背包的区别就是每类物品有了数量的限制,01 背包是只能选或者不选,完全背包是由无数种可以选,01 背包是有 k 种可以选。
Bob: 是的,那直接改一下状态转移公式得了,直接按照完全背包的方式换一下最内层的状态转移好了,dp[j] = max(dp[j], dp[j-vi * k] + wi * k)
k 取值 0,1,2,3 …
Alice: 为啥要按照完全背包,从最大体积开始求解 ?
Bob: 看状态转移方程,我们还用一维度的 dp 数组的话,对于第 i 个物品,还需要用到第 i-1 个物品的 j-vi * k
的状态,从最大体积往小了计算才是对的。
Alice: 这个理解起来还不太难。
Bob: 是的,这里可以关注一下计算量,暴力去算实际上是 O^3 的,数据范围都是 100,最大也就是 10^6
,这样不会超时的。
Alice: 二进制优化讲的是啥 ?
Bob: 二进制优化的前提是,把多重背包转换成 01 背包问题,但是转换的方式有很多种,二进制优化是其中一种的优化方法。举个例子,第 0 种物品有 7 个,我们该如何拆分呢,拆成 7 个1 ?每个体积和价值都是 v0,w0。这样的计算量大概,O^2 也就是双重循环,1000 * 2000 * 2000 大概 2* 10^9
, 1s 的时间限制大概能完成10^6~10^7
,这样会超时的。
Alice: 是的,直接拆,拆出来的物品数量太多了
Bob: 这样其实就转换成了拆分的问题,把 7 拆成若干个数字,且这些数字能组成 0-7 的所有数字。
Alice: 哦哦,就是二进制啊,二进制是能够表达所有整数的,0到7也就是,2^1 , 2^1, 2^2
这些。然后其实就是二进制的表示一样,7 就是 111,对应到三个物品,就是全选 ?
Bob: 对,但是还有一个问题,如果你的数字是 8 呢,拆成 1,2, 4,8 ?那样的话,就有可能选出 1+2 + 4 + 8 == 15 的选择,但是原来最多也就是 8个。
Alice: 可以这样子,8 的数字就用 7 的那套,剩下的差值额外补齐,0-7 的范围再加个 1,所能表达的范围就是 0-8。
Bob: 应该就是这样,然后具体拆分的时候还可以直接从 1,2,4 累乘,然后不断地减下来,最后剩下的额外加一套。这样应该比较方便而且很快。
Alice:nice,拆外之后直接用 01 背包就行了。这样算的计算量应该是 log 2000 * 1000 * 2000
== 2.2 * 10^7
,应该没问题啦。
Bob:单调队列优化呢 ?
Alice: 这个看起来很麻烦的样子,是啊,这个需要先知道单调队列是啥。可以先看一下 这篇文章
Bob:单调队列优化的思路大概是这样的,还得从完全背包的递推公式讲起,考虑第 i 种物品 dp[j] = max(dp[j], dp[j-vi * k] + wi * k)
k 取值 0,1,2,3 …。这里实际的计算过程是什么样的呢,j-vi*k
减到最后,无论 j 的取值是啥,一定剩下的是 vi 的各种余数,1,2,3 … vi-1,然后从 1+v,1+2v,1+3v … 的状态转移计算过程会相互影响,而 2+v,2+2v,2+3v 会相互影响,两个余数之间的计算过程互相不影响,这样我们就能把对 i 物品的计算划分为互相独立的 vi-1 个,然后单独计算。
Alice: 然后的,拆分成 多个计算过程就能变快 ?并行处理吗 ?
Bob: 然后就是难以理解的地方了,我先举个例子吧,假设背包体积是 100, 第 i 种物品的体积是 5,价值是 4,数量是 3,考虑第 i 个物品时候的状态的计算,dp[j] = max(dp[j- 5*0] + 4*0, dp[j- 5*1] + 4*1, dp[j-5*2] + 4*2), dp[j - 5*3] + 4*3
,具体的计算过程,从底向上就是 求 dp[0 + 5*0], dp[0 + 5*1] + 4*1, dp[0 + 5*2] + 4*2, dp[0 + 5*3] + 4*3
之间的最大值,然后再根据放 1 个,2个,3个求出 dp[0+20]
而 dp[1+20]
看的是 dp[1 + 5*0] + 4*0, dp[1 + 5*1] + 4*1, dp[1 + 5*2] + 4*2, dp[1 + 5*3] + 4*3
的最大值。明白了吗 ? 0,1,2 在这里就是不同的计算序列,每个计算序列都要根据 4 的滑动窗口求前面的最大值,然后再计算当前的最大值。
Alice: 滑动窗口就在这呢,原来是对第 i 种物品的体积余数的每个计算序列里面滑动,滑动窗口的大小就是物品的最大数量。
Bob: 其实滑动窗口的大小不一定是物品的最大数量,k 的实际取值范围是 math.min (si + 1, maxVolum / vi)
,不过这个可以在代码层直接实现掉,可以暂时认为是最大数量,不影响理解。
Alice: 然后单调队列里面维护的时候什么呢 ?队首和队尾都是怎么维护的呢 ?
Bob: 单调队列里面维护的是当前窗口里面最大价值所对应的体积,这样我们应该能够比较轻松的写出 dp[j] 的更新,dp[j] = lastRowDp[queue[0]] + (j - queue[0]) / vi * wi
,想一下,queue[0] 里面是当前窗口的最大价值对应的体积,我们在这个体积之上更新 dp[j]。队首的维护其实还是窗口的大小,只不过这里窗口的大小是通过体积的计算来校验的。
Alice: 这些都还好理解,那队尾的维护呢 ?我记得滑动窗口最大值里面是直接计算窗口里面的数字之和,这里应该不是吧。
Bob: 确实不是,这里还有点不太好理解。这里还是按照最大价值来计算的,只是比较的是第 i-1
个物品对应的体积和 第 i
个物品对应的体积所能给 dp[j]
带来的价值收益。要知道我们在 queue[0]
队首的位置维护的是在窗口中能给 dp[j]
带来最大价值的体积,而单调队列的维护正式通过队首和队尾维护的,所以队尾的维护逻辑实际和 dp[j]
的更新逻辑是一致的。
Alice: 更新逻辑是一致的 ?!我好像有点明白了。还有一些细节问题,滑动窗口的大小不定是怎么实现的 ?
Bob: 这个好说,你从余数 r 开始,r 就是 r + 0 * vi
然后每次给 r += vi
,让 r
不要超过最大体积就可以了。
Alice: 这题真难。
Bob:这题真难,我看别人的题解看了半天才明白滑动窗口在哪滑呢,看别人代码看了半天,单调队列维护的代码都快背下来了,也没看明白怎么维护的,还是得实际举个例子。
Alice: 还有一个小问题,这里为啥不能把状态压缩成一个一位数组,为啥还要一个 lastRowDp 呢 ?
Bob:简单,第 i 个物品的 dp[j] 的更新需要依赖于体积 j 的前 si 个状态,如果直接用 dp[j - vi],那用的就是更新过的值了,就不是 i-1 个物品的状态了。
代码:
暴力
const solve = (count, maxVolum, volumAndWeight) => {
const dp = new Array(maxVolum + 1).fill(0);
for(let i=0; i<count; ++i){
// 第 i 个物品的体积和价值
const [ivolum, iweight, itotal] = volumAndWeight[i];
for(let j=maxVolum; j>=ivolum; --j) {
const candiantes = [];
for(let k=0; k<=itotal; ++k) {
j - ivolum * k >= 0 && candiantes.push(dp[j - ivolum * k] + k * iweight);
}
dp[j] = Math.max(...candiantes);
}
}
console.log(dp[maxVolum]);
}
二进制优化
const solve = (count, maxVolum, volumAndWeightAndCount) => {
// 二进制拆分为 01 背包
const volumnAndWeight = [];
volumAndWeightAndCount.forEach(item => {
let [v, w, s] = item;
for (let k=1; k <= s; k*=2) {
s -= k;
volumnAndWeight.push([k*v, k*w]);
}
if(s > 0) {
volumnAndWeight.push([s*v, s*w]);
}
});
// 01 背包解法
const dp = new Array(maxVolum + 1).fill(0);
for(let i=0; i<volumnAndWeight.length; ++i){
// 第 i 个物品的体积和价值
const [ivolum, iweight] = volumnAndWeight[i];
for(let j=maxVolum; j>=ivolum; --j) {
dp[j] = Math.max(dp[j], dp[j - ivolum] + iweight);
}
}
console.log(dp[maxVolum]);
}
单调队列优化
const fs = require('fs');
let buffer = '';
process.stdin.on('readable', () => {
const chunk = process.stdin.read();
if (chunk) {
buffer += chunk.toString()
}
});
// 输入的字符串转换为数字
const convert = (inputString) => {
const list = [];
inputString.split('\n').forEach((line) => {
const tokens = line.split(' ');
list.push(tokens.map(num => parseInt(num, 10)));
});
return list;
}
// 批量调用
const batchCall = (list, solve) => {
// 划分数据
const data = [];
let countAndVolumIndex = 0;
while(countAndVolumIndex < list.length) {
const [count, volum] = list[countAndVolumIndex];
data.push({
volum: volum,
count: count,
volumAndWeightAndCount: list.slice(countAndVolumIndex + 1, countAndVolumIndex + 1 + count)
});
countAndVolumIndex += count + 1;
}
data.forEach(item => {
if(solve && item && item.count && item.volum) {
solve(item.count, item.volum, item.volumAndWeightAndCount);
}
});
}
const solve = (count, maxVolum, volumAndWeightAndCount) => {
// 单调队列优化方法
const dp = new Array(maxVolum + 10).fill(0);
// 对于每种物品
for (let i=0; i<count; ++i) {
// 状态压缩
const lastRowDp = [...dp];
// 取出第 i 种物品的体积,价值,数量
const [vi, wi, si] = volumAndWeightAndCount[i];
// 对于每种可能剩余的体积,0,1,2, ... vi-1
for (let r=0; r<vi; ++r) {
// 单调队列求解每种可能的最大值,滑动窗口大小是,math.min (si, maxVolum / vi) 下取整
// 0 + 0v, 0 + 1v, 0 + 2v ... 0 + kv 的数组中滑动,每次一步
// 最大价值对应的体积的单调队列,双端队列
const queue = [];
for(let j=r; j<=maxVolum; j+=vi) {
// 维护队首
// i 物品的体积超了,注意这里是大于而不是大于等于,要把 r+0*vi也包括进来
while(queue.length && j-queue[0] > vi*si) {
queue.shift();
}
// 维护队尾
while(queue.length && lastRowDp[queue[queue.length-1]] + (j - queue[queue.length-1]) /vi * wi <= lastRowDp[j]) {
queue.pop();
}
// 入队
queue.push(j);
// 更新 dp
dp[j] = lastRowDp[queue[0]] + (j-queue[0]) / vi * wi;
}
}
}
console.log(dp[maxVolum]);
}
process.stdin.on('end', function() {
batchCall(convert(buffer), solve)
});
参考: