问题描述:有N件物品和一个总容量为C的背包,第i件物品的重量是Wi,得到的价值是Vi,求解将哪些物品放入背包中可使价值总和最大
分析:0-1背包问题是最基础的背包问题,同样属于子集和问题,用二维数组F[i][w]来代表前i件物品恰好放入一个容量为w的背包时的最大价值总和。与K-Sum问题类似,对于第i件物品,我们有放入包内和不放入包内两种策略(W表示当前包内剩余容量):
1、如果选择放入,则问题转化为是否能在前i-1件物品中挑选重量不超过W-Wi的物品组合
2、如果不放入第i件物品,则问题可以转化为是否能在前i-1件物品中挑选重量不超过W的物品组合
而问题所求为总价值最大的物品组合,因此我们需要比较这两种方案中哪一个的价值更大,这样就得到一个状态转移方程:F[i][W]=max{F[i-1][W],F[i-1][W-Wi]+Vi}
所以问题的关键,是要利用状态转移方程,将F[i][W]的值全部填充完毕,也就是一个填表的过程
最大价值表:
这个表格可由状态方程解出,但更重要的是理解它的现实意义:F[i][w]是指背包容量在w的情况下,备选物品为F[i]情况下,物品组合最大的价值。以F[4][5]=24为例,其意义为,在当前背包容量为5,备选物品为F[4]={1,2,3,4}的情况下,最佳方案(选择物品1和物品3)可获得的价值为24,是所有方案中所能获得的最大价值。只要理解这一现实意义,就可以很容易看出题目所求的最大价值即为表格的最后一项F[5][12]
另外,二维表中第一列意义是,对于一个容量为0的背包,不论选择怎样的备选组合,其价值都必定为0;第一行的意义是,对于一个任意容量的背包,其备选组合为空集的情况下,背包内部的价值必定为0
根据状态转移方程,我们可以通过最终的最优解,利用回溯的方式找到对应的解空间,有以下的两种寻解方式:
1、若第i件物品未被选择,则有F[i][W]=F[i-1][W]
2、若第i件物品被选择,说明该物品是最优解的一部分,则有F[i][W]=F[i-1][W-Wi]
因此,从二维表的右下角往回看,如果垂直下降,也就是发生了F[i][W]=F[i-1][W],说明第i件物品未被选择,如果是倾斜下降,则说明第i件物品被选中。如下图:
代码实现:
#include <iostream>
using namespace std;
typedef struct goods {
int weight;
int value;
}goods;
const int Capacity = 11;
const int N = 5; //物品件数
goods comodity[N + 1] = { {0,0},{1,1},{2,6 }, {5, 18}, {6, 22}, {7, 28} };
int selected[N + 1][Capacity + 1];
int Max(int a,int b) {
return a > b ? a : b;
}
int GetMaxValue() {
for (int w = 0; w <= Capacity; w++)
selected[0][w] = 0; //表示容量为w的背包为空时,其价值为0
for (int i = 1; i <= N; i++)
selected[i][0] = 0; //表示容量为0的背包,其价值为0
for(int i=1;i<=N;i++)
for (int w = 1; w <= Capacity; w++) {
if (comodity[i].weight > w)
selected[i][w] = selected[i - 1][w];
else
selected[i][w] = Max(selected[i - 1][w], comodity[i].value + selected[i - 1][w - comodity[i].weight]);
}
return selected[N][Capacity];
}
int main()
{
cout << "Max Value is " << GetMaxValue() << endl;
int remainspace = Capacity;
for (int i = N; i > 0; i--) {
//从最优解出发,将所有满足选中状态的物品编号输出即可得到最优解的组成
if (selected[i][remainspace] == comodity[i].value + selected[i - 1][remainspace - comodity[i].weight]) {
cout << "item " << i << " is selected" << endl;
remainspace -= comodity[i].weight;
}
}
return 0;
}
使用这种方式,最终的时间复杂度为O(CN),其空间复杂度为O(CN)
但实际上,对于状态转移方程,考虑到每个F[i][W]都仅和F[i-1][W]或F[i-1][W-Wi]有关,那么可以通过利用滚动数组的方式,将F[N][C]所需要空间压缩至F[2][C],从而将空间复杂度降为O(C)。举个例子,我们将F[0][Wi]状态存在二维表的第0行,接着推出F[1][Wi]对应的值,并将F[1][Wi]的状态存放于二维表的第1行。再接着推出F[2][Wi]的状态时,F[0][Wi]的状态已经没有保存意义了,因此直接将F[2][Wi]的状态保存到二维表的第0行,并以此类推,使数组空间得到循环利用。
代码实现:
//实现上仅仅只是对selected数组的行标进行模2处理,并最终返回selected[1][Capacity]即可
int GetMaxValue() {
for (int w = 0; w <= Capacity; w++)
selected[0][w] = 0; //表示容量为w的背包为空时,其价值为0
for (int i = 1; i <= N; i++)
selected[i % 2][0] = 0; //表示容量为0的背包,其价值为0
for(int i=1;i<=N;i++)
for (int w = 1; w <= Capacity; w++) {
if (comodity[i].weight > w)
selected[i % 2][w] = selected[(i - 1) % 2][w];
else
selected[i % 2][w] = Max(selected[(i - 1) % 2][w], \
comodity[i].value + selected[(i - 1)%2][w - comodity[i].weight]);
}
return selected[1][Capacity];
}
在理解了滚动数组优化空间的基础之上,还可以进一步将长度2*C的二维表进一步压缩成一个一维数组,其状态转移方程为B[W]=max{B[W],B[W-Wi]+Vi}。
代码实现:
int selected[Capacity];
int GetMaxValue() {
for (int i = 1; i <= N; i++)
for (int w = Capacity; w >= 0; --w) {
if (comodity[i].weight <= w)
selected[w] = Max(selected[w], comodity[i].value + selected[w - comodity[i].weight]);
//从二维表变化可以看出,第i件物品不选的情况下,select[i]的值是不发生变化的(即垂直下降)
}
return selected[Capacity];
}
这种优化的原理也是非常明显的,先计算F[i-1][W]并存入数组B当中,然后再计算F[i][W]并覆盖数组B中的内容,依次类推直至所有状态求出。由于B[W]可由B[i-1][W-Wi]推出,因此在覆盖数组时必须采用倒序存储。举个例子,当你计算出B[0]的状态并覆盖掉原来B[0]中的值时,再要求B[1]时,由于B[0]的值已经发生了改变,所以你无法得到正确的B[1]。
然而,不管是滚动数组还是一维数组,在压缩空间的同时,其带来的缺点也很明显,那就是无法通过回溯的方式将所有的最优解求出。若要通过回溯方式求出最优解,那必须对所有的历史状态都进行保留。而压缩存储空间的同时也压缩了问题的解空间,滚动数组的解空间最多仅能容纳2个物品的解,而一维数组的解空间最多只能容纳1个
总结:
1、01背包问题属于NP问题之一,每个物品有选和不选两种策略,若采用暴力搜索算法,其时间复杂度为O(2n),而采用动态规划的方式,则可以将时间复杂度从O(2n )降到O(n2),通过自底向上逐层递推可以求得最优解
2、在动态规划的过程中,由于其无后效性,往往能够通过对状态转移方程的分析,使用滚动数组的方式压缩存储空间,当当前状态仅仅与前一状态相关时,甚至可以采用一维数组进行进一步压缩,此时需要特别注意数组下标的遍历次序。
参考资料:
1、《编程之法——面试和算法心得》
2、《0-1背包问题的动态规划算法》:https://zhuanlan.zhihu.com/p/30959069
3、《背包问题九讲》