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

本文详述了背包问题的动态规划解法,包括0-1背包、完全背包、多重背包及其变种,分析了12道练习题,探讨了时间复杂度、状态压缩等问题。文章介绍了不同背包问题的状态定义、转移方程,并提供了代码实现,涉及多种优化策略,如滚动数组和一维状态压缩。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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,因此我们可以再添加一层循环,来遍历当前这个物品到底装多少个,于是转移方程就变成了 :

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

我们还是从最基础的无状态压缩的版本写起,代码如下:

const backPackIII = function (A, V, m) {
    let n = A.length;
    let dp = Array(n + 1).fill().map(() => Array(m + 1).fill(0));
    for (let i = 1; i <= n; i++) {
        for (let j = 1; j <= m; j++) {
            dp[i][j] = dp[i - 1][j];
            // 注意这里再对物品的数目做循环
            for (let k = 0; A[i - 1] * k <= j; k++) {
                dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - k * A[i - 1]] + k * V[i - 1]);
            }
        }
    }
    return dp[n][m];
};

接下来用滚动数组做状态压缩:

const backPackIII = function (A, V, m) {
    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];
            for (let k = 0; A[i - 1] * k <= j; k++) {
                dp[i % 2][j] = Math.max(dp[i % 2][j], dp[(i - 1) % 2][j - k * A[i - 1]] + k * V[i - 1]);
            }
        }
    }
    return dp[n % 2][m];
};

那么这道题是不是同样可以压缩到一维?答案是肯定的。实际上就是125. 背包问题 II 的内层循环由大到小改为由小到大,代码如下。原理很简单,这里设状态 f[j] 表示容量为j时,最大可以装多少价值的东西。转移方程就是:

f[j] = Max(f[j], f[j - A[i]] + V[i]);

代码如下,注意我们的内层循环,j只要从A[i]开始就可以了,这样可以省去判断 j - A[i] 下标为负的情况。

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

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

注意,这道题目改了一下描述方式,重量成了我们需要最大化的目标。这道题额外加了数量的限制条件,翻译一下就是给定一些物品装入背包,每个物品有数量限制,能装入背包的最大价值是多少?记得在440. 背包问题 III 中,我们无状态压缩版的转移方程是

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

要解决这道题,只要在对k循环的时候,添加一个额外的数量限制即可,代码如下(因为OJ平台的关系,输入参数有所变化,在提交代码的时候请格外注意)。注意到我们在循环k的时候,循环终止条件是:k <= amounts[i - 1] && k * prices[i - 1] <= j,额外添加了 k <= amounts[i-1]这个判断条件。

/**
 * @param n: the money of you
 * @param prices: the price of rice[i]
 * @param weight: the weight of rice[i]
 * @param amounts: the amount of rice[i]
 * @return: the maximum weight
 */
const backPackVII = function (n, prices, weights, amounts) {
    let numOfItems = prices.length;
    let dp = Array(numOfItems + 1).fill().map(() => Array(n + 1).fill(0));
    for (let i = 1; i <= numOfItems; i++) {
        for (let j = 1; j <= n; j++) {
            dp[i][j] = dp[i - 1][j];
            for (let k = 0; k <= amounts[i - 1] && k * prices[i - 1] <= j; k++) {
                dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - k * prices[i - 1]] + weights[i - 1] * k);
            }
        }
    }
    return dp[numOfItems][n];
};

实际上,0 - 1背包就是这里k = 0或1的特例。这里我们还是可以用滚动数组做状态压缩,代码如下:

const backPackVII = function (n, prices, weights, amounts) {
    let numOfItems = prices.length;
    let dp = Array(2).fill().map(() => Array(n + 1).fill(0));
    for (let i = 1; i <= numOfItems; i++) {
        for (let j = 1; j <= n; j++) {
            dp[i % 2][j] = dp[(i - 1) % 2][j];
            for (let k = 0; k <= amounts[i - 1] && k * prices[i - 1] <= j; k++) {
                dp[i % 2][j] = Math.max(dp[i % 2][j], dp[(i - 1) % 2][j - k * prices[i - 1]] + weights[i - 1] * k);
            }
        }
    }
    return dp[numOfItems % 2][n];
};

我们换一种思路,每件物品都有数量限制,那就等价于0-1背包问题中,存在多个物品具有同样的价格,重量。因此在一维状态数组的0-1背包解法上,添加一层物品数量的循环,就得到了如下解法。这里要注意,k是从1开始循环,因为转换成0-1背包解法的时候,每种物品只少得有一个。

const backPackVII = function (n, prices, weights, amounts) {
    let numOfItems = prices.length;
    let f = Array(n + 1).fill(0);
    for (let i = 0; i < numOfItems; i++) {
        for (let k = 1; k <= amounts[i]; k++) {
            for (let j = n; j >= prices[i]; j--) {
                f[j] = Math.max(f[j], f[j - prices[i]] + weights[i]);
            }
        }
    }
    return f[n];
};

562. 背包问题 IV : 给出 n 个物品, 以及一个数组, nums[i]代表第i个物品的大小, 保证大小均为正数并且没有重复, 正整数 target 表示背包的大小, 找到能填满背包的方案数。 每一个物品可以使用无数次。

这道题的目标是求刚好装满背包的方案数,有不少类似的题目,比如爬楼梯问题,每次可以爬1级或2级,求总共有多少种方法爬到顶?或者是机器人从网格的左上角走到右下角,每次可以向右或向下一格,求有多少种路径到达右下角?当然,这两道题目和本题还是有一些细节差异,我们后面同样会分析。一般这种题目的状态表示的就是到达当前这个状态,有多少种方式。回到这道题目,注意要求的是填满背包的方案数,假设背包容量为3,物品重量为[1,2],那么[1,2], [2,1]只能算是一种方案。我们这里定义状态dp[i][j] 表示前i件物品,在容量为j的背包下,有几种方案。我们在计算dp[i+1][?]的时候,由于物品A[i]是新加入的,所以不会存在 [1,2], [2,1] 这种方案重复的情况。接下来考虑转移方程,很简单,有多少个状态能转移到dp[i][j],那么dp[i][j]就应该是多少,也就表示有多少种方案,此外,由于每种物品没有数量限制,那么我们还需要循环物品数量k。

dp[i][j] += dp[i-1][j-k*nums[i-1]];

这道题还需要额外考虑一些边界情况,根据上面的转移方程,如果之前的状态值为0,那么当前状态并不会加,因此我们必须对dp[i][0]的情况都赋值为1,这表示背包容量为0的情况下,有一种方案。反之,dp[0][j] 表示0个物品填满容量为j的背包,由于物品数为0,无法填满,因此dp[0][j]应该为0。代码如下:

const backPackIV = function (nums, target) {
    let n = nums.length;
    let dp = Array(n + 1).fill().map(() => Array(target + 1).fill(0));
    dp[0][0] = 1;
    for (let i = 1; i <= n; i++) {
        dp[i][0] = 1;
        for (let j = 1; j <= target; j++) {
            for (let k = 0; k * nums[i - 1] <= j; k++) {
                dp[i][j] += dp[i - 1][j - k * nums[i - 1]];
            }
        }
    }
    return dp[n][target];
};

然后我们还是做状态压缩,这里的状态压缩和之前的略微有所不同,在每次循环物品的开始,必须把存储当前状态的数组重置为0,第一位设为1,参考无状态压缩的版本,dp[i][?]的初始值都为0,它的数值仅来自于之前可转移状态的总数。

const backPackIV = function (nums, target) {
    let n = nums.length;
    let dp = Array(2).fill().map(() => Array(target + 1).fill(0));
    dp[0][0] = 1;
    for (let i = 1; i <= n; i++) {
        dp[i % 2].fill(0); // 注意重置当前这一行状态
        dp[i % 2][0] = 1;
        for (let j = 1; j <= target; j++) {
            for (let k = 0; k * nums[i - 1] <= j; k++) {
                dp[i % 2][j] += dp[(i - 1) % 2][j - k * nums[i - 1]];
            }
        }
    }
    return dp[n % 2][target];
};

更进一步,尝试压缩到一维数组,我们无需关心每个状态包含了多少物品,只要加上前一个状态的方案数即可,并且由于物品在外层循环,也不会发生[1,2], [2,1]这样的重复问题。代码如下:

const backPackIV = function (nums, target) {
    let f = Array(target+1).fill(0);
    f[0] = 1;
    for(let n of nums){
        for(let i=n;i<=target;i++){
            f[i] += f[i-n];
        }
    }
    return f[target]
}

564. 背包问题VI (组合总和) : 给出一个都是正整数的数组 nums,其中没有重复的数。从中找出所有的和为 target 的组合个数,注意一个数可以在组合中出现多次, 数的顺序不同则会被认为是不同的组合。

注意到题目要求中,数的顺序不同也被认为是不同的组合(更确切地说,这是一个排列问题)那么我们之前通过先循环物品来避免重复的做法就不能使用了,反而这题应该反过来,先循环背包容量,再循环物品,这样我们就可以满足题目中”数的顺序不同则被认为是不同的组合“的要求。此时的状态定义也不需要前 i 个物品这个维度了,只需要一个一维数组 f,f[i] 表示正好装满容量i的组合个数。转移方程就是:

f[i] += f[i - nums[j]]

代码如下:

const backPackVI = function (nums, target) {
    let f = Array(target + 1).fill(0);
    f[0] = 1; // 初始状态,我们认为放满容量为0的背包,有一种方案
    for (let i = 0; i <= target; i++) {
        for (let n of nums) { // 这里跟转移方程略有不同,直接用for of做循环了
            if (i >= n) {
                f[i] += f[i - n];
            }
        }
    }
    return f[target];
};

563. 背包问题 V : 给定一些物品装入背包,每个物品只能用一次,能装满背包的方案数有多少?假设背包容量为m,每个物品大小为A[i]。

这道题里每个物品只能用一次,因此我们还是要使用 dp[i][j] 来表示前 i 个物品,在容量为 j 的情况下,能装多少个,用来避免重复问题。转移方程为:

dp[i][j] = dp[i-1][j] + dp[i-1][j - nums[i-1]]; 

其中dp[i-1][j]表示不放这个物品,dp[i-1][j - nums[i-1]] 则表示放当前这个物品。这里我们直接放滚动数组的状态压缩版代码。这里我们在没有在每次循环开始的时候,将当前数组重置为全0,这是因为 dp[i % 2][j] = dp[(i - 1) % 2][j] 这步会直接将当前状态赋值为上一状态,置为0是多余的。

const backPackV = function (nums, target) {
    let n = nums.length;
    let dp = Array(2).fill().map(() => Array(target + 1).fill(0));
    dp[0][0] = 1;

    for (let i = 1; i <= n; i++) {
        dp[i % 2][0] = 1;
        for (let j = 1; j <= target; j++) {
            dp[i % 2][j] = dp[(i - 1) % 2][j];
            if (j - nums[i - 1] >= 0) {
                dp[i % 2][j] += dp[(i - 1) % 2][j - nums[i - 1]];
            }
        }
    }
    return dp[n % 2][target];
};

接下来我们压缩成一维,仍然是采用从后往前遍历的方式。

const backPackV = function (nums, target) {
    let n = nums.length;
    let f = Array(target + 1).fill(0);
    f[0] = 1;
    for (let i = 0; i < n; i++) {
        for (let j = target; j >= nums[i]; j--) {
            f[j] += f[j - nums[i]];
        }
    }
    return f[target];
};

799. 背包问题VIII : 给一些不同价值和数量的硬币。找出这些硬币可以组合在1 ~ n范围内的值的数量 。每个硬币价值为value[i],每个硬币数量为amount[i]。

需要解释一下,这道题里”找出这些硬币可以组合在1 ~ n范围内的值的数量”,指的是,1 ~ n 这些数值中,有多少是可以用这些硬币组成的。我们之前的DP数组求的是前i个物品,装满容量为j的背包的方案数有多少,那么我们把1~n这个总价值当做背包容量,数一下有多少个1 ~ n的容量,有至少一种方案,就是我们要求的结果了。再简化一下,我们的DP数组只需要存储这个容量能放还是不能放就可以了,不需要关心到底有几种方案能装满当前这个容量。因此,定义状态 dp[i][j] 表示,对于前 i 个物品,是否能装满容量为j的背包,转移方程为:

dp[i][j] |= dp[i-1][j - k * value[i-1]]

其中 k 表示当前物品取的个数,k必须满足k <= amount[i-1],以及 k * amount[i-1] <= j。初始化的时候,可以直接设置dp[?][0] = true。

const backPackVIII = function (n, value, amount) {
    let numOfItems = value.length;
    let dp = Array(numOfItems + 1).fill().map(() => Array(n + 1).fill(false));

    for (let i = 0; i <= numOfItems; i++) {
        dp[i][0] = true;
    }

    for (let i = 1; i <= numOfItems; i++) {
        for (let j = 1; j <= n; j++) {
            for (let k = 0; k <= amount[i - 1] && j >= k * value[i - 1]; k++) {
                // 注意这里我们用的是 |= 或等于,因为只要前面有一个状态为 true 就足够了
                dp[i][j] |= dp[i - 1][j - k * value[i - 1]];
            }
        }
    }

    let count = 0;
    for (let i = 1; i <= n; i++) {
        if (dp[numOfItems][i]) {
            count++;
        }
    }
    return count;
}

不过这一版本的解法会超时,以及使用滚动数组做状态压缩的解法仍然会超时。我们可以大致估算一下当前算法的时间复杂度,应该是O(numOfItems*n*amount),其中amount可以假设为每种物品的平均数量。之前798. 背包问题VII中的思路是,把物品展开,当成0-1背包的问题来做,但是时间复杂度上并没有减少,仍然需要每次循环amount。正确的解法是引入一个数组来专门计算组成 j 时,使用了多少个当前物品,如果超过数量上限就不更新。count数组的大小为n+1,数组元素表示的是,组成这个容量,当前物品i用了多少个,在遍历物品的时候,每新加一个物品,都要重开一个count数组。这里count[j - value[i]] 必须严格小于amount[i],这表示当前物品还可以至少装一个。通过count数组的引入,我们就无需遍历物品个数这一维度,大大降低了时间复杂度。

const backPackVIII = function (n, value, amount) {
    let numOfItems = value.length;
    let f = Array(n + 1).fill(false);
    f[0] = true;
    let res = 0;
    for (let i = 0; i < numOfItems; i++) {
        let count = Array(n + 1).fill(0);
        for (let j = value[i]; j <= n; j++) {
            if (!f[j] && f[j - value[i]] && count[j - value[i]] < amount[i]) {
                f[j] = true;
                res++;
                count[j] = count[j - value[i]] + 1;
            }
        }
    }
    return res;
};

800. 背包问题 IX : 你总共有 n 万元,希望申请国外的大学,要申请的话需要交一定的申请费用,给出每个大学的申请费用以及你得到这个大学offer的成功概率,大学的数量是 m。如果经济条件允许,你可以申请多所大学。找到获得至少一份offer最高可能性。总共有n万元,prices数组表示每所大学的申请费,probability表示每所大学对应的录取概率。

问题要求的是至少得到一份offer的最高可能性,那么我们只要计算出一所都申请不到的概率,然后用1减去就是至少一份offer的可能性了,在预处理的时候可以先把所有概率转换成申请不上的概率。我们把n万元看成背包容量,申请费看成每个物品的重量,相比传统的背包问题,传统背包问题的目标是求最大价值,我们这里只不过换成了求最大概率。并且由于一所大学只需要申请一次,这是典型的0-1背包问题。定义状态dp[i][j]表示申请前i所学校,花费j万元,至少拿到一个offer的最大概率,转移方程如下

dp[i][j] = max(dp[i][j], 1 - (1 - dp[i-1][j-prices[i-1]]) * (1 - probability[i-1]))

后面的式子需要解释一下,dp[i-1][j-prices[i-1]] 表示的是至少有一个offer的最大概率,我们首先要用 1 减去它,得到一个offer都没有的概率,然后乘上 1 - probability[i-1],得到前i个学校,一个offer都没有的概率,最后再用 1 减去它。这样的转移方程稍显复杂了一些,其实我们可以把目标改一下,改成一个学校都申不到的最小概率,在最后返回的时候,用1减去就可以了。对probability都预处理成 1 - probability[i],假设新的申请不到的概率为noProbability。 新的状态dp[i][j]就可以被定义为前i所学校,花费j万元,一所都申请不到的最小概率,转移方程如下:

dp[i][j] = min(dp[i][j], dp[i-1][j-prices[i-1]]*noProbability[i-1])

代码如下:

const backpackIX = function (n, prices, probability) {
    let numOfItems = prices.length;
    let dp = Array(numOfItems + 1).fill().map(() => Array(n + 1).fill(1));
    let noProbability = probability.map(p => 1 - p);
    for (let i = 1; i <= numOfItems; i++) {
        for (let j = 0; j <= n; j++) {
            dp[i][j] = dp[i - 1][j];
            if (j >= prices[i - 1]) {
                dp[i][j] = Math.min(dp[i][j], dp[i - 1][j - prices[i - 1]] * noProbability[i - 1]);
            }
        }
    }
    return 1 - dp[numOfItems][n];
};

仿照之前的状态压缩方法,压缩成一维数组:

const backpackIX = function (n, prices, probability) {
    let dp = Array(n + 1).fill(1);
    for (let i = 0; i < probability.length; i++) {
        probability[i] = 1 - probability[i];
    }

    for (let i = 0; i < probability.length; i++) {
        for (let j = n; j >= prices[i]; j--) {
            dp[j] = Math.min(dp[j], dp[j - prices[i]] * probability[i]);
        }
    }
    return 1 - dp[n];
};

801. 背包问题X : 你总共有 n 元,商人总共有三种商品,它们的价格分别是150元,250元,350元,三种商品的数量可以认为是无限多的,购买完商品以后需要将剩下的钱给商人作为小费,求最少需要给商人多少小费。

这道题的换一个说法,其实就是背包最多能装多满,然后把结果用背包总容量减去这个最大值,就是留给商人的最小小费了。代码如下:

const backPackX = function (n) {
    let prices = [150, 250, 350];
    // 最大价格即可
    let f = Array(n + 1).fill(0);
    for (let i = 0; i <= n; i++) {
        for (let p of prices) {
            if (i - p >= 0) {
                f[i] = Math.max(f[i], f[i - p] + p);
            }
        }
    }
    return n - f[n];
};

971. 剩余价值背包 : 有一个容量为 c 的背包。 有 n 个 A 类物品,第 i 个 A 类物品的体积为 a[i],物品的价值为装入该物品后背包剩余容量 * k1。 有 m 个 B 类物品,第 i 个 B 类物品的体积为 b[i],物品的价值为装入该物品后背包剩余容量 * k2。 求最大可以获得的价值。

注意到,我们在装物品的时候,总是先装入体积小的物品,使得剩余的容量尽可能大,从而获得的价值越高。因此我们首先对物品进行排序,按体积从小到大。此外,对A类物品,当我们取第 i 个物品的时候,必然有已经取了第 0 ~ i - 1个物品,这是因为前面的物品体积总是小于当前的,因此必然会取到。所以在计算剩余价值的时候,可以用前缀和数组来优化计算结果,用sa和sb来表示。用dp[i][j]表示A类前i个,B类前j个物品的时候,能获得的最大剩余价值,于是转移方程如下,注意到不论是从dp[i-1][j]还是dp[i][j-1]状态转移过来,剩余容量都是 c - sa[i] - sb[j]。

dp[i][j] = Math.max(dp[i-1][j] + (c - sa[i] - sb[j]) * k1, 
                    dp[i][j - 1] + (c - sa[i] - sb[j]) * k2)

实现代码如下

const getMaxValue = function (k1, k2, c, n, m, a, b) {
    let dp = Array(n + 1).fill().map(() => Array(n + 1).fill(0));

    // 我们在放背包的时候,排好序后,一定是先放小体积的
    a.sort((x, y) => x - y);
    b.sort((x, y) => x - y);

    // 前缀和数组
    let sa = Array(n + 1).fill(0);
    let sb = Array(m + 1).fill(0);

    for (let i = 0; i < n; i++) {
        sa[i + 1] = a[i] + sa[i];
    }
    for (let i = 0; i < m; i++) {
        sb[i + 1] = b[i] + sb[i];
    }

    let ans = 0;
    for (let i = 0; i <= n; i++) {
        for (let j = 0; j <= m; j++) {
            if (i + j > 0 && sa[i] + sb[j] <= c) {
                dp[i][j] = 0;
                if (i > 0) {
                    dp[i][j] = dp[i - 1][j] + (c - sa[i] - sb[j]) * k1;
                }
                if (j > 0) {
                    dp[i][j] = Math.max(dp[i][j], dp[i][j - 1] + (c - sa[i] - sb[j]) * k2);
                }
                ans = Math.max(ans, dp[i][j]);
            }
        }
    }
    return ans;
};

1382. 大容量背包 : 给出一个背包容量 s, 给出 n 件物品,第i件物品的价值为 vi,第i件物品的体积为ci,问这个背包最多能装多少的价值的物品,输出这个最大价值。(每个物品只能用一次),注意,这里的容量s可能非常大,但是物品数量会比较少。注意事项 1 <= s, vi, ci <= 10^13, 1 <= n <= 31。

采用常规开DP数组的方式,我们至少需要一个一维数组,大小为背包容量,但是这里的背包容量可以高达10^13,直接开数组显然是不可能的。不过好在物品的上限只有31个,当然直接用DFS的话,枚举每个物品放或者不放,复杂度为 O(2^n)。考虑用二分法来降低时间复杂度,将物品分成2堆,然后枚举2堆物品内部的所有组合方式,然后遍历其中一堆的所有组合方式,在另一堆中用二分法来查找,使得总价值最大的两个组合。这样可以将总的时间复杂度降低到 O(2^(n/2) * log(2^n/2)),这道题的测试数据会卡这个时间复杂度。除了大的方向之外,还有一些细节值得考虑,要采用二分法,就必须要对我们的数据进行排序。对于每个组合,我们用[a,b]来表示,其中a表示这个组合的总体积,b表示这个组合的总价值。我们可以按照体积从小到大的顺序排,如果体积相同,那么按价值从小到大排。依据这个排好序的数组,我们可以先做一次过滤,对于体积大,价值小的组合,直接丢弃。下面我们分部分解释代码。

首先是划分部分,将所有物品划分成leftPart和rightPart两个数组,数组内部元素为[vi,ci]。

const getMaxValue = function (s, v, c) {
    let leftPart = [];
    let n = v.length;
    // [c[i],v[i]]
    for (let i = 0; i < Math.floor(n / 2); i++) {
        leftPart.push([c.shift(), v.shift()]);
    }
    let rightPart = v.map((item, index) => [c[index], v[index]]);
    // ... 
}

然后根据划分好的物品,各自生成所有组合,用最常规的DFS即可。

// arr: 划分成一半的物品
// res: 存储组合结果
// index: 在arr数组中当前的下标
// curV: 当前的总价值
// curC: 当前的总体积
// s: 背包容量
const dfs = function (arr, res, index, curV, curC, s) {
    if (curC > s) { // 如果当前总体积已经超过了容量,那么不需要做后面的搜索了。
        return;
    }
    if (index === arr.length) { // 遍历完,加到结果数组中
        res.push([curC, curV]);
        return;
    }
    let [c, v] = arr[index];
    dfs(arr, res, index + 1, curV, curC, s); // 不取当前的物品
    dfs(arr, res, index + 1, curV + v, curC + c, s); // 取当前的物品
};

接下来是过滤生成的组合,combs就是用dfs生成的所有组合,combs数组像这样: [[0,0],[3,4] ... ],combs[i][0] 是第i个组合的总体积,combs[i][1]是第i个组合的总价值。注意传进来的combs数组是排好序的,排序规则就是体积从小到大,体积相同,按价值从小到大排。在遍历的时候,用maxV维护遍历到的组合中最大的价值,如果出现当前组合的价值小于这个最大价值,那么意味着当前这个组合,体积大,价值还低,应该直接丢弃。

const filterCombs = function (combs) {
    let res = [];
    let maxV = -1;
    for (let comb of combs) {
        if (comb[1] > maxV) {
            res.push(comb);
        }
        maxV = Math.max(maxV, comb[1]);
    }
    return res;
};

最核心的部分,二分查找使得总价值最大的两个组合,这里filteredLeftCombs, filteredRightCombs就是过滤过的,排好序的组合。遍历filteredRightCombs,用总容量减去当前右半部分组合的体积,就是左半部分体积的上限。注意到这个时候过滤过的组合,已经把所有总体积大,但是总价值小的组合排除了,因此在剩下的组合里,必然有总体积越大,价值越高的单调递增关系。所以我们只要找到满足小于等于左半部分体积上限的,总体积最大的组合即可。这里的二分查找模板比较容易记忆,在之后的文章中会详细介绍。

    // ...
    let ans = 0;
    for (let i = 0; i < filteredRightCombs.length; i++) {
        let cap = s - filteredRightCombs[i][0]; // 组合体积上限
        if (cap > 0) {
            let l = 0;
            let r = filteredLeftCombs.length - 1;
            while (l + 1 < r) {
                let middle = Math.floor((l + r) / 2);
                if (filteredLeftCombs[middle][0] <= cap) {
                    l = middle;
                } else {
                    r = middle;
                }
            }

            if (filteredLeftCombs[r][0] <= cap) {
                ans = Math.max(ans, filteredLeftCombs[r][1] + filteredRightCombs[i][1])
            }

            if (filteredLeftCombs[l][0] <= cap) {
                ans = Math.max(ans, filteredLeftCombs[l][1] + filteredRightCombs[i][1])
            }
        }
    }
    return ans;
}

完整的代码如下:

const dfs = function (arr, res, index, curV, curC, s) {
    if (curC > s) {
        return;
    }
    if (index === arr.length) {
        res.push([curC, curV]);
        return;
    }
    let [c, v] = arr[index];
    dfs(arr, res, index + 1, curV, curC, s);
    dfs(arr, res, index + 1, curV + v, curC + c, s);
};

const cmp = function (a, b) {
    if (a[0] === b[0]) {
        return a[1] - b[1];
    }
    return a[0] - b[0];
};

const filterCombs = function (combs) {
    let res = [];
    let maxV = -1;
    for (let comb of combs) {
        if (comb[1] > maxV) {
            res.push(comb);
        }
        maxV = Math.max(maxV, comb[1]);
    }
    return res;
};

// 这道题的核心是,拆半,过滤(丢弃不可能的解法),二分
const getMaxValue = function (s, v, c) {
    let leftPart = [];
    let n = v.length;
    // [c[i],v[i]]
    for (let i = 0; i < Math.floor(n / 2); i++) {
        leftPart.push([c.shift(), v.shift()]);
    }
    let rightPart = v.map((item, index) => [c[index], v[index]]);
    let leftCombs = [];
    dfs(leftPart, leftCombs, 0, 0, 0, s);
    let rightCombs = [];
    dfs(rightPart, rightCombs, 0, 0, 0, s);

    leftCombs.sort(cmp);
    rightCombs.sort(cmp);

    let filteredLeftCombs = filterCombs(leftCombs);
    let filteredRightCombs = filterCombs(rightCombs);

    let ans = 0;
    for (let i = 0; i < filteredRightCombs.length; i++) {
        let cap = s - filteredRightCombs[i][0];
        if (cap > 0) {
            let l = 0;
            let r = filteredLeftCombs.length - 1;
            while (l + 1 < r) {
                let middle = Math.floor((l + r) / 2);
                if (filteredLeftCombs[middle][0] <= cap) {
                    l = middle;
                } else {
                    r = middle;
                }
            }

            if (filteredLeftCombs[r][0] <= cap) {
                ans = Math.max(ans, filteredLeftCombs[r][1] + filteredRightCombs[i][1])
            }

            if (filteredLeftCombs[l][0] <= cap) {
                ans = Math.max(ans, filteredLeftCombs[l][1] + filteredRightCombs[i][1])
            }
        }
    }
    return ans;
};

LeetCode相关题目

322. 零钱兑换 :给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

解法如下,设dp[i]表示凑满i元的最少硬币个数,遍历所有的硬币,转移方程就是dp[i] = min(dp[i], dp[i-c]+1)。

var coinChange = function (coins, amount) {
    let dp = Array(amount + 1).fill(Infinity);
    dp[0] = 0;
    for (let i = 1; i <= amount; i++) {
        for (let c of coins) {
            if (i - c >= 0) {
                dp[i] = Math.min(dp[i], dp[i - c] + 1);
            }
        }
    }
    return dp[amount] === Infinity ? -1 : dp[amount];
};

474. 一和零 :现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。 你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。

输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。

定义dp[i][j][k]表示,前k个字符串,给定分别给i个0和j个1,最多可以拼出的字符串数量。对于当前这个字符串,我们可以选择拼或者不拼,转移方程如下,其中假设当前这个字符串需要a个0和b个1,

dp[i][j][k] = Math.max(dp[i-a][j-b][k-1]+1,dp[i][j][k-1])

代码如下:

/**
 * @param {string[]} strs
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var findMaxForm = function(strs, m, n) {
    let len = strs.length;
    let dp = Array(m+1).fill().map(()=>Array(n+1).fill().map(()=>Array(len).fill(0)));
    
    // count数组记录了每个字符串中包含了多少个0和1,
    // count[i][0]表示的是0的数量,count[i][1]表示的是1的数量
    let count = []; 
    for(let i=0;i<strs.length;i++){
        let cur = strs[i];
        let tmp = [0,0];
        for(let k=0;k<cur.length;k++){
            if(cur[k] === "0"){
                tmp[0]++;
            }else{
                tmp[1]++;
            }
        }
        count.push(tmp);
    }
    
    for(let k=1;k<=len;k++){
        for(let i=0;i<=m;i++){
            for(let j=0;j<=n;j++){
                let [a,b] = count[k-1];
                dp[i][j][k] = dp[i][j][k-1];
                if(i - a >= 0 && j - b >= 0){
                    dp[i][j][k] = Math.max(dp[i][j][k],dp[i-a][j-b][k-1]+1);
                }
            }
        }
    }
    return dp[m][n][len];
};

494. 目标和 :给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。 返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

一共有5种方法让最终目标和为3。

把这里的 + 看成背包问题中取某个物品,- 看成背包问题中不取某个物品,和背包问题唯一的区别就是,这里不取物品,是要减去物品重量的。因为引入了减去这个操作,因此最终的获得的价值可以是负数,最小为 - sum(nums),即物品和的负数,最大可以是 + sum(nums)。于是我们的状态定义为dp[i][j]表示,前i个数字,结果为j的种数。这里j可以是负数,在代码实现的时候,用字典来代替数组可以避免负数下标的问题。转移方程就是:

dp[i][j] += dp[i-1][j-nums[i-1]]
dp[i][j] += dp[i-1][j+nums[i-1]]

初始情况,dp[0][0] = 1,这表示,0个数,组成和为0的情况,有1种解法。完整的代码如下:

var buildDict = function (sum) {
    let dict = {};
    for (let i = -sum; i <= sum; i++) {
        dict[i] = 0;
    }
    return dict;
};

// 注意到,这里是有负数的,dp[1][-a],这个是有可能为1的
var findTargetSumWays = function (nums, S) {
    let sum = nums.reduce((s, n) => s + n, 0);
    let n = nums.length;
    let dp = Array(2).fill().map(() => buildDict(sum));
    dp[0][0] = 1;

    for (let i = 1; i <= n; i++) {
        dp[i % 2] = buildDict(sum);
        for (let j = -sum; j <= sum; j++) {
            if (j - nums[i - 1] >= -sum) {
                dp[i % 2][j] += dp[(i - 1) % 2][j - nums[i - 1]];
            }
        }
        for (let j = sum; j >= -sum; j--) {
            if (j + nums[i - 1] <= sum) {
                dp[i % 2][j] += dp[(i - 1) % 2][j + nums[i - 1]];
            }
        }
    }
    return dp[n % 2][S] ? dp[n % 2][S] : 0
};

139. 单词拆分:给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。 说明: 拆分时可以重复使用字典中的单词。 你可以假设字典中没有重复的单词。

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。

这道题的解法有很多,BFS, DFS,都可以解,用动态规划的话,我们可以定义状态dp[i]表示前i个单词是否可以正好拆分成所有字典中的词,dp[i]如果是true的话,那么它一定是从前面某个dp[j]为true的状态转移过来的,且满足从 j -> i组成的字符串(前闭后开区间,跟substring方法一致)在字典中。于是代码如下

var wordBreak = function (s, wordDict) {
    let set = new Set(wordDict);
    let maxLen = Math.max(...wordDict.map(item => item.length));
    let dp = Array(s.length + 1).fill(false);
    dp[0] = true;
    for (let i = 1; i <= s.length; i++) {
        for (let j = i - 1; j >= i - maxLen; j--) {
            if (set.has(s.substring(j, i))) {
                dp[i] = dp[j] || dp[i];
            }
        }
    }
    return dp[s.length]
};

总结

  1. 背包问题总会有一个数组表示一些物品,这些物品可以取或者不取
  2. 最终需要优化的结果是最大价值/最大体积/重量,或者一个和当前价值/体积/重量相关的,需要通过计算转换的值。或者是求所有满足一定要求的方案数量。
  3. 一般包含两种循环,一个是物品的循环,物品循环一般是在最外层(除了 564. 背包问题VI),二是容量循环。
  4. 做状态压缩的时候,观察某个维度的变量,是不是仅依赖 i-1 时的状态,是的话就可以用滚动数组做状态压缩。要压缩成一维,常常会从后往前遍历。

全部题目及链接

LintCode

  1. 92. 背包问题:https://www.lintcode.com/problem/backpack/description
  2. 125. 背包问题 II:https://www.lintcode.com/problem/backpack-ii/description
  3. 440. 背包问题 III:https://www.lintcode.com/problem/backpack-iii/description
  4. 798. 背包问题VII:https://www.lintcode.com/problem/backpack-vii/description
  5. 562. 背包问题 IV:https://www.lintcode.com/problem/backpack-iv/description
  6. 564. 组合总和 IV:https://www.lintcode.com/problem/combination-sum-iv/description
  7. 563. 背包问题 V:https://www.lintcode.com/problem/backpack-v/description
  8. 799. 背包问题VIII:https://www.lintcode.com/problem/backpack-viii/description
  9. 800. 背包问题 IX:https://www.lintcode.com/problem/backpack-ix/description
  10. 801. 背包问题X:https://www.lintcode.com/problem/backpack-x/description
  11. 971. 剩余价值背包:https://www.lintcode.com/problem/surplus-value-backpack/description
  12. 1382. 大容量背包:https://www.lintcode.com/problem/high-capacity-backpack/description

LeetCode

  1. 322. 零钱兑换:https://leetcode-cn.com/problems/coin-change/
  2. 474. 一和零:https://leetcode-cn.com/problems/ones-and-zeroes/
  3. 494. 目标和:https://leetcode-cn.com/problems/target-sum/
  4. 139. 单词拆分:https://leetcode-cn.com/problems/word-break/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值