01揹包問題完全詳解

揹包問題 (Knapsack problem) 是一種組合優化的 NP 完全問題。問題可以描述爲:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。問題的名稱來源於如何選擇最合適的物品放置於給定揹包中。相似問題經常出現在商業、組合數學,計算複雜性理論、密碼學和應用數學等領域中。

本文主要介紹最常見的 01 揹包問題,且只介紹動態規化的解法。

1. 基本題目描述與分析

問題描述

現有 NN 物品和一個容量爲 CC 的揹包。放入第 i (1N)i\ (1\dots N) 件物品耗費的空間是 wiw_i ,得到的價值是 viv_i 。求解將哪些物品裝入揹包可使價值總和最大。

之所以叫做 “0-1” 揹包是因爲在這裏,每個物件只有一個。如果我們把 x\vec{x} 視爲解空間中的某一個解,那麼可以這麼來表述:

xi={0第 i 件物品未放入揹包1第 i 件物品被放入揹包i=1,2,,N x_i = \begin{cases} &&0 &\text{第 i 件物品未放入揹包}\\ &&1 &\text{第 i 件物品被放入揹包} \end{cases} \quad i = 1, 2,\dots, N

這個問題的形式化描述如下:

maxxi=1Nvixis.t.{i=1NwixiCxi{0,1} (1iN) \max_{\vec{x}}\sum_{i=1}^N{v_ix_i}\\ \text{s.t.} \left\{ \begin{aligned} &\sum_{i=1}^N{w_ix_i} \leqslant C\\ &x_i \in \{0, 1\}\ (1\leqslant i \leqslant N) \end{aligned} \right.

分析

爲了使用動態規化,我們首先定義目標函數 F(i, c)F(i,\ c),它表示前 i 個物品放入容量爲 c 的揹包裏最大的收益價值爲多少

首先我們只考慮前 i 個物品放入容量爲 c 的揹包裏收益價值可以有多少,它可以分爲兩種情況:

  • 把物品 i 放入揹包中,則價值爲 F(i1, cwi)+viF(i - 1,\ c - w_i) + v_i
  • 不把物品 i 放入揹包中,則價值爲 F(i1, c)F(i - 1,\ c)

F(i, c)F(i,\ c) 應當取這兩者的較大值。

不過我們上面的考慮有一個疏漏,沒有考慮到物品 i 放不進去的情況,在這種情況下 F(i, c)=F(i1, c)F(i,\ c) = F(i - 1,\ c)

再考慮初始狀況,則有:

{F(0, 1C)=0F(1N, 0)=0 \begin{cases} F(0,\ 1\dots C) = 0\\ F(1\dots N,\ 0) = 0\\ \end{cases}

前一個表示不把任何物品放入揹包,後一個表示揹包容量爲 0。顯然兩種情況下,最大的價值都只能爲 0。

這樣一來,我們就有了完整的目標函數的定義:

F(i, c)={0c=0  or i=0F(i1, c)c<wimax{F(i1,c),F(i1, cwi)+vi}other cases F(i,\ c) = \\ \left\{ \begin{aligned} &0 &c = 0\ \text{ or } i = 0\\ &F(i-1,\ c) &c < w_i\\ &\max{\{F(i-1, c), F(i - 1,\ c - w_i) + v_i\}} &\text{other cases} \end{aligned} \right.

代碼實現

有了以上的分析,我們可以得出基本的算法框架。我們一個一個地考察物品,每次都嘗試着把一個新的物品放入到揹包中,看會發生什麼,直到所有的 N 的物品全部考察完畢爲止

F[0, 0..C] = 0
for i = 1 to N
    for c = Ci, i = 1 to C
        if i 可以放入容量爲 c 的揹包
            F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi}
        else
            F[i, c] = F[i - 1, c]
        end
    end
end

具體實現如下:

const int items = 10;
const int capacity = 20;
int weight[items + 1];
int value[items + 1];
bool used[items + 1];
//dp[i][j]: 前 i 件物品放入容量爲 j 的揹包裏產生的最大價值
int dp[items + 1][capacity + 1];

void Knapsack()
{
    //放入容量爲 0 的揹包裏,價值爲 0
    for (int i = 0; i <= items; ++i)
        dp[i][0] = 0;
    //沒有東西放入,價值爲 0
    for (int c = 0; c <= capacity; ++c)
        dp[0][c] = 0;
    //DP
    int v_i, w_i;
    for (int i = 1; i <= items; ++i)
    {
        for (int c = 1; c <= capacity; ++c)
        {
            //第 i 件物品的價值和重量 (1 <= i <= items)
            v_i = value[i];
            w_i = weight[i];
            //如果剩餘的容量比 w_i 還小,那物品 i 肯定放不進來
            if (c < w_i)
            {
                dp[i][c] = dp[i - 1][c];
            }
            //否則,就有兩種選擇: 放入或者不放入
            else
            {
                //方案一: 不把物品 i 放入揹包
                //則獲得的價值和把前 i - 1 個物品放入容量爲 c 的揹包中相同
                int reward1 = dp[i - 1][c];
                //方案二: 把物品 i 放入揹包
                int reward2 = dp[i - 1][c - w_i] + v_i;
                //取價值更大的一種方案
                dp[i][c] = max(reward1, reward2);
            }
        }
    }
}

int main()
{
    for (int i = 1; i <= items; ++i)
    {
        cout << "Item " << i << ": ";
        cin >> weight[i] >> value[i];
    }
    Knapsack();
    cout << dp[items][capacity] << endl;
    return 0;
}

查看使用了哪些物品

通過 dp 數組可以反向查看哪些物品放入了揹包。思路很簡單,從後往前遍歷 dp,如果 dp[i][c] > dp[i - 1][c],說明物品 i 肯定放入揹包中了。

void UseWhich()
{
    //首先從 dp[items][capacity] 查起
    int c = capacity;
    for (int i = items; i >= 1; --i)
    {
        //看看是不是用了
        if (dp[i][c] > dp[i - 1][c])
        {
            used[i] = true;
            c = c - weight[i];
        }
        else
            used[i] = false;
    }
    int v = 0;
    c = capacity;
    for (int i = 1; i <= items; ++i)
    {
        if (used[i])
        {
            v += value[i];
            c -= weight[i];
            cout << "Item " << i << " used! ";
            cout << "Value: " << value[i];
            cout << ", Weight: " << weight[i];
            cout << ", TotalValue: " << v;
            cout << ", LeftCapacity: " << c << endl;
        }
    }
}

2. 優化空間複雜度

以上方法的時間和空間複雜度均爲 O(N×C)O(N\times C) ,其中時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到 O(C)O(C)

我們來仔細看看用到的狀態轉移式子:

F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi}
F[i, c] = F[i - 1, c]

把算法抽象一下,其實是這樣的:

for i = 1 to N
    for c = c1 to cn
        F[i, c] = F[i - 1, xxx]

也就是說要填充 F[i, c] 的內容,需要用的兩部分數據:

  • 同一行的某個數據
  • 上一行正對應的數據

稍加思考我們發現這個迭代式子完全可以改成這樣:

for i = 1 to N
    for c = c1 to cn
        F[c] <- F[xxx]

爲啥?因爲前面的 i - 1 行數據壓根就用不到啊!既然用不到,那幹嘛不乾脆扔了算了呢?

在這裏插入圖片描述

這樣的思路,叫做“滾動數組”,這在動規中是一個很常用的縮減空間開銷的法子。用了滾動數組後,原本的 dp[items][capacity] 就變成了 dp[capacity]。在整個外層循環期間,我們可以把 dp[capacity] 形象地想象成一個在 dp[items][capacity] 上不斷往下滾動的窗口,透過窗口,我們看到的就是當前行的 dp[items][capacity] 的數據。由於只保留了當前的數據,以前的數據全部丟棄,所以大大減少了空間消耗。

在這裏插入圖片描述

特別需要注意的是,實現的時候內層循環要從後往前來進行

這是因爲從 F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi} 可以看出,F[i, c] 要用到老數據中考前部分的數據,如果從前往後更新,就會造成信息的丟失。

int dp[MAX_CAPACITY + 1];
void Knapsack()
{
    //在空間爲 0 的揹包內放入
    dp[0] = 0;
    for (int i = 1; i <= items; ++i)
    {
        for (int c = capacity; c >= 1; --c)
        {
            //只有在能放入物品的時候才做一下更新
            if (c >= weight[i])
            {
                //不放入
                int reward1 = dp[c];
                //放入
                int reward2 = dp[c - weight[i]] + value[i];
                dp[c] = max(reward1, reward2);
            }
        }
    }
}

3. 初始化條件

我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。

有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 F[0]F[0] 爲 0 ,其
F[1...C]F[1...C] 均設爲 -∞ ,這樣就可以保證最終得到的 F[C]F[C] 是一種恰好裝滿揹包的最優解。

如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該
F[0...C]F[0...C] 全部設爲 0。

可以這樣理解:初始化的 FF 數組事實上就是在沒有任何物品可以放入揹包時的合法狀態

如果要求揹包恰好裝滿,那麼此時只有容量爲 0 的揹包可以在什麼也不裝且價值爲 0 的情況下被“恰好裝滿”,其它容量的
揹包均沒有合法的解,屬於未定義的狀態,應該被賦值爲 -∞ 了。

如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的
價值爲 0,所以初始時狀態的值也就全部爲 0 了。

參考資料

  • 《揹包九講》
  • 《算法設計與分析》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章