<audio id="audio" title="25 | 动态规划（下）：背包问题与动态规划算法优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/f9/4833ba6b3a0bad6d4e115043151f31f9.mp3"></audio>

你好，我是胡光，欢迎回来。

上节课呢，我们学习了动态规划算法的一般求解步骤：状态定义，推导状态转移方程、正确性证明，以及程序设计与实现。在这个过程中，我们又一次用到了之前我们讲的重要数学思维：数学归纳法 。

今天这节课，将是我们“算法数据结构篇”的最后一篇，其实从这个章节开始，我都在试图用我的语言和有序的课程设计，让你感受算法数据结构在思维层面的魅力。我还希望，你能通过这部分知识的学习，能对算法和数据结构产生兴趣，并且消除对算法学习的畏难心理。

好了，进入正题，今天我将借由动态规划算法，向你展示算法中追求极致的那一部分基因：算法优化。

## 初识：0/1 背包问题

想要感受算法优化，我们先从一类经典的动态规划问题，背包类问题开始。

简单的背包类问题，可以分成三类：0/1背包问题，完全背包问题与多重背包问题。我们今天要讲的就是 0/1背包与多重背包这两个问题。

0/1背包问题可以说是所有背包问题的基础，它描述的场景是这样的：假设你有一个背包，载重上限是 W，你面前有 n 个物品，第 i 个物品的重量是 w<sub>i</sub>，价值是 v<sub>i</sub>，那么，在不超过背包重量上限的前提下，你能获得的最大物品价值总和是多少？

按照动态规划问题的四步走，咱们来分析一下这个问题。

#### 1.  状态定义

关于状态定义，我们首先来分析0/1背包问题中的**自变量**和**因变量**。

因变量比较好确定，就是问题中所求的最大价值总和。自变量呢？经过分析你会发现，物品种类和背包承重上限就是自变量，因为它们都能够影响价值总和的最大值。这样我们就可以设置一个二维的状态，状态定义如下：

**0/1背包状态定义**：dp[i][j] 代表使用前 i 个物品，背包最大载重为 j 的情况下的最大价值总和。

#### 2.  推导状态转移方程

推导状态转移方程，也就是推导 dp[i][j] 的表达式。根据 dp[i][j] 的含义，我们可以将 dp[i][j] 可能达到最大值时的方案分成两类：一类是方案中不选择第 i 个物品的最大价值和，另一类是方案中选择了第 i 个物品的最大价值和。只需要在这两类方案的最大值中，选择一个价值和较大的方案，转移到 dp[i][j] 即可。下面，我们就分别表示一下这两种方案的公式。

不选择第 i 个物品的最大价值和，就是 dp[i - 1][j]。也就是说，在背包最大载重为 j 的情况下，前 i 个物品中，不选择第 i 个物品的最大价值和，就等于在前 i - 1 个物品中选择的最大价值和。

选择第 i 个物品的最大价值和就是 dp[i - 1][j - w<sub>i</sub>] + v<sub>i</sub>。关于这个公式的理解，可以参考我们前面讲的凑钱币问题，既然要求一定选择了第 i 个物品，那我们就可以先给第 i 个物品预留出来一个位置，然后给剩余的 i - 1 个物品留的载重空间就只剩下 j - w<sub>i</sub> 了，那么 i - 1 个物品选择的最大价值和是 dp[i - 1][j - w<sub>i</sub>]，再加上 v<sub>i</sub> 就是选择第 i 个物品时，我们能够获得最大价值和。

最终，我们得到 dp[i][j] 的状态转移方程，如下所示：

```
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])

```

#### 3.  正确性证明

动规算法的正确性证明，还是需要依赖于数学归纳法，下面我们开始数学归纳法的三步走。

首先，dp[0][j] = 0，就是当没有物品的时候，无论背包限重是多少，能得到的最大价值和都是0，这也就是已知 k<sub>0</sub> 正确。

其次，假设我们已经正确计算得到了，在 i - 1 个物品的任意一种背包容量下的价值最大和值，也就是所有 dp[i - 1] 中的值。那么根据状态转移方程，我们也肯定可以正确的得到所有 dp[i] 中的值。

最后两步联立，整个求解过程对于任意 dp[i][j]，均正确。

请你认真理解这个证明过程，因为接下来的程序处理过程，其实和这个证明过程是一致的。

#### 4.  程序设计与实现

完成了关于0/1背包问题的求解过程后，最后我们来看看程序的设计与实现。下面呢，是我给出的一段参考代码：

```
#define MAX_N 100
#define MAX_V 10000
int v[MAX_N + 5], w[MAX_N + 5];
int dp[MAX_N + 5][MAX_V + 5];

int get_dp(int n, int W) {
    // 初始化 dp[0] 阶段
    for (int i = 0; i &lt;= W; i++) dp[0][i] = 0;
    // 假设 dp[i - 1] 成立，计算得到 dp[i]
    // 状态转移过程，i 代表物品，j 代表背包限重
    for (int i = 1; i &lt;= n; i++) {
        for (int j = 0; j &lt;= W; j++) {
            // 不选择第 i 种物品时的最大值
            dp[i][j] = dp[i - 1][j];
            // 与选择第 i 种物品的最大值作比较，并更新
            if (j &gt;= w[i] &amp;&amp; dp[i][j] &lt; dp[i - 1][j - w[i]] + v[i]) {
                dp[i][j] = dp[i - 1][j - w[i]] + v[i];
            }
        }
    }
    return dp[n][W];
}

```

可以看到，get_dp 函数就是求解0/1背包问题的过程，函数传入两个整形参数 n 和 W，分别代表了物品数量与背包最大限重。程序中有三个数组：v、w 与 dp，v[i] 代表第 i 个物品的价值，w[i]代表第 i 个物品的重量，dp[i][j] 代表背包问题相关的状态。

这一段代码，采用了正向递推的程序实现。而且，如果你注意观察 get_dp 函数的实现过程，你会惊奇地发现，这就是数学归纳法的证明过程。

首先，初始化 dp[0] 阶段的所有值，也就是保证了 k<sub>0</sub> 成立；然后从 dp[1] 开始迭代计算到 dp[n] 中所有值，每一次 dp[i]依赖的就是 dp[i - 1] 中的值，只有 dp[i - 1] 中所有值是正确的，才能保证 dp[i]中所有值是正确的，这就是数学归纳法的第二步。最后，两步联立，就证明了以 dp 数组的第一维作为阶段，进行状态转移，计算得到的所有 dp 值均是正确的。

面对这种更加广义的数学归纳法，我们也有一个名称，叫做“**结构归纳法**”。

## 进阶：多重背包问题

接下来我们提高一下问题的复杂度，说说多重背包问题。

其实这个问题，整体和0/1背包问题类似，只不过从 n 个物品变成了 n 种物品，且每种物品都有不同的数量，我们可以设定第 i 种物品的数量是 c<sub>i</sub>。

现在你有一个载重上限为 15kg 的背包，有如下 4 件物品：

- 镀金极客币，每个4kg，每个价值 10 块钱，一共有 5 个；
- 胡船长手办，每个3kg ，每个价值 7 块钱，一共有 4 个；
- 西瓜，每个12kg ，每个价值 12 块钱，一共有 2 个；
- 哈密瓜，每个9kg ，每个价值 8 块钱，一共有 7 个。

经过分析，在不超过背包载重上限的情况下，你可以选择3个镀金极客币和1个胡船长手办装到背包里面，这种选择方案能获得最大价值为：37 块钱。

回到我们说的这个多重背包问题，你想如何求解呢？

其实最简单的解决办法，就是把 n 种物品中的每一个，都看成是0/1背包中的一个物品，然后按照 0/1背包问题的求解过程来做即可。这也就是说，如果一种物品有 12 件，就相当于0/1背包中多了 12 件物品，我们就多做 12 轮运算，要是有 120 件呢，那就是多做 120 轮运算。

这种做法虽然可行，可显然太浪费我们计算机的计算资源了。下面就让我们看看怎么做，才能更优化。

#### 1.  二进制拆分法

我们先来想象另外一个场景，假设你是一个卖白菜的老农，手上有23斤白菜和若干个筐，出于某种不知名的原因，你今天不能把称重器带到菜市场，只能提前把白菜称好装入不同的筐里贩卖给顾客。问题来了，白菜要如何分到这些筐里面，才能使得第一个顾客无论要多少斤白菜，你都能通过挑选其中的几筐白菜，从而满足顾客的需求呢？

一种最直接的装筐方法，就是每个筐里面装 1 斤白菜，共需要23个筐。这样，第一个顾客要多少斤白菜，你就给他多少筐就行。这种方法简单粗暴，可是用的筐太多了。

我们转换一个思路去想这件事：当你准备挑几个筐满足第一个顾客需求的时候，对于每个筐来说，都有两种状态，选或者不选，这不就是二进制每位上的数字么？我们就可以把每个筐，看成是二进制相应的位权。

<img src="https://static001.geekbang.org/resource/image/d0/a2/d0d279b5a1b56347dede04e3007c32a2.jpg" alt="" title="图1： 二进制拆分法分白菜"><br>
可以看到，从第一个筐开始，我们依次装上 1 斤、2 斤、4斤、8 斤，第五个筐应该装16斤的，可剩下的白菜不够 16 斤，所以就一起放到最后一个筐里面。这样，我们只需要 5 个筐，就装了 23 斤白菜，并且可以保证无论第一个客人要几斤白菜，都能满足他的需求。

以上，就是我讲的二进制拆分法。

#### 2.  多重背包的拆分优化

看完二进制拆分法以后，我们再看看多重背包转0/1背包的这种解题思路有什么问题。

假设多重背包中，某一种物品有 23 件，转换到0/1背包问题中，就是 23 个物品，就跟前面一斤白菜装一筐的做法是一样的。我们虽然不知道，在0/1背包问题的最优方案中，这种物品被具体选择了多少件，可是只要我们通过一种合理的拆分方法，使得无论最优方案中选择了多少件这种商品，我们都可以组合出来。

简单粗暴地拆分成 23 份，是一种拆分方法，而二进制拆分法也是一种拆分方法，并且二进制拆分法只需要拆成 5 份物品，作为0/1背包问题中的 5 个单独的物品即可，这么做可以达到和拆分成 23 件物品等价的效果，并且节省了大量的计算资源。

例如，前面多重背包问题的那个例子中，按照原本简单粗暴的方式，我们是把 5 个镀金极客币、4 个胡船长手办、2 个西瓜、7 个哈密瓜，当作 18 个物品的0/1背包问题来求解的。但如果采用二进制拆分法，我们就会得到如下拆分方案：

```
1个镀金极客币，4kg每个，价值 10 块钱
2个镀金极客币，8kg每个，价值 20 块钱
2个镀金极客币，8kg每个，价值 20 块钱

1个胡船长手办，3kg每个，价值 7 块钱
2个胡船长手办，6kg每个，价值 14 块钱
1个胡船长手办，3kg每个，价值 7 块钱

1个西瓜，12kg每个，价值 12 块钱
1个西瓜，12kg每个，价值 12 块钱

1个哈密瓜，9kg每个，价值 8 块钱
2个哈密瓜，18kg每个，价值 16 块钱
4个哈密瓜，36kg每个，价值 32 

```

这种拆分方案等价于求解 11 个物品的0/1背包问题，比之前求解的18个物品的 0/1背包问题显然要优秀。

实际上，随着某个物品数量的增加，二进制拆分法的优势会愈加地明显。想一想 32 个二进制位能表示的数字大小，你就明白了。

#### 3.  程序设计与实现

关于多重背包优化版本的程序，给你留作一个作业题，请你根据本节所讲的内容，用二进制拆分法实现多重背包的优化程序。相信你没问题的！

## 课程小结

按照惯例，最后我来做一下这节课的知识点总结：

1. 0/1背包问题中的自变量是物品的种类和背包限重，所以我们把这两维设计到了状态定义中。
1. 多重背包问题可以转换成 0/1背包进行求解，转换过程不同，效率也就不同。
1. 二进制拆分法，本质思想就是二进制的数字表示法，0/1 表示两种状态，表示选或不选。

好了，“算法数据结构篇”到这里就结束了，日后若有机会，我希望跟你分享更多编程中的美妙思维过程。

我是胡光，我们最后一章见。
