动态规划算法之背包问题

152 阅读7分钟

背包问题

问题定义:背包问题

输入:物品价值v1,v2,,vnv_1,v_2,…,v_n;物品大小s1,s2,,sns_1,s_2,…,s_n;背包容量CC(所有的值均为正整数)。

输出:一个物品子集S{1,2,,n}S \subseteq \{ 1, 2,…,n \} ,具有最大的价值之和iSvi\sum_{i \in S} v_i,但必须满足总大小iSsi\sum_{i \in S} s_i 不超过 CC

考虑下面这个背包问题的实例,背包的容量C= 6并且有4件物品

物品价值大小
134
223
342
443

最优解决方案的总价值是多少呢?

为了在背包问题中应用动态规划,我们必须推断出正确的子问题集合。
为了实现这个目标,我们需要推断出最优解决方案的结构,并确认从更小子问题的最优解决方案构造更大子问题解决方案的不同方式。

这种操作的另一个成果是一个推导公式,它可以从两个更小子问题的解决方案中计算出一个更大子问题的解决方案。

这个最优解决方案看上去应该是怎么样的呢? 我们可以从一个思路出发:SS 要么包含了最后一件物品(物品 nn),要么不包含它。

背包问题的最优子结构

SS是具有n1n≥1件物品、物品价值v1,v2,,vnv_1,v_2,…,v_n、物品大小s1,s2,,sns_1,s_2,…,s_n、背包容量CC的背包问题的最优解决方案。 则SS必为下面两者之一:

(a)由前n1n - 1件物品组成的背包容量为CC的子问题的最优解决方案。

(b)由前n1n - 1件物品组成的背包容量为CsnC - s_n的子问题的最优解决方案再加上最后一件物品nn

(a)的解决方案总是最优解决方案的选项之一。(b)的解决方案当且仅当snCs_n≤C时才是选项之一。
在这种情况下,可以有效地预先为物品nn保留sns_n个单位的容量。具有更大总价值的那个选项就是最优解决方案,从而形成下面的推导公式。

背包问题的推导公式

根据背包问题的最优子结构的假设和说明,设Vi,cV_{i,c}表示总大小不超过cc的前ii件物品所组成的子集的最大总价值 (当i=0i = 0时,Vi,cV_{i,c}可以看成00)。对于每个i=1,2,,ni = 1, 2,…, nc=0,1,2,,Cc = 0, 1, 2,…, C

Vi,c={Vi1,c情况1Si>cmax{Vi1,c情况1,Vi1,csi+vi情况2}SicV_{i,c} = \begin{cases} \underbrace{V_{i-1,c}}_{情况1} & S_i > c \\ max \{ \underbrace{V_{i-1,c}}_{情况1}, \underbrace{V_{i-1,c-s_i} + v_i}_{情况2} \} & S_i \le c \end{cases}

由于cc和物品的大小都是整数,因此第二个表达式中的剩余容量csic - s_i也是整数。

下一个步骤是定义相关子问题的集合,并使用背包问题的推导公式系统地解决这些子问题。至于现在,我们把注意力集中在计算每个子问题的最优解决方案的总价值上。

对于背包问题,子问题应该由两个索引进行参数化:前几个物品的长度ii和可用的背包容量cc。对两个参数所有相关的值均加以考虑,我们就可以得到子问题。

背包问题的子问题

计算前ii个物品和背包容量为cc的最优背包解决方案的总价值Vi,ci=0,1,2,,nc=0,1,2,,CV_{i,c}(i =0,1,2,…,n,c=0,1,2,…, C)

最大子问题i=nc=C(i = n且c = C)就与原问题相同。由于所有物品的大小和背包容量CC都是正整数,并且由于容量总是会减去某个物品的大小(为它保留空间),因此剩下的容量只可能在0C0~C

明确了子问题和推导公式之后,我们立即就能想到背包问题的一种动态规划算法。

算法:背包问题

输入:物品价值v1,v2,,vnv_1,v_2,…,v_n,物品大小s1,s2,,sns_1,s_2,…,s_n和背包容量CC(均为正整数)。

输出:具有最大总价值的子集S{1,2,,n}S \subseteq \{ 1, 2,…,n \} ,满足总大小iSsiC\sum_{i \in S} s_i \le C


// 子问题的解决方案(索引从0开始)
A:=(n+1)×(C+1)A := (n + 1) \times (C + 1) 二维数组
// 基本情况(i=0i = 0
for c = 0 to C do
A[0][c]=0A[0][c] = 0
//系统性地解决所有的子问题
for i = 1 to n do
for c = 0 to C do
// 使用背包问题的推导公式
if sis_i > c then
A[i][c]:=A[i1][c]A[i][c] := A[i − 1][c]
else
A[i][c]:=max{A[i1][c]情况1,A[i1][csi]+vi情况2}A[i][c] := max \{ \underbrace{A[i-1][c]}_{情况1}, \underbrace{A[i-1][c-s_i] + v_i}_{情况2} \}
return A[n][C]A[n][C]
//最大子问题的解决方案


def knapsack(weights, values, capacity):
    assert len(weights) == len(values), "物品价值数组长度必须等于物品大小数组"
    n = len(weights)
    A = [[0 for _ in range(capacity+1)] for _ in range(n+1)]
    for c in range(capacity+1):
        A[0][c] = 0

    for i in range(1, n+1):
        for c in range(0, capacity+1):
            if weights[i-1] > c:
                A[i][c] = A[i-1][c]
            else:
                A[i][c] = max(A[i-1][c], A[i-1][c-weights[i-1]] + values[i-1])

    return A[n][capacity]

现在,数组A是一个二维数组,反映了对子问题进行参数化时所使用的索引iicc
当双重for循环的一次迭代必须计算子问题解决方案A[i][c]A[i][c]时, 两个相关的更小子问题的值A[i1][c]A[i−1][c]A[i1][csi]A[i−1][c−s_i]在外层循环之前的一次迭代时(或作为基本情况)已经计算出来了。 我们可以得出结论,这个算法花费O(1)O(1)的时间解决(n+1)(C+1)=O(nC)(n + 1)(C + 1) = O(nC)个子问题中的每一个,因此总体运行时间是O(nC)O(nC)

示例

考虑下面这个背包问题的实例,背包的容量C= 6并且有4件物品

物品价值大小
134
223
342
443

最优解决方案的总价值是多少呢?

由于n=4n = 4C=6C = 6,因此背包算法中的数组AA可以用一个55列(对应于i=0,1,,4i = 0, 1,…, 477行(对应于c=0,1,,6c = 0, 1,…, 6)的表格形象地说明。 最终的数组值如图所示。

knapsack1.png

背包算法(按照从左到右的顺序)可以计算这些项。在同一列中,背包算法则是按照从下到上的顺序进行计算。
为了填充第ii列的某一项,算法把它左边紧邻的那个项(对应于情况1)和“viv_i与左侧向下sis_i行的那一项之和”进行比较, 并取两者中较大的那个。
对于A[2][5]A[2][5]而言,更好的选择是跳过后者直接选择左边紧邻的“33”。
但对于A[3][5]A[3][5]而言,更好的选择是包含物品33,也就是选择44v3v_3)加上22(左侧向下sis_i行的那一项,即A[2][3]A[2][3])。

# 示例
weights = [4, 3, 2, 3]  # 物品重量
values = [3, 2, 4, 4]   # 物品价值
capacity = 6            # 背包容量

max_value = knapsack(weights, values, capacity)
print(f"最大价值为: {max_value}")  # 输出: 最大价值为: 8
最大价值为: 8

重建

背包算法只计算最优解决方案的总价值,并不产生最优解决方案本身。
我们可以通过回溯填充数组A的过程来重新构建一个最优解决方案。
这个重建算法以右上角的最大子问题为起点,确认使用推导公式的某种情况来计算A[n][C]A[n][C]
如果是第一种情况,算法就忽略物品nn,并从A[n1][C]A[n−1][C]这一项继续重建过程。
如果是第二种情况,算法就在它的解决方案中包含物品nn,并从A[n1][Csn]A[n−1][C–s_n]这一项继续重建过程。

背包问题的重建算法

输入:背包算法为物品价值是v1,v2,,vnv_1,v_2,…,v_n,物品大小是s1,s2,,sns_1,s_2,…,s_n和背包容量是CC的背包问题所计算产生的数组AA

输出:一个最优的背包问题解决方案。


S:=S := \emptyset // 最优解决方案中的物品
c:=Cc := C // 剩余的容量
for i = n downto 1 do
if sics_i \le c and A[i1][csi]+viA[i1][c]A[i-1][c-s_i] + v_i \ge A[i-1][c] then
S:=S{i}S := S \cup \{i\} // 第一种情况获胜,包含ii
c:=csic := c − s_i // 为它保留空间
// else跳过i,容量保持不变
return SS


def knapsack_with_items(weights, values, capacity):
    assert len(weights) == len(values), "物品价值数组长度必须等于物品大小数组"
    n = len(weights)
    A = [[0 for _ in range(capacity+1)] for _ in range(n+1)]
    for c in range(capacity+1):
        A[0][c] = 0

    for i in range(1, n+1):
        for c in range(0, capacity+1):
            if weights[i-1] > c:
                A[i][c] = A[i-1][c]
            else:
                A[i][c] = max(A[i-1][c], A[i-1][c-weights[i-1]] + values[i-1])

    # 回溯找出选择的物品
    S = []
    c = capacity
    for i in range(n, 0, -1):
        if weights[i-1] <= c and A[i-1][c-weights[i-1]] + values[i-1] > A[i-1][c]:
            S.append(i)  # 添加物品索引,这里是从1开始计算的索引
            c -= weights[i-1]
    
    return A[n][capacity], S

对上图所示的数组进行回溯产生最优解决方案{3,4},如图所示。

knapsack2.png

# 示例
weights = [4, 3, 2, 3]  # 物品重量
values = [3, 2, 4, 4]   # 物品价值
capacity = 6            # 背包容量

max_value, selected_items = knapsack_with_items(weights, values, capacity)
print(f"最大价值为: {max_value}")          # 输出: 最大价值为: 8
print(f"选择的物品索引: {selected_items}")  # 输出: 选择的物品索引: [4, 3]
最大价值为: 8
选择的物品索引: [4, 3]

参考文档

《算法详解(卷3)——贪心算法和动态规划》:4.5 背包问题