1. 寫在前面
在上一篇博客中,我們通過選擇問題瞭解了貪心算法。這一篇博客將繼續介紹貪心算法,主要談談貪心算法的原理,並簡單分析一下揹包問題。
2. 貪心算法原理
通過上一篇博客中的選擇問題,我們看到,貪心算法可以由如下幾個步驟來實現:
- 確定問題的最優子結構;
- 設計一個遞歸算法;
- 證明如果我們做出一個貪心選擇,則只剩下一個子問題;
- 證明貪心選擇是安全的;
- 設計並實現貪心算法。
對比動態規劃,我們發現貪心算法和它十分相似,首先它們都必須具備最優子結構性質,然後通常都是將原問題分解爲子問題,根據最優子結構性質與問題的分解,設計一個遞歸算法。不同之處在於,動態規劃算法在對問題進行分解時,由於無法確定哪一種分解能夠得到原問題的最優解,因此需要考察所有的分解情況,並且正是由於這種分解問題的不確定性,通常會導致子問題重疊,爲了提高效率,通常會用一個“備忘錄”去備忘子問題的解;而在貪心算法中,我們很明確的知道如何分解問題能產生最優解(或者說是很明確的知道哪種選擇的結果在最優解中),因此通常貪心算法沒有子問題重疊重疊問題,但我們必須要確保貪心選擇的正確性。
和動態規劃一樣,我們也總結出貪心算法的兩個特點(必要條件)。
2.1. 最優子結構性質
這個性質和動態規劃一樣,因此不再贅述:
如果一個問題的最優解包含其子問題的最優解,我們就稱此問題具有最優子結構性質。
2.2. 貪心選擇性質
對於某個問題,如果我們可以通過做出局部最優(貪心)選擇來構造全局最優解,那麼我們就稱該問題具有貪心選擇性質。
3. 揹包問題
下面通過兩種不同形式的揹包問題,來進一步說明動態規劃算法與貪心算法的區別之處,進而加深對貪心算法的理解。
首先說明一下揹包問題:
給定一組物品和一個限定重量的揹包,每種物品都有自己的重量和價格。在限定的總重量內,我們如何選擇,才能使得揹包內的物品總價格最高。
在0-1揹包問題中,對於每一件物品,你只能做出二元(0-1)選擇,即只能選擇 選擇該物品或不選擇該物品,而不能只選擇該物品的一部分;分數揹包問題相反。
我們先做如下說明:
設共有\(n\)件物品,記爲\(t_1,t_2,...,t_n\),其中物品\(t_i\)重量爲\(w_i\),價值爲\(p_i\);限重爲\(W\)。
3.1. 0-1揹包
首先,我們可證明,0-1揹包問題具有最優子結構性質:
因爲,對於某種最優選擇方案,假設\(t_i\)屬於其中,如果我們拿走\(t_i\),那麼原問題則變爲子問題:從商品\(t_1, ...,t_{i-1},t_{i+1}, ...,t_n\)中選擇某些物品,限重爲\(W-p_i\)。在子問題的所有選擇方案中,要想讓其中的某種方案與\(t_i\)組合成原問題的一個最優方案,該方案必須是子問題的一個最優方案。用剪切-粘貼法可以很容易證明,不再贅述。
根據上面的分析,我們先這麼考慮,設\(P_{T, w}\)爲物品集合爲\(T\),限重\(w\)時,選擇的物品的最高總價值,我們可以很容易用一個遞歸式去表示最優解:
下面給出此遞歸式的Python實現:
def knapsack_0_1(T, w):
if len(T) == 1:
if T[0][2] <= w:
return T[0][1]
return 0
maxValue = 0
for i, t in enumerate(T):
if t[2] <= w:
value = t[1] + knapsack_0_1(T[:i] + T[i + 1:], w - T[i][2])
if maxValue < value:
maxValue = value
return maxValue
我們作如下測試:
if __name__ == '__main__':
'''
1號商品:$60 - 10kg
2號商品:$100 - 20kg
3號商品:$120 - 30kg
限重 50kg
'''
T = [(1, 60 , 10), (2, 100, 20), (3, 120, 30)]
W = 50
print(knapsack_0_1(T, W))
打印結果:220
重新審視上述遞歸式和以上的實現代碼,我們發現其壓根就不是動態規劃算法,而只是簡單的遞歸算法。因爲它不滿足動態規劃問題的一個必要條件:子問題重疊。實際上它也沒有充分利用最優子結構性質,而導致“重複”求解了許多問題。其時間複雜度爲\(O(n!)\)。
要用上動態規劃算法,我們可以這麼去考慮,設\(P[i, w]\)表示物品\(t_1, ...,t_i\)在限重爲\(w\)時,能夠選擇的物品的最高價值。我們可以用如下遞歸式去表示\(P[i, w]\):
簡單解釋一下上述遞歸式:當\(w = 0\)或\(i = 0\)時,顯然\(P[i, w] = 0\);當\(w_i > w\)時,因爲無法將物品\(t_i\)放入揹包(即不能選擇\(t_i\)),因此\(P[i, w] = P[i-1, w]\);第三種情況,既可以選擇\(t_i\)也可以不選擇\(t_i\),因此需要在這二者之間找出最大值。我們的目標是求出\(P[n, W]\)。
下面給出一個自底向上的Python實現代碼:
def knapsack_0_1(T, w):
P = [[0] * (w + 1)for i in range(len(T) + 1)]
for i, t in enumerate(T):
i = i + 1 # i從0開始迭代,因此必須加上1
for j in range(1, w + 1):
if t[2] > j:
P[i][j] = P[i - 1][j]
else:
P[i][j] = max(P[i - 1][j], t[1] + P[i-1][j - t[2]])
return P
我們做同樣的測試:
if __name__ == '__main__':
'''
1號商品:$60 - 10kg
2號商品:$100 - 20kg
3號商品:$120 - 30kg
限重 50kg
'''
# 注意:下面我們將重量都同步縮減爲原來的0.1倍,不影響結果。
T = [(1, 60 , 1), (2, 100, 2), (3, 120, 3)]
W = 5
print(knapsack_0_1(T, W)[len(T)][W])
打印結果爲:220
分析上述動態規劃算法,我們發現其時間複雜度爲:\(O(n \times W)\),比一開始的遞歸算法要好。
3.2. 分數揹包
分數揹包同0-1揹包一樣,也具有最優子結構,其證明和0-1揹包差不多,這裏不再贅述。
在分數揹包問題中,直覺告訴我們,這樣做能夠總價值最高的商品:首先用平均價值最高的商品去填充揹包。若揹包限重還有剩餘,則用平均價值第二高的商品去填充揹包……之後的情況以此類推,直到揹包被“填滿”。先提前聲明,揹包一定是能被“填滿”的,即所給的商品的總重量是大於揹包的限重的;對於揹包限重大於或等於商品總重量的“平凡”情況,沒有考慮的必要。
以上的選擇策略便是該問題的貪心選擇。接下來我們必須證明貪心選擇是安全的。
考慮在某種最優選擇方案中,在第\(k\)次選擇時,\(t_i\)是當前所剩的商品中平均價值最高的商品。假設在該最優方案的該次選擇中,選擇的商品\(t_j\)不是平均價值最高的商品,即\(j \neq i\)。我們可以採用剪切-粘貼法,即考慮用\(t_i\)去替代\(t_j\)(若\(w_i \geq w_j\),則取重量爲\(w_j\)的部分\(t_i\)去替代;若\(w_i < w_j\),則取全部的\(t_i\)去替代 ),很明顯,替代後的方案比之前的方案更優。因此假設不成立,即\(j = i\),即在第\(k\)次選擇時,選擇的商品\(t_j\)是剩餘商品中平均價值最高的商品,由於\(k\)具有任意性,因此貪心選擇是安全的。
有了上述的分析,我們可以很容易設計出一個貪心算法,首先將所有商品按平均價值遞減的順序排序,然後再採用如上貪心選擇策略做出選擇,這樣可以在\(O(n\lg n)\)的時間內解決分數揹包問題。
下面給出Python實現代碼:
def knapsack_fraction(T, w):
T.sort(key = lambda t: t[1] / t[2], reverse= True)
p = 0
for t in T:
if w <= t[2]:
p += w * t[1] / t[2]
return p
else:
p += t[1]
w -= t[2]
作如下測試:
if __name__ == '__main__':
'''
1號商品:$60 - 10kg
2號商品:$100 - 20kg
3號商品:$120 - 30kg
限重 50kg
'''
T = [(1, 60 , 10), (2, 100, 20), (3, 120, 30)]
W = 50
print(knapsack_fraction(T, W))
打印:240.0
3.3 0-1揹包 VS 分數揹包
你可能會想,爲什麼不把用於分數揹包問題的貪心算法也用於0-1呢?事實上,是不行的。從我們測試的例子中,就能看出問題。
在分數揹包中,最優方案揹包裏的物品是:10kg的1號商品,20kg的2號商品和20kg的3號商品。注意此時3號商品只取了20kg,它被“分割”了,而在0-1揹包問題中,這種“分割”是不允許的。
換個角度說,如果我們對0-1揹包問題同樣採用如上貪心算法,並且還要保證不能“分割”物品,那麼最終我們只能將10kg的1號商品,20kg的2號商品,其總價值爲$160,揹包還有20kg的載重空間被白白浪費了。
再換個角度,如果把上面的對分數揹包貪心選擇安全性的證明套用到0-1揹包問題中,我們就會發現,在證明中用\(t_i\)去替代\(t_j\)的做法不一定能夠成功,原因還是因爲0-1揹包問題中,商品是不允許"切割"的,其重量總是一個固定值。