動態規划問題一直是算法中的難點和重點之一,而三類背包問題使用的動態規划思想本質上是一樣的,下面我會通過背包問題解釋如何用動態規划求解,如果覺得文字太啰嗦可以直接看代碼。
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]
代碼我測試了幾個例子沒有發現問題,如果有錯誤可以在評論區告訴我。