[動態規划] 一文搞定三類背包問題(超基礎詳細)


動態規划問題一直是算法中的難點和重點之一,而三類背包問題使用的動態規划思想本質上是一樣的,下面我會通過背包問題解釋如何用動態規划求解,如果覺得文字太啰嗦可以直接看代碼。

01背包問題

有n個物品,每個物品有體積和價值兩個屬性,現在有一個固定容量的背包,要將這些物品放入背包內,使得背包內物品總價值最大,問要如何放?

注意:這里每個物品的數量為1。

設$V = 14$。

為了描述方便起見,我們用$c_1,c_2,c_3,....c_n$表示物品的體積, 用$w_1,w_2,w_3,....w_n$表示物品的價值,用$V$表示背包的容量,$n$表示物品的數量。

動態規划描述

背包問題是動態規划問題的一種,和分治算法一樣,它是將一個大問題分成一個一個的小問題,然后找到大問題和小問題之間的遞推式,將一個個小問題組合成大問題,

並且通常要求這些小問題也要達到最優。能用動態規划解決的問題一般含有兩種性質:最優子結構重疊子問題。動態規划從本質上講就是窮舉法, 但是它的卻比一般的算法效率要高,這就是因為它能剪去重疊的子問題,使得總體復雜度不那么高。

 

解決方法

1.遞歸方法

我們先從最容易入手的地方開始,用遞歸的方法來解這個問題。

首先對於每個物品,我們的選擇只有兩個:放或者不放。我們將所有的可能都窮舉出來,就可以得到下面這個樹狀圖(只畫了前四個結點):

這個樹上每一個分支都只能選一個,每一行就代表一個子問題。我們用$F(i, v_i)$表示前$i$個物品放入背包內的最大價值(即最優方案),此時這個$v_i$指的是放入前$i$個物品后背包剩余的容量。

顯而易見$F(5, v_5)$就是我們要解的大問題,分割一下子問題就是:$F(1, v_1)$, $F(2, v_2)$, $F(3, v_3)$, $F(4, v_4)$。要解決這個大問題,我們就得先解決它的前面一個子問題$F(4, v_4)$(求解原問題時子問題也要達到最優,所以該問題具有最優子結構,這是判斷問題能否用動態規划解的條件之一)。

至於為什么解決$F(5, v_5)$問題之前要先解決$F(4, v_4)$,這是因為對於問題$F(5, v_5)$來說,如果解決了問題$F(4, v_4)$剩下就只需要判斷最后一個物品體積是否比剩余背包容量大,是直接放入背包,否則就不放。

而對於子問題$F(4, v_4)$,我們得解決$F(3, v_3)$....從而得解決$F(1, v_1)$。對於子問題$F(1, v_1)$,由於它前面沒有子問題,所以解決它只需要判斷$c_1 \le V$是否成立。

所以對於每一個子問題,由於前面的子問題已被解決,因此我們都只需要做兩個選擇:放,還是不放。

假設我們已經知道了前$i-1$個物品放入背包的最優方案$F(i-1, v_{i-1})$,那么對於第$i$個物品要放入背包就有三種情況:

   若物品的體積$c_i$大於背包剩余的容量$v_{i-1}$,那么只能丟棄這個物品:

     $F(i, v_i) = F(i-1, v_{i-1})$

   否則就有兩種選擇:

   1. 不放第$i$個物品:

    $F(i, v_i) = F(i-1, v_{i-1})$

   2. 放第$i$個物品:

    $F(i, v_i) = w_i + F(i-1, v_{i-1}-c_i)$

要從這兩個方案中選擇總價值最大的,所以:

$$
F(i, v_i) =
\begin{cases}
F(i-1, v_{i-1}), & \text{ $c_i > v_{i-1}$ } \\
max(F(i-1, v_{i-1}), w_i +F(i-1, v_{i-1}-c_i)), & \text{ $c_i \le v_{i-1}$ }
\end{cases}
$$

 用Python實現代碼如下:

#遞歸求解
def rec_bag(c, w, v, i=0):
    '''
    param c: 物品體積
    param w: 物品價值
    param v: 當前背包剩余容量
    param i: 當前物品編號
    return: 背包裝下物品的最大價值
    '''
    if i > len(c)-1:
        return 0
    elif v <= 0: #體積不能為負
        return 0
    elif v > 0:
        if c[i] <= v:
            A = w[i] + rec_bag(c, w, v-c[i], i+1)
            B = rec_bag(c, w, v, i+1)
            res = max(A, B)#兩種方案中選最優的那個並返回
        else:
            res = rec_bag(c, w, v, i+1)#物品體積大於背包容量,直接返回
    return res

 

2.動態規划方法

遞歸方法最大的缺點就在於有較多重復的計算,通過上面的樹狀圖可以知道,遞歸會遍歷這棵樹的所有結點,所以它的時間復雜度為:$O(2^{n})$。

指數階的時間復雜度對於計算機來說簡直就是災難!那有沒有什么辦法減少這些重復的計算呢?這就是接下來要說的動態規划,它可以通過存儲已經計算出來的結果來減少重疊子問題。

和遞歸一樣,動態規划也是要找出所有可能的情況並從中選出最優的,但不同的是動態規划每次計算都會將結果存儲在一個表中,下一次遇到同樣的問題時就直接查表,而不必重復計算。

為了方便描述,我們重新將$F(i, V)$定義為前$i$個物品在總容量為$V$背包里的最大價值(注意和這里的$V$指的是背包總容量並非剩余容量)。那么現在的大問題就是$F(5, 14)$,按照上面遞歸的思想,改寫一下式子就是:

$$
F(i, V) =
\begin{cases}
F(i-1, V), & \text{ $c_i > V$ } \\
max(F(i-1, V), w_i +F(i-1, V-c_i)), & \text{ $c_i \le V$ }
\end{cases}
$$

對於動態規划通常我們會用自底向上的方法,而遞歸是自頂向下的。自底向上的意思就是我們從子問題$F(0, 0)$一直計算到$F(5, 14)$,我們知道要計算$F(5, 14)$,得先解決它前面的子問題,但由於是自底向上的,所以我們並不知道$F(5, 14)$的子問題有哪些,因此我們只能夠把它所有可能的子問題都找出來,即$F(4, V), (V \in (0, 1, 2, 3......14))$,而$F(5, 14)$的子問題一定是包含在內的,比如$F(5, 14)$的子問題就是$F(4, 14)$和$F(4, 14-c_5)$。所以自頂向下的過程就相當於在填這個表:

表一旦填完,答案也就出來了。

#動態規划求解
def dp_bag(c, w, v):
    res = [[0 for j in range(v+1)]for i in range(len(c)+1)] #構建一個大小為(len(c)+1)*(v+1)的表
    for i in range(1, len(c)+1): #i當前表示物品編號
        for j in range(1, v+1): #j表示背包容量
            if j<c[i-1]:
                res[i][j] = res[i-1][j]
            else:
                res[i][j] = max(res[i-1][j-c[i-1]] + w[i-1], res[i-1][j])
    return res[len(c)][v]

算法的時空復雜度都是:$O(n \times V)$,並且如果$V$足夠小,可進一步降低至線性($O(n)$),比遞歸方法要高效地多。

空間復雜度優化:根據遞推式可知,第$i$個子問題只與第$i-1$個子問題有關,因此我們用兩個列表來存儲第$i$個和第$i-1$個子問題,每當前面一個子問題求解完,我們就用另一個列表來存下一個子問題。

def dp_bag_opt(c, w, v):
    res = [[0 for j in range(v+1)] for i in range(2)] #只需要創建一個大小為2*(V+1)的表
    res1, res2 = res[0], res[1]
    for i in range(1, len(c)+1): #i當前表示物品編號
        for j in range(1, v+1): #j表示背包容量
            if j<c[i-1]:
                res1[j] = res2[j]
            else:
                res1[j] = max(res2[j-c[i-1]] + w[i-1], res2[j])
        res1, res2 = res2, res1
    return res2[-1]

此時空間復雜度為$O(2V)$。

 

完全背包問題

這里還是以01背包的例子為例,除了每個物品可取放的數量是無限的,其他條件不變。現在對於每件物品來說,能做的選擇變多了,之前要么放要么不放;現在是不放和放多少個。也就是說遞推式現在應該有多個選擇,對於遞歸解法,根據上面01背包遞歸解法的遞推式,這里要能使得$F(i, V)=w_i +F(i-1, V-c_i)$對於每個物品都能重復進行多次,而不是一次就結束了;對於動態規划解法,在一個子問題$F(i, j)$內(即第二層for循環里),可以設計一個for循環來計算在這個容量下最多可以放幾個同類的物品。

用Python實現的代碼如下:

#遞歸求解
def rec_complete_bag(c, w, v, i=0):
    if i > len(c)-1:
        return 0
    elif v <= 0: 
        return 0
    elif v > 0:
        if c[i] <= v:
            A = w[i] + rec_complete_bag(c, w, v-c[i], i)#一直遞歸地選擇A情況,直到背包容量小於0
            B = rec_complete_bag(c, w, v, i+1)
            c = max(A, B)
        else:
            c = rec_complete_bag(c, w, v, i+1)  
    return c

#動態規划求解
def dp_complete_bag(c, w, v):
    res = [[0 for j in range(v+1)]for i in range(len(c)+1)]
    for i in range(1, len(c)+1):
        for j in range(1, v+1):
            if j<c[i-1]:
                res[i][j] = res[i-1][j]
            elif j>=c[i-1]:
                A = -1
                t = j
                k = 1
                while t>=c[i-1]:
                    t -= c[i-1]
                    A = max(A, k * w[i-1] + res[i-1][t])#計算多個同類物品疊加的價值
                    k += 1 
                res[i][j] = max(A, res[i-1][j])#還要算上不放的情況
    return res[len(c)][v]

動態規划解法我沒有優化空間,可以自行用上面的方法進行優化。

 

多重背包問題

問題描述:與完全背包不同的地方在於,現在每件物品都有一個固定的數量,仍然是求在特定的背包容量下的最大放進背包的物品價值。解法:明白了完全背包,多重背包問題就非常簡單了。它的解決方法和完全背包問題基本相同,完全背包問題需要計算在固定容量下,每件物品能拿放的數目,對於一個固定背包容量,每件物品可以拿很多個(只要不超出背包的總容量就可以),但是在多重背包,每件物品的數量是固定的,有限的。所以就是在這基礎上多加個限定條件,就是:每件物品可以拿多個,但不能超過最大限制的數量,同時也不能超過背包的總容量。也就是說之前完全背包的最大數量是$+\infty$,現在將最大數量修改成對應每件物品的數量即可。遞歸方法:控制$F(i, V)=w_i +F(i-1, V-c_i)$的最大遞歸次數不能大於物品數量;動態規划方法:給while循環設置條件使得它的重復次數不能大於物品的個數。

#遞歸求解
def rec_multiple_bag(c, w, v, i, num, count=0):
    '''
    param num: 每件物品對應的數量
    param count: 統計的遞歸次數
    '''
    if i > len(c)-1:
        return 0
    elif v <= 0:
        return 0
    elif v > 0:
        if c[i] <= v and count < num[i]:
            A = w[i] + rec_multiple_bag(c, w, v-c[i], i, num, count+1) #重點在於控制遞歸深度(最大遞歸次數),這里A的最大遞歸次數不能大於物品的數量
            B = rec_multiple_bag(c, w, v, i+1, num, 0)
            c = max(A, B)
        elif c[i] > v or count >= num[i]:
            c = rec_multiple_bag(c, w, v, i+1, num, 0)  
    return c

#動態規划求解
def dp_multiple_bag(c, w, v, num):
    '''
    param num: 每件物品對應的數量
    '''
    res = [[0 for j in range(v+1)]for i in range(len(c)+1)]
    for i in range(1, len(c)+1):
        for j in range(1, v+1):
            if j<c[i-1]:
                res[i][j] = res[i-1][j]
            elif j>=c[i-1]:
                A = -1
                t = j
                k = 1
                while t>=c[i-1] and k<=num[i-1]:   #和遞歸一樣,這里也是要控制最大的重復放置的次數不能大於物品的數量
                    t -= c[i-1]
                    A = max(A, k * w[i-1] + res[i-1][t])
                    k += 1 
                res[i][j] = max(A, res[i-1][j])
    return res[len(c)][v]

代碼我測試了幾個例子沒有發現問題,如果有錯誤可以在評論區告訴我。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM