01背包问题 (0/1 Knapsack Problem) 动态规划+多级图转化【Python】

1、问题描述

假设有n件商品,分别编号为1, 2…n。其中第i件商品价值为vi,它的重量为wi。假设我们有一个背包,其最大承重能力称为W。现在,我们希望往包里装这些商品,使得包里装的商品价值最大,那么我们该选择装哪些商品呢?

例如之前电视台有一档综艺节目叫“超市大赢家”,限定参赛选手在几分钟内把购物车装满,谁的购物车的商品总价值最大谁就赢得了比赛,这辆购物车的商品就全部免费赠送。这就是典型的背包问题。

如果每种商品只有拿和不拿两种状态,且每种商品只有一个,则称为01背包问题;如果每种商品可以被拿取的数量是有限制的则称为多重背包问题;如果商品的个数是无限量的,则称为完全背包问题。由于后两者是前者的衍生,因此我们更关注01背包问题,其他类型的解法可以参考文末的链接。本文侧重对背包问题求解思想的阐述,不侧重实现技巧方面。

2、数学表征

以上问题本质上即是求在一定约束条件下的最优化问题。该问题的最优化公式如下:
《01背包问题 (0/1 Knapsack Problem) 动态规划+多级图转化【Python】》
其中,xi表示第i个商品是否被选择,选择则为1,不被选择则为0;第一行是优化目标,第二行是约束条件,第三行是决策变量。从上式也可以看出来这是一个很基本的优化模型,很多复杂的问题都可看作是此问题衍生得到的。

3、解决方法

解决这个问题有多种方法,比如暴力求解法,贪婪算法,动态规划法。

  • 暴力求解法:可以将该问题的所有组合可能都罗列出来,选择符合约束条件(商品总重量不超过W)的可行解中的最优解。但是由于每一个商品都有被拿和不被拿两种可能性,而商品数是n,因此其组合的可能性为2n,因此可行解随着商品个数的增多呈现指数增长,显然效率是不高的。
  • 贪婪算法:每次装商品只装性价比最高的,一直装到商品的总重量达到约束条件。由于贪婪算法考虑的不够“长远”,因此往往不是最优解,优点是速度很快,在时间性能要求很高的场景下也可以得到满意解。例如“超市大赢家”节目中,限定参赛选手在几分钟内把购物车装满,在这种情况下人们往往会选择性价比(价值/体积)最大的商品优先装入购物车。
  • 动态规划:把原问题分解为相对简单的子问题的方式,从而求解复杂问题的方法。动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。但动态规划与逐个击破策略(The Divide-and-Conquer Strategy)不同点主要在于,逐个击破的各个问题之间并无关联,而动态规划的各个子问题必须有重叠性。

4、动态规划

a) 动态规划适用条件

想要使用动态规划方法求解问题,问题本身必须具有以下几个性质。

动态规划的适用条件(wiki):
1.具有最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。
2.无后效性。即子问题的解一旦确定,就不再改变。
3.子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

适用条件分析:01背包问题就具有以上几点性质。首先,对于子结构性质来说,可以从最后的状态反推看问题是否具有最优子结构。比如,拿最后一件商品的前一步,即拿倒数第二件商品时,是否要保证该商品已经是当前最好的结果。这显然是的。其次,无后效性也是成立的,最后一件商品的选择不会影响到拿倒数第二件商品的选择。最后,其子问题也都是具有重叠性质的,即拿第i件商品与拿第i-1件商品的问题是相关的,如果把每个子问题都看成是一种状态,那么这种状态是可相互转移的。

能否使用动态规划求解问题的关键是要准确判断原问题是否有以上几点性质。动态规划常见模型有四大类,包括线性模型(公共子序列问题)、树形模型、区间模型、背包问题(01/多重/完全),相关问题超出了本次讨论的范围,感兴趣可以自行在网上查找资料。

b) 动态规划解01背包问题

定义与假设:假设拿取第i件商品时的总价值是total_value[i] = value[1]+value[2]+…+value[i-1]+value[i], 此时的总重量是 total_weight[i] = weight[1]+weight[2]+…+weight[i-1]+weight[i],我们将拿第i件商品时的总价值状态定义为total_value_state[i,j],i表示第i件商品的序号,j表示此时包内商品的总重量。状态函数可以包含很多信息,此处仅包含了价值和重量。

状态转移函数:我们要做的判断其实就是拿第i件商品时,其总价值是否会更高。拿第i件商品时无非有两种可能,total_value更高则拿第i件商品,此时的状态为total_value_state[i, j] = total_value_state[i – 1, j – weight[i]] + value[i] ;如果total_value_state不会更高则不拿该商品,其总价值、总重量保持不变total_value_state[i, j] = total_value_state[i-1, j] 。最终我们是取两种可能性中总价值最高的,即total_value_state[i, j] = max(total_value_state[i – 1, j – weight[i]] + value[i], total_value_state[i-1, j]。至此我们就得到了最重要的状态转移函数。有了这个函数实际上这个问题就可以通过递归的方式解决了。

临界值:另外我们还需要格外关注临界值的情况,假设倒推到拿第0个商品,那么价值和重量都是0,因此total_value_state[0, 0] = 0。

5、Python实现(动态规划)

在实现层面,除了要考虑问题本身的代码转化之外,还要考虑到求解的通用性以及求解速度。
对于问题通用性则需要考虑给定不同的包的承重量W如何如何进行计算。简单来说,状态函数total_value_state[i, j]中的包内商品的总重量j不能大于承重量W。

对于求解速度一般会从两个角度考虑,一个是降低时间复杂度,一个是降低空间复杂度。本问题的时间复杂度和空间复杂度均为O(N*W) 。降低复杂度提高效率属于技巧性讨论,不是本次讨论的重点。因此有兴趣的可以参考文末给出的链接。

我将本次代码进行了逐行备注,这样基本可以保证大家都能理解。

import numpy as np  # 导入numpy包

def solve(value, weight, W): # value:商品的价值、weight:商品的重量、W:背包的最大承重能力
    N = len(value) - 1   # N表示商品总个数
    total_value_state = np.zeros((N + 1, W + 1), dtype=np.int32) # total_value_state代表每个状态的总价值。初始化矩阵,包括第0件商品,以及重量为0的最初状态。
    for i in range(1, N + 1):  # 从第1个商品逐个拿,拿到第i个商品时 ,每个商品都做判断最后到第N个。
        for j in range(1, W + 1): # 拿第i个商品时,此时的总重量若为j时
                total_value_state[i, j] = max(total_value_state[i - 1, j - weight[i]] + value[i],  # 第i次最终的价值应有两种可能性,一种是拿第i个商品,一种是没拿,取两种情况的最大值。
                                  # 原来的价值,由于拿了第i个商品,因此计算原来的价值时重量需要减去weight[i](即Final[i-1,j-weight[i]]) + 第i个商品的价值
                                  total_value_state[i - 1, j])  # 不拿第i件商品,则保留原来的价值,由于没有拿第i件商品,因此总重量j不包含weight[j]
    return total_value_state[-1, -1], total_value_state # 最终返回倒数第一行倒数第一列的价值,即为满足包容量下,遍历了所有可能性的最大价值。

if __name__ == '__main__':
    v = [0, 10, 20, 30] # 价值
    w = [0, 1, 2, 3] # 重量
    weight = 5
    result = solve(v, w, weight)
    print(result)

 # 最终的结果:50,以及全部的价值矩阵如下:
 # (50, array([[ 0,  0,  0,  0,  0,  0],
 #             [ 0, 10, 10, 10, 10, 10],
 #             [ 0, 10, 20, 30, 30, 30],
 #             [ 0, 10, 20, 30, 40, 50]]))

6、拓展:多级图转化

很多组合优化问题均可转化成基于图的表述。这主要是因为大部分组合优化问题都可以分拆成有序进行的子问题,而解决每个子问题都看成是多级图中从源到汇逐渐解决子问题的过程。比如背包问题的子问题是拿第一个商品一直拿到最后一个商品。那么第0个商品就是多级图中的源,拿第一个商品则是多级图的第一级,拿第一个商品付出的代价则是从源到第一个商品可能的节点的边的权重,即将商品的价值转化为了路径上边的权重。这样,在网络图中,从源到汇逐渐扩展创建的过程实际上就是对商品进行组合的过程。每一条从源到汇的可行路线均是一个可行的商品组合,总价值最大实际上就转化了从源到汇过程最长路径问题。

至此,就将组合问题转化为了基于图的表述。当然转化问题这个过程,问题的复杂度并不会降低。一旦将问题转化之后,最长路径问题则可以使用图论相关已有的经验进行求解计算。

《01背包问题 (0/1 Knapsack Problem) 动态规划+多级图转化【Python】》 01背包问题的图表述

参考资料:
    原文作者:猪了个龙猪
    原文地址: https://www.jianshu.com/p/502d1c61da89
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞