c语言贪心算法背包问题_GGTalk 中的算法知识 01背包问题
前幾天 GGTalk 發了一期關于算法類的播客,主持人磊子和嘉賓 WAMaker 都分享了很有趣的算法經歷。這一系列文會幫你梳理一下在這期電臺中,你應該知道的知識點。
這一篇來聊聊博客中 WAMaker?提及到的 01 背包問題。
[11:25] WAMaker:項目里面有這樣一個功能。從各個學科中抽取題目組成一道模擬試卷...我們如何抽取題目從而剛好令其分值湊夠滿分 10 分?
其實 WAMaker 具體的場景就是:有一個題庫假設有 N 道題,每一道題都會有他的分值 m[i] ,現在我需要用這些題目來湊成一個 total 分數的試卷,那么我該如何選擇。
為什么說這是一道 01 背包問題,我們來對比一下原題目的場景:有 N 件物品和一個容量為 V 的背包,第 i 件物品的費用是 c[i] ,體積是 w[i] ,求解將哪些物品裝入背包可使價值總和最大。
是不是和問題場景題目非常像?唯一不同的就是在題目湊分問題中,需要將總分湊到等于 total ,而下面的背包問題中,只是上限是 V 求一個最大值。
統一一下問題場景,我們把湊分的問題也當做是背包裝物品,其體積和價值相等,這時我們要背一個總和為 V 價值的物品。好了,統一了場景之后,我們來看這兩個情況。
背最大值情況
首先來看如何背一個體積上限為 V 但是價值總和最大呢?
先來考慮題目中有這么一個特點:每個物品只有一件,可以選擇放或者不放。那么我就來考慮每個東西是否要放!
我們從一個中間狀態來入手,假設背包中還有 V0 的容量剩余,那么我們放入第 i 個物品后,其容量就變為 V1 = V0 - w[i] ,并且我們原來背包中的總價值 C0 也就變成了 C1 = C0 + c[i] 。
我們將 C1 和 C0 當做自變量,將 i 也當做自變量,來思考一個函數:
F(i, w) 表示背包中在前 i 個物品中選擇了一些東西放入到容積是 w 的背包所產生的最大價值。
由于這個中間情況我們已經遍歷到了第 i 個物品,那么其前面的狀態就是考慮了 i - 1 個物品。所以我們從已經考慮了 i - 1 個物品出發,假設這時候我們要決定第 i 個物品是否要放入背包,如果不放的話我們的價值就不會變化,通過上述的二元函數中就有如下式子:
翻譯成文字就是對于容積是 w 的背包來說,考慮前 i - 1 個物品的最大值和考慮前 i 個物品包中價值和的最大值是一樣的。
那么第 i 個物品如果放進去了呢?我們要怎么表示遞推關系呢?如果能放下第 i 個物品,則肯定說明 w > w[i],此時我們要求放入第 i 個物品的價值,由于我們計算的都是在 w 容積下的最大值,那么放了 w[i] 容積的物品之后,其實是一個 w - w[i] 容積的背包。那其實,我們只要知道我們在考慮前 i - 1 的物品放入 w - w[i] 容積的背包的最大價值,再加上第 i 個物品的價值,其實就是我們要求的 F(i, w) 了“
如此,我們通過 i 和容積 w 為這個中間情況得到了兩個遞推式,下面我們將其做一下結合。考慮到我們的 F 函數代表的是包中每種狀態下能放入的最大值,那么結合以上兩個式子,可以推導出完整的 F(i, w) 表達式:
在上面兩個式子取最大值,就可以完整的將 i - 1 狀態推導到 i 狀態。其中我們借用了包的最大容量 w 也做為一個狀態的影響因素,所以這復合一個二維動態規劃的模型。這個用來表示兩次狀態的遞推式,在動態規劃中就叫做狀態轉移方程,這也就是動態規劃問題的核心要素,一般只要確定了狀態轉移,基本上依照這個思路就能編程解決了。
但是這個式子要怎么理解呢?之前為也用了很長一段時間來理解這個 01 背包的狀態轉移方程,最后發現最好的理解方法是來畫一張表。
這里我舉一個例子,有五個物品,屬性如下,背包的總體積是 10 :
來看動態規劃的狀態轉移,紅色的地方就是狀態轉換的地方:
給出一個描述吧,例如第二行標紅的價值為 10 的位置,推導式如下:
這個例子表示了當考慮了 a 和 b 物品的取舍時,如果背包的容量最大是 9,可取得最大的物品價值是 10。
那么要如何寫代碼來實現?可以把上表中的行和列作為而一個二維數組的下標,利用以上規律來迭代完成。
# 物品容積w = [0, 4, 5, 5, 2, 2]
# 物品價值
c = [0, 6, 4, 6, 3, 6]
# 背包大小
W = 10
# 初始化 dp 狀態數組
dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]
for i in range(1, len(w)):
for j in range(0, W + 1):
if (j < w[i]):
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + c[i])
# debug
for i in range(1, len(w)):
print(dp[i])
print(f'可以裝最大價值是 {dp[-1][-1]}')
"""
[0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6]
[0, 0, 0, 0, 6, 6, 6, 6, 6, 10, 10]
[0, 0, 0, 0, 6, 6, 6, 6, 6, 12, 12]
[0, 0, 3, 3, 6, 6, 9, 9, 9, 12, 12]
[0, 0, 6, 6, 9, 9, 12, 12, 15, 15, 15]
可以裝最大價值是 15
"""
背準確值情況
回歸到 WAMaker 所說的,如果我們想對一個背包背指定大小的容量總和,這個要怎么實現呢?
我們繼續從上面的背包來看這個問題。如果我們將物品的體積當做題目的分數,物體的價值等于物體的體積,也就是分數在這個場景下既是體積,也是價值。背包的大小等于試卷的總分值,就轉化成為了一道 01 背包問題。
但是背包問題中,我們最后的求解是最大能獲得多少價值,在我們這個場景下,是需要獲得準確的分數,這個要怎么處理?
還是需要從上方的狀態轉移來尋找思路?
我們觀察到第二行的狀態值都是由第一行對應相同顏色的狀態值轉移過來的。而第一行無物品的狀態都是由我們定義的。當無物品狀態時,背包大小再怎么擴大,其價值都是 0。這種定義在背包問題下看起來是合適的。但是如果我們不把它當做背包容量,而是當做試卷滿分,那么當試卷滿分是 0 分的時候,無題目才是有效的(題目分數相加總和等于總分數),否則我們做為無效狀態。
從這個角度出發,我們定義一個負無窮 -∞ 狀態,并且規定負無窮加上任何正數都是負無窮。如此,我們將有意義的初始狀態填 0,而無意義的都用負無窮來填充。我們來看看上述的狀態轉移變成了什么?
通過這種方式,我們剩下的都是剛好填滿背包的情況。總結下來,我們只需要修改無物品時的初始狀態,就可以解決背準確值問題。使用代碼來實現一下:
# 物品容積w = [0, 4, 5, 5, 2, 2]
# 物品價值
c = [0, 6, 4, 6, 3, 6]
# 背包大小
W = 10
# 初始化 dp 狀態數組
dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]
for i in range(1, len(dp[0])):
dp[0][i] = -1e5
for i in range(1, len(w)):
for j in range(0, W + 1):
if (j < w[i]):
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + c[i])
if dp[i][j] < 0:
dp[i][j] = -1e5
for i in range(1, len(w)):
for j in range(len(dp[i])):
print('-∞' if dp[i][j] < 0 else dp[i][j], '\t', end='')
print('\n')
"""
表中所有的數字都是可以正好裝滿時候的價值總和
0 -∞ -∞ -∞ -∞ -∞ -∞ -∞ -∞ -∞ -∞
0 -∞ -∞ -∞ 6 -∞ -∞ -∞ -∞ -∞ -∞
0 -∞ -∞ -∞ 6 4 -∞ -∞ -∞ 10 -∞
0 -∞ -∞ -∞ 6 6 -∞ -∞ -∞ 12 10
0 -∞ 3 -∞ 6 6 9 9 -∞ 12 10
0 -∞ 6 -∞ 9 6 12 12 15 15 10
"""
路徑記錄
上面所有的記錄和解法,我們雖然了解決背包中能裝的最大價值以及題目能廣告湊出的總分值情況,但是我們無法求得在取得最大價值中我們具體裝了哪些物品、在湊得指定總分時我們收錄了哪幾道題目。這其實就是 dp 中的記錄路徑。
我們繼續使用上述的狀態轉移表,以 dp[5][10] 這個最終結果為例:
其實這里的路徑分成兩種情況
同列相當于沒有增加物品;
非同列相當于之前的狀態增加新的物品;
我們注意第二種情況,在狀態轉移的時候來記錄這個一個關系標記 col[i][j] ,之后從后向前將每個物品的選用狀況就可以了。
# 物品容積w = [0, 4, 5, 5, 2, 2]
# 物品價值
c = [0, 6, 4, 6, 3, 6]
# 背包大小
W = 10
# 初始化 dp 狀態數組
dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]
# 標記數組
col = [[0 for _ in range(W + 1)] for _ in range(len(w))]
for i in range(1, len(w)):
for j in range(0, W + 1):
# 容量不夠放
if (j < w[i]):
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j]
# 如果發現從上一狀態放入當前物品價值更大,則放入記錄價值并記錄路徑
if dp[i - 1][j - w[i]] + c[i] > dp[i][j]:
dp[i][j] = dp[i - 1][j - w[i]] + c[i]
col[i][j] = 1
for i in range(0, len(w)):
for j in range(len(dp[i])):
print('-∞' if dp[i][j] < 0 else dp[i][j], '\t', end='')
print('')
print("總價值", dp[-1][-1])
# 從最后一個物品向上查詢路徑,即裝入物品
print("選擇的物品有:")
i, j = len(dp) - 1, len(dp[0]) - 1
while i > 0 and j > 0:
if col[i][j] == 1:
print(f'{i}({w[i]}, {c[i]}) ', end="")
j -= w[i]
i -= 1
"""
0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 6 6 6 6 6 6 6
0 0 0 0 6 6 6 6 6 10 10
0 0 0 0 6 6 6 6 6 12 12
0 0 3 3 6 6 9 9 9 12 12
0 0 6 6 9 9 12 12 15 15 15
總價值 15
選擇的物品有:
5(2, 6) 4(2, 3) 1(4, 6)
"""
代碼實現 WAMaker 試卷問題
上面講了這么多的背包問題,接下來我們用 WAMaker 的實際場景來寫一個試卷湊分問題的 Demo。假設我們要湊夠一個 100 分的試卷,我們手上的題目有 5 分、10 分、16 分、20 分、14 分、30 分、36 分、40 分、45 分的題目各一道,那么我們要如何選擇題目湊足成一個 100 分的試卷呢?
我們將上述的的問題直接套入到前面 01 背包問題中背準確值情況來計算:
# 題目分數w = [0, 5, 10, 16, 20, 14, 30, 36, 40, 45]
c = w
# 總分
W = 100
# 初始化 dp 狀態數組
dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]
for i in range(1, len(dp[0])):
dp[0][i] = -1e5
# 標記數組
col = [[0 for _ in range(W + 1)] for _ in range(len(w))]
for i in range(1, len(w)):
for j in range(0, W + 1):
if (j < w[i]):
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j]
if dp[i - 1][j - w[i]] + c[i] > dp[i][j]:
dp[i][j] = dp[i - 1][j - w[i]] + c[i]
col[i][j] = 1
dp[i][j] = -1e5 if dp[i][j] < 0 else dp[i][j]
print("總分", dp[-1][-1])
print("選擇題目有:")
i, j = len(dp) - 1, len(dp[0]) - 1
while i > 0 and j > 0:
if col[i][j] == 1:
print(f'題目{i}({c[i]}分) ', end="")
j -= w[i]
i -= 1
"""
總分 100
選擇題目有:
題目7(36分) 題目6(30分) 題目5(14分) 題目4(20分)
"""
做到這里,我們已經解決了 WAMaker 問題。但是其實是無法覆蓋全部場景的。這里我提幾個延伸問題:
如果有些習題類型相同,所以造成有問題 A 就不能出現問題 B 的場景;
有些習題是一種延伸習題(思考題),例如 C 問題出現的前提是有 D 問題已經出現;
有些習題的分數是泛化的,這些習題只是有會隨著某些因素而變化分值,從而造成了這些習題的分值是某個區間;
當然可以猜測的需求還有很多,但是以上三個需求分別對應背包問題中的三個子類型:分組背包、有依賴背包和泛化物品。有興趣的讀者可以去看 Tianyi Cui 大牛寫的《背包問題九講》來針對性的看背包問題。
空間復雜度優化 - 滾動數組
在上面所有的代碼中,為了方便大家理解我全部都使用了二維的 dp 狀態數組來推導所有的子狀態。但其實,背包問題由于我們只需要一個待求的子狀態,所以中間狀態在遞推的規律中只為下一個狀態使用一次。所以我們可以將 dp 數組使用一維數組來做空間上的優化。
for i in range(1, len(w)):for j in range(W, w[i] - 1, -1):
dp[j] = max(dp[j], dp[j - w[i]] + c[i])
可以繼續利用前文狀態數組的推理的方法,大家自行去演繹一下。這里我就不再贅述了。
對于 bitset 還有一點要說的
在 WAMaker 的試卷問題中,其實如果我們不需要求具體湊分的每一道題,而是只判斷是否已知題庫中的題目可以湊成一個指定 X 分的試卷的可行性時,我們沒有必要去維護一個這么重的 dp 數組。在這個情況下,又一個數據結構叫做 bitset 就可以完成判斷湊數可行的方法。
例如我們用上面的實例來計算是否可以湊成 100 分,103 分和 105 分(Python 中 bitset 不是很了解,換成 C++)。
#includeusing namespace std;
int a[10] = {0, 5, 10, 16, 20, 14, 30, 36, 40, 45};
bitset<120> bs;
int main() {
bs[0] = 1;
for (int i = 1; i < 10; ++ i) {
bs |= bs << a[i];
}
cout << bs[100] << endl; // 1
cout << bs[103] << endl; // 0
cout << bs[105] << endl; // 1
}
我們可以考慮一下 bs |= bs << a[i] 這個操作,其實他把每一位都當做是否可表示的數,1 表示可以湊出,0 表示不能。每一次的左移就是在讓當前的答案都增加 a[i] ,位或運算則代表增加新的可表示數。
是不是很巧妙?
但是有一個弊端,就是不能記錄路徑,從而得到具體的情況。但其實也是有方法的,只不過又要開一個數組來表示。這個留給大家思考。
總結
01 背包問題是背包問題的基礎,也是動態規劃問題的經典范例問題。根據筆者的經驗,在學習背包問題的時候是最容易找到動態規劃的解題感覺的。希望通過這類題目也給你有發散性的思維。它在面試中和業務中都不常用,但是一旦遇到只有學習過的人才有思路。
運籌學是應用數學中接近生活且有趣的分支學科,強大的動態規劃就是其中的研究方向之一。希望這篇小的知識點也能幫助你激發學習的樂趣,并且知道我們的計算機是能解決更多數學問題的強大工具。?
總結
以上是生活随笔為你收集整理的c语言贪心算法背包问题_GGTalk 中的算法知识 01背包问题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 飞傲 KB3 HiFi 音乐机械键盘明日
- 下一篇: 阿里CEO吴泳铭:阿里云专注AI和公共云