此文章首发于微信公众号:酷酷的算法
如果你觉得文章还不错,可以直接关注我的微信公众号。
所有的文章都会第一时间发布在这里。谢谢你的关注。
酷酷的算法的二维码:
什么是 0-1 背包
问题描述:
0-1 背包问题是这样的,现在有一个背包,这个背包是有容量限制的,使用字母 C 来表示背包的容量( C 是一个整数 )。与此同时,拥有 N 个互不相同的物品,每个物品有两个属性。假设我现在拿到的是这 N 个物品中的第 i 个物品。第一个属性是此物品装进背包所需要的容量 w[i] (w: weight),使用 w[i] 表示第 i 个物品所需的容量 w[i],另外一个属性是此物品的价值,将该物品装入背包后对你产生的价值为 v[i] (v: value)。问题是:每个物品可以选择装或者不装,那么怎样的装法可以使得背包的总价值最大。在不装任何物品时,背包的价值为 0 。
0-1 背包名字的由来:
由于对物品的选择只有两种情况,装或不装,所以使用 0 或 1 来表示一个物品此时的状态,使用 0 表示此物品没有装进背包中,使用 1 表示此物品装进了背包中。
解决 0-1 背包问题的三种方法
解决 0-1 背包问题有两种思路,自顶向下的方法以及自底向上的动态规划方法
1 自顶向下递归求解
在计算机的世界中最容易想到的求解问题的方法就是暴力穷举所有的可能性,现在假设一共有 N 个物品,每一种物品都有两种状态,分别为被装进背包中和另外一种为没有装进背包中。很容易能想到所有的组合情况就是 2^N 种情况(那就是 2 * 2 * 2 ……),对于每一种情况还要做一个检查。检查什么呢,需要检查这每一种情况的所有被选中的物品所需要的容量总和是不是超过背包的容量了,如果超过了背包的最大容量 C, 那么就直接 pass。直至最后得到一个所占容量总和不超过背包的最大容量并且价值是最大的那一种选择方法。
物品的属性表格如下:(假设现在对三个物品考虑)
在下图中 id 表示该物品的编号,w 用来表示该物品所占容量,v 表示该物品的价值。
设题目中的 C=4 且 N = 3,即背包容量为 4 共有 3 个物品。
定义函数 f(i, c),这个函数的意义是:当背包容量为 c 并且仅考虑第 0 号物品到第 i 号物品(包括第 i 号物品)是否要放进背包时候的背包最大值。例如:f(2,4)表达的意思就是当背包容量为 4 时并且考虑第 0 号元素到第 2 号元素这些个元素时背包的最大价值。
好啦,想要得到 f(2,4) 的结果,就面临着两个选择,我是要第 2 号物品还是不要第 2 号物品呢。
1> 不要第 2 号物品:当不要第 2 号物品时就需要知道 f(1,4) 的情况是怎样的,此时选择不要第 2 号物品的答案就是 f(1,4) 的答案。不要第 2 号物品意味着 2 号物品没有放进背包中也就不占背包容量所以背包的容量还是 4,同时我们已经对 2 号物品作出了决策,现在就只需要考虑第 0 号物品到第 1 号物品就可以了,这样一来就得到了 f(1,4)。
2> 要第 2 号物品: 当要第 2 号物品的时候就需要知道 f(1,4-w[2]), 因为我们此时要第 2 号物品所以背包的容量就变成了 4-w[2](如上图可知此题中 w[2] 的值为 2)。这样一来 f(1,4) 的答案其实就是 v[i] + f(1,2), 由于我们对第 2 号物品作出的决策是要。所以最终的价值需要在 f(1,2) 的基础上加上 v[2] 的值。此时 v[2] 的值为 12 。
由于 f(2,4) 表达的意义是:在考虑 [0,2] 这个区间中的物品时且背包容量 C=4 的时候所得到的价值的最大值。所以 f(2,4) 最终的值取决于以上两种选择的最大的那个值。用数学表达式来表达是这样的:f(2, 4) = max{f(1, 4), v[2]+f(1, 2)}
更一般化的表达式是:f(i, c) = max{f(i-1,c), v[i]+f(i-1, c-w[i])}.它的意义为当考虑区间为 [0,i] 的物品时的最大值取决于 f(i-1, c) 和 v[i] + f(i-1, c-w[i]) 两者的最大值。
好了,有了上面的分析,我将使用图的方式将整个求解此问题的过程画出来。如下图所示:(在下图中我使用 1 表示要, 使用 0 表示不要)
在上面这张图中就是我们求解此问题的全部递归过程了,上面一行的元素的值会取决于它两个分支中的值较大的哪一个,这和我们的状态转移方程是一样的。什么叫状态?可以看到图中的每一个方框都是一个状态,每一个状态都有它的值。比如 i=2 且 C=4 的时候就是一种状态,这一种状态的值又可以根据其他的状态推导出来。所以我们称它为状态转移方程。还记得高中学数列的时候以递推式的形式给出 Sn 给出 An。让我们求解通式吗?
但是到这里,你会不会很奇怪? 左下角的 f(-1,4) 是什么鬼?列表中并没有编号为 -1 的物品。现在的意思可以理解为考虑区间 [0, -1] 这些元素并且背包容量为 4 时的背包容量最大值,显然这个区间是不合法的,那也就是说这是一个空集,就是不考虑任何物品。所以 f(-1, 4) 的值应该等于 0 对不对?其他的情况也都是这样理解的。
那么再来看看另外一个相对上面提到的情况更为特殊的一种情况,看我画了红色圈圈并且标记了五角星的位置。这种情况下的状态是 f(0, 0) 再来看它下面的左边那个状态 f(-1, 0) 这个还是可以理解的。但是右边的 f(-1, -1) 是什么鬼? 容量也为负数了?其实是这样的,f(0, 0)这种状态的值应该为 0,根据常识也是可以知道的。那么在我们编写程序的时候,只要触发了这样两类特殊的情况时,就让函数值为 0 就好了,用计算机的话说就是让返回值为 0 就可以咯。
使用 python 实现上述逻辑
由于最近我一直在使用 python, 那么我就使用它了。
首先手动的将刚才我们在表格中看到的数据创建在程序中吧,我在这里使用列表方式存储。
N = 3
C = 4
weight = [1, 2, 2]
value = [12, 10, 12]
接下来再写递归函数 best_value 这里的 best_value 函数就是我在刚才的数学表达式中用到的 f, 只是换了一个名字额已。
def best_value(w, v, i, c):
if i < 0 or c <= 0:
return 0
其中的参数 w 和 v 都应该是一个类似列表(或数组)这样的数据结构可以使用索引来访问元素值。这里的 if 判断语句就是刚才我在上面提到的两类特殊的情况应该令函数的返回值为 0, 这也叫做递归触底,因为这时候已经不会再向下继续递归了。当 i < 0 也就是区间为 [0,负数] 的时候是空集 或者 容量 c 小于等于 0 的时候就让函数返回 0 。
好了,继续完善 best_value 函数:
def best_value(w, v, i, c):
if i < 0 or c <= 0:
return 0
# 不选当前的物品
res = best_value(w, v, i-1, c)
# 容量满足的情况下选择当前的物品并取最大值
if c >= w[i]:
res = max(res, best_value(w, v, i-1, c-w[i]) + v[i])
return res
首先计算不选择第 i 个物品情况下的值并将其存储到变量 res 中也就是通过这行代码:
res = best_value(w, v, i-1, c)
其次计算另外一种情况,但是我们应该做一个判断,判断一下当前的容量 c 是不是比该物品所需要的容量大,如果 c 小于该物品所需的容量,直接不再考虑就好了,这里程序真正的运行和上面我画的图还是有一点点的不同的,选择要第 i 个物品的情况下直接通过这行代码来计算:
best_value(w, v, i-1, c-w[i]
最后通过 python 的内置函数 max 直接取两者的最大值并更新进 res 变量就好了。就是这样:
res = max(res, best_value(w, v, i-1, c-w[i]) + v[i])
好啦好啦,大功告成,让我运行一下,并将可运行的完整代码贴在下面。
N = 3
C = 4
weight = [1, 2, 2]
value = [12, 10, 12]
def best_value(w, v, i, c):
if i < 0 or c <= 0:
return 0
# 不选当前的物品
res = best_value(w, v, i-1, c)
# 容量满足的情况下选择当前的物品并取最大值
if c >= w[i]:
res = max(res, best_value(w, v, i-1, c-w[i]) + v[i])
return res
res = best_value(weight, value, N-1, C)
print(res)
运行截图:
2 自顶向下递归求解(记忆化搜索)
还是刚才的图,仔细看下面图中我画了绿色圈圈的地方:
如上图,相同的状态被计算了两次,就是 f(0,2) 这个状态。现在的数据量还比较小,如果数据量再大一些,这种重复性是极高的。那么有什么好的方法来解决这种多余的计算呢。
因为每个状态一旦确定那么它的值肯定是确定的。对于非随机性的程序而言只要输入一样程序本身一样,那么输出结果必然是一样的。
每一个状态都可以由一对数字来表示,那么只需要开辟一个二维数组便可解决这个问题,在第一次计算的时候将这个状态计算出来的值存储,再下一次再碰到这种情况的时候直接返回数组中存储的值即可。
代码如下:
N = 3
C = 4
weight = [1, 2, 2]
value = [12, 10, 12]
memo = [
[-1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1]
]
def best_value(w, v, i, c):
if i < 0 or c <= 0:
return 0
# 如果该状态已被计算
# 直接返回 memo 中存储的值即可
if memo[i][c] != -1:
return memo[i][c]
# 不选当前的物品
res = best_value(w, v, i-1, c)
# 容量满足的情况下选择当前的物品并取最大值
if c >= w[i]:
res = max(res, best_value(w, v, i-1, c-w[i]) + v[i])
# 计算出 res 后在这里存储一下
memo[i][c] = res
return res
res = best_value(weight, value, N-1, C)
print(res)
print(memo[0])
print(memo[1])
print(memo[2])
运行截图:
为了直观我在这里使用了手动的方式创建了 memo 数组,在初始的状态下 memo 数组中用 -1 表示这个位置没有存储这个状态的值,如此,只需要在程序中检测 memo[i][c] 是不是等于 -1 便可直接能不能直接 返回 memo[i][c] 的值了。如果没有存储那么才进行下面的递归过程,并且求出 res 的值后更新进 memo 数组中对应的位置即可。
3 自底向上-动态规划方法求解
下一篇文章将介绍 动态规划的求解方法