变种 背包问题_背包问题(Knapsack)全解析

e81e7ef008c8b72997c0e35157ff89a4.png

导读

背包问题(Knapsack Problem) 是动态规划中非常经典的一类,一般的解题思路是:状态为前 i 中物品在容量为 j 的背包下,最多可以装的物品总价值,然后遍历物品和背包容量,根据之前的状态不断更新当前状态。但是这里面仍然有不少的细节问题,如:

  1. 背包问题的时间复杂度是多少(注意背包问题是NP完全问题)?
  2. 如何做状态压缩?
  3. 二重循环的顺序问题,为什么一定是先循环物品,后循环容量?
  4. 当背包的容量非常大(大于内存可以分配的数组上限),该如何解决?
  5. 背包问题的一些变种

本文分析了12道经典背包问题及其变种,以及Leetcode上部分用背包问题思路解的题目。


背包问题简介

背包问题(Knapsack Problem) 的描述通常为:给定一些物品,每个物品都有自己的重量,价格等属性,求选哪些物品装入一个容量固定的背包,可以使总价格最高。除了基础描述之外,还可能会加一些限制条件,比如每种物品个数。背包问题又可以分为这三大类:0-1 背包,多重背包,完全背包。0-1 背包指的就是每个物品只能放0件或1件进背包;多重背包则会给出每个物品的数量上限,只要每个物品不超过自己的上限即可;完全背包则是物品数量无限,可以取任意数量。注意,背包问题是NP完全问题,而为什么我们又可以提供多项式时间复杂度的动态规划解,在后面会详细分析。我们常把此类问题称为伪多项式时间算法。

12道练习题

92. 背包问题 : 给定一些物品装入背包,最多能装多满?假设背包容量为m,每个物品的大小为A[i]。

这道题每个物品只能选择装或者不装,是典型的0-1背包问题,甚至连物品的价格都省了。我们先考虑最基础的动态规划解法。动态规划解法最核心的就是状态定义和转移方程,几乎所有背包问题的状态都可以定义为:

前 i 件物品,背包容量为 j 的情况下,可以装多满/装的最大价格是多少/... 其他需要最大化的目标。

这道题里我们可以定义当前状态dp[i][j]为前 i 件物品,在容量 j 的背包里,能装的的最大重量。注意这里是前 i 件物品,因此我们当前面对的物品下标是 i - 1,重量为A[i-1],注意,在所有背包问题中,都是用的这种 ”前 i 项“ ,而不是 ”第 i 项“ 来表示状态,这是因为 ”第 0 项“ 没法表示背包为空的情况。

接下来考虑转移方程,对于当前物品(下标 i - 1),我们的选择只有放或者不放两种,如果不放入背包,那么前 i 件物品的状态和前 i - 1件物品的状态是一样的,也就是 dp[i][j] = dp[i-1][j]。如果装入该物品,由于背包已经占用了 A[i-1] 的重量,我们要找的就是在剩余容量下的最大装载量,也就是 dp[i-1][j - A[i]]。由此我们得到了转移方程:

dp[i][j] = Max(dp[i-1][j], dp[i-1][j-A[i-1]]+A[i-1])

注意到,如果A[i-1] > j,表示当前物品完全无法放入当前容量的背包,那么显然这时候dp[i][j] = dp[i-1][j]。

最后考虑边界条件,dp[?][0]表示在容量为 0 的背包里,那么无论当前是第几个物品,dp[?][0]都应该是0。同样的,dp[0][?]表示前 0 个物品装入背包,显然也为0。因此我们的循环下标可以从 1 开始。下面是代码:

/**
 * @param m: An integer m denotes the size of a backpack
 * @param A: Given n items with size A[i]
 * @return: The maximum size
 */
const backPack = function (m, A) {
    
    let n = A.length;
    let dp = Array(n + 1).fill().map(() => Array(m + 1).fill(0)); // 开一个大小为 n+1 * m+1 的二维数组
    for (let i = 1; i <= n; i++) {
    
        for (let j = 1; j <= m; j++) {
    
            dp[i][j] = dp[i - 1][j];  // 先直接赋值为dp[i-1][j]
            if (A[i - 1] <= j) {
    
                dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - A[i - 1]] + A[i - 1]);
            }
        }
    }
    return dp[n][m];
};
题外话:关于javascript中的二维数组初始化,这里new Array的new关键词可以省略,使用map来防止每行数组的引用问题。

遗憾的是这种解法超出了内存限制

775b42de2d2bbbeda079ee383f29631b.png
超出内存限制

注意到我们的转移方程中,dp[i][j]的状态仅和dp[i-1][?]的状态相关,因此我们可以对前 i 个物品这一维度做压缩,采用滚动数组可以将空间压缩到 2 * (m+1)。一种非常简单的状态压缩策略是用一个 2 * (m+1) 的二维数组,配合模2来使用。代码如下:

const backPack = function (m, A) {
    
    let n = A.length;
    let dp = Array(2).fill().map(() => Array(m + 1).fill(0)); // 开一个大小为 2 * m+1 的数组
    for (let i = 1; i <= n; i++) {
    
        for (let j = 1; j <= m; j++) {
    
            dp[i % 2][j] = dp[(i - 1) % 2][j]; 
            if (A[i - 1] <= j) {
    
                dp[i % 2][j] = Math.max(dp[i % 2][j], dp[(i - 1) % 2][j - A[i - 1]] + A[i - 1]);
            }
        }
    }
    return dp[n % 2][m];
};

这种压缩对于第一种代码的修改方式仅仅是修改 dp 数组的初始化大小,并在每个 dp[i][?], dp[i-1][?]的i后面添加一个 %2 即可,完全没有记忆的负担。

注意到,我们在计算前 i 个物品的状态时,仅仅依赖前 i - 1 个物品的状态数组,且 j 总是依赖较小的值,dp[i][j] 依赖 dp[i-1][j - A[i-1]],这里 j - A[i-1] 总是小于 j 的,如果我们在遍历重量的时候,采用逆序遍历,是不是就可以把状态数组压缩成一维了?这里的状态 f[j] 表示容量为j的情况下,最大可以装多少。代码如下

const backPack = function (m, A) {
    
    let n = A.length;
    let f = Array(m + 1).fill(0);

    for (let i = 1; i <= n; i++) {
    
        for (let j = m; j >= A[i - 1]; j--) {
     // 注意这里是逆序遍历
            f[j] = Math.max(f[j], f[j - A[i - 1]] + A[i - 1]);
        }
    }
    return f[m];
};

注意到代码中在遍历重量的时候,我们只从 m 遍历到了 A[i-1],这是因为,当j小于A[i-1]时,此时不需要更新f[j]的值,和前文代码中 if (A[i - 1] <= j)的判断逻辑是一样的。

这里我们探讨两个问题:1. 为什么先遍历物品数,后遍历重量?2. 为什么说背包问题是伪多项式时间复杂度?

问题一:为什么先遍历物品数,后遍历重量?

可以先测试一下,在未做状态压缩的情况下,交换物品和重量的循环顺序是不会影响结果的正确性的,这是因为对于任意状态 dp[i][j] 都只会被更新一次。而对于采用了滚动数组状态压缩的解法,循环顺序一旦改变就会出错。考虑一个简单的例子,m = 5,A = [1,1,1],先循环重量,假设当前重量为4,然后循环物品(1 -> 3),由于1%2 = 3%2,先更新dp[1%2][4],然后dp[3%2][4]则会把这个值覆盖,当重量为5的时候,我们要计算dp[2%2][5] = dp[(2-1)%2][5 - A[1]] = dp[1%2][4],而这个值已经被之前的dp[3%2][4]覆盖,并且显然有dp[1][4] != dp[3][4],因此这里就会出错。当然,在实际代码中,只要记住总是先循环物品,后循环重量就不会有问题。

问题二:为什么说背包问题是伪多项式时间复杂度?

In computational complexity theory, a numeric algorithm runs in pseudo-polynomial time if its running time is a polynomial in the numeric value of the input (the largest integer present in the input) — but not necessarily in the length of the input (the number of bits required to represent it), which is the case for polynomial time algorithms.

我们这里摘一段Wiki上的定义,下划线部分,如果算法的时间复杂度跟输入的数值是多项式关系,注意这里说的是输入的数值,而不是输入的规模,一个数组长度为n,那么它的输入规模确实是n,但是对于一个数值而言,它在内存中仅占log(N)的bits,这才是它的输入规模。背包问题中,时间复杂度是 m*n,这里的m在内存中仅占log(m)的存储空间,因此真正的复杂度应该是指数级的,所以被称为伪多项式时间复杂度。

125. 背包问题 II : 给定一些物品装入背包,能装入背包的最大价值是多少?假设背包容量为m,每个物品的大小为A[i],价值为V[i]。

这道题和92. 背包问题相比,唯一的变化就是每个物品都有价值,最大化的目标是背包内能装的价值。状态的定义稍有变化,dp[i][j]表示前i个物品,背包容量为j时能装的最大价值,转移方程就是

dp[i][j] = Max(dp[i-1][j], dp[i-1][j-A[i-1]]+V[i-1])

这里我们直接放用滚动数组做状态压缩的代码

const backPackII = function (m, A, V) {
    
    let n = A.length;
    let dp = Array(2).fill().map(() => Array(m + 1).fill(0));
    for (let i = 1; i <= n; i++) {
    
        for (let j = 1; j <= m; j++) {
    
            dp[i % 2][j] = dp[(i - 1) % 2][j];
            if (j - A[i - 1] >= 0) {
    
                dp[i % 2][j] = Math.max(dp[i % 2][j], dp[(i - 1) % 2][j - A[i - 1]] + V[i - 1]);
            }
        }
    }
    return dp[n % 2][m]
};

这道题同样可以被压缩成一维数组,代码如下。并且注意到我们的外层循环直接用了 i = 0 作为初始值,i < n 作为循环终止条件,这样我们访问价值和重量数组的时候就不需要 - 1 了。

const backPackII = function (m, A, V) {
    
    let n = A.length;
    let f = Array(m + 1).fill(0);
    for (let i = 0; i < n; i++) {
    
        for (let j = m; j >= A[i]; j--) {
    
            f[j] = Math.max(f[j], f[j - A[i]] + V[i]);
        }
    }
    return f[m];
};

440. 背包问题 III : 给定一些物品装入背包,每个物品有无限个,能装入背包的最大价值是多少?假设背包容量为m,每个物品大小为A[i],价值为V[i]。

这道题就是所谓的完全背包问题,和0-1背包的最大区别就是每个物品可以取无数个。题目的变化对于状态的定义并没有任何影响,dp[i][j]仍然表示,前i个物品,在容量为j的背包下,最大的装载价值。对于当前物品,我们可以考虑装 0 ... k 个,直到超过当前背包容量j,也就是 k * A[i] <= j,因此我们可以再添加一层循

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值