【动态规划】01背包问题_三种解法
问题描述
0-1背包问题:给定\(n\)种物品和一背包。物品i的重量是\(w_i\),其价值为\(v_i\),背包的容量为\(C\)。问:应该如何选择装入背包的物品,使得装人背包中物品的总价值最大?
在选择装人背包的物品时,对每种物品\(i\)只有两种选择,即装人背包或不装入背包。不能将物品\(i\)装入背包多次,也不能只装入部分的物品\(i\)。因此,该问题称为0-1背包问题
。
此问题的形式化描述是,给定\(C>0\),\(w_i>0\),\(v_i>0\),\(1≤i≤n\),要求找出\(n\)元0-1向量\((x_1,x_2,\cdots,x_n), x_i\in\{0,1\},1 \leq i \leq n\),使得\(\sum_{i=1}^{n} w_ix_i \leq C\),而且\(\sum_{i=1}^{n} v_ix_i\)达到最大。因此,0-1背包问题是一个特殊的整数规划问题。$$max\sum_{i=1}^{n} v_ix_i$$ $$\left{\begin{matrix}
\sum_{i=1}^{n} w_ix_i \leq C & \
x_i\in{0,1}, & 1 \leq i \leq n
\end{matrix}\right.$$
最优子结构性质
0-1背包问题具有最优子结构性质。设\((y_1,y_2,\cdots, y_n)\)是所给0-1背包问题的一个最优解,则\((y_2,\cdots, y_n)\)是下面相应子问题的一个最优解:$$max\sum_{i=2}^{n} v_ix_i$$ $$\left{\begin{matrix}
\sum_{i=2}^{n} w_ix_i \leq C-w_1y_1 & \
x_i\in{0,1}, & 2 \leq i \leq n
\end{matrix}\right.$$
因若不然,设\((z_2,\cdots, z_n)\)是上述子问题的-一个最优解,而\((y_2,\cdots, y_n)\)不是它的最优解。由此可知,\(\sum_{i=2}^{n} v_iz_i > \sum_{i=2}^{n} v_iy_i\),且\(w_1y_1 + \sum_{i=2}^{n} w_iz_i \leq C\)。因此,$$v_1y_1 + \sum_{i=2}^{n} v_iz_i > \sum_{i=1}^{n} v_iy_i$$ $$w_1y_1 + \sum_{i=2}^{n} w_iz_i \leq C$$
这说明\((z_1,z_2,\cdots, z_n)\)是所给0-1背包问题的更优解,从而\((y_1,y_2,\cdots, y_n)\)不是所给0-1背包问题的最优解。此为矛盾。
递归关系
设所给0-1背包问题的子问题$$max\sum_{k=1}^{n} v_kx_k$$ $$\left{\begin{matrix}
\sum_{k=1}^{n} w_kx_k \leq j & \
x_k\in{0,1}, & 1 \leq k \leq n
\end{matrix}\right.$$ 的最优值为\(m(i,j)\),即\(m(i,j)\)是背包容量为\(j\),可选择物品为\(i,i+1,.,n\)时0-1背包问题的最优值。由0-1背包问题的最优子结构性质,可以建立如下计算\(m(i,j)\)的递归式:$$m(i,j)=\left{\begin{matrix}
max(m(i+1,j),m(i+1,j-w_i)+v_i) & j \geq w_i & ---选\
m(i+1,j) & 0 \leq j < w_i & ---不选
\end{matrix}\right.$$ $$m(n,j)=\left{\begin{matrix}
v_n & j \geq w_i & ---选\
0 & 0 \leq j < w_i & ---不选
\end{matrix}\right.$$
算法实现-DP表解法
示例
代码实现
基于以上讨论,当\(w_i(1 \leq i \leq n)\)为正整数时,用二维数组\(m[][]\)存储\(m(i,j)\)的相应值,可设计解0-1背包问题的动态规划算法knapsack如下:
01backpack_DPTable-python
class Kbackpack(object):
def knapsack(self, c, w, v):
m = []
for i in range(len(v)):
m.append([0] * (c + 1))
n = len(v) - 1
# 步骤①:将m(n,j)记录在表中
jMax = min(w[n], c)
for t in range(jMax, c + 1):
if t >= w[n]:
m[n][t] = v[n]
# 步骤②:逐个记录m(i,j)
for i in range(n - 1, 0, -1):
# j<w_i: 不选
jMax = min(w[i], c)
for j in range(jMax):
m[i][j] = m[i + 1][j]
# j>w_i: 选
for j in range(jMax, c + 1):
m[i][j] = max(m[i + 1][j], m[i + 1][j - w[i]] + v[i])
# 步骤③:单独算最后一个物品(最后一个物品无需再计算除C以外的其他容量的最优解)
m[0][c] = m[1][c]
if c > w[0]:
m[0][c] = max(m[1][c], v[0] + m[1][c - w[0]])
return m
回溯打印最优解
按上述算法knapsack计算后,\(m[1][c]\)给出所要求的0-1背包问题的最优值。相应的最优解可由算法traceback计算如下:
如果\(m[1][c]=m[2][c]\),则\(x_1='choose'\);否则\(x_1='discard'\)。
当\(x_1='discard'\)时,由\(m[2][c]\)继续构造最优解;
当\(x_1='choose'\)时,由\(m[2][c-w_1]\)继续构造最优解。依此类推,可构造出相应的最优解\((x_1,x_2, \cdots, x_n)\)。
traceback-python
def traceback(self, m, w, c):
x = ['discard'] * len(w)
for i in range(len(w) - 1):
if m[i][c] != m[i + 1][c]:
x[i] = 'choose'
c -= w[i]
x[len(w) - 1] = 'choose' if m[len(w) - 1][c] > 0 else 0
return x
计算复杂度分析
从计算\(m(i,j)\)的递归式容易看出,上述算法knapsack需要\(O(nC)\)计算时间,而算法traceback需要\(O(n)\)计算时间。
上述算法knapsack有两个较明显的缺点:
① 算法要求所给物品的重量\(w_i(1≤i≤n)\)是整数;
② 当背包容量C很大时,算法需要的计算时间较多。例如,当\(c>2^n\)时,算法knapsack 需要\(O(n2^n)\)计算时间。
事实上,注意到计算\(m(i,j)\)的递归式在变量\(j\)是连续变量,即背包容量为实数时仍成立,可以采用以下方法克服算法knapsack的上述两个缺点。
算法实现-跳跃点解法
首先考查0-1背包问题的上述具体实例:
物品(n):0, 1, 2, 3, 4
重量(w):2, 2, 6, 5, 4
价值(v):6, 3, 5, 4, 6
容量(C):10
由计算\(m(i,j)\)的递归式,当\(i=4\)时,$$m(4,j)=\left{\begin{matrix}
6 & j \geq 4 \
0 & 0 \leq j < 4
\end{matrix}\right.$$该函数是关于变量\(j\)的阶梯状函数。由\(m(i,j)\)的递归式容易证明,在一般情况下,对每一个确定的\(i(1≤i≤n)\),函数\(m(i,j)\)是关于变量\(j\)的阶梯状单调不减函数。跳跃点是这一类函数的描述特征。如函数\(m(4,j)\)可由其两个跳跃点(0,0)和(4,6)唯一确定。在一般
情况下,函数m(i,j)由其全部跳跃点唯一确定,如下图所示:
在变量\(j\)是连续变量的情况下,可以对每一个确定的\(i(1≤i≤n)\),用一个表\(p[i]\)存储函数\(m(i,j)\)的全部跳跃点。对每一个确定的实数\(j\),可以通过查找表\(p[i]\)确定函数m(i,j)的值。\(p[i]\)中全部跳跃点\((j ,m(i,j))\)依\(j\)的升序排列。由于函数\(m(i,j)\)是关于变量\(j\)的阶梯状单调不减函数,故\(p[i]\)中全部跳跃点的\(m(i,j)\)值也是递增排列的。
表\(p[i]\)可依计算\(m(i,j)\)的递归式递归地由表\(p[i+1]\)计算,初始时\(p[n+ 1]={(0,0)}\)。事实上,函数\(m(i,j)\)是由函数\(m(i+1,j)\)与函数\(m(i+1,j-w_i)+v_i\)做max运算得到的。因此,函数\(m(i,j)\)的全部跳跃点包含于函数\(m(i+1,j)\)的跳跃点集\(p[i+1]\)与函数\(m(i+1,j-w_1)+v_1\)的跳跃点集\(q[i+1]\)的并集中。易知,\((s,t)∈q[i+1]\)当且仅当\(w_i≤s≤C\)且\((s-w_i,t-v_i)∈p[i+1]\)。因此,容易由\(p[i+1]\)确定跳跃点集\(q[i+1]\)如下:
另一方面,设\((a,b)\)和\((c,d)\)是\(p[i+1]∪q[i+ 1]\)中的两个跳跃点,则当\(c≥a\)且\(d < b\)时,\((c,d)\)受控于\((a,b)\),从而\((c,d)\)不是\(p[i]\)中的跳跃点。除受控跳跃点外,\(p[i+1]Uq[i+1]\)中的其他跳跃点均为\(p[i]\)中的跳跃点。由此可见,在递归地由表\(p[i+1]\)计算表\(p[i]\)时,可先由\(p[i+ 1]\)计算出\(q[i+1]\),然后合并表\(p[i+1]\)和表\(q[i+ 1]\),并清除其中的受控跳跃点得到表\(p[i]\)。
对于上面的例子,表\(p[]\)和表\(q[]\)分别如下:
代码实现
综上所述,可设计解0-1背包问题改进的动态规划算法如下:
代码在实现过程中,把二维表用作三维表,通过head[]数组划分,达到三维表的效果。
jumpPoint1-python
class Kbackpack(object):
def knapsack1(self, c, w, v):
p = [(0, 0)]
q = []
res = [] + p
# head[i]表示p[i]在res[]中首元素的位置
# 区分不同物品i对应的数据,相当于行标记
head = [0] * len(v)
head[-1] = len(res)
for i in range(len(w) - 1, -1, -1):
wv = (w[i], v[i])
for ele in p:
q.append(ele)
qEle = tuple(map(lambda x: x[0] + x[1], zip(ele, wv)))
if qEle[0] <= c:
q.append(qEle)
q.sort(key=lambda x: x[0])
p.clear()
tempEle = q[0]
for t in range(1, len(q)): # 清除受控点
if tempEle[0] <= q[t][0] and tempEle[1] > q[t][1]:
continue
else:
p.append(tempEle)
tempEle = q[t]
p.append(tempEle)
res += p
if i != 0:
head[i - 1] = len(res)
q.clear()
return res, head
第二种跳跃点写法
jumpPoint2-python
def knapsack2(self, c, w, v):
p = [(0, 0)]
# head[i]表示p[i]在res[]中首元素的位置
# 区分不同物品i对应的数据,相当于行标记
head = [0] * len(v)
head[-1] = len(p)
# 通过p[i+1]推导p[i]时,借助left,right指针
# 卡在p[i+1]的左右边界
left = 0
right = 0
for i in range(len(w) - 1, -1, -1):
k = left # 从p[i+1]的左边界移至右边界
wv = (w[i], v[i])
for j in range(left, right + 1):
qEle = tuple(map(lambda x: x[0] + x[1], zip(p[j], wv)))
if qEle[0] > c: break
while (k <= right and p[k][0] < qEle[0]):
p.append(p[k])
k += 1
if (k <= right and p[k][0] == qEle[0]):
qEle = (qEle[0], max(qEle[1], p[k][1]))
k += 1
if (qEle[1] > p[-1][1]):
p.append(qEle)
while (k <= right and p[k][1] <= p[-1][1]):
k += 1
while (k <= right):
p.append(p[k])
k += 1
left = right + 1
right = len(p) - 1
if i != 0:
head[i - 1] = len(p)
return p, head
回溯打印最优解
表p最后一个元素即为最优值,根据这个值与\(p[i+1]\)的跳跃点集可判断物品\(i\)是否被选择。
tracebact4Jump-python
def traceback(self, w, v, p, head):
bestRes = p[-1]
x = ['discard'] * len(w)
head.append(-1)
for i in range(len(w)):
# 从p[i+1]的开头遍历到p[i+1]的末尾
for k in range(head[i + 1], head[i] - 1):
k = 0 if k == -1 else k
# 判断第i个物品是否选择,若选,则将该物品对应p[i-1]的元素赋值给bestRes
if (p[k][0] + w[i] == bestRes[0] and p[k][1] + v[i] == bestRes[1]):
x[i] = 'choose'
bestRes = p[k]
break
return x
计算复杂度分析
上述算法的主要计算量在于计算跳跃点集\(p[i](1≤i≤n)\)。由于\(q[i+1]=p[i+ 1] \oplus (w_i,v_i)\),故计算\(q[i+1]\)需要\(O(|p[i+1]|)\)计算时间。合并\(p[i+1]\)和\(q[i+1]\)并清除受控跳跃点也需要\(O(|p[i+1]|)\)计算时间。从跳跃点集\(p[i]\)的定义可以看出,\(p[i]\)中的跳跃点相应于\(x_1,x_2, \cdots, x_n\)的0/1赋值。因此,\(p[i]\)中跳跃点个数不超过\(2^{n-i+1}\)。由此可见,算法计算跳跃点集\(p[i](1≤i≤n)\)所花费的计算时间为$$O(\sum_{i=2}^n |p[i+1]|)=O(\sum_{i=2}^n 2{n-i})=O(2n)$$从而,改进后算法的计算时间复杂性为\(O(2^n)\)。当所给物品的重量,\(w_i\)是整数时,\(|p[i]|≤c+1\),其中,\(1≤i≤n\)。在这种情况下,改进后算法的计算时间复杂性为\(O( min\{nc,2^n\})\)。
算法实现-回溯法
0-1背包问题的解空间可用子集树表示。解0-1背包问题的回溯法与装载问题的回溯法十分类似。在搜索解空间树时,只要其左儿子结点是一个可行结点,搜索就进人其左子树。当右子树有可能包含最优解时才进入右子树搜索;否则将右子树剪去。设\(r\)是当前剩余物品价值总和;\(cv\)是当前价值;\(bestv\)是当前最优价值。当\(cv+r≤bestv\)时,可剪去右子树。计算右子树中解的上界的更好方法是将剩余物品依其单位重量价值排序,然后依次装入物品,直至装不下时,再装入该物品的一部分而装满背包。由此得到的价值是右子树中解的上界。
为了便于计算上界,可先将物品依其单位重量价值从大到小排序,此后只要顺序考查各物品即可。在实现时,由bound计算当前结点处的上界。类Knapsack的数据成员记录解空间树中的结点信息,以减少参数传递以及递归调用所需的栈空间。在解空间树的当前扩展结点处,仅当要进人右子树时才计算上界bound,以判断是否可将右子树剪去。进入左子树时不需计算上界,因为其上界与其父结点的上界相同。
示例
物品(n):0, 1, 2, 3
重量(w):3, 5, 2, 1
价值(v):9, 10, 7 4
容量(C):7
以物品单位重量价值的递减顺序装入物品。先装入物品3,然后装入物品2和0。装入这3个物品后,剩余的背包容量为1,只能装入0.2的物品1。由此得到一个解为\(x=[1,0.2,1,1]\),其相应的价值为22。 尽管这不是一个可行解,但可以证明其价值是最优值的上界。因此,对于这个实例,最优值不超过22。
代码实现
解0-1背包问题的回溯法python代码如下:
backtrace-python
class Kbackpack(object):
def knapsack(self, c, w, v):
self.c=c # 背包容量
self.n=len(w) # 物品总数
q = list(map(lambda x: x[0] / x[1], zip(v, w))) # 每个物品单位重量的价值
id=[x for x in range(self.n)]
self.items = sorted(list(zip(w,v, q,id)), key=lambda x: x[2], reverse=True) # 列表的每个元素为各个物品的重量、价值、单位重量的价值
self.cw=0 # 当前总重量
self.cv=0 # 当前总价值
self.bestv=0 # 当前最优价值
self.bestx=['discard']*self.n
self.tempx=['discard']*self.n
self.backtrack(0)
return self.bestv,self.bestx
def backtrack(self,i):# i为递归深度(递归到第i个物品)
if i>=self.n: # 到达叶子节点
self.bestv=self.cv
self.bestx=self.tempx.copy()
return
# 搜索左子树
if (self.cw+self.items[i][0]<=self.c):
self.cw+=self.items[i][0]
self.cv+=self.items[i][1]
self.tempx[self.items[i][3]]='choose'
self.backtrack(i+1)
# 回溯
self.cw-=self.items[i][0]
self.cv-=self.items[i][1]
self.tempx[self.items[i][3]]='discard'
# 搜索右子树
if (self.treeBound(i+1)>self.bestv):
self.backtrack(i+1)
# 计算子树最优值的上界
def treeBound(self,i):
cleft=self.c-self.cw # 剩余容量
bound=self.cv # 初始化最优值的上界
# 以物品单位重量价值递减顺序装入物品
while (i<self.n and self.items[i][0]<=cleft):
cleft-=self.items[i][0]
bound+=self.items[i][1]
i+=1
# 若还剩物品,则填满背包
if (i<self.n):
bound+=self.items[i][2]*cleft
return bound
计算复杂度分析
计算上界需要\(O(n)\)时间,在最坏情况下有\(O(2^n)\)个右儿子节点需要计算上界,故解0-1背包问题的回溯算法所需的计算时间为\(O(n2^n)\)。