【01揹包問題】算法
揹包問題是一類問題。一般其模型就是往一個揹包裏面裝各類物品,來求一個極限狀況時的物品明細或者某些物品屬性。把這些描述給具體化能夠獲得不少不一樣分化的揹包問題。數組
01揹包問題是揹包問題中基礎的一類。其描述是:app
有n個物品分別編號爲a1,a2,a3...an。這些物品每一個都有兩個屬性,分別是重量和價值,物品ai對應的重量和價值分別用wi和vi表示。而後咱們手裏還有一個揹包,這個揹包有一個容量屬性volumn,意思是這個揹包可以裝下的最重的重量是volumn。ide
咱們要求解的是,在揹包不被撐爆的狀況下,如何安排物品的放入才能使揹包中物品價值vi的總和最大化。函數
這個問題中的幾個細節還須要強調一下:測試
1. 物品是個不是類,也就是說a1,a2...都只有一個,針對一個物品放入揹包的操做只能作一次。對於「類」的揹包問題,稱爲徹底揹包問題,下面有機會細說優化
2. 物品沒法分割,好比放入揹包 二分之一個a1這樣的操做是不容許的。要麼物品放入,要麼不放入。這也是01揹包問題中01這個前綴的由來。事實上,對於這種可分割的物品(好比把物品換算成多少錢一斤的糖果,這樣感受上就是一種可分割的揹包問題),可使用貪心算法來解決,還略微簡單一些。因爲是說明01,因此不展開了。spa
3. 題目中沒有要求剛好裝滿揹包,也就是說容許揹包中有空。若是要求剛好裝滿揹包,則能夠稍稍修改01揹包問題的算法實現。code
■ 動態規劃求解blog
很明顯,當選取一些物品放入揹包以後,剩下物品中還能放入哪些物品受到以前放入物品決策的影響。所以能夠經過動態規劃的思想來解決。
(嘗試了一下可否經過本身的語言來說一下這個算法從無到有的前因後果,不過發現仍是搞不太清… 目前先把現成的作法拿過來,等之後有機會再來解釋解釋原理)
咱們構造這樣一個二維數組做爲動態規劃的中間值存儲。這個數組的元素res[i][j],指的是從a1到ai這(i-1)個物品中選出若干個物品放入最大承重量爲 j 的揹包,此時可以取到的物品總價值的最大值。(我不是很能理解爲何 j 是最大承重量爲 j 的揹包。事實上,咱們手中並不存在一個最大承重量是j的揹包,這個揹包是虛擬的) 顯然,全部的res[0][j]表明了0件物品放入承重爲j的揹包,此時最大價值確定是0,由於沒有物品。相對的,res[i][0]表明了i件物品放入承重爲0的揹包,因爲揹包徹底不承重,天然一個物品都不能放,因此其總價值也是0。其實到這裏已經能夠看出來,res這個二維數組的第一行和第一列的全部元素都是0。而咱們比較關注的部分的i的取值是1到n,j的取值是1到volumn。
到這裏,其實感受這個二維數組很是像求最長子序列時須要構造的那個東西,也是第一行第一列置0。有了這樣一個構造以後,後續數據的填充就不困難了。
對於二維數組中的一個元素res[i][j],其實能夠看到,分紅是否將ai放入揹包兩種狀況。
1. 若是不將ai放入揹包,那麼很明顯,res[i][j]應該等於res[i-1][j]
2. 若是將ai放入揹包,則res[i][j] == res[i-1][j-wi]。這個判斷最開始我也想不太明白,主要問題是咱們沒有在作徹底揹包問題,總感受j-wi不精確等於i-1個物品時,取物品的總質量,這樣的推斷不太可靠。可是後來想了想,加入j-wi和i-1個物品時,取物品的總質量之間有一些「空隙」的話,那麼這些空隙必然是不能容納a(i-1)以前其餘未進入揹包的物品的,不然物品的總價值還能夠繼續變大。所以j-wi再加上wi以後,這個空隙仍是存在,而且依然不能容納更多的物品。所以這個等式沒毛病。
除了這兩種狀況以外,其實咱們還應該注意到,若是j < wi,那麼j-wi變成了負數,顯然不是咱們指望的。事實上,j < wi的意思是說ai的重量已經超過了當前揹包最大承重量,此時ai確定不能加入揹包。所以當j < wi的時候不用考慮第二種狀況,直接走第一種狀況。(事實上在Python中不這麼處理可能不會報錯,由於負下標是指從倒數開始計數的,可是這樣的話數據確定不正確了)
填充數據時從1,1位置開始,一直走到最右下角。填充完畢以後,數組中的每一個元素都是相應i(對a1,a2...ai這些物品而言)和j(對揹包總承重量爲j的時候而言)時,能夠取到的揹包中物品價值最大值的狀況。
顯然,若是隻是求V的最大值的話,直接取最右下角元素的值便可。
若是咱們想要求出此時揹包中物品的明細,參照之前作最長公共子序列的作法,從右下角元素回溯上去,找出一條路徑便可。此次咱們關心的,是每一行(即每一個i)的物品是否取。
具體而言,能夠作一個由n個0構成的數組,對於要取的物品ai,將對應下標是i的元素置爲1便可。而判斷當前行的那個物品要不要取,就看當前所在數組元素的值的來源便可。若是這個值和同一列上一行的值(即[i-1][j])相同,說明這個物品並無取。不然就認爲取了,而後將當前的j減去這個物品的重量,i再減去1,獲得的新的res[i][j]就是沒有加入這個物品前揹包的狀態,以此類推直到走到
綜上所述,代碼以下:(下面這個代碼是附加結構代替遞歸模式的DP,若是要改形成遞歸也不困難,就是搞一個填充(i,j)位置的函數,而後按照代碼中的邏輯填充數據便可。無非是調用入口的i,j是右下角的i,j)
# -*- coding:utf-8 -*- def knapsack(_weight,_value,_volumn): ''' :param _weight: 物品重量參數列表 :param _value: 物品價值參數列表 :param _volumn: 揹包容量 ''' weight = list(_weight) weight.insert(0,0) # 在頭上加一個0,方便構造那個二維數組 value = list(_value) value.insert(0,0) volumn = _volumn + 1 # 列數也加1,方便構造二維數組 n = len(weight) res = [[0 for i in range(volumn)] for j in range(n)] for i in range(1,n): for j in range(1,volumn): # 從1,1位置開始填充數據 if j < weight[i]: res[i][j] = res[i-1][j] else: res[i][j] = max(res[i-1][j], res[i-1][j-weight[i]]+value[i]) # j-weight[i],加入i,j的狀況下總重量不到j,留出的空檔不足以讓一個新元素放進來 # 那麼減去weight[i]以後留下的空檔依然是不夠任何一個元素放進來的 # 打印一下整個二維數組看下狀況 for row in res: for item in row: print item,'\t', print '' hot = [0] * (n-1) # n是算上了第一個元素0的長度,結果列表裏沒必要包含這個0所以減1 i,j = n-1,volumn-1 while i > 0 and j > 0: if res[i-1][j] == res[i][j]: i -= 1 else: hot[i-1] = 1 # 別忘了這裏也要減1 i -= 1 j -= weight[i] print hot if __name__ == '__main__': weight = [2,2,6,5,4] value = [6,3,5,4,6] volumn = 10 knapsack(weight,value,volumn)
上面的測試數據中途生成的整個二維數組應該是這樣的:
好比看res[2][3]這一格,意思是a1,a2做爲可取物品,揹包總承重量是3的狀況可以取的最大價值。由於a1,a2的重量都是2,因此二者只能取其一放入揹包,而a1的價值是6,a2的價值是3,天然取a1入揹包比較好。
至於回溯路徑,從res[5][10]開始回溯的話,標紅的幾個格子就是幾個關鍵節點,表示V達到格子中的值時是經過了取了第i個物品所致的。
■ 優化
上述算法,若是說物品數量是M件,而揹包大小是N的話,那麼整體的 時間複雜度是O(M*N)(恰好兩層循環)。而空間複雜度很好判斷,就是多用了這麼一個二維數組結構,是O(M*N)。
和不少DP問題相似,在時間複雜度上,優化的空間不大,可是在空間複雜度上,若是不考慮放入物品的明細,只是求出最大價值的話,能夠將二維數組優化爲一個一維數組求解。
原理很簡單,就是把一維數組視做上面二維數組裏面的每一行,而後一遍遍地遍歷修改更新數組中的值,使之成爲下一行,直到最後一行。
在這個過程當中,如何遍歷更新數組中的值很是重要。通常咱們想到簡單的從左到右依次遍歷,可是這個動態規劃的狀態轉移方程中,設咱們的一維數組是vtable,那麼要求是vtable[k] = max(vtable[k], vtable[k-weight] + value),也就是說絕對vtable[k]的值的因素,還有一個vtable[k-weight]這樣一個處於vtable左邊的值。所以依次遍歷若是是從左到右,可能在決定vtable[k]的時候其左端的值被改變從而沒法求出正確的值。通常來講,實踐上會把遍歷的順序調整爲從右到左。整個核心遍歷過程的僞代碼以下:
for i in 1..M,M是物品個數 for j in N..0,N是揹包大小 vtable[j] = max(vtable[j], vtable[j-weight]+value), weight,value是當前i對應物品的屬性
能夠看到,裏層循環的循環順序是從N到0,倒過來的。基於這個僞代碼咱們能夠很快寫出Python的相關代碼,而且作一些邏輯的補充和優化:
def knapsack(weights,values,pack): num = len(weights) vtable = [0] * (pack+1) # vtable = [0] + ([float('-inf')] * pack) # 2 for i in range(num): weight = weights[i] value = values[i] for j in range(pack,weight-1,-1): # if j - weight < 0: # 1 # continue vtable[j] = max(vtable[j],vtable[j-weight]+value) return vtable print knapsack([2,2,6,5,4],[6,3,5,4,6],10)
這裏能夠注意一下,vtable初始化的長度應該是揹包最大承重 + 1,由於要在邏輯上考慮揹包承重爲0的狀況。而外層循環的i能夠直接從0開始,由於上面二維數組中i是0的狀況的那一行已經在vtable的初始化中給出了,即都是0的那行數組。
這段代碼的輸出是[0, 0, 6, 6, 9, 9, 12, 12, 15, 15, 15],剛好也應該是以前二維數組的最後一行的值。
而後看註釋#1處,按照上面給出的僞代碼,裏層循環應該是for j in range(pack, -1, -1),而後爲了不j - weight是負數的狀況,在循環中加上一條continue條件。因爲這個循環十分簡單,能夠直接將j - weight < 0的條件加載循環控制條件中。即range(pack ,weight - 1, -1)。
再看註釋#2處。這個地方初始化採用了只有第一項(即j = 0,揹包最大承重爲0的時候)爲0,其他項都初始化爲負無窮的策略。這個策略是用來解決「剛好裝滿」的01揹包問題的。方法是這麼個方法,可是爲何是這樣呢? 經典揹包九講中的解釋是,能夠理解爲,不要求「剛好裝滿」時,只要揹包內物品不超過最大承重量,揹包的狀態都是合法的,有機會將這種狀態記錄到結果集中。初始狀態下無論j是多大,因爲放入的物品是0,即不放入任何物品,總價值天然也是0,因此都初始化爲0。 另外一方面,若是要求「剛好裝滿」,除了j=0的狀態是合法的外,其他任何大於0的j,放入物品爲0時都不屬於「裝滿」狀態,所以都不能做爲合法狀態,一次你初始化爲負無窮。
以負無窮表示非法狀態的好處是,-inf無論加上的value是多少仍然是-inf。也就是說,非法的狀態(好比揹包有空隙時),不管再加入多少個什麼物品(加入物品的同時j也變大,因此以前的空隙不會被填滿)都仍是非法的。反過來,合法的狀態再加入任何物品,只要不超過實際的揹包承重量pack,也都是合法的。
只須要在初始化的時候進行這麼一個簡單的改造,就可使得不要求「剛好裝滿」的變成了要求「剛好裝滿」的。另外須要指出,這個「剛好裝滿」的控制條件普適於幾乎全部的揹包問題。
利用一維數組解01揹包問題,不只僅是空間複雜度下降這個意義而已,不少擴展的揹包問題的算法都要基於一維01揹包算法,所以應該好好記住。
重申下幾個重點。
1. 裏層循環的逆序遍歷 2. 裏層循環循環控制條件優化 3. 數組初始化長度應+1 4. 數組初始化值的不一樣對應是否剛好裝滿問題
【徹底揹包問題】
上面也說過了,徹底揹包問題中,可選的物品再也不是一件件,而是一類類的了。也就是說,每種物品均可以取用無數次放入揹包。
■ 預處理
在徹底揹包的場景中,有一些以前01揹包問題中不是很明朗的東西變得很清晰。好比對於物品,咱們能夠作一個預處理了。預處理包括了兩方面:
1. 若是一種物品am和另外一種an,知足weights[m] <= weights[n] and value[m] >= value[n],那麼咱們就能夠很是自信地說,an物品能夠扔掉了。由於任何一個帶有an的分配狀況中,把全部的an都換成am,總可使得重量不超標的狀況下總價值增長(準確的說是不減小)。
2. weight[k] > pack的那些ak物品也能夠直接扔掉了,由於揹包根本裝不下這些物品哪怕只有 一件,這個其實在01揹包中也是成立的。
對於第二點,實現起來很方便,只要遍歷的時候加個條件便可。
對於第一條就要稍微思考一下了。我本身的解決思路是現將weights從小到大排序,同時也將values按照weights排序的順序進行從新排列。而後從頭開始用i遍歷這兩個數組。因爲對於weights而言隨着i的增大weights[i]都會增大,所以只要關注values[i]的變化狀況。而values[i]這個東西,若是它的值小於以前values中出現過的最大值,那麼說明當前的這個i 對應的weights和values應該被捨棄,由於在它以前能夠找到一個weights小於它可是values大於它的東西; 反之,若是它的值大於以前的最大值,那麼就能夠更新最大值。以前的那個最大值要不要也捨棄,這個取決於weights[i]和weights[max_i]是否相同。若是相同,那麼相同重量的物品,確定是取價值大的一種。若是不一樣,那麼不能直接捨棄。
下面是個人代碼實現,直接按想法寫出來的,比較弱…:
def preprocess(_weights,_values,pack): def double_sort(w,v,left,right): # 由於涉及到同步排序,直接本身用快排實現了 if left >= right: return pivot = w[left] i = idx = left + 1 while i <= right: if w[i] <= pivot: w[idx],w[i] = w[i],w[idx] v[idx],v[i] = v[i],v[idx] idx += 1 i += 1 idx = idx - 1 w[left],w[idx] = w[idx],w[left] v[left],v[idx] = v[idx],v[left] double_sort(w,v,left,idx-1) double_sort(w,v,idx+1,right) weights,values = list(_weights),list(_values) leng = len(weights) double_sort(weights,values,0,leng-1) max,maxIdx,drop = values[0],0,[] # 三個變量分別記錄values出現過的最大值,最大值所在下標,全部要被捨棄的物品的下標 for i in range(1,leng): if weights[i] > pack: # 對於出現超重的記錄,能夠直接將剩餘記錄都捨棄(由於從小到大排列)就結束了 drop.extend(range(i,leng)) break if max >= values[i]: # 當前value小於歷史最大值狀況 drop.append(i) else: if weights[maxIdx] == weights[i]: # 兩物品同重量狀況 drop.append(maxIdx) # 捨棄以前那個,若不一樣重量則不能捨棄 max = values[i] # 更新歷史最大值 maxIdx = i # 返回值中,全部drop中存在的下標的物品都不用了 resWeight = [weights[i] for i in range(leng) if i not in drop] resValue = [values[i] for i in range(leng) if i not in drop] return resWeight,resValue
值得一說的是, 最開始我誤覺得求的只是性價比,即單純的value/weight,可是立刻發現這樣不對勁。好比weights = [2,3]以及values=[20,29]這樣兩種物品,顯然第一種物品的性價比高,而按照上述算法這兩種物品都會被保留。若是要求裝一個容量是3的揹包,那麼最大價值顯然是裝一個重量是3的進去而不是2。所以,2雖然性價比比3高,可是不能直接捨棄3。反過來,若是values=[20,19],此時按照上述算法3會被拋棄。而事實上,裝一個容量是3的揹包的時候,裝一個3進去還不如裝一個2進去的價值高,所以能夠捨棄3。
■ 主要邏輯
預處理完成後,就能夠看看到底如何選擇物品來解決徹底揹包問題了。
按照最上面的二維數組DP解法,能夠獲得狀態轉移方程是res[i][j] = max(res[i-1][j], res[i][j-weight] + value)。res[i-1][j]的意思是不取用第i種物品,而res[i][j-weight],因爲如今第i種物品能夠取用若干次,因此「加取一件第i種物品」後總價值,其來源不該該是「明確不取用第i種物品」的res[i-1][j-weight],而是「有可能取用了若干個第i種物品」的res[i][j-weight]。 因此二維數組的解法,01揹包和徹底揹包相差就只有這麼一點細節。
二維數組解法的具體代碼就不寫了。下面基於這個二維數組的推導式,嘗試找用一維數組就能解決的辦法。
一維數組的狀況,狀態轉移方程和01問題時如出一轍。即vtable[k] = max(vtable[k], vtable[k-weight] + value)。這是能夠理解的,由於不管是01仍是徹底,其取一件物品的時候都面臨的是取or不取兩個選擇。那麼不一樣之處在哪裏? 在01問題的時候,咱們強調過算法裏層循環的逆序特徵。回憶一下,那是由於推導res[i][j]的值的時候要用到res[i-1][j-height],而[i-1]在一維數組遍歷的時候,指「上一次遍歷結果」,所以整個數組要從右往左遍歷以求當前值左邊的全部值都還保持着「上一次遍歷結果」時的狀態。而到了徹底問題中,用到的值變成了res[i][j-weight],[i]此時表示的,是「本次遍歷結果」,即徹底問題中,遍歷一維數組時要保證當前元素左邊的全部元素都是通過本次遍歷,而被更新過了的數據。 換言之,遍歷順序變爲了從左到右。
事實上,遍歷順序的改變也是惟一的,01問題和徹底問題在一維數組DP算法上的差異。僞代碼:
for i = 1..M, M是物品種類數 for j = 0..N,N是揹包最大承重量 vtable[j] = max(vtable[j], vtable[j-weight] + value),weight和value是當前i對應物品的重量和價值
再次重申,徹底問題的算法和01問題的差異只在裏層循環遍歷方向上,前者逆序,後者順序(固然是以一維數組DP做爲解決算法的前提下)
參照上述僞代碼和01問題中的代碼,很容易就能夠寫出徹底揹包問題的代碼:
def totalknap(weights, values, pack): num = len(weights) vtable = [0] * (pack + 1) for i in range(num): weight = weights[i] value = values[i] for j in range(weight,pack+1): # 注意循環控制條件,仍是j-weight不小於0 vtable[j] = max(vtable[j], vtable[j-weight] + value) return vtable
固然這段代碼裏沒加預處理。此外,正如上面01問題中提到的,vtable的初始化方式能夠控制「是否剛好塞滿」這個子問題。