1. 概述
有一个背包,它的容量为C (Capacity)。现在有n种不同的物品,编号为0…n-1,其中每一件物品的重量为w(i),价值为v(i)。 问可以向这个背包中盛放哪些物品,使得在不超过背包容量的基础上,物品的总价值最大。
暴力解法: 每一件物品都可以放进背包,也可以不放进背包。时间复杂度为:O((2^n)*n)
另辟蹊径: F(n, C)考虑将n个物品放进容量为C的背包,使得价值最大。一件物品只有选与不选两种情况,也即每件物品只能选一次。
(1)当不考虑选取第 i 件物品时,F(i, C) = F(i - 1, C);
(2)当考虑选取第 i 件物品时,F(i, C) = v(i) + F(i - 1, C - w(i))
要在上述两种情况中取最大值,所以状态转移方程即为:
F(i, C) = max( F(i - 1, C), v(i) + F(i - 1, C - w(i)) )
2. 第一种方法——记忆化搜索
(1)主函数中传入w数组(每个物品的容量),v数组(每个物品的价值)和C(背包的总容量)
(2)使用一个递归函数bestValue,在这个函数中首先传入w和v,index表示考虑到的物品的序列号,以及要处理的背包容量C (bestValue的含义就是用[0…index]的物品,填充容积为C的背包的最大价值)
- 首先看递归的终止条件:若当前的index<0,也就是说此时物品的集合是一个空集了,选不出任何东西了 或者 当前的背包容量c<=0,也就是说无法装入任何新的物品了,此时就应该返回0 (简单点说就是无法选物品或无法装物品)
- 否则就要尝试向背包中放入新的物品:考虑物品集合中的最后一个物品index该不该放进去
(1)一是这个物品根本不去管——bestValue(w, v, index-1, c)
(2)二是将这个index物品放进背包里,为了保险起见,此处需先确定当前背包的容量大于等于index物品的容积(if c>= w[index]
),这时的价值就是v[index] + bestValue(w, v, index-1, c - w[index])
要将两者取最大值给res,返回res即可。
但是又存在重叠子问题,来源于index和c构成了一个数据对,求解过程中同样的数据对可能出现多次,因此可以用记忆化搜索。由于背包问题有两个约束条件(w和v),每个状态是被两个变量所定义的,所以开辟的记忆化空间应该是一个二维数组 memo=[[-1 for j in range(C+1)] for i in range(n)]
(一共有n行,C+1列(来表示从0到C这么多的容量))
- 递归终止条件依然不变;
- 否则就要看 memo[index][c] 这个状态之前有没有被计算过,若被计算过,直接返回 memo[index][c] 即可;
- 否则才运行下面的逻辑,计算出来res之后,还要将res的值记录下来(
memo[index][c]=res
),最后返回res即可。
class Knapsack01:
def knapsack01(self, w, v, C):
n = len(w)
self.memo = [[-1 for j in range(C + 1)] for i in range(n)]
return self.bestValue(w, v, n-1, C)
def bestValue(self, w, v, index, c):
if index < 0 or c <= 0:
return 0
if self.memo[index][c] != -1:
return self.memo[index][c]
res = self.bestValue(w, v, index-1, c)
if c >= w[index]:
res = max(res, v[index] + self.bestValue(w, v, index - 1, c - w[index]))
self.memo[index][c] = res
return res
k = Knapsack01()
w = [1, 2, 3]
v = [6, 10, 12]
C = 5
print(k.knapsack01(w, v, C))
输出:22
3. 第二种方法——动态规划
使用动态规划,依旧是先确定n的数值(n=len(w)
),注意: 我们传入了两个数组w和v,那么这两个数组所含有的元素个数应该是一样的:assert(len(w) == len(v))
,有了n之后就可以设立动态规划的二维数组,有n行,每一行有C+1列(从0到C)memo=[[-1 for j in range(C+1)] for i in range(n)]
- 动态规划是自底向上的,所以我们先处理最基础的问题:先看memo第0行的每个元素是多少,进行一个for循环遍历列:
for j in range(0, C+1)
,memo[0][j]里的j表示此时处理的背包容量,只考虑0号物体 ,所以将j的大小和w[0]进行比较,若j>=w[0],就能将0号物品放进去,最大值就是v[0];否则的话背包中无法放入物品,最大值就是0,为了保险起见,需要添加上若n=0(也即传进来的w和v中没有任何元素),直接返回0即可。 - 最基础的问题解决了,剩下的问题就是一个递推的过程,现在是逐行的解决问题:
for i in range(1, n+1)
(因为基础问题已经处理过第0行,所以此处从第1行开始),在每行中逐列的解决问题:for j in range(0, C+1)
,在每一列中要处理的是计算memo[i][j](考虑0~i这些物品且容积为j的背包获得的最大值)的值:
(1)第一种情况是不考虑 i,直接memo[i][j] = memo[i-1][j]
;
(2)第二种情况是考虑 i,但需要判断此时的容量 j 是否大于等于w[i](即第 i 个物品确实能放进背包中)——memo[i][j] = max(memo[i][j], v[i] + memo[i-1][j-v[i]])
整个循环一直进行下去,直到求出memo[n-1][C],直接返回该值即可。
使用for x in memo
可以很清晰的查看 memo 这个二维数组中的每个数字,右下角的最后一个数即为所求。
class Knapsack01:
def knapsack01(self, w, v, C):
assert (len(w) == len(v))
n = len(w)
if n == 0:
return 0
memo = [[-1 for j in range(C + 1)] for i in range(n)]
for j in range(0, C + 1):
memo[0][j] = v[0] if j >= w[0] else 0
for i in range(1, n):
for j in range(0, C + 1):
memo[i][j] = memo[i - 1][j]
if j >= w[i]:
memo[i][j] = max(memo[i][j], v[i] + memo[i - 1][j - w[i]])
for x in memo:
print(x)
return memo[n - 1][C]
k = Knapsack01()
w = [1, 2, 3]
v = [6, 10, 12]
C = 5
print(k.knapsack01(w, v, C))
输出:
[0, 6, 6, 6, 6, 6]
[0, 6, 10, 16, 16, 16]
[0, 6, 10, 16, 18, 22]
22
4. 优化算法空间(只使用两行)
上述动态规划的时间复杂度为O(n * C),空间复杂度为O(n * C)。但实际上第 i 行元素只依赖于第 i-1 行元素,理论上,只需要保持两行元素即可,不需要保持n行元素,这样可以优化空间,空间复杂度为O(2 * C)。
开辟两行空间,最开始第一行是 i=0,第二行处理 i=1 的情况,依靠第一行 i=0 的数据,处理完毕后接着处理 i=2 的情况(i=2 这一行的元素是依靠 i=1这行的元素,与 i=0 这行无关)所以可以将 i=2 覆盖掉 i=0这行……以此类推,可以发现,第一行永远处理的是 i 为偶数时的情况,第二行永远处理的是 i 为奇数时的情况。
具体实现如下:
- 首先一开始定义memo这个二维数组时,改成两行即可
memo = [[-1 for j in range(C+1)] for i in range(2)]
; - 接下来初始化i=0的这一行不需要变动,重点是下面的循环,我们还是从i=1开始一直到n依次来更新每一行,在更新的过程中,对于第i行来说,实际上使用的是memo这个二维空间中的 i%2 那一行(不是第0行就是第1行,具体是哪一行让它和2求余数,i为偶数时在第0行,为奇数时在第1行),所以就有了
memo[i%2][j] = memo[(i-1)%2][j]
,表示第i行还是要根据第i-1行来,但是我们的物理空间中只有两行,那么第i行现在在 i%2 这个位置,第i-1行在(i-1)%2这个位置,下面以此类推,一旦涉及到memo的行号,都要将它%2; - 最后返回的是
memo[(n-1)%2][C]
,我们依然是要将第n-1行的第C个元素返回回去,只不过当前的物理存储中,第n-1行是在 (n-1)%2 这个位置。
class Knapsack01:
def knapsack01(self, w, v, C):
assert (len(w) == len(v) and C >= 0)
n = len(w)
if n == 0 or C == 0:
return 0
memo = [[-1 for j in range(C + 1)] for i in range(2)]
for j in range(0, C + 1):
memo[0][j] = v[0] if j >= w[0] else 0
for i in range(1, n):
for j in range(0, C + 1):
memo[i % 2][j] = memo[(i - 1) % 2][j]
if j >= w[i]:
memo[i % 2][j] = max(memo[i % 2][j], v[i] + memo[(i - 1) % 2][j - w[i]])
return memo[(n - 1) % 2][C]
k = Knapsack01()
w = [1, 2, 3]
v = [6, 10, 12]
C = 5
print(k.knapsack01(w, v, C))
输出:22
注意:
这个优化使得我们可以处理的背包容量比原先大很多,举例子:假设我们有100个物品,背包容量为100,此时的空间就达到了极限,原来使用n*C的空间复杂度,我们的空间最多有100 * 100=10000个单元,但使用这个优化后,我们可以将这10000个空间重新分配 10000=2 * 5000,换句话说,我们依然可以处理有100个物品的背包问题,但是此时我们处理的背包容量的最大限额达到了5000。
5. 进一步优化算法空间(只使用一行)
如何只使用一行大小为C的数组完成动态规划?
先看两行的,对于上一行,我们永远只使用正上方和左边的元素,右边的不会去碰,就提示了我们,如果只有一行,在之前的逻辑中每一次更新只参考上面和左边的内容,所以我们可以从右往左的刷新这一行的内容。比如:
下图初始化的是 i = 0 这行的内容
我们要更新 i = 1 这行的内容,就从最后一列开始,背包容量为5时,可以放下编号为1的物品(重量为2,价值为10),这时就有两种情况:(1)不放编号为1的物品时,直接把上一行该列的数字拿过来,也就是此时的价值为6;(2)放入编号为1的物品时,就是v[i] + memo[i - 1][j - w[i]]
,也就是将编号为1的物品价值10 加上 上一行(第0行)第3列的数值6 等于16。取两者之间的最大值16放入最后一个数字的位置…依次类推不断向前更新数值,直到当前的容量无法再装下当前的物品。
具体实现如下:
- memo就初始化为一维数组:
memo = [-1 for i in range(C + 1)]
; - 下面看初始化的过程,首先对第一行的内容进行初始化(也就是i=0时的值进行初始化),因为是一维数组,直接写成
memo[j] = v[0] if j >=w[0] else 0
; - 紧接着我们对i=1开始一直到n依次求解,在求解的过程中我们对列的处理,每次都是从C开始的,一直向前处理,直到j大于等于当前要处理的物品的重量
for j in range(C, w[i] - 1, -1)
,因为当 j 比 w[i] 小时,说明当前物品已经不能放入背包里了,那么当前行中停留的那些 j<w[i] 的元素就是上一行中相应的元素:memo[j] = max(memo[j], v[i] + memo[j - w[i]])
; - 最后memo[C]中存放的就是最终的答案。
class Knapsack01:
def knapsack01(self, w, v, C):
assert (len(w) == len(v) and C >= 0)
n = len(w)
if n == 0 or C == 0:
return 0
memo = [-1 for i in range(C + 1)]
for j in range(0, C + 1):
memo[j] = v[0] if j >= w[0] else 0
for i in range(1, n):
for j in range(C, w[i] - 1, -1):
memo[j] = max(memo[j], v[i] + memo[j - w[i]])
return memo[C]
k = Knapsack01()
w = [1, 2, 3]
v = [6, 10, 12]
C = 5
print(k.knapsack01(w, v, C))
输出:22