背包详解:01 背包

目录

  刷掉了《剑指offer》的天梯后,感觉自己对动态规划,回溯,枚举等类型的问题还感觉十分生疏,就打算把这些类型挑出来个个击破。

  背包问题是动态规划的入门问题之一,于是我找到了师兄之前推荐给我的《背包九讲》,就着 Lintcode 的 backpack 天梯,学习了一下这个方面的问题。

简单 01 背包

有一个大小为 m 的背包,有 N 个物品, 每个物品的重量为 A_i,最多能装多满?(Lintcode-92)

  这就是最简单的 01 背包问题,01 指得是给的物品只有两种状态,放进包里,或者没放进包里。

  如果不知道动态规划,一般脑子里会浮现出贪心的思想,先放最大,然后慢慢填满。如果有测试集,很快就会发现这个思想并不是每步最优,最终导致全局也不是最优解。

  如果贪心不能用,那么就枚举吧,一个 N 个物品,每次放一个,把所有情况都存下来,但感觉存储每步的结果好像很麻烦,但是没关系,我们先用一个测试集感受一下使用枚举寻找最优解的过程,在这个过程中来寻找存储的方法。

  假如背包大小为11,有 4 个物品,大小分别为 [2, 3, 5, 7]。

  • 最初始,背包里没有物品,记为 {0, {}},表示背包重量为 0,后面花括号中为已经装入的物品列表
  • 第一次,放入 2,记为 {2, {2}}
  • 第三次,放入 3,从背包为 0 时放入,得到 {3, {3}},从背包重量为 2 时放入,得到 {5, {2, 3}}
  • 第三次,放入 5,从背包为 0 时放入,可以得到 {5, {5}}, 我们已经有 {5, {2, 3}},但这个问题中我们不记录装的内容,随便保留一个就好了,选择保留新的 {5, {5}}。从背包为 2,3,5 时放入,分别得到 {7, {2, 5}}, {8, {3, 5}}, {10, {2, 3, 5}}
  • 第四次,放入 7,可以得到 {7, 0}, {9, {2, 7}}, {10, {3, 7}}, {12, {5, 7}}, 不对,背包大小为 11,这个结果不用记,背包重量为 7,8 和 10 时更加装不下,都不用记录

列一下每一步的表:
1. {0, {0}}
2. {0, {0}}, {2, {2}}
3. {0, {0}}, {2, {2}}, {3, {3}}, {5, {2, 3}}
4. {0, {0}}, {2, {2}}, {3, {3}}, {5, {5}}, {7, {2, 5}}, {8, {5, 3}}, {10, {2, 3, 5}}
5. {0, {0}}, {2, {2}}, {3, {3}}, {5, {5}}, {7, {7}}, {8, {5, 3}}, {9, {2, 7}}, {10, {3, 7}}

  我们可以得出最多装入的重量为 10, 那么问题来了,会出现枚举重复吗?这个枚举覆盖到所有情况了吗?我们需要多大的存储空间来完成枚举?

  我们每一轮放入的都是不同的物体,不会重复。每一轮都尝试过把目前的物体放入所有上一轮的结果,从列表的过程中也可以看出,肯定覆盖全面了。存储空间的话,只要记录下上面的表就可以了,由于背包内部不用记录,超出背包容量的情况不需要记录,所以只要用 物品个数 * 背包容量 大小的二维数组来存储就好了。

我们来画出这个二维数组:

轮数 i 物品重量 A_i 背包重量 j 1 2 3 4 5 6 7 8 9 10 11
0 - 0 0 0 0 0 0 0 0 0 0 0 0
1 2 0 0 2 0 0 0 0 0 0 0 0 0
2 3 0 0 2 3 0 5 0 0 0 0 0 0
3 5 0 0 2 3 0 5 0 7 8 0 10 0
4 7 0 0 2 3 0 5 0 7 8 9 10 0

  可以看出,求得的解已经放在相应的位置上了。在第一轮中,背包重量大于 2 时,是不是也能装入背包为 2 时的物品呢?在第二轮中,背包重量等于 4 时,是不是也可以装入背包重量为 3 时的物品呢?依此类推:

轮数 i 物品重量 A_i 背包重量 j 1 2 3 4 5 6 7 8 9 10 11
0 - 0 0 0 0 0 0 0 0 0 0 0 0
1 2 0 0 2 2 2 2 2 2 2 2 2 2
2 3 0 0 2 3 3 5 5 5 5 5 5 5
3 5 0 0 2 3 3 5 5 7 8 8 10 10
4 7 0 0 2 3 3 5 5 7 8 9 10 10

  现在这个二维数组中的值,是不是就是第 i 轮时,背包重量为 j 时 所能装入的最大容量? 这和我们之前对枚举覆盖的回答是一致的。只要我们把每一轮中我们所做的操作变成公式,就可以求解出背包容量为 m 时所能装入的最大重量。

  设该二维数组的元素为 dp[i][j], 第 i+1 轮每一个元素更新时,我们比较的是 dp[i][j] 与 dp[i][j - A[i]] + A[i],比如在第 1 轮中放入重量为 2 的物体时,我们进行了如下比较 :

dp[1][2] <= dp[1][0] + 2

  意即,背包容量为 0 时放入该物体后 比 原来背包容量为 2 时(0) 重量要大,那么我们则更新 dp[1][2],可以类推第 1 轮中需要需比较的还有{dp[0][3], dp[0][1] + 2}, {dp[0][4], dp[0][2] + 2} 等等,那么通用的公式为:

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

  我们尝试把整段代码写一下:

int backPack(int m, vector<int> &A) {
    int N = A.size();
    int dp[N + 1][m + 1] = {0}; // C++14 后,允许使用 size_t 为数组赋值, misual Studio 好像没有实现这个特性
    for (int i = 0; i < N; ++i)
        for (int j = A[i]; j <= m; --j)
            dp[i + 1][j] = max(dp[i][j], dp[i][j - A[i]] + A[i]);
    return dp[N][m];
}

  每一轮我们递增一次 i,并更新 dp[i + 1][j]。在内层循环中,j 的范围是 A[i] 到 m, 当背包容量小于 A[i] 时,是无法装下重量为 A[i] 的物品的。
  但当我们试着手动运行上面这段代码时,在第一轮中会出现:

轮数 i 物品重量 A_i 背包重量 j 1 2 3 4 5 6 7 8 9 10 11
0 - 0 0 0 0 0 0 0 0 0 0 0 0
1 2 0 0 2 2 4 4 6 6 8 8 10 10

  这是怎么回事呢?我们看一下第一个出错的 dp[1][4],由上面的公式可以得知

dp[1][4] = max(dp[0][4], dp[0][2] + 2)

  而此时,dp[0][2] 已经计算过了,用上面的制表法来表示,就是 dp[0][2] = {2, {2}}, dp[0][4] = {4, {2, 2}},又放了一个重量为 2 的物体进去。后面的 6, 8, 10 都是因为放了多个重量为 2 的物体进去导致的。因为我们先计算了背包重量小的情况,再计算重量大的情况时,就又会放置一遍物体,那么只要我们反过来先计算背包重量大的情况,就不会进行重复计算,可以得到正确的结果。

for (int i = 0; i < N; ++i)
    for (int j = m; j >= A[i]; --j)
        dp[i + 1][j] = max(dp[i][j], dp[i][j - A[i]] + A[i]);

  这就是01背包的解法。那么,动态规划的思想体现在哪儿呢?我们来看一下根据制表法得出的公式。

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

  动态规划是得来这种公式的一种系统思路。动态规划有和分治一样的特点:拿到问题,先把问题化小。问题里让我们求容量为 m 时的最大重量,我们来看问题化小可以怎么化:
1. 如果已知容量为 m-1 时的最大重量,求容量为 m 的最大重量
2. 如果已知放入第 i-1 个物品时的最大重量,求放入第 i 个物品后的最大重量

  第一种思路感觉又很像贪心法,我们来看第二种思路,首先,动态规划需要定义状态,按
  这种思路,用第一个变量 i 代表轮数,第二个变量 j 代表容量为 j 时的最大重量,dp[i][j] 的意义刚好与我们之前得到的一样,dp[i][j] 就称为状态,我们只要弄清楚 dp[i - 1][j] 如何变为 dp[i][j] 的就好。

  已知的就是 i - 1 轮的那一行,假设为第二轮,那么我们会自热而然地尝试在第 3 轮将重量为 5 的物品加到 第二轮 dp[2][j] 上,尝试手动更新一下第 3 轮的值:

轮数 i 物品重量 A_i 背包重量 j 1 2 3 4 5 6 7 8 9 10 11
2 3 0 0 2 3 3 5 5 5 5 5 5 5

我们就会发现手动做了如下操作 :

dp[3][j + 5] = max(dp[2][j] + 5, dp[2][j + 5])

将其下标改一改

dp[3][j] = max(dp[2][j - 5] + 5, dp[2][j])

再替换成变量

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

  这个公式就称为动态规划的状态转移公式,每一轮中,新状态都是由旧状态转换而来。和之前通过启发式思路得到的公式一样,但我们需要加强使用动态规划这个思考方式。

空间复杂度优化

  可能有些同学已经发现了,计算的时候并不需要二维数组,我们在一维数组上滚动更新就行了。但一定要清楚状态转移公式仍然是二维的。这样,空间复杂度可以从 O(VN) 降到 O(V)。

int backPack(int m, vector<int> &A) {
    int N = A.size();
    int dp[m + 1] = {0}; 
    for (int i = 0; i < N; ++i)
        for (int j = m; j >= A[i]; --j)
            dp[j] = max(dp[j], dp[j - A[i]] + A[i]);
    return dp[m];
}

另一种状态转移

  还有同学会发现,如果按我们最开始的表来做,我们只要用 bool 变量来表示每个状态就好了。这种方法也可以在降低了空间复杂度的情况下进一步降低内存用量。

轮数 i 物品重量 A_i 背包重量 j 1 2 3 4 5 6 7 8 9 10 11
0 - 0 0 0 0 0 0 0 0 0 0 0 0
1 2 0 0 2 0 0 0 0 0 0 0 0 0
2 3 0 0 2 3 0 5 0 0 0 0 0 0
3 5 0 0 2 3 0 5 0 7 8 0 10 0
4 7 0 0 2 3 0 5 0 7 8 9 10 0
int backPack(int m, vector<int> &A) {
    int N = A.size();
    bool dp[m + 1] = {false};
    dp[0] = true; 
    for (int i = 0; i < N; ++i)
        for (int j = m; j >= A[i]; --j)
            if(dp[j - A[i]] && !dp[j])
                dp[j] = dp[j - A[i]];

    for(int i = m; i >= 0; --i)
        if(dp[i])
            return i;
}

  这里进行的状态转移是 “可以被填满”的状态,状态转移的条件是 dp[j - A[i]] && !dp[j], 条件表达式的前者代表 dp[j - A[i]] 已经被填满,并可以通过填入 A[i] 使得 dp[j] 也被填满,其状态转移方程为:

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

普通 01 背包

有一个大小为 m 的背包,有 N 个物品,每个物品的重量为 A_i,价值为 V_i, 背包最多能装入的总价值为多少?(Lintcode-125)

  我们直接用动态规划的思路来考虑这个,已知上一轮的状态 dp[i - 1][j],,表示 i-1 轮时,重量为 j 的背包的最大价值,我们来试着找出状态转移方程。根据简单 01 背包的问题,我们可以把简单 01 背包里物品的重量视为其的价值 V[i]。

简单 01 背包状态转移公式:

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

可以知道上式中最后一个 A[i] 就是 V[i],那么 普通 01 背包的状态转移方程:

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

  只需要将简单 01 背包的代码改一点就可以了。是不是体会到了动态规划的威力?我们再来看一些相关问题。

求方案数

有一个大小为 m 的背包,有 N 个物品,每个物品的重量为 A_i,求能使得背包最重的方案数?

  我们还是来直接考虑状态转换方程,假设有 dp[i - 1][j] 表示 i-1 轮时的方案数,那 dp[i][j] 怎么求?

  假设有 {2, {2}}, {5, {5}},{5, {1, 4}},现在放入重量为 3 的物品,那么有 dp[i][5] = dp[i][5] + dp[i][5 - 3] = 2,由此可以得出

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

  另外,我们还需要考虑初始条件,dp[0][0] 时应当为 1,这样之后才能累加出结果。

刚好填满背包

有一个大小为 m 的背包,有 N 个物品,每个物品的重量为 A_i,价值为 V_i, 求刚好使得背包填满的最大价值?([Lintcode-563](https://www.lintcode.com/problem/backpack-v/description?_from=ladder))

  求方案数的状态转移方程和上题是一样的,如果要刚好填满背包,则要从初始条件和转移条件上入手。我们参考简单 01 背包里使用 bool 的解法:

bool dp[m + 1] = {false};
dp[0] = true; 

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

  在这里,初始情况都被设做 false,并且仅在其为 false 的情况下进行转移,转移的状态是 “可以被填满的状态”,我们可以通过类似的条件进行转移,将初始值设为 -1 或者 INT_MIN,等其大于 0 时才进行转移,其状态转移方程为

dp[i + 1][j] = dp[i][j] + dp[i][j - A[i]] (dp[i][j - A[i]] > 0)

  别忘了,重量为 0 时的初始值必须是可达的,否则没有“被填满的状态”可以被转移

vector<int> dp(m + 1, -1);
dp[0] = 0;

总结

   01 背包的动态规划思路还是比较简单的,特别是将其降到一维之后。动态规划有几个难点:状态的定义,状态转移的条件,降低空间复杂度(和相关的一些算法)。需要多做练习,总结想出状态转移方程的思路。

返回目录

猜你喜欢

转载自blog.csdn.net/weixin_43072157/article/details/82119345