貪心算法(2)——算法導論(22)

1. 寫在前面

上一篇博客中,我們通過選擇問題瞭解了貪心算法。這一篇博客將繼續介紹貪心算法,主要談談貪心算法的原理,並簡單分析一下揹包問題

2. 貪心算法原理

通過上一篇博客中的選擇問題,我們看到,貪心算法可以由如下幾個步驟來實現:

  1. 確定問題的最優子結構;
  2. 設計一個遞歸算法;
  3. 證明如果我們做出一個貪心選擇,則只剩下一個子問題;
  4. 證明貪心選擇是安全的;
  5. 設計並實現貪心算法。

對比動態規劃,我們發現貪心算法和它十分相似,首先它們都必須具備最優子結構性質,然後通常都是將原問題分解爲子問題,根據最優子結構性質與問題的分解,設計一個遞歸算法。不同之處在於,動態規劃算法在對問題進行分解時,由於無法確定哪一種分解能夠得到原問題的最優解,因此需要考察所有的分解情況,並且正是由於這種分解問題的不確定性,通常會導致子問題重疊,爲了提高效率,通常會用一個“備忘錄”去備忘子問題的解;而在貪心算法中,我們很明確的知道如何分解問題能產生最優解(或者說是很明確的知道哪種選擇的結果在最優解中),因此通常貪心算法沒有子問題重疊重疊問題,但我們必須要確保貪心選擇的正確性。

動態規劃一樣,我們也總結出貪心算法的兩個特點(必要條件)。

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\)時,選擇的物品的最高總價值,我們可以很容易用一個遞歸式去表示最優解:

\[P_{T, w} = \begin {cases} 0 & \text{若$T = \{t_i\}, w_i > w$ }\\ p_i&\text{若$T = \{t_i\}, w_i \leq w$}\\ \max\limits_{t_i \in T, w_i \leqslant w}(p_i + P_{T-t_i, w-w_i})&\text{其他} \end{cases} \]

下面給出此遞歸式的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]\)

\[P[i,w] = \begin {cases} 0 & \text{$w=0$或$i=0$}\\ P[i-1, w] & \text{$w_i > w, i = 1, 2,..., n $}\\ \max\limits_{w_i \leq w} \{P[i-1, w], p_i + P[i-1, w-w_i]\} & \text{$i = 1, 2,..., n$} \end{cases} \]

簡單解釋一下上述遞歸式:當\(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揹包問題中,商品是不允許"切割"的,其重量總是一個固定值。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章