动态规划算法之背包问题

背包问题

问题定义:背包问题

输入 :物品价值 <math xmlns="http://www.w3.org/1998/Math/MathML"> v 1 , v 2 , ... , v n v_1,v_2,...,v_n </math>v1,v2,...,vn;物品大小 <math xmlns="http://www.w3.org/1998/Math/MathML"> s 1 , s 2 , ... , s n s_1,s_2,...,s_n </math>s1,s2,...,sn;背包容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C(所有的值均为正整数)。

输出 :一个物品子集 <math xmlns="http://www.w3.org/1998/Math/MathML"> S ⊆ { 1 , 2 , ... , n } S \subseteq \{ 1, 2,...,n \} </math>S⊆{1,2,...,n} ,具有最大的价值之和 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∑ i ∈ S v i \sum_{i \in S} v_i </math>∑i∈Svi,但必须满足总大小 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∑ i ∈ S s i \sum_{i \in S} s_i </math>∑i∈Ssi 不超过 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C。

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

物品 价值 大小
1 3 4
2 2 3
3 4 2
4 4 3

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

为了在背包问题中应用动态规划,我们必须推断出正确的子问题集合。

为了实现这个目标,我们需要推断出最优解决方案的结构,并确认从更小子问题的最优解决方案构造更大子问题解决方案的不同方式。

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

这个最优解决方案看上去应该是怎么样的呢? 我们可以从一个思路出发: <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S 要么包含了最后一件物品(物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n),要么不包含它。

背包问题的最优子结构

设 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S是具有 <math xmlns="http://www.w3.org/1998/Math/MathML"> n ≥ 1 n≥1 </math>n≥1件物品、物品价值 <math xmlns="http://www.w3.org/1998/Math/MathML"> v 1 , v 2 , ... , v n v_1,v_2,...,v_n </math>v1,v2,...,vn、物品大小 <math xmlns="http://www.w3.org/1998/Math/MathML"> s 1 , s 2 , ... , s n s_1,s_2,...,s_n </math>s1,s2,...,sn、背包容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C的背包问题的最优解决方案。 则 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S必为下面两者之一:

(a)由前 <math xmlns="http://www.w3.org/1998/Math/MathML"> n − 1 n - 1 </math>n−1件物品组成的背包容量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C的子问题的最优解决方案。

(b)由前 <math xmlns="http://www.w3.org/1998/Math/MathML"> n − 1 n - 1 </math>n−1件物品组成的背包容量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> C − s n C - s_n </math>C−sn的子问题的最优解决方案再加上最后一件物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n。

(a)的解决方案总是最优解决方案的选项之一。(b)的解决方案当且仅当 <math xmlns="http://www.w3.org/1998/Math/MathML"> s n ≤ C s_n≤C </math>sn≤C时才是选项之一。

在这种情况下,可以有效地预先为物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n保留 <math xmlns="http://www.w3.org/1998/Math/MathML"> s n s_n </math>sn个单位的容量。具有更大总价值的那个选项就是最优解决方案,从而形成下面的推导公式。

背包问题的推导公式

根据背包问题的最优子结构 的假设和说明,设 <math xmlns="http://www.w3.org/1998/Math/MathML"> V i , c V_{i,c} </math>Vi,c表示总大小不超过 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c的前 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i件物品所组成的子集的最大总价值 (当 <math xmlns="http://www.w3.org/1998/Math/MathML"> i = 0 i = 0 </math>i=0时, <math xmlns="http://www.w3.org/1998/Math/MathML"> V i , c V_{i,c} </math>Vi,c可以看成 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 0 </math>0)。对于每个 <math xmlns="http://www.w3.org/1998/Math/MathML"> i = 1 , 2 , ... , n i = 1, 2,..., n </math>i=1,2,...,n和 <math xmlns="http://www.w3.org/1998/Math/MathML"> c = 0 , 1 , 2 , ... , C c = 0, 1, 2,..., C </math>c=0,1,2,...,C:

<math xmlns="http://www.w3.org/1998/Math/MathML"> V i , c = { V i − 1 , c ⏟ 情况 1 S i > c m a x { V i − 1 , c ⏟ 情况 1 , V i − 1 , c − s i + v i ⏟ 情况 2 } S i ≤ c V_{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} </math>Vi,c=⎩ ⎨ ⎧情况1 Vi−1,cmax{情况1 Vi−1,c,情况2 Vi−1,c−si+vi}Si>cSi≤c

由于 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c和物品的大小都是整数,因此第二个表达式中的剩余容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> c − s i c - s_i </math>c−si也是整数。

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

对于背包问题,子问题应该由两个索引进行参数化:前几个物品的长度 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i和可用的背包容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。对两个参数所有相关的值均加以考虑,我们就可以得到子问题。

背包问题的子问题

计算前 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i个物品和背包容量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c的最优背包解决方案的总价值 <math xmlns="http://www.w3.org/1998/Math/MathML"> V i , c ( i = 0 , 1 , 2 , ... , n , c = 0 , 1 , 2 , ... , C ) V_{i,c}(i =0,1,2,...,n,c=0,1,2,..., C) </math>Vi,c(i=0,1,2,...,n,c=0,1,2,...,C)。

最大子问题 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( i = n 且 c = C ) (i = n且c = C) </math>(i=n且c=C)就与原问题相同。由于所有物品的大小和背包容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C都是正整数,并且由于容量总是会减去某个物品的大小(为它保留空间),因此剩下的容量只可能在 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 ~ C 0~C </math>0~C。

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

算法:背包问题

输入 :物品价值 <math xmlns="http://www.w3.org/1998/Math/MathML"> v 1 , v 2 , ... , v n v_1,v_2,...,v_n </math>v1,v2,...,vn,物品大小 <math xmlns="http://www.w3.org/1998/Math/MathML"> s 1 , s 2 , ... , s n s_1,s_2,...,s_n </math>s1,s2,...,sn和背包容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C(均为正整数)。

输出 :具有最大总价值的子集 <math xmlns="http://www.w3.org/1998/Math/MathML"> S ⊆ { 1 , 2 , ... , n } S \subseteq \{ 1, 2,...,n \} </math>S⊆{1,2,...,n} ,满足总大小 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∑ i ∈ S s i ≤ C \sum_{i \in S} s_i \le C </math>∑i∈Ssi≤C。


// 子问题的解决方案(索引从0开始)
<math xmlns="http://www.w3.org/1998/Math/MathML"> A : = ( n + 1 ) × ( C + 1 ) A := (n + 1) \times (C + 1) </math>A:=(n+1)×(C+1)二维数组

// 基本情况( <math xmlns="http://www.w3.org/1998/Math/MathML"> i = 0 i = 0 </math>i=0)
for c = 0 to C do
<math xmlns="http://www.w3.org/1998/Math/MathML"> A [ 0 ] [ c ] = 0 A[0][c] = 0 </math>A[0][c]=0

//系统性地解决所有的子问题
for i = 1 to n do
for c = 0 to C do
// 使用背包问题的推导公式
if <math xmlns="http://www.w3.org/1998/Math/MathML"> s i s_i </math>si > c then
<math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i ] [ c ] : = A [ i − 1 ] [ c ] A[i][c] := A[i − 1][c] </math>A[i][c]:=A[i−1][c]
else
<math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i ] [ c ] : = m a x { A [ i − 1 ] [ c ] ⏟ 情况 1 , A [ i − 1 ] [ c − s i ] + v i ⏟ 情况 2 } A[i][c] := max \{ \underbrace{A[i-1][c]}{情况1}, \underbrace{A[i-1][c-s_i] + v_i}{情况2} \} </math>A[i][c]:=max{情况1 A[i−1][c],情况2 A[i−1][c−si]+vi}
return <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ n ] [ C ] A[n][C] </math>A[n][C]

//最大子问题的解决方案


python 复制代码
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是一个二维数组,反映了对子问题进行参数化时所使用的索引 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i和 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。

当双重for循环的一次迭代必须计算子问题解决方案 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i ] [ c ] A[i][c] </math>A[i][c]时, 两个相关的更小子问题的值 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i − 1 ] [ c ] A[i−1][c] </math>A[i−1][c]和 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i − 1 ] [ c − s i ] A[i−1][c−s_i] </math>A[i−1][c−si]在外层循环之前的一次迭代时(或作为基本情况)已经计算出来了。 我们可以得出结论,这个算法花费 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 1 ) O(1) </math>O(1)的时间解决 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( n + 1 ) ( C + 1 ) = O ( n C ) (n + 1)(C + 1) = O(nC) </math>(n+1)(C+1)=O(nC)个子问题中的每一个,因此总体运行时间是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n C ) O(nC) </math>O(nC)。

示例

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

物品 价值 大小
1 3 4
2 2 3
3 4 2
4 4 3

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

由于 <math xmlns="http://www.w3.org/1998/Math/MathML"> n = 4 n = 4 </math>n=4且 <math xmlns="http://www.w3.org/1998/Math/MathML"> C = 6 C = 6 </math>C=6,因此背包算法中的数组 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A可以用一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> 5 5 </math>5列(对应于 <math xmlns="http://www.w3.org/1998/Math/MathML"> i = 0 , 1 , ... , 4 i = 0, 1,..., 4 </math>i=0,1,...,4) <math xmlns="http://www.w3.org/1998/Math/MathML"> 7 7 </math>7行(对应于 <math xmlns="http://www.w3.org/1998/Math/MathML"> c = 0 , 1 , ... , 6 c = 0, 1,..., 6 </math>c=0,1,...,6)的表格形象地说明。 最终的数组值如图所示。

背包算法(按照从左到右的顺序)可以计算这些项。在同一列中,背包算法则是按照从下到上的顺序进行计算。

为了填充第 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i列的某一项,算法把它左边紧邻的那个项(对应于情况1)和" <math xmlns="http://www.w3.org/1998/Math/MathML"> v i v_i </math>vi与左侧向下 <math xmlns="http://www.w3.org/1998/Math/MathML"> s i s_i </math>si行的那一项之和"进行比较, 并取两者中较大的那个。

对于 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ 2 ] [ 5 ] A[2][5] </math>A[2][5]而言,更好的选择是跳过后者直接选择左边紧邻的" <math xmlns="http://www.w3.org/1998/Math/MathML"> 3 3 </math>3"。

但对于 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ 3 ] [ 5 ] A[3][5] </math>A[3][5]而言,更好的选择是包含物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> 3 3 </math>3,也就是选择 <math xmlns="http://www.w3.org/1998/Math/MathML"> 4 4 </math>4( <math xmlns="http://www.w3.org/1998/Math/MathML"> v 3 v_3 </math>v3)加上 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 2 </math>2(左侧向下 <math xmlns="http://www.w3.org/1998/Math/MathML"> s i s_i </math>si行的那一项,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ 2 ] [ 3 ] A[2][3] </math>A[2][3])。

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

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

重建

背包算法只计算最优解决方案的总价值,并不产生最优解决方案本身。

我们可以通过回溯填充数组A的过程来重新构建一个最优解决方案。

这个重建算法以右上角的最大子问题为起点,确认使用推导公式的某种情况来计算 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ n ] [ C ] A[n][C] </math>A[n][C]。

如果是第一种情况,算法就忽略物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n,并从 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ n − 1 ] [ C ] A[n−1][C] </math>A[n−1][C]这一项继续重建过程。

如果是第二种情况,算法就在它的解决方案中包含物品 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n,并从 <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ n − 1 ] [ C -- s n ] A[n−1][C--s_n] </math>A[n−1][C--sn]这一项继续重建过程。

背包问题的重建算法

输入 :背包算法为物品价值是 <math xmlns="http://www.w3.org/1998/Math/MathML"> v 1 , v 2 , ... , v n v_1,v_2,...,v_n </math>v1,v2,...,vn,物品大小是 <math xmlns="http://www.w3.org/1998/Math/MathML"> s 1 , s 2 , ... , s n s_1,s_2,...,s_n </math>s1,s2,...,sn和背包容量是 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C的背包问题所计算产生的数组 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A。

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


<math xmlns="http://www.w3.org/1998/Math/MathML"> S : = ∅ S := \emptyset </math>S:=∅ // 最优解决方案中的物品
<math xmlns="http://www.w3.org/1998/Math/MathML"> c : = C c := C </math>c:=C // 剩余的容量
for i = n downto 1 do
if <math xmlns="http://www.w3.org/1998/Math/MathML"> s i ≤ c s_i \le c </math>si≤c and <math xmlns="http://www.w3.org/1998/Math/MathML"> A [ i − 1 ] [ c − s i ] + v i ≥ A [ i − 1 ] [ c ] A[i-1][c-s_i] + v_i \ge A[i-1][c] </math>A[i−1][c−si]+vi≥A[i−1][c] then
<math xmlns="http://www.w3.org/1998/Math/MathML"> S : = S ∪ { i } S := S \cup \{i\} </math>S:=S∪{i} // 第一种情况获胜,包含 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i
<math xmlns="http://www.w3.org/1998/Math/MathML"> c : = c − s i c := c − s_i </math>c:=c−si // 为它保留空间
// else 跳过i,容量保持不变
return <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S


python 复制代码
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},如图所示。

python 复制代码
# 示例
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}")  # 输出: 选择的物品索引: [3, 2]
makefile 复制代码
最大价值为: 8
选择的物品索引: [4, 3]

参考文档

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

相关推荐
小蜗牛狂飙记9 分钟前
在github上传python项目,然后在另外一台电脑下载下来后如何保障成功运行
开发语言·python·github
小苏兮9 分钟前
【C语言】字符串与字符函数详解(上)
c语言·开发语言·算法
倔强青铜三19 分钟前
苦练Python第27天:嵌套数据结构
人工智能·python·面试
一只小蒟蒻28 分钟前
DFS 迷宫问题 难度:★★★★☆
算法·深度优先·dfs·最短路·迷宫问题·找过程
倔强青铜三36 分钟前
苦练Python第26天:精通字典8大必杀技
人工智能·python·面试
martian6651 小时前
深入详解随机森林在眼科影像分析中的应用及实现细节
人工智能·算法·随机森林·机器学习·医学影像
apocelipes1 小时前
使用uint64_t批量比较短字符串
c语言·数据结构·c++·算法·性能优化·golang
一只IT攻城狮1 小时前
构建一个简单的Java框架来测量并发执行任务的时间
java·算法·多线程·并发编程
WanderInk1 小时前
在递归中为什么用 `int[]` 而不是 `int`?——揭秘 Java 参数传递的秘密
java·后端·算法
creator_Li2 小时前
python学习笔记
笔记·python·学习