关于动态规划法解决0-1背包问题时遍历顺序的探讨

对动态规划法解决0-1背包问题十分熟悉的人可以直接看末尾的结论,然后再有选择性的参阅文章的细节。

一、0-1背包的题目描述

有n种物品,每种只有1个。第i种物品的体积为Vi,重量为Wi。选一些物品装到一个容量为C的背包中,使得背包内物品在总体积不超过C的前提下重量尽可能大,问总重量的最大值为多少。

二、基本思路与其实现

既然要用用动态规划法解决0-1背包问题,我们就先定义动态规划的三个要点,即状态、状态转移方程和边界条件。

首先我们用子问题定义状态,我们用F(i,j)表示“把前i件物品放入容量为j的背包中的最大总重量”。

然后我们要考虑怎样的状态转移方程可以把这个问题转化为更小的子问题。我们依然以“每一个物品都有放或不放两种选择”的策略为基础,考虑第i件物品,如果我们选择不放第i件物品,那么问题就直接转化为“把前i-1件物品放入容量为j的背包中的最大总重量”,如果我们选择放第i件物品,那么问题就转化为“把前i-1件物品放入容量为j-V[i]的背包中的最大总重量加上第i件物品的重量”(这里值得注意的一点是,如果j-V[i]<0,即放入第i件物品后超过了背包容量的限制,那么我们就只能选择不放第i件物品了)。所以状态转移方程为F(i,j)=max{F(i-1,j),F(i-1,j-V[i])+W[i]}

边界条件则很容易得到,i=0时F(i,j)为0(没有物品就没有重量),j<0时F(i,j)为负无穷(但在代码中并不会这样初始化,我们在j-V[i]<0时不计算第二种情况即可)。最终答案则是f(n,C)。

代码如下:

for(j = 0; j <= C; j++)
    f[0][j]=0;
for(i = 1; i <= n; i++)
   for(j = 1; j <= C; j++)
         if(j >= V[i]) 
            f[i][j] = max(f[i-1][j], f[i-1][j-V[i]]+W[i]);
        else
            f[i][j] = f[i-1][j];`

上述算法的时间复杂度是O(nC)。
实际上,我们还可以逆序枚举容量,改为for(j = C; j >= 0; j–),效果是一样的。仔细观察状态转移方程,我们可以发现f[i][j]的值只与上一次迭代有关,与本次迭代无关,所以同一次迭代内,对于任意的j1和j2,f[i][j1]和f[i][j2]是互不影响的,先计算哪一个都是可以的。

三、对空间复杂度的优化

上述算法的时间复杂度已经很难再优化了,不过空间复杂度却有很大的优化余地。由于我们是顺序遍历物品,所以我们其实可以边读取边计算,这样就能少定义两个一维数组:

for(j = 0; j <= C; j++)
    f[0][j]=0;
for(i = 1; i <= n; i++)
{
    scanf(“%d %d”,&V,&W);
    for(j = 1; j <= C; j++)
        if(j >= V[i]) 
            f[i][j] = max(f[i-1][j], f[i-1][j-V]+W);
        else
            f[i][j] = f[i-1][j];
}

当然,由于二维数组f的存在,少定义两个一维数组并不能显著减少空间复杂度,尤其是当n远小于C时。所以我们希望能把二维数组优化为一维数组。实际上,利用滚动数组是可以把空间复杂度从O(nC)降低至O©的。为了方便说明,我将先展示代码,然后对于其中三个比较难理解的地方进行解释:

memset(f, 0, sizeof(f));
for(i = 1; i <= n; i++) 
{
scanf("%d %d", &V, &W);  
for(j = C; j >= V; j--)  //这里只能逆序遍历
f[j] = max(f[j], f[j-V]+W);
}

最后的答案为f[C]。

1、如何理解改写后的状态方程
根据状态转移方程,我们可以发现在f[j]=max(f[j], f[j-V]+W)计算之前,f[j]对应于原来的f[i-1][j],f[j-V]对应于原来的f[i-1][j-V],而进行计算后f[j]就对应于原来的f[i][j]了,所以这是一个不断更新f[j]、不断滚动的过程。

2、为什么逆序遍历可以不考虑j小于V[i]的情况
实际上,我们依然要考虑j小于V[i]的情况,但由于滚动数组的特点,我们恰好不需要在代码里写这种情况而已,属于“歪打正着”。
在刚进入第i次迭代,还没有进行任何计算时,对于任意的j,f[j]对应于f[i-1][j],存储的值也是f[i-1][j]。
假设我们不进行任何计算直接结束第i次迭代,那么所有的f[j]对应于f[i][j],但存储的值依然是f[i-1][j],这也就相当于对任意的j 都执行了f[i][j]=f[i-1][j]。
所以说如果我们想执行f[i][j]=f[i-1][j],只要不计算就可以了。

3、为什么必须逆序遍历容量而不能顺序遍历容量
首先要提醒的一点是,该算法只是对原来二维数组算法的一种优化,原来我们每次迭代都要用一行数组来记录,一共需要n行;现在我们只要一行,后一次迭代的结果会覆盖掉前一次的结果,而且是边计算边覆盖,不是全算完了再覆盖,我们采用一维数组只是为了节省空间,所以请以原来的算法的进行过程为基础进行理解。另外,接下来的解释会非常抽象,如果不能理解,我推荐结合我的解释在纸上推演每次迭代的更新过程,最好不用数字而用字母,这个问题适合抽象地去理解。

对于第i次迭代,如果逆序遍历j,当执行f[j]=max(f[j], f[j-V]+W)时, f[j-V]一定不变,所以当j1=j-V时,在计算f[j1]=max(f[j1], f[j1-V]+W)前,f[j1]一定没有被更新,也就说是,当前未被更新的元素之前的元素在本次迭代中一定没有被更新过,之前被更新的元素无法通过状态转移方程f[j] = max(f[j], f[j-V]+W)对当前未被更新的元素产生影响。

对于第i次迭代,如果顺序遍历j,当执行f[j]=max(f[j], f[j-V]+W)时,f[j]一定被更新了,所以当j1=j+V时,在计算f[j1]=max(f[j1], f[j1-V]+W)前,f[j1-V]已经被更新了,也就说是,当前未被更新的元素之前的元素在本次迭代中可能被更新过,而之前被更新的元素会通过状态转移方程f[j] = max(f[j], f[j-V]+W)对当前未被更新的元素产生影响。

那么后者的问题在哪里呢?我们知道,更新的本质实际上是对于取还是不取物体的决策,我们在第i次迭代中更新时,如果f[j]小于 f[j-V]+W,那我们就会把第i个物体放入背包中,也就是说每一次更新都有可能取物体。由于0-1背包的题目告诉我们每一个物品只有一个,所以我们必须保证取物体的可能性在一次迭代中只出现一次,否则的话,如果一次迭代中已更新的元素会对未更新的元素产生影响,那么我们就可能放入了多件相同的物品,这就有可能违背0-1背包的题意了。

四、补充

最后再讲讨论一下逆序遍历物品的情况。我们只要稍微修改一下动态规划的三个要点就可以了,效果是一样的。我们用d(i,j)表示“把第i, i+1, i+2,…, n个物品装到容量为j的背包中的最大总重量”,这样状态转移方程就是d(i,j)=max{d(i+1,j),d(i+1,j-V[i])+W[i]},边界则是i>n时d(i,j)=0,j<0时为负无穷(同理我们一般不会初始化这个边界,而是只当j≥V[i]时才计算第二项),答案是d[1][C]。只不过由于是逆序遍历物品,所以不能边读取边计算了。从这里我们也可以看出子问题定义状态的方式并不是唯一的
代码如下:

for(i = n; i >= 1; i--)//初始化省略了
for(j = 0; j <= C; j++) //同样可以逆序遍历容量
if(j >= V[i]) 
d[i][j]=max(d[i+1][j],d[i+1][j-V[i]]+W[i]);
        else
            d[i][j]= d[i+1][j];

五、小结

这篇总结的核心在于“遍历顺序”,这是许多背包问题的讲解没有说清楚的地方。通过上述探讨我们可以看出两点:
1、物品的遍历顺序是受状态定义影响的。
2、使用二维数组时,容量有顺序和逆序两种遍历方法;而用一维数组时,容量只有逆序一种遍历方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值