动态规划入门——详解完全背包与多重背包问题
本文始发于个人公众号:TechFlow,原创不易,求个关注
今天是算法数据结构专题的第13篇文章,也是动态规划专题的第二篇。
上一讲当中我们一起学习了动态规划算法中的零一背包问题,我们知道了所谓的零一背包是指每一种物品只有一个,所以它的状态只有0和1两种,即拿或者不拿。而今天我们要来讨论物品不止有一个的情况,物品不止有一个也分两种,一种是不作任何限制,要多少有多少,这种称为完全背包问题,另一种是依然有个数限制,这种称为多重背包问题。
我们一个一个来看,我们先从其中比较简单的完全背包开始。由于我们这是一个连续的专题,没有看过上篇文章或者是新关注的同学可以移步我们专题的第一篇:
完全背包
在之前的文章当中,我们阐述了动态规划当中状态和决策以及状态转移的相关概念。在背包问题当中,背包的容量是状态,而选择哪个物品进行获取则是决策,当我们制定了一个决策之后,背包会从一个状态转移到另一个状态。而动态规划算法就是枚举所有状态和决策,获得所有的状态转移,并且记录这个过程中每个状态能够获得的最优解。
在之前的文章当中,我们先遍历了所有的决策,然后再枚举了所有的状态,计算在决策下进行转移之后得到的结果。在之前的零一背包问题当中,由于我们每个物品只能获取一个,如果在前面的状态执行了决策,那么后面的状态则不能进行相同的决策。这也就是动态规划的后效性,而在完全背包问题当中,我们去掉了这个限制,也就意味着决策之间不再有后效性,一个决策可以重复应用在各个状态当中。
所以如果你能理解上面这段话,那么整个算法其实非常简单,几乎就是零一背包的代码。只不过我们把其中倒叙遍历的背包状态再”修正“回来。
之前我们为了避免物品的重复获取,所以采用了倒叙遍历的方法,如今我们不再对数量进行限制,意味着我们可以自由地采取决策进行转移。要做到这点,就是单纯的两重循环,第一种枚举决策, 第二重枚举状态,记录所有转移可能带来的最优解即可。我们来看代码:
dp = [0 for _ in range(11)]
items = [[6, 10], [5, 8], [5, 9]]
# 遍历物品
for v, w in items:
# 遍历背包空间(状态)
# 更新vp+v的状态,即当前容量放入物品之后的状态
for vp in range(0, 10-v+1):
dp[vp+v] = max(dp[vp+v], dp[vp] + w)
print(max(dp]))
如果你还没能完全理解其中的逻辑,我们可以对照一下代码再来理解一下。在第一种循环当中,我们遍历了所有的物品,每一个物品对应了一种决策。每一个决策可以应用在各个状态上,比如第一个物品是6, 15,代表它的体积是6,价值是15。那么我们遍历所有能够应用这个决策的状态,也就是在不超过背包容量的情况下能够放下的状态。显然对于一个体积是6的物品来说,只有0到4的状态可以放下。比如说我们选择状态2,状态2放下了这个物品之后,自然会转移到状态8,因为体积增加了6。有可能这个决策会使得状态8获得更好的结果,也有可能不会,如果会的话我们就更新一下状态8记录的值。这个从一个状态采取决策到另一个状态的过程就是状态转移。
完全背包就是零一背包的无限制版,从原理上来说,两者的思路和做法基本上是一样的。如果你能理解零一背包,那么完全背包对你来说也一定不在话下。
细小的优化
在完全背包当中,由于所有的物品都可以无限获取。所以我们可以引入一些零一背包不能进行的优化,比如对于同样体积的物品而言,我们可以只保留价值最高的物品,将其他的物品过滤掉。这个思路很朴素,我想大家应该都能理解。
比如两个物品体积都是3,一个价值是4,另一个价值是3,我们完全可以忽略价值是3的那一种。因为两者带来的状态转移是一样的,但是明显前者收益更好。而这个优化在零一背包当中不可行是因为每个物品只有一个,很有可能会出现两者都要的情况,所以不能简单地替换。而在完全背包当中则没有这个问题。
多重背包
和零一背包以及完全背包相比,多重背包要难上一些,它的解法也非常多样。我们今天先来看一些相对比较简单的方法。
同样,我们从最简单的方法开始讲起。最简单的方法当然就是将多重背包蜕化成零一背包来解决,比如一个物品最多可以拿N个,我们就把它拆成N种物品,这N种每种物品最多拿一个,相当于我们一种物品可以最多拿N个。这个思路应该很简单,大家都能想明白,但是有个很大的问题,就是复杂度。当然我们可以根据背包的体积做一些优化,比如当物品的数量很多并且超过了背包容量的时候,我们可以把超过容量的数量去掉,但是整体的复杂度还是很高。尤其是当我们背包容量很大的时候。
那么,我们怎么来解决这个问题呢?
这里要介绍一个比较通用的算法,这个算法可以用来优化很多问题,也是很多算法的思想。它就是二进制表示法。这个方法我们在之前的文章当中曾经讲到过,思想非常简单,但是非常实用。
二进制表示法
所谓二进制表示法就是将一个int类型的数表示成二进制,整个算法的思想就是这一句话,所以我想大家应该都能理解。但是我们为什么要将一个int转成二进制,以及转成二进制之后怎么样来优化算法,这个才是我们想知道的,也才是算法的核心重点,不要着急,我们一点点来说明。
我们都知道在计算机系统当中都是以二进制存储的所有数据,最典型的就是整数。一个32位的int,可以表示最大21亿的整数。这个都是我们已知的,但是换一个角度来看,一个21亿的数最后用32个二进制位就表示了,其实非常惊人。为什么说二进制是一个非常伟大的思想?不在于它难,而在于它高效地压缩了数据。
我们进一步来看,32个二进制位为什么能表示这么大的数据呢?因为这32位int表示的数据是不一样的,第0位表示1,第1位表示2,第2位表示4……到了第31位的时候,表示的数已经非常庞大。我们用这32个数不同的组合来表示不同的数,换句话说范围内的所有数最终都变成了这32个数中若干个的累加。我们写成公式就是:,这里的表示的是第i位的系数,它只有0和1两个取值。
这个式子大家都熟悉,但是我们把它应用在方程当中可能很多人就不清楚了。比如说某个函数如果满足这样的性质:,如果直接求很麻烦,或者是开销很大,我们就可以用和来获得。同理,我们用在二进制上,我们可以得到:
看到了吗,我们把的值转化成了最多32个值的和,在有些场景当中是很容易计算的,但是很难直接计算,这个时候我们通过二进制转化就会很简单。
同理,累加理解了,累乘也就水到渠成。如果某个函数满足:,那么我们同样可以用二进制来表达:
对于多重背包这个问题,显然我们满足的是累加性质。也就是说,对于一个较大的x而言,我们可以用若干个子状态累加得到。由于,所以我们很容易发现,,也就是说这些子状态之间彼此存在倍数关系。因此我们可以很轻松地计算出这些子状态,再根据x的二进制表示来累加求到,而直接计算则困难得多,计算量也大得多。
在这个问题当中,函数f表示的是我们拿取物品的价值。也就是说,某一种物品,假设最多有n个,并且单个的价值是p,那么我们拿取2个就是2p,拿取4个就是4p,对于所有2的幂个数的价值都很容易计算。我们需要枚举这n个物品拿取的情况,我们枚举的范围应该是[0, n]。我们将n转化成二进制之后,可以通过logn个2的幂排列组合的和得到[0, n]当中的任意一个数。那么,我们只需要将2的幂个数的物品看成是新的物品,这样,我们可以用新的物品的01组合,来代替原物品拿取0-n的所有情况。
举个例子,我们有一个物品一共有15个,价值是3,其中15=,也就是说我们用4个二进制位就可以表示1-15这15这数字。那么我们用4种物品映射这4个二进制位之后,就可以用这4种物品的组合来表示获取1-15个原物品了。也就是说我们把15个价值是3的物品打了四个包,第一个包里有一个,第二个包里有两个,第三个包里有四个,第四个包里有八个。如果我们要拿3个原物品,相当于拿第一和第二个包裹。如果我们要拿5个原物品,相当于拿第一个和第三个包裹。这样我们就把多重背包的问题转化回了零一背包。
我们之前说了,32位二进制位就可以表示20亿以上的数,所以虽然我们进行二进制处理之后物品的数量会增多一些,但也是非常有限的。我们做个简单的复杂度分析,假设物品的总数是N,每种物品最多M个,背包的容量是V。如果用朴素的拆分方法,复杂度是,而使用二进制拆分的复杂度是。和前者相比,从M到logM是一个巨大的优化,尤其当M很大的时候。
最后,还有一个小问题,我们的物品数量并不一定刚好能分成若干个2的幂的和,这种情况下怎么办呢?其实也简单,我们把分剩下的部分单独打一个包就好了。比如如果物品的数量是10,10=1+2+4+3,所以最后一个包就是3。虽然我们用1+2也能表示3,但是这并不会影响结果的正确性。
到这里,多重背包的解法就介绍完了,说了这么多其实也只是介绍了二进制表示这个方法而已。理解了这个方法,它就转化成了零一背包。不得不说这个方法实在是非常巧妙,并且除了在背包问题之外,在许多其他问题中也有类似的运用。所以这个方法不建议错过。
最后,我们来看下代码,首先我们来看下二进制拆分的部分:
def binary_divide(cnt, volume, price):
divides = []
for i in range(32):
# 从0位开始枚举
cur = 1 << i
# 如果小于枚举值,说明已经拆分完毕了
if cnt < cur:
# 把剩下的部分打包
divides.append((cnt, cnt * volume, cnt * price))
break
else:
# 否则继续拆分,打包1 << i个物品
cnt -= cur
divides.append((cur, cur * volume, cur * price))
return divides
进行完二进制拆分之后,这个问题就转化成了零一背包。我们只需要套用零一背包的代码就可以了:
# 物品,分别是数量,体积和单位价格
items = [(10, 3, 5), (5, 6, 3), (2, 2, 4)]
volume = 20
dp = [0 for _ in range(volume+1)]
new_items = []
for i in items:
# 二进制拆分
new_items.extend(binary_divide(*i))
for item in new_items:
v, p = item[1], item[2]
for i in range(volume-v, -1, -1):
dp[i + v] = max(dp[i+v], dp[i] + p)
print(dp[20])
通过神乎其神的二进制表示法,我们将多重背包问题又还原成了零一背包,不得不说实在是神奇。但二进制表示法并不是唯一的方案,我们也可以不用二进制来完成这道题。这涉及到一种全新的方法,由于篇幅限制,我们会在下篇文章当中和大家一起学习。
今天关于多重背包和完全背包的文章就到这里,如果觉得有所收获,请顺手点个关注或者转发吧,你们的举手之劳对我来说很重要。