動態規划入門——詳解完全背包與多重背包問題


本文始發於個人公眾號:TechFlow,原創不易,求個關注


今天是算法數據結構專題的第13篇文章,也是動態規划專題的第二篇。

上一講當中我們一起學習了動態規划算法中的零一背包問題,我們知道了所謂的零一背包是指每一種物品只有一個,所以它的狀態只有0和1兩種,即拿或者不拿。而今天我們要來討論物品不止有一個的情況,物品不止有一個也分兩種,一種是不作任何限制,要多少有多少,這種稱為完全背包問題,另一種是依然有個數限制,這種稱為多重背包問題。

我們一個一個來看,我們先從其中比較簡單的完全背包開始。由於我們這是一個連續的專題,沒有看過上篇文章或者是新關注的同學可以移步我們專題的第一篇:

動態規划入門——詳解經典的零一背包問題

完全背包

在之前的文章當中,我們闡述了動態規划當中狀態和決策以及狀態轉移的相關概念。在背包問題當中,背包的容量是狀態,而選擇哪個物品進行獲取則是決策,當我們制定了一個決策之后,背包會從一個狀態轉移到另一個狀態。而動態規划算法就是枚舉所有狀態和決策,獲得所有的狀態轉移,並且記錄這個過程中每個狀態能夠獲得的最優解

在之前的文章當中,我們先遍歷了所有的決策,然后再枚舉了所有的狀態,計算在決策下進行轉移之后得到的結果。在之前的零一背包問題當中,由於我們每個物品只能獲取一個,如果在前面的狀態執行了決策,那么后面的狀態則不能進行相同的決策。這也就是動態規划的后效性,而在完全背包問題當中,我們去掉了這個限制,也就意味着決策之間不再有后效性,一個決策可以重復應用在各個狀態當中。

所以如果你能理解上面這段話,那么整個算法其實非常簡單,幾乎就是零一背包的代碼。只不過我們把其中倒敘遍歷的背包狀態再”修正“回來。

之前我們為了避免物品的重復獲取,所以采用了倒敘遍歷的方法,如今我們不再對數量進行限制,意味着我們可以自由地采取決策進行轉移。要做到這點,就是單純的兩重循環,第一種枚舉決策, 第二重枚舉狀態,記錄所有轉移可能帶來的最優解即可。我們來看代碼:

dp = [0 for _ in range(11)]

items = [[6, 10], [5, 8], [5, 9]]

# 遍歷物品
for v, w in items:
# 遍歷背包空間(狀態)
# 更新vp+v的狀態,即當前容量放入物品之后的狀態
for vp in range(0, 10-v+1):
dp[vp+v] = max(dp[vp+v], dp[vp] + w)

print(max(dp]))

如果你還沒能完全理解其中的邏輯,我們可以對照一下代碼再來理解一下。在第一種循環當中,我們遍歷了所有的物品,每一個物品對應了一種決策。每一個決策可以應用在各個狀態上,比如第一個物品是6, 15,代表它的體積是6,價值是15。那么我們遍歷所有能夠應用這個決策的狀態,也就是在不超過背包容量的情況下能夠放下的狀態。顯然對於一個體積是6的物品來說,只有0到4的狀態可以放下。比如說我們選擇狀態2,狀態2放下了這個物品之后,自然會轉移到狀態8,因為體積增加了6。有可能這個決策會使得狀態8獲得更好的結果,也有可能不會,如果會的話我們就更新一下狀態8記錄的值。這個從一個狀態采取決策到另一個狀態的過程就是狀態轉移。

完全背包就是零一背包的無限制版,從原理上來說,兩者的思路和做法基本上是一樣的。如果你能理解零一背包,那么完全背包對你來說也一定不在話下。

細小的優化

在完全背包當中,由於所有的物品都可以無限獲取。所以我們可以引入一些零一背包不能進行的優化,比如對於同樣體積的物品而言,我們可以只保留價值最高的物品,將其他的物品過濾掉。這個思路很朴素,我想大家應該都能理解。

比如兩個物品體積都是3,一個價值是4,另一個價值是3,我們完全可以忽略價值是3的那一種。因為兩者帶來的狀態轉移是一樣的,但是明顯前者收益更好。而這個優化在零一背包當中不可行是因為每個物品只有一個,很有可能會出現兩者都要的情況,所以不能簡單地替換。而在完全背包當中則沒有這個問題。

多重背包

和零一背包以及完全背包相比,多重背包要難上一些,它的解法也非常多樣。我們今天先來看一些相對比較簡單的方法。

同樣,我們從最簡單的方法開始講起。最簡單的方法當然就是將多重背包蛻化成零一背包來解決,比如一個物品最多可以拿N個,我們就把它拆成N種物品,這N種每種物品最多拿一個,相當於我們一種物品可以最多拿N個。這個思路應該很簡單,大家都能想明白,但是有個很大的問題,就是復雜度。當然我們可以根據背包的體積做一些優化,比如當物品的數量很多並且超過了背包容量的時候,我們可以把超過容量的數量去掉,但是整體的復雜度還是很高。尤其是當我們背包容量很大的時候

那么,我們怎么來解決這個問題呢?

這里要介紹一個比較通用的算法,這個算法可以用來優化很多問題,也是很多算法的思想。它就是二進制表示法。這個方法我們在之前的文章當中曾經講到過,思想非常簡單,但是非常實用。

二進制表示法

所謂二進制表示法就是將一個int類型的數表示成二進制,整個算法的思想就是這一句話,所以我想大家應該都能理解。但是我們為什么要將一個int轉成二進制,以及轉成二進制之后怎么樣來優化算法,這個才是我們想知道的,也才是算法的核心重點,不要着急,我們一點點來說明。

我們都知道在計算機系統當中都是以二進制存儲的所有數據,最典型的就是整數。一個32位的int,可以表示最大21億的整數。這個都是我們已知的,但是換一個角度來看,一個21億的數最后用32個二進制位就表示了,其實非常驚人。為什么說二進制是一個非常偉大的思想?不在於它難,而在於它高效地壓縮了數據。

我們進一步來看,32個二進制位為什么能表示這么大的數據呢?因為這32位int表示的數據是不一樣的,第0位表示1,第1位表示2,第2位表示4……到了第31位的時候,表示的數已經非常龐大。我們用這32個數不同的組合來表示不同的數,換句話說范圍內的所有數最終都變成了這32個數中若干個的累加。我們寫成公式就是: ,這里的 表示的是第i位的系數,它只有0和1兩個取值。

這個式子大家都熟悉,但是我們把它應用在方程當中可能很多人就不清楚了。比如說某個函數如果滿足這樣的性質: ,如果直接求 很麻煩,或者是開銷很大,我們就可以用 來獲得。同理,我們用在二進制上,我們可以得到:

看到了嗎,我們把 的值轉化成了最多32個值的和,在有些場景當中 是很容易計算的,但是 很難直接計算,這個時候我們通過二進制轉化就會很簡單。

同理,累加理解了,累乘也就水到渠成。如果某個函數 滿足: ,那么我們同樣可以用二進制來表達

對於多重背包這個問題,顯然我們滿足的是累加性質。也就是說,對於一個較大的x而言,我們可以用若干個子狀態累加得到。由於 ,所以我們很容易發現 ,也就是說這些子狀態之間彼此存在倍數關系。因此我們可以很輕松地計算出這些子狀態,再根據x的二進制表示來累加求到 ,而直接計算 則困難得多,計算量也大得多。

在這個問題當中,函數f表示的是我們拿取物品的價值。也就是說,某一種物品,假設最多有n個,並且單個的價值是p,那么我們拿取2個就是2p,拿取4個就是4p,對於所有2的冪個數的價值都很容易計算。我們需要枚舉這n個物品拿取的情況,我們枚舉的范圍應該是[0, n]。我們將n轉化成二進制之后,可以通過logn個2的冪排列組合的和得到[0, n]當中的任意一個數。那么,我們只需要將2的冪個數的物品看成是新的物品,這樣,我們可以用新的物品的01組合,來代替原物品拿取0-n的所有情況。

舉個例子,我們有一個物品一共有15個,價值是3,其中15= ,也就是說我們用4個二進制位就可以表示1-15這15這數字。那么我們用4種物品映射這4個二進制位之后,就可以用這4種物品的組合來表示獲取1-15個原物品了。也就是說我們把15個價值是3的物品打了四個包,第一個包里有一個,第二個包里有兩個,第三個包里有四個,第四個包里有八個。如果我們要拿3個原物品,相當於拿第一和第二個包裹。如果我們要拿5個原物品,相當於拿第一個和第三個包裹。這樣我們就把多重背包的問題轉化回了零一背包

我們之前說了,32位二進制位就可以表示20億以上的數,所以雖然我們進行二進制處理之后物品的數量會增多一些,但也是非常有限的。我們做個簡單的復雜度分析,假設物品的總數是N,每種物品最多M個,背包的容量是V。如果用朴素的拆分方法,復雜度是 ,而使用二進制拆分的復雜度是 。和前者相比,從M到logM是一個巨大的優化,尤其當M很大的時候。

最后,還有一個小問題,我們的物品數量並不一定剛好能分成若干個2的冪的和,這種情況下怎么辦呢?其實也簡單,我們把分剩下的部分單獨打一個包就好了。比如如果物品的數量是10,10=1+2+4+3,所以最后一個包就是3。雖然我們用1+2也能表示3,但是這並不會影響結果的正確性。

到這里,多重背包的解法就介紹完了,說了這么多其實也只是介紹了二進制表示這個方法而已。理解了這個方法,它就轉化成了零一背包。不得不說這個方法實在是非常巧妙,並且除了在背包問題之外,在許多其他問題中也有類似的運用。所以這個方法不建議錯過。

最后,我們來看下代碼,首先我們來看下二進制拆分的部分:

def binary_divide(cnt, volume, price):
divides = []
for i in range(32):
# 從0位開始枚舉
cur = 1 << i
# 如果小於枚舉值,說明已經拆分完畢了
if cnt < cur:
# 把剩下的部分打包
divides.append((cnt, cnt * volume, cnt * price))
break
else:
# 否則繼續拆分,打包1 << i個物品
cnt -= cur
divides.append((cur, cur * volume, cur * price))
return divides

進行完二進制拆分之后,這個問題就轉化成了零一背包。我們只需要套用零一背包的代碼就可以了:

# 物品,分別是數量,體積和單位價格
items = [(10, 3, 5), (5, 6, 3), (2, 2, 4)]
volume = 20
dp = [0 for _ in range(volume+1)]
new_items = []
for i in items:
# 二進制拆分
new_items.extend(binary_divide(*i))

for item in new_items:
v, p = item[1], item[2]
for i in range(volume-v, -1, -1):
dp[i + v] = max(dp[i+v], dp[i] + p)
print(dp[20])

通過神乎其神的二進制表示法,我們將多重背包問題又還原成了零一背包,不得不說實在是神奇。但二進制表示法並不是唯一的方案,我們也可以不用二進制來完成這道題。這涉及到一種全新的方法,由於篇幅限制,我們會在下篇文章當中和大家一起學習。

今天關於多重背包和完全背包的文章就到這里,如果覺得有所收獲,請順手點個關注或者轉發吧,你們的舉手之勞對我來說很重要。


免責聲明!

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



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