1. 程式人生 > 實用技巧 >經典動態規劃:0-1 揹包問題

經典動態規劃:0-1 揹包問題

-----------

後臺天天有人問揹包問題,這個問題其實不難啊,如果我們號動態規劃系列的十幾篇文章你都看過,藉助框架,遇到揹包問題可以說是手到擒來好吧。無非就是狀態 + 選擇,也沒啥特別之處嘛。

今天就來說一下揹包問題吧,就討論最常說的 0-1 揹包問題。描述:

給你一個可裝載重量為 W 的揹包和 N 個物品,每個物品有重量和價值兩個屬性。其中第 i 個物品的重量為 wt[i],價值為 val[i],現在讓你用這個揹包裝物品,最多能裝的價值是多少?

舉個簡單的例子,輸入如下:

N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]

演算法返回 6,選擇前兩件物品裝進揹包,總重量 3 小於 W

,可以獲得最大價值 6。

題目就是這麼簡單,一個典型的動態規劃問題。這個題目中的物品不可以分割,要麼裝進包裡,要麼不裝,不能說切成兩塊裝一半。這就是 0-1 揹包這個名詞的來歷。

解決這個問題沒有什麼排序之類巧妙的方法,只能窮舉所有可能,根據我們「動態規劃詳解」中的套路,直接走流程就行了。

動規標準套路

看來我得每篇動態規劃文章都得重複一遍套路,歷史文章中的動態規劃問題都是按照下面的套路來的。

第一步要明確兩點,「狀態」和「選擇」

先說狀態,如何才能描述一個問題局面?只要給幾個物品和一個揹包的容量限制,就形成了一個揹包問題呀。所以狀態有兩個,就是「揹包的容量」和「可選擇的物品」

再說選擇,也很容易想到啊,對於每件物品,你能選擇什麼?選擇就是「裝進揹包」或者「不裝進揹包」嘛

明白了狀態和選擇,動態規劃問題基本上就解決了,只要往這個框架套就完事兒了:

for 狀態1 in 狀態1的所有取值:
    for 狀態2 in 狀態2的所有取值:
        for ...
            dp[狀態1][狀態2][...] = 擇優(選擇1,選擇2...)

PS:此框架出自歷史文章 團滅 LeetCode 股票問題

第二步要明確 dp 陣列的定義

首先看看剛才找到的「狀態」,有兩個,也就是說我們需要一個二維 dp 陣列。

dp[i][w] 的定義如下:對於前 i 個物品,當前揹包的容量為 w,這種情況下可以裝的最大價值是 dp[i][w]

比如說,如果 dp[3][5] = 6

,其含義為:對於給定的一系列物品中,若只對前 3 個物品進行選擇,當揹包容量為 5 時,最多可以裝下的價值為 6。

PS:為什麼要這麼定義?便於狀態轉移,或者說這就是套路,記下來就行了。建議看一下我們的動態規劃系列文章,幾種套路都被扒得清清楚楚了。

根據這個定義,我們想求的最終答案就是 dp[N][W]。base case 就是 dp[0][..] = dp[..][0] = 0,因為沒有物品或者揹包沒有空間的時候,能裝的最大價值就是 0。

細化上面的框架:

int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0

for i in [1..N]:
    for w in [1..W]:
        dp[i][w] = max(
            把物品 i 裝進揹包,
            不把物品 i 裝進揹包
        )
return dp[N][W]

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。

第三步,根據「選擇」,思考狀態轉移的邏輯

簡單說就是,上面偽碼中「把物品 i 裝進揹包」和「不把物品 i 裝進揹包」怎麼用程式碼體現出來呢?

這就要結合對 dp 陣列的定義和我們的演算法邏輯來分析了:

先重申一下剛才我們的 dp 陣列的定義:

dp[i][w] 表示:對於前 i 個物品,當前揹包的容量為 w 時,這種情況下可以裝下的最大價值是 dp[i][w]

如果你沒有把這第 i 個物品裝入揹包,那麼很顯然,最大價值 dp[i][w] 應該等於 dp[i-1][w],繼承之前的結果。

如果你把這第 i 個物品裝入了揹包,那麼 dp[i][w] 應該等於 dp[i-1][w - wt[i-1]] + val[i-1]

首先,由於 i 是從 1 開始的,所以 valwt 的索引是 i-1 時表示第 i 個物品的價值和重量。

dp[i-1][w - wt[i-1]] 也很好理解:你如果裝了第 i 個物品,就要尋求剩餘重量 w - wt[i-1] 限制下的最大價值,加上第 i 個物品的價值 val[i-1]

綜上就是兩種選擇,我們都已經分析完畢,也就是寫出來了狀態轉移方程,可以進一步細化程式碼:

for i in [1..N]:
    for w in [1..W]:
        dp[i][w] = max(
            dp[i-1][w],
            dp[i-1][w - wt[i-1]] + val[i-1]
        )
return dp[N][W]

最後一步,把偽碼翻譯成程式碼,處理一些邊界情況

我用 C++ 寫的程式碼,把上面的思路完全翻譯了一遍,並且處理了 w - wt[i-1] 可能小於 0 導致陣列索引越界的問題:

int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
    // base case 已初始化
    vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
    for (int i = 1; i <= N; i++) {
        for (int w = 1; w <= W; w++) {
            if (w - wt[i-1] < 0) {
                // 這種情況下只能選擇不裝入揹包
                dp[i][w] = dp[i - 1][w];
            } else {
                // 裝入或者不裝入揹包,擇優
                dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1], 
                               dp[i - 1][w]);
            }
        }
    }
    
    return dp[N][W];
}

至此,揹包問題就解決了,相比而言,我覺得這是比較簡單的動態規劃問題,因為狀態轉移的推導比較自然,基本上你明確了 dp 陣列的定義,就可以理所當然地確定狀態轉移了。

_____________

我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!