上一节学习了贪心算法,贪心算法的代码编写比较简单,而且效率也比较高;但是它得出来的并不是最好的解决方法,并且我们不知道它距离真正的最好的答案的差距有多远。这一节我们来学习brute force 算法(暴力算法),也就是穷举法,把所有可能性都列举出来最后选择最大或最小的。
我们先来看一下搜索树(searchtree):
从顶端的根节点开始,根节点背包里还什么都没有装,我们有一个list,其中是所有可供我们选择的项;
左子节点是往背包里装list里的第一项,右子节点是不装第一项,同时list中第一项被排除;
一直这样递归下去,每个点都做上一步同样的操作,直到list中没有可供选择的项或者背包已经被装满了,这时就结束递归,最末尾的子节点都叫做叶子结点,叶子结点没有子节点;
所有的叶子节点都是一种可能,在其中选出最好的答案(最大或最小)。
来看一下用搜索树进行的bruteforce算法的计算复杂度
时间复杂度是基于节点数,搜索树总层数是可供选择的项的数量加一(以下写作n+1),每一层的节点数是2i(i是层数),把所有节点加起来,求和得到 2n+1-1,于是算法复杂度是O(2n+1)。
接下来实现代码(定义函数进上一节的Food类):
def maxVal(toConsider, avail):
"""Assumes toConsider a list of items, avail a weight
Returns a tuple of the total value of a solution to the
0/1 knapsack problem and the items of that solution"""
if toConsider == [] or avail == 0:
result = (0, ())
elif toConsider[0].getCost() > avail:
#Explore right branch only
result = maxVal(toConsider[1:], avail)
else:
nextItem = toConsider[0]
#Explore left branch
withVal, withToTake = maxVal(toConsider[1:],
avail - nextItem.getCost())
withVal += nextItem.getValue()
#Explore right branch
withoutVal, withoutToTake = maxVal(toConsider[1:], avail)
#Choose better branch
if withVal > withoutVal:
result = (withVal, withToTake + (nextItem,))
else:
result = (withoutVal, withoutToTake)
return result
代码运行是递归形式,toConsider是当前可供选择项的list,avail是背包剩余的空间;当已经没有了可以选择的项(toConsider == [])或者背包已经装满没有空闲空间了(avail == 0),返回结果(0,());当toConsider的第一项占的空间比剩余空间还大,那么就传入除去这一项的新的list进行递归;如果不是这两种情况,选择toConsider的第一项作为下一个装入背包的item,左子树和右子树分别进行递归,同时左子树的value值要更新(加上添加的新的项),将左子树和右子树的value值进行比较,返回更大的那个结果。
定义测试函数
def testMaxVal(foods, maxUnits, printItems = True):
print('Use search tree to allocate', maxUnits,
'calories')
val, taken = maxVal(foods, maxUnits)
print('Total value of items taken =', val)
if printItems:
for item in taken:
print(' ', item)
赋值进行测试(使用的还是上一节的menu数据)
names = ['wine', 'beer', 'pizza', 'burger', 'fries',
'cola', 'apple', 'donut', 'cake']
values = [89,90,95,100,90,79,50,10]
calories = [123,154,258,354,365,150,95,195]
foods = buildMenu(names, values, calories)
testMaxVal(foods, 750)
运行得很快,并且得到了最优的结果,而不是像贪心算法一样得到的是一个大约的结果;但事实上这是因为数据量小,我们可以用一个大的数据量来测试运行时间:
def buildLargeMenu(numItems, maxVal, maxCost):
items = []
for i in range(numItems):
items.append(Food(str(i),
random.randint(1, maxVal),
random.randint(1, maxCost)))
return items
for numItems in (5, 10, 15, 20, 25, 30, 35, 40, 45):
print('Try a menu with', numItems, 'items')
items = buildLargeMenu(numItems, 90, 250)
testMaxVal(items, 750, False)
会发现运行的很慢,那么有没有什么加速的方法呢,动态规划是一个不错的选择。
我们先来看一下斐波那契的递归:
def fib(n):
if n == 0 or n == 1:
return 1
else:
return fib(n - 1) + fib(n - 2)
for i in range(121):
print('fib(' + str(i) + ') =', fib(i))
我们用树的形式来表达一下:
你会发现这个算法做了很多重复的工作,fib(4)重复计算了两次,fib(3)计算了三次....等等
如果我们把所有计算过的数值存储在一个字典里,当又一次遇到同样的计算时,直接使用它而不是再次计算,这样是不是会节省很多时间呢,让我们来试一试,快速版的fib(n)的代码实现如下:
def fastFib(n, memo = {}):
"""Assumes n is an int >= 0, memo used only by recursive calls
Returns Fibonacci of n"""
if n == 0 or n == 1:
return 1
try:
return memo[n]
except KeyError:
result = fastFib(n-1, memo) + fastFib(n-2, memo)
memo[n] = result
return result
for i in range(121):
print('fib(' + str(i) + ') =', fastFib(i))
先try一下,看看字典memo里是否有该值,如果没有则会出现keyerror的错误,这时给字典添加新的键值对。
这个方法在什么时候会有用呢?当满足以下两个条件时:
(1)最优子结构(optimal substructure):当一个问题的最优解决方案可以通过结合他所有子问题的最优解决方案来获得,就称它有最优子结构的属性;例如,fib(x) = fib(x-1) + fib(x-2) 就具有最优子结构的属性,每个x都可以通过它的子问题的最优解决方案来获得自己的解决方案。
(2)重叠子问题(overlapping subproblems):如果在寻找一个问题的最优解决方案的过程中有很多相同的问题进行了多次重复计算,就说明该问题具有重叠子问题属性;依然可以用fib(x)举例,像上面那张树状图里所显示的,fib(4)重复计算了两次,fib(3)计算了三次.....
现在我们来看我们现在研究的 0/1背包问题是否具有这两个属性。
看前面的搜索树(一个啤酒、一个披萨、一个汉堡)似乎没有重叠子问题,那么如果我们有两杯啤酒呢?来画一下搜索树:
我没有画完,在我画的这些节点中,我们能看到最后两个是同一个问题,虽然这两杯啤酒并不是同一杯,但是这并不重要,他们都是啤酒;在这个情况中,它是具有重叠子问题属性的。
探讨得更深入一些,其实每个节点并不需要完全一致才能说是具有重叠子问题属性,只要剩下的选项list和剩下的背包空间一样,其实这就有重叠子问题属性,举个例子:
上图中只要 left 和 remaining calories 相等就可以看作是同一个子问题,前面的选择前面的value值都不重要,所以节点2和节点7就是同一个问题。
现在通过动态规划来优化我们的背包问题的算法:
def fastMaxVal(toConsider, avail, memo = {}):
"""Assumes toConsider a list of subjects, avail a weight
memo supplied by recursive calls
Returns a tuple of the total value of a solution to the
0/1 knapsack problem and the subjects of that solution"""
if (len(toConsider), avail) in memo:
result = memo[(len(toConsider), avail)]
elif toConsider == [] or avail == 0:
result = (0, ())
elif toConsider[0].getCost() > avail:
#Explore right branch only
result = fastMaxVal(toConsider[1:], avail, memo)
else:
nextItem = toConsider[0]
#Explore left branch
withVal, withToTake =\
fastMaxVal(toConsider[1:],
avail - nextItem.getCost(), memo)
withVal += nextItem.getValue()
#Explore right branch
withoutVal, withoutToTake = fastMaxVal(toConsider[1:],
avail, memo)
#Choose better branch
if withVal > withoutVal:
result = (withVal, withToTake + (nextItem,))
else:
result = (withoutVal, withoutToTake)
memo[(len(toConsider), avail)] = result
return result
def testMaxVal(foods, maxUnits, algorithm, printItems = True):
print('Menu contains', len(foods), 'items')
print('Use search tree to allocate', maxUnits,
'calories')
val, taken = algorithm(foods, maxUnits)
if printItems:
print('Total value of items taken =', val)
for item in taken:
print(' ', item)
for numItems in (5, 10, 15, 20, 25, 30, 35, 40, 45, 50):
items = buildLargeMenu(numItems, 90, 250)
testMaxVal(items, 750, fastMaxVal, True)
明显更快了。动态规划并不是奇迹,但在某些情况下有一定作用,能更快地获得最优的结果。