首页 > Python算法 > 动态规划 阅读数:56

背包问题—动态规划经典题目

在矿工挖矿问题中我们研究了一个动态规划求解最优化问题的例子。在本节中,我们将研究另外一个经典的可以用动态规划解决的最优化问题——背包问题。这个问题和矿工挖矿问题非常类似,可以巩固提高我们使用动态规划求解最优化问题的能力。

1. 问题描述

假设有一个承重量为 C 的背包。现有 n 件物品,质量分别为 w1,w2,…,wn,价值分别为 v1,v2,…,vn,求让背包里装入的物品具有最大的价值总和的物品子集。和矿工挖矿问题类似,在选择装入物品时,对每种物品只有装入或者不装入两种选择。

不能将同一个物品装入背包多次,也不能只装入该物品的一部分。因为上述限制,我们将这个问题也称为“0-1 背包问题”。如果物品可以部分装入直至装满背包则是另外一个问题,我们将在之后学习。

现在我们转化下这个问题的描述。设物品 i 的质量为 wi,价值为 vi,1≤i≤n,则已知 wi>0,vi>0, C>0。求 n 元向量 {x1,x2,…,xn},其中,xi∈{0,1},0 表示不选择该物体,1 表示选择该物体,1≤i≤n,使得:
动态规划

2. 问题分析

那么下面我们试着用动态规划的方法进行求解。这次区别于矿工挖矿问题,我们先就一般性的情况进行抽象,然后再结合具体实例进行求解。

1) 分析最优子结构性质

先看背包问题是否具有最优子结构性质。这个问题可以用反证法证明。如果背包问题的子问题不是最优子结构,那么必然存在另外一组解使得背包在不超重的情况下价值更大,这显然与原命题的子问题的表述形式(价值最大)不符。因此,背包问题具有最优子结构的性质。

在这里,我们将上述的反证法证明背包问题的最优子结构性质表述成如下形式。

假设 (x1,x2,…,xn) 是背包问题的最优解,则 (x2,x3,…,xn) 是其子问题的最优解。因为如果另外一组向量 (y2,y3,…,yn) 才是该子问题最优解,也即 (x2,x3,…,xn) 不是该子问题的最优解,则应有 (v2y2+v3y3+…+vnyn)+v1x1>(v2x2+v3x3+…+vnxn)+v1x1

而 (v2x2+v3x3+…+vnxn)+v1x1=(v1x1+v2x2+…+vnxn)<(v2y2+v3y3+…+vnyn)+v1x1,说明可以找到新的最优解 (x1,y2,y3,…,yn),在限制的最大质量下可以获得更高的价值,与条件中(x1,x2,…,xn)为最优解矛盾,故假设不成立。因此,(x2,x3,…,xn) 是该子问题的最优解。

2) 分析递归关系,写出状态转移函数

首先考虑一个包含前 i(1≤i≤n) 个物品的实例,物品的质量分别为 w1,w2,…,wi,价值为 v1,v2,…,vi,背包目前的承重量为 j(1≤j≤C)。设 F(i,j) 为组成该实例最优解的物品的总价值,即 F(i,j) 为包含前 i 个物品的质量为 j 的背包中所能装入的最大价值。

在这里,我们可以把前 i 个物品中能够放进承重量为 j 的背包中的子集分为两种类别:包括第 i 个物品的子集和不包括第 i 个物品的子集。

于是我们可以得到以下结论:
  • 根据定义,在不包括第 i 个物品的子集中,最优子集的价值是 F(i-1,j);
  • 在包括第 i 个物品的子集中(j-wi≥0),最优子集是由该物品和前 i-1 个物品中能够放进承重量为 j-wi 的背包的最优子集组成。这种最优子集的总价值等于 vi+F(i-1,j-wi)。

因此,在前 i 个物品中,最优解的总价值等于以上两种情况中求得的价值的较大值,以上便是该问题的最优子结构。

从以上分析可知,如果第 i 个物品不能放进背包,从前 i 个物品中选出的最优子集的总价值即等于从前 i-1 个物品中选出的最优子集的总价值。于是,可以得到以下递推式,即状态转移函数:
动态规划经典题目
同时,我们可以获得背包问题的初始边界条件为:
动态规划经典题目
至此,该问题的最优子结构、初始边界条件和状态转移函数均已求出。

我们的目标是求 F(n,C),即给定 n 个物品中能够放进承重量为 C 的背包的最大总价值以及得到最大总价值的物品组合。下面我们通过一个具体实例进行研究。

3. 问题实例

假设周末学校要组织跳蚤市场,某学生准备了电子词典、篮球、网球拍和考研书 4 件物品进行交易,要用自己的书包把这些物品带到学校。各个物品的质量 w 和价值 v 如表 1 所示,该学生书包的最大承重量 C=8。我们要解决的问题是帮助该同学找到最合理的搭配方案,使他能用书包带到学校的物品价值最大。

表 1:质量价值表
参数 电子词典 篮球 网球拍 单词书
w 2 4 5 3
v 5 4 6 2

定义 F(i,j):与前文保持一致,定义 F(i,j) 为组成当前背包容量为 j 物品数量为 i 的最优解的物品总价值。

根据之前的论述,我们已经找到了使用动态规划解决背包问题的初始边界条件、最优子结构和状态转移函数,为了之后填表过程中方便参考,仍然列示如下。

初始边界条件:

状态转移函数:
动态规划经典例题

从 i 和 j 取值由小到大开始,逐步计算各阶段的 F(i,j),将内容汇总到记录表中。针对本节开头给出的问题,具体填表过程如下:

1) 初始化阶段,根据边界条件对表格中的相关内容进行填充,内容区域的第一行和第一列均应填充 0,如表 2 所示。

表 2:初始化边界条件
i/j 0 1 2 3 4 5 6 7 8
0 0 0 0 0 0 0 0 0 0
1                  
2                  
3                  
4                  

2) 之后,按照 i 和 j 递增的顺序,依据状态转移函数逐行填充该记录表,举例如下:
  • 当 i=1,j=1 时,根据条件可知 w(1)=2、v(1)=5,有 j<w(1),因此,F(1,1)=F(1-1,1)=0。
  • 当 i=1,j=2 时,根据条件可知 w(1)=2、v(1)=5,有 j=w(1),因此,F(1,2) 取 F(1-1,2)=0 和 v(1)+F(1-1,2-2)=5 中较大的值,即 5。
  • 当 i=3,j=5 时,根据条件可知 w(3)=5、v(3)=6,有 j=w(3),因此,F(3,5) 取 F(3-1,5)=5 和 v(3)+F(3-1,5-5)=6 中较大的值,即 6。
  • 当 i=3,j=6 时,根据条件可知 w(3)=5、v(3)=6,有 j>w(3),同样是 F(3,6) 取 F(3-1,6)=9 和 v(3)+F(3-1,6-5)=6 中较大的值,即 9。
  • 当 i=3,j=7 时,根据条件可知 w(3)=5、v(3)=6,有 j>w(3),根据状态转移函数可得 F(3,7) 取 F(3-1,7)=9和v(3)+F(3-1,7-5)=11 中较大的值,即 11。

3) 以此类推,表中所有空白项均可填充完毕,最终得到如表 3 所示结果。

表 3:背包实例
i/j 0 1 2 3 4 5 6 7 8
0 0 0 0 0 0 0 0 0 0
1 0 0 5 5 5 5 5 5 5
2 0 0 5 5 5 5 9 9 9
3 0 0 5 5 5 6 9 11 11
4 0 0 5 5 5 7 9 11 11

填完表格后,可以得到最优解为 11,即能装下的物品的最大总价值为 11,在 (i,j) 取值分别为 (3,7)、(3,8)、(4,7)、(4,8) 时均可得到。

为了进一步明确是由哪些物品组合构成了最优解,我们可以从最终得到的最优解出发,依据表格的信息进行回溯,在回溯过程中根据每一步骤中取值所用的状态转移函数公式,来判断该步骤中当前检验的物品是否在背包中,从而得到形成最优解的物品组合。

根据状态转移函数,我们可以得到以下回溯方法:
  • 若 F(i,j)=F(i-1,j),则说明当前物品没有放到背包中,回溯到 F(i-1,j)中。
  • 若 F(i,j)=vi+F(i-1,j-wi),则说明当前物品已经放到背包中,将该物品记录为组成最优解的元素,回溯到 F(i-1,j-wi)。
  • 重复上述回溯过程,直到 i=0,即可获得所有组成最优解的物品集合。

回到上面的例子中,描述回溯过程:对于得到最优解的 4 组取值来说,每一组的回溯过程均类似,且回溯后得到的最优解物品集合相同。

我们任选一组进行回溯演示,不失一般性,选择从 F(4, 7) 作为回溯起点,得到以下结果:
  • F(4,7)=F(3,7)=11 为最优解,而 F(4,7)不等于 F(4-1,7-w(4))=F(3,4)=5,因此可知在该子步骤中未将物品 4(单词书)放入背包,物品 4 不是最优解组合的组成部分,则回溯至 F(3,7);
  • F(3,7)=v(3)+F(3-1,7-w(3))=6+F(2,2)=11,而不等于 F(2,7)=9,说明在该步骤中将物品 3(网球拍)放入了背包,物品3是最优解组合的组成部分,回溯至 F(2,2);
  • 对于 F(2,2) 而言,由于 w(2)=4>2,说明 F(2,2) 的取值通过 F(1,2)=5获得,该物品未放入背包,物品 2(篮球)不是最优解组合的组成部分,回溯至F(1,2);
  • 对于 F(1,2) 而言,回溯过程与 F(3,7)类似,经过计算可知是通过 F(0,0)+v(1) 获得的当前取值,因此,该步骤中将物品1(电子词典)放入了背包,物品1是最优解组合的组成部分,回溯至 F(0,0)。

至此,回溯过程结束,得到了组成最优解的组合是由物品 1 和物品 3 组成的结论,如图 1 所示。
背包实例问题解决状态示意图
图 1:背包实例问题解决状态示意图

这是小明可以带往跳蚤市场的最大价值。感兴趣的读者可以试一下,从其他几个最优解出发,可以得到与现在一样的结论。另外,大家也可以用这种回溯的方式找到上一节矿工挖矿问题的最优解。

综上,我们通过动态规划解决了背包问题。该算法的时间复杂度和空间复杂度都与记录表的规模有关,而记录表的规模由物品数量 n 和背包总承重量 C 决定,因此,时间复杂度为 O(ni C),空间复杂度为 O(ni C)。

在这里要注意的一点是,虽然背包问题的复杂度看起来不高,却有一定迷惑性,其实背包问题是一个伪多项式时间可以解决的问题,而实际是一个 NPC 问题。因为多项式时间通常是相对于输入规模来说的,输入规模比较直观的一种理解就是输入到该算法的数据占了多少 Byte 内存。

假设背包总重量 C 占用的 Byte 数为 B,那么一般 C=2B。其算法的复杂度对于输入规模 B 就是指数级别的。随着输入规模 B 的增长,算法的运算时间会迅速增加。在回溯的过程中我们发现,每次影响 F(i,j) 取值的仅仅是记录表中上方的一行 F(i-1,j)的取值,因此,可以思考将原来的二维数组格式的记录表转换为一维数组,可以极大降低空间复杂度。

但要注意的是,在此过程中,更新一维数组时需要从右往左逆序更新,否则,可能导致需要的信息被提前覆盖。感兴趣的同学可以自己实现一下该优化方案。另外,这个问题和矿工挖矿问题非常类似,大家可以自己做一个比较和归纳。

4. 参考实现

背包问题代码如下:
def backpack_record(n, c, w, v):
    # 初始化记录表
    backpack_rec = [[0 for i in range(c + 1)] for i in range(len(n) + 1)]
    for i in range(1, len(n)+1):
        for j in range(1, c+1):
            # 使用状态转移函数填写记录表
            if j < w[i - 1]:
                backpack_rec[i][j] = backpack_rec[i - 1][j]
            else:
                backpack_rec[i][j] = max(backpack_rec[i - 1][j], backpack_rec[i - 1][ j w[i - 1]] + v[i - 1])
    return backpack_rec
def backpack_results(n, c, w, res):
    print('可容纳最大价值为:', res[len(n)][c])
    x = [False for i in range(len(n)+1)]
    j = c
    i = len(n)
    # 回溯
    while i>=0:
        if res[i][j] > res[i - 1][j]:
            x[i] = True
            j -= w[i - 1]
        i -= 1
    print('选择的物品为:')
    for i in range(len(n)+1):
        if x[i]:
            print('第', i, '个,', end='')
        print('')
# 验证
n = ['a', 'b', 'c', 'd']
c = 8
w = [2, 4, 5, 3]
v = [5, 4, 6, 2]
res = backpack_record(n, c, w, v)
backpack_results(n, c, w, res)
输出的结果为:

可容纳最大价值为:11
选择的物品为:
第1个,第3个,