起因
面试官即排序算法、斐波那契数列后的第三个问题——背包问题
在学习的同时,我尽可能用通俗易懂的解释、代码注释、代码分析、问题优化加深这样的过程来和大家一起分析这个问题。
问题的描述
背包:背包容量一定,在一组东西中如何选择,使得背包最有价值
本质:是一个组合优化的问题。
问题描述:给一个固定大小,能够携重W的背包,以及一组有价值重量的物品,
请找出一个最佳的方案,使得装入包中的物品重量不超过W且总价值最大。
问题分析
我们以一个具体的例子进行分析
示例
物品个数 n:5
物品重量 weights:[2,2,6,5,4]
物品价值 values:[6,3,5,4,6]
背包总容量 W:10
需求:怎么装使得总重量w<10,且value价值最大
思路
假设一个函数为: f(i,j) :表示装入背包的最大价值
其中的 i:表示装入的物品 (从第一个到第五个都有可能装入)
其中的 j:表示背包目前的重量 (j<w)
逻辑分析:
对于一种物品,要么装入背包,要么不装。
所以对于一种物品的装入状态只是1或0, 此问题称为01背包问题
对第一行分析:
目前我们只有盒子0
当背包重量j是0时,什么都装不下,所以f(0,0)=0
当背包重量j是1时,什么都装不下,所以f(0,1)=0
当背包重量j等于2或者大于2时,盒子0能装入,所以f(0,j)=6 (j>=2)
即可得方程式:
第二行
对第二行分析:
目前有盒子0和盒子1
当背包重量j是0时,什么都装不下,所以f(1,0)=0
当背包重量j是1时,什么都装不下,所以f(1,1)=0
当背包重量j是2时,可以装盒子0或盒子1,需要比较盒子0和盒子1的价值,所以f(1,2)=6
当背包重量j是3时,可以装盒子0或盒子1,需要比较盒子0和盒子1的价值,所以f(1,3)=6
当背包重量j是4或者>4时,可以装盒子0和盒子1,所以f(1,j)=9 (j>=4)
方程式为:
这个时候,递归就已经有一定的体现了,只要接下来的符合上述逻辑我们就可以实现JavaScript函数了。
我们看第三行:
对第三行分析:
目前有盒子0,盒子1,盒子2
当背包重量j是0时,什么都装不下,所以f(2,0)=0
当背包重量j是1时,什么都装不下,所以f(2,1)=0
当背包重量j是2时,可以装盒子0或盒子1,装不下盒子2,需要比较盒子0和盒子1的价值,所以f(2,2)=6
当背包重量j是3时,可以装盒子0或盒子1,装不下盒子2,需要比较盒子0和盒子1的价值,所以f(2,3)=6
当背包重量j是4或者5时,可以装盒子0和盒子1,装不下盒子2,所以f(2,j)=9 (4<= j <6)
当背包重量j是6或者7时,可以选择装盒子2,也可以选择装盒子0和盒子1,比较价值,所以f(2,j)=9 (6<= j <8)
当背包重量j是8,9时,可以选择装下盒子2后在装下盒子0和盒子1中的一个,所以f(2,j)=11 (8<= j <10)
当背包重量j是10时,可以同时装下盒子0、盒子1、盒子2,所以f(2,10)=14
整理方程可得到第1行和第2行的使用方程:
解释:
依次求出剩余行
整合我们的方程式为:
函数实现(上述方程式)
function knapsack(weights, values, w){
var n = weights.length -1; // 获取盒子个数i 从盒子0开始
var f=[[]]; //定义f的矩阵
for(var j=0;j<=w;j++){
// 背包的重量j从0开始,一直到我们输入的w
if(j<weights[0]){ // 背包的重量小于盒子0的重量,价值为0
f[0][j]=0;
}else{
f[0][j]=values[0]; // 否则容量为物品0的价值
}
}
// 上述for循环实现了方程式的前两条
for(var j=0;j<=w;j++){
// 当盒子数不是1个时
for(var i=1;i<=n;i++){
if(!f[i]){ // 创建新的一行
f[i]=[];
}
if(j<weights[i]){ // 等于之前的最优值
f[i][j]=f[i-1][j];
}else{
f[i][j]=Math.max(f[i-1][j],f[i-1][j-weights[i]]+values[i]);
}
}
}
return f[n][w];
}
var a = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(a)
执行结果
到这里已经可以解决问题了,如果想深入的去学习,下面还有变种优化以及其他可以实现的思路
优化:
合并循环
现在方法里面有两个大循环,它们可以合并成一个。
function knapsack(weights, values, W){
var n = weights.length; // 盒子个数
var f = new Array(n) // 定义矩阵
for(var i = 0 ; i < n; i++){
f[i] = [] // 共计五行数据
}
for(var i = 0; i < n; i++ ){ // i表示当前盒子
for(var j = 0; j <= W; j++){ // j表示当前背包的重量
if(i === 0){ //第一行
f[i][j] = j < weights[i] ? 0 : values[i]
// 三元表达式 小于盒子重量 价值0 大于盒子重量 价值第一个盒子重量
}else{
if(j < weights[i]){ //等于之前的最优值
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i])
}
}
}
}
return f[n-1][W]
}
这时候我们发现必须要有一个if条件去专门的处理盒子0的状态(即第一行的数据),f[i][j]=j<weights[i]?0:values[i]
可不可以也能转换为 f[i][j]=Math.max(f[i-1][j],f[i-1][j-weights[i]]+values[i])
。Math.max可以轻松转换为三元表达式,结构极其相似。而看一下i-1的边界问题,我们可以在第一行上面再加一行即i可以取-1值。
如图:
那么方程式如下:
function knapsack(weights, values, W){
var n = weights.length;
var f = new Array(n)
f[-1] = new Array(W+1).fill(0) // 引入了-1行
for(var i = 0 ; i < n ; i++){ //注意边界,没有等号
f[i] = new Array(W).fill(0)
for(var j=0; j<=W; j++){//注意边界,有等号
if( j < weights[i] ){ //注意边界, 没有等号
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);
}
}
}
return f[n-1][W]
}
负一行的出现可以大大减少了在双层循环的分支判定。是一个很好的技巧。
选择物品(可能就会问:到底选择了那些盒子呢)
上面讲解了如何求得最大价值,现在我们看到底选择了哪些盒子,这个在现实中更有意义。
仔细观察矩阵,从逆着走向,设i=n-1,j=W,
只要我们的方程式中==成立就说明包里面有第i件物品,因此我们只要当前行不等于上一行的总价值,就能挑出第i件物品,然后j减去该物品的重量,一直找到j = 0就行了。
function knapsack(weights, values, W){
var n = weights.length;
var f = new Array(n)
f[-1] = new Array(W+1).fill(0)
var selected = []; // 用来保存选择的盒子
for(var i = 0 ; i < n ; i++){ //注意边界,没有等号
f[i] = [] //创建当前的二维数组
for(var j=0; j<=W; j++){ //注意边界,有等号
if( j < weights[i] ){ //注意边界, 没有等号
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);
}
}
}
// 数组逆遍历
var j = W, w = 0
for(var i=n-1; i>=0; i--){
if(f[i][j] > f[i-1][j]){
selected.push(i)
console.log("物品",i,"其重量为", weights[i],"其价格为", values[i])
j = j - weights[i];
w += weights[i]
}
}
console.log("背包最大承重为",W," 现在重量为", w, " 总价值为", f[n-1][W])
// selected.reverse() 数组的翻转函数 我们是逆遍历,数组翻过来
return [f[n-1][W], selected.reverse() ]
}
var a = knapsack([2,3,4,1],[2,5,3, 2],5)
console.log(a)
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
结果展示
使用滚动数组压缩空间
所谓滚动数组,目的在于优化空间,因为目前我们是使用一个的二维数组来储存每一步的最优解。在求解的过程中,我们可以发现,当前状态只与前一行的状态有关,那么更之前存储的状态信息已经无用了,可以舍弃的,我们只需要存储当前状态和前一行状态,所以只需使用的空间,循环滚动使用,就可以达到跟一样的效果。这是一个非常大的空间优化。
function knapsack(weights, values, W){
var n = weights.length
var lineA = new Array(W+1).fill(0)
var lineB = [], lastLine = 0, currLine
var f = [lineA, lineB]; //case1 在这里使用es6语法预填第一行
for(var i = 0; i < n; i++){
currLine = lastLine === 0 ? 1 : 0 //决定当前要覆写滚动数组的哪一行
for(var j=0; j<=W; j++){
f[currLine][j] = f[lastLine][j] //case2 等于另一行的同一列的值
if( j>= weights[i] ){
var a = f[lastLine][j]
var b = f[lastLine][j-weights[i]] + values[i]
f[currLine][j] = Math.max(a, b);//case3
}
}
lastLine = currLine//交换行
}
return f[currLine][W];
}
var a = knapsack([2,3,4,1],[2,5,3, 2],5)
console.log(a)
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
注意,这种解法由于丢弃了之前N行的数据,因此很难解出挑选的物品,只能求最大价值。
使用一维数组压缩空间
观察我们的状态迁移方程:
weights为每个物品的重量,values为每个物品的价值,W是背包的容量,i表示要放进第几个物品,j是背包现时的容量(假设我们的背包是魔术般的可放大,从0变到W)。我们假令i = 0
f中的-1就变成没有意义,因为没有第-1行,而weights[0], values[0]继续有效,也有意义,因为我们全部放到一个一维数组中。于是:
这方程后面多加了一个限制条件,要求是从大到小循环。为什么呢?
假设有物体z容量2,价值很大,背包容量为5,如果j的循环顺序不是逆序,那么外层循环跑到物体时, 内循环在时 ,z被放入背包。当时,寻求最大价值,物体z放入背包,, 这里毫无疑问后者最大。 但此时中的
javascript实现:
function knapsack(weights, values, W){
var n = weights.length;
// 借助new Array()生成指定数组长度的假数据的时候,此时数组是空的
// 使用fill()这个数组方法,由于没有传值,fill()会自动根据数组长度替换数组中所有的值为undefined
var f = new Array(W+1).fill(0)
// 值全为0
for(var i = 0; i < n; i++) {
for(var j = W; j >= weights[i]; j--){
f[j] = Math.max(f[j], f[j-weights[i]] +values[i]);
}
console.log(f.concat()) //调试
}
return f[W];
}
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
1.4 递归法解01背包
由于这不是动态规则的解法,大家多观察方程就理解了:
function knapsack(n, W, weights, values, selected) {
if (n == 0 || W == 0) {
//当物品数量为0,或者背包容量为0时,最优解为0
return 0;
} else {
//从当前所剩物品的最后一个物品开始向前,逐个判断是否要添加到背包中
for (var i = n - 1; i >= 0; i--) {
//如果当前要判断的物品重量大于背包当前所剩的容量,那么就不选择这个物品
//在这种情况的最优解为f(n-1,C)
if (weights[i] > W) {
return knapsack(n - 1, W, weights, values, selected);
} else {
var a = knapsack(n - 1, W, weights, values, selected); //不选择物品i的情况下的最优解
var b = values[i] + knapsack(n - 1, W - weights[i], weights, values, selected); //选择物品i的情况下的最优解
//返回选择物品i和不选择物品i中最优解大的一个
if (a > b) {
selected[i] = 0; //这种情况下表示物品i未被选取
return a;
} else {
selected[i] = 1; //物品i被选取
return b;
}
}
}
}
}
var selected = [], ws = [2,2,6,5,4], vs = [6,3,5,4,6]
var b = knapsack( 5, 10, ws, vs, selected)
console.log(b) //15
selected.forEach(function(el,i){
if(el){
console.log("选择了物品"+i+ " 其重量为"+ ws[i]+" 其价值为"+vs[i])
}
})