問題描述
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(2^n)$$從而,改進后算法的計算時間復雜性為\(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)\)。