动态规划算法之背包问题

背包问题

问题定义:背包问题

输入 :物品价值 v 1 , v 2 , ... , v n v_1,v_2,...,v_n v1,v2,...,vn;物品大小 s 1 , s 2 , ... , s n s_1,s_2,...,s_n s1,s2,...,sn;背包容量 C C C(所有的值均为正整数)。

输出 :一个物品子集 S ⊆ { 1 , 2 , ... , n } S \subseteq \{ 1, 2,...,n \} S⊆{1,2,...,n} ,具有最大的价值之和 ∑ i ∈ S v i \sum_{i \in S} v_i ∑i∈Svi,但必须满足总大小 ∑ i ∈ S s i \sum_{i \in S} s_i ∑i∈Ssi 不超过 C C C。

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

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

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

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

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

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

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

背包问题的最优子结构

S S S是具有 n ≥ 1 n≥1 n≥1件物品、物品价值 v 1 , v 2 , ... , v n v_1,v_2,...,v_n v1,v2,...,vn、物品大小 s 1 , s 2 , ... , s n s_1,s_2,...,s_n s1,s2,...,sn、背包容量 C C C的背包问题的最优解决方案。 则 S S S必为下面两者之一:

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

(b)由前 n − 1 n - 1 n−1件物品组成的背包容量为 C − s n C - s_n C−sn的子问题的最优解决方案再加上最后一件物品 n n n。

(a)的解决方案总是最优解决方案的选项之一。(b)的解决方案当且仅当 s n ≤ C s_n≤C sn≤C时才是选项之一。

在这种情况下,可以有效地预先为物品 n n n保留 s n s_n sn个单位的容量。具有更大总价值的那个选项就是最优解决方案,从而形成下面的推导公式。

背包问题的推导公式

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

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} Vi,c=⎩ ⎨ ⎧情况1 Vi−1,cmax{情况1 Vi−1,c,情况2 Vi−1,c−si+vi}Si>cSi≤c

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

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

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

背包问题的子问题

计算前 i i i个物品和背包容量为 c c c的最优背包解决方案的总价值 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) Vi,c(i=0,1,2,...,n,c=0,1,2,...,C)。

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

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

算法:背包问题

输入 :物品价值 v 1 , v 2 , ... , v n v_1,v_2,...,v_n v1,v2,...,vn,物品大小 s 1 , s 2 , ... , s n s_1,s_2,...,s_n s1,s2,...,sn和背包容量 C C C(均为正整数)。

输出 :具有最大总价值的子集 S ⊆ { 1 , 2 , ... , n } S \subseteq \{ 1, 2,...,n \} S⊆{1,2,...,n} ,满足总大小 ∑ i ∈ S s i ≤ C \sum_{i \in S} s_i \le C ∑i∈Ssi≤C。


// 子问题的解决方案(索引从0开始)
A : = ( n + 1 ) × ( C + 1 ) A := (n + 1) \times (C + 1) A:=(n+1)×(C+1)二维数组

// 基本情况( i = 0 i = 0 i=0)
for c = 0 to C do
A 0 c = 0 A0c = 0 A0c=0

//系统性地解决所有的子问题
for i = 1 to n do
for c = 0 to C do
// 使用背包问题的推导公式
if s i s_i si > c then
A i c : = A i − 1 c Aic := Ai − 1c Aic:=Ai−1c
else
A i c : = m a x { A i − 1 c ⏟ 情况 1 , A i − 1 c − s i + v i ⏟ 情况 2 } Aic := max \{ \underbrace{Ai-1c}{情况1}, \underbrace{Ai-1c-s_i + v_i}{情况2} \} Aic:=max{情况1 Ai−1c,情况2 Ai−1c−si+vi}
return A n C AnC AnC

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


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是一个二维数组,反映了对子问题进行参数化时所使用的索引 i i i和 c c c。

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

示例

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

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

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

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

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

为了填充第 i i i列的某一项,算法把它左边紧邻的那个项(对应于情况1)和" v i v_i vi与左侧向下 s i s_i si行的那一项之和"进行比较, 并取两者中较大的那个。

对于 A 2 5 A25 A25而言,更好的选择是跳过后者直接选择左边紧邻的" 3 3 3"。

但对于 A 3 5 A35 A35而言,更好的选择是包含物品 3 3 3,也就是选择 4 4 4( v 3 v_3 v3)加上 2 2 2(左侧向下 s i s_i si行的那一项,即 A 2 3 A23 A23)。

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的过程来重新构建一个最优解决方案。

这个重建算法以右上角的最大子问题为起点,确认使用推导公式的某种情况来计算 A n C AnC AnC

如果是第一种情况,算法就忽略物品 n n n,并从 A n − 1 C An−1C An−1C这一项继续重建过程。

如果是第二种情况,算法就在它的解决方案中包含物品 n n n,并从 A n − 1 C -- s n An−1C--s_n An−1C--sn这一项继续重建过程。

背包问题的重建算法

输入 :背包算法为物品价值是 v 1 , v 2 , ... , v n v_1,v_2,...,v_n v1,v2,...,vn,物品大小是 s 1 , s 2 , ... , s n s_1,s_2,...,s_n s1,s2,...,sn和背包容量是 C C C的背包问题所计算产生的数组 A A A。

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


S : = ∅ S := \emptyset S:=∅ // 最优解决方案中的物品
c : = C c := C c:=C // 剩余的容量
for i = n downto 1 do
if s i ≤ c s_i \le c si≤c and A i − 1 c − s i + v i ≥ A i − 1 c Ai-1c-s_i + v_i \ge Ai-1c Ai−1c−si+vi≥Ai−1c then
S : = S ∪ { i } S := S \cup \{i\} S:=S∪{i} // 第一种情况获胜,包含 i i i
c : = c − s i c := c − s_i c:=c−si // 为它保留空间
// else 跳过i,容量保持不变
return S S 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 背包问题

相关推荐
To_OC11 小时前
LC 128 最长连续序列:别上来就排序,O (n) 解法才是这题的灵魂
javascript·算法·leetcode
ServBay16 小时前
9 个 Python 第三方库推荐,不用 AI 都好像多出一个团队
后端·python
用户83562907805116 小时前
如何使用 Python 添加和管理 Excel 批注(完整示例)
后端·python
用户83562907805116 小时前
使用 Python 管理 Excel 工作表:创建、复制、删除与重命名
后端·python
05Kevin1 天前
lk每日冒险题--数据结构6.27
算法
荣码1 天前
LangGraph多Agent协作:3个Agent干活比1个强,但我踩了4个坑
java·python
To_OC1 天前
从一次栈溢出报错说起,我把递归彻底扒明白了
javascript·算法·程序员
千纸鹤安安2 天前
千问Qwen-AgentWorld来了:一个语言模型搞定七大Agent场景,GPT-5.4都输了
算法