賊,夜入豪宅,可偷之物甚多,而負重能力有限,偷哪些才更加不虛此行?

前言

相信很多讀者看完標題會會心一笑,沒錯,今天咱們要介紹的是 0-1 揹包問題的解題思路。java

談動態規劃(簡稱 dp),揹包問題是繞不過去的話題,揹包問題能夠說是 dp 中的一種很是經典的問題了,掌握了揹包問題, dp 才能夠說是入門了,因此今天咱們來看看揹包問題怎麼解,揹包問題主要分爲 0-1 揹包,徹底揹包,多重揹包,其中掌握 0-1 揹包是基礎,徹底揹包,多重揹包實際上是在 0-1 揹包上的變形,因此咱們今天主要談談 0-1 揹包問題的解題技巧。git

咱們會分別用貪心, dp 兩種方法來進行解題,另外咱們以前在這篇文章裏詳細闡述了 dp 的解題技巧,其中提到了解 dp 解決的一種思路,先寫出狀態轉移方程,其實 dp 還能夠用另外的解題思路:狀態轉移表法。這篇文章咱們會一塊兒看看。github

從介紹中就能夠看到乾貨不少,建議先收藏再看,看完以後相信 0-1 揹包再也不是問題 ^_^算法

本文會從如下幾點來說解 0-1 揹包問題數組

  • 什麼是 0-1 揹包問題
  • 只考慮重量的揹包問題兩種解法
    1. 貪心算法
    2. dp 解法
  • 考慮物品重量及價值的 0-1 揹包問題

什麼是 0-1 揹包問題

簡單地說,就是有一組不一樣重量、不可分割的物品(每一個物品有且僅有一個),每一個物品都有其價值,咱們須要選擇一些裝入揹包,在知足揹包最大重量限制的前提下,求解揹包中物品總價值最大值。緩存

如圖示:已知揹包可承載 15 kg,怎麼裝其餘物品,能使裝入物品在知足裝入揹包總重量不超過 15 kg 的狀況下裝載的總價值最大。微信

若是考慮到物品價值確實比較複雜,那咱們先不考慮價值,看看先只考慮重量該怎麼解,即如何往揹包裏裝物品,使在不超過揹包最大可承載重量的狀況下裝入物品的總重量最大。函數

若是能求出最大可裝載重量的解,再求最大價值的解相信應該有跡可尋。優化

接下來咱們就用三種解法來試着求解 0-1 揹包問題中揹包可承載物品的最大重量和spa

貪心算法

以以上揹包問題爲例,揹包最大承載量是 15 kg,物品重量分別爲 12 kg,2 kg ,1 kg,1 kg,那麼用貪心算法行不行呢,試試看,貪心算法的每一步都是求問題的最優解,因此第一次選 12 kg 的物品,第二次 取 2 kg,第 3 次取 1 kg,恰好達到了揹包的最大承載量 15 kg,因此這麼看來用貪心算法能夠解咯?咱們在 上文學到貪心算法時提到若是一個問題用貪心算法得出的解是全局最優解,必定要警戒!是否只是相關的條件剛好知足貪心算法得出的解而已,若是我換個條件呢,好比我如今換成揹包的最大承載量是 10 kg,物品的重量分別爲 7 kg, 5 kg,4 kg,若是用貪心算法的話,第一次選 7 kg,接下來 5 kg,4 kg 都不能選了,因而用貪心算法得出的解是 7 kg,但實際上很明顯應該選 5 kg,4 kg,也就是說最大重量是 9 kg。因此說貪心算法不可行

dp

接下來開始咱們的重頭戲,來看看如何用 dp 來求解 0-1 揹包問題。

套用 dp 解題四步曲來求解,步驟以下:

一、判斷是否可用遞歸來解

顧名思義, 0-1 揹包問題中的 0-1 指的是每一個物品要麼選,要麼不選,選的話用 1 表示,不選的話用 0 表示,這顯然就是個組合嘛,把全部組合都窮舉出來再求出最大重量不就完了。既然是組合問題,那顯然能夠用遞歸求解(排列組合怎麼解,強烈看下這篇文章),沒看過也不要緊,我會一步步來帶你們看看如何用遞歸來解(強烈建議看下這篇遞歸解題的文章,本號成名做,好評如潮!)。

假設揹包的最大承載重量是 9。有 5 個不一樣的物品,每一個物品的重量分別是 2,2,4,6,3。物品編號從 0 到 4。

咱們定義 f(i, w) 爲將要決策第 i 個物品是否放入揹包時揹包當前的總重量爲 w,好比 f(1, 2) 即表明將要決策第 2(物品編號從 0 開始,因此是決策第 2 個物品) 個物品是否放入揹包,決策前揹包物品的總重量爲 2。

則不可貴出遞推公式以下

f(i+1, w) =
\begin{cases}
f(i, w), 第 i 個物品不放入揹包 \\
f(i, w+第 i 個物品重量), 第 i 個物品放入揹包
\end{cases}

何時終止呢,顯然是沒有物品可選或者揹包中選擇的物品總重量超過了揹包可承載的重量時。

有遞推公式也有終止條件,顯然符合遞歸的條件,因而咱們寫下了以下遞歸代碼

public class Solution {

    // 最終的解:即揹包可放入的最大重量
    private static int maxw = Integer.MIN_VALUE;

    // 每一個物品的重量
    private static int[] weights = {2,2,4,6,3};

    // 揹包能承載的最大重量
    private static final int knapsackWeigth = 9;

    private static void knapsack(int i, int w) {
        // 物品有多少個
        int weightSize = weights.length;

        // 物品選完或者揹包裏選的物品總重量超過了揹包可承載的總重量,遞歸結束
        if (i > weightSize-1 || w >= knapsackWeigth) {
            if (w <= knapsackWeigth) {
                maxw = Math.max(maxw, w);
            }
            return;
        }

        // 第 i 個物品不選
        knapsack(i + 1, w);

        if (w + weights[i] <= knapsackWeigth) {
            // 選了第 i 個物品
            knapsack(i + 1, w + weights[i]);
        }
    }

    public static void main(String[] args) {
        knapsack(0, 0);
        System.out.println("maxw = " + maxw);
    }
}
複製代碼

時間複雜度是多少呢,每一個物品要麼選要麼不選,兩種狀態,若是有 n 個物品,時間複雜度顯然是 O(2n),指數級,不可接受!

二、分析在遞歸的過程當中是否存在大量的重複子問題( dp 第二步)

怎麼分析是否有重複子問題,看不出來能夠畫出遞歸樹,經過分析不可貴出從 f(0,0) 開始的遞歸樹以下

圖中能夠看出存在重疊子問題,f(2,2) 與 f(3,4) 重複

三、採用備忘錄的方式來存子問題的解以免大量的重複計算(剪枝)

既然存在大量重複子問題,咱們就把這些子問題緩存住,避免重複計算,代碼以下

// 備忘錄,緩存子問題
private static HashMap mem = new HashMap<String, Integer>();

private static void knapsack(int i, int w) {
    // 物品有多少個
    int weightSize = weights.length;

    // 物品選完或者揹包裏選的物品總重量超過了揹包可承載的總重量,遞歸結束
    if (i > weightSize-1 || w >= knapsackWeigth) {
        if (w <= knapsackWeigth) {
            maxw = Math.max(maxw, w);
        }
        return;
    }

    String key = i + "," + w;
    // 有 value,說明子問題以前已經解過了,無需再計算!
    if (mem.get(key) != null) {
        return;
    }
    mem.put(key, 1);
    
    // 第 i 個物品不選
    knapsack(i + 1, w);

    if (w + weights[i] <= knapsackWeigth) {
        // 選了第 i 個物品
        knapsack(i + 1, w + weights[i]);
    }
}
複製代碼

緩存以後就作了大量減枝的操做,時間複雜度天然急劇降低

四、改用自底向上的方式來遞推,即 dp 解法

既然知足使用 dp 的條件 (遞歸+重疊子問題),那咱們來看看如何用 dp 來解,在咱們以前的一文學會 dp 解題技巧 文章中,咱們求解 dp 時都是要列出狀態轉移方程,但在 0-1 揹包問題中,若是用 dp 方程很差表示,因此咱們能夠看看動態規劃的另外一種也是比較經常使用的解法:狀態轉移表法

咱們知道,動態規劃實際上是把問題分解成了多個階段(或者說多個子問題),每一個階段的解實際上是由上個階段的解推導而來,因此咱們只要把每一個階段的全部解都保存下來,天然而然能推導到下一階段的全部解,這樣層層推導,求出最後一個階段的全部解以後,從最後一個階段的解中便可以得出全局的最優解,在保存每一個階段全部解的過程當中,其實咱們也合併了重複解,這樣也就避免了問題規模的指數增加。

如圖示:在第二階段解的過程當中,有兩個 f(2,2),咱們在保存的過程當中,實際上是把這兩個重複的解給合併成一個解了,避免了問題規模的指數增加!

如今問題來了,怎麼表示每一個階段的解呢,從以上遞歸樹的推導中,咱們不難發現應該是個二維數組(f(i, w) 有兩個變量,因此是個二維數組)。咱們定義這個二維數組爲 state[i][w], 表明第 i 個階段所能達到的全部狀態(從 0 到揹包能承載的重量)。

一樣,假設揹包的最大承載重量是 9。有 5 個不一樣的物品,每一個物品的重量分別是 2,2,4,6,3。**物品編號從 0 到 4 ** 舉個例子,對於第 0 個(物品編號從 0 開始)物品(重量爲 2)來講,它要麼選,要麼不選,選了的話揹包中物品的總重量爲 2,不選的話則爲 0 ,因而咱們有 state[0][0] = true, state[0][2] = true 這兩種狀態,填入狀態轉移表以下

第 1 個物品的重量爲 2,它也是要麼選,要麼不選,若是選了的話總重量爲 2 (第一個物品不選) 或 4(第一個物品選了),不選的話總重量爲 0(第一個物品不選) 或 2(第一個物品選了)。 因而咱們可知 state[1][0] = true, state[1][2] = true, state[1][4] = true, 填入狀態轉移表以下圖所示

依此類推不斷地決策後能夠把每一個階段的解都填滿,整個階段的狀態轉移表以下

最大重量和怎麼看呢,在最後一個階段決策後(最後一行),從右往左數第一個值爲 true 對應的重量,好比以上圖爲例,最後一個階段決策後,從右往左數第一個值爲 true 的狀態對應的重量爲 9,因此此揹包問題的最大重量和即爲 9。

思路有了,看下代碼如何實現的

/** * * @param weights 各個物品的重量 * @param knapsackWeight 揹包可承受的最大重量 * @return */
public int knapsack(int[] weights, int knapsackWeight) {
    int n = weights.length; // 物品個數
    boolean[][] states = new boolean[n][knapsackWeight+1];

    // 第一個物品不選
    states[0][0] = true;

    if (weights[0] <= knapsackWeight) {
        // 第一個物品選了
        states[0][weights[0]] = true;
    }

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < knapsackWeight + 1; j++) {
            // 第 i 個物品不放入揹包中
            if (states[i-1][j] == true) {
                states[i][j] = states[i-1][j];
            }
        }

        for (int j = 0; j <= knapsackWeight-weights[i]; ++j) {
            //把第i個物品放入揹包
            if (states[i-1][j]==true) {
                states[i][j+weights[i]] = true;
            }
        }
    }

    // 最後一個階段決策後,從最後一行右到左取第一個值爲 true 對應的重量,即爲所求解
    for (int j = knapsackWeight; j >= 0; j--) {
        if (states[n-1][j]) {
            return j;
        }
    }
    return 0;
}
複製代碼

時間複雜度是多少?兩重循環,顯然是 O(n2),空間複雜度呢,states 是個二維數組,因此也是 O(n2),空間複雜度可否優化,在以上的推導過程當中,其實咱們知道,當前階段的解,只與上一個階段的解有關,與上上個階段的解無關,也就是動態規劃的一個性質:無後效性,即當前階段只要知道上個階段的解便可,不關心上個階段的解是怎麼得來的。以咱們的狀態轉移表爲例

第五階段的解其實只與第四階段的解有關,與前面幾個階段的解無關,也就是說其實咱們只用一個一維數組保存每一個階段的解便可,這樣空間複雜度就從 O(n2) 變成了 O(n),來看看用一維數組怎麼作

/** * 使用一維數組來保存每一個階段的解 * @param weights 每一個物品的重量 * @param knapsackWeight 揹包可承受的最大重量 * @return */
public static int knapsack2(int[] weights, int knapsackWeight) {
    int n = weights.length; // 物品個數
    boolean[] states = new boolean[knapsackWeight+1];
    // 第一個物品不選
    states[0] = true;

    if (weights[0] <= knapsackWeight) {
        // 第一個物品選了
        states[weights[0]] = true;
    }

    for (int i = 1; i < n; i++) {
        for (int j = knapsackWeight - weights[i]; j >= 0; --j) {
            //把第i個物品放入揹包
            if (states[j]) {
                states[j + weights[i]] = true;
            }
        }
    }

    // 最後一個階段決策後,從最後一行右到左取第一個值爲 true 對應的重量
    for (int j = knapsackWeight; j >= 0; j--) {
        if (states[j]) {
            return j;
        }
    }
    return 0;
}
複製代碼

注意下面紅框的 代碼:

j 必須從後往前遍歷,而不能從 0 開始遍歷,若是從 0 開始遍歷,states 前面 index 的值會影響到後面值的計算,若是不理解,能夠本身動手試試,打下斷點調試一下。

考慮物品重量及價值的 0-1 揹包問題

以前咱們只考慮了物品重量的 0-1 揹包問題,接下來咱們考慮一下若是考慮物品價值狀況,怎麼在知足揹包最大承載重量的狀況下求物品的最大價值的解。

先考慮用遞歸求解,以前咱們是用 f(i, w) 表示選擇物品 i 前的重量爲 w,如今既然考慮價值,那咱們再加個價值變量不就完了,因而咱們定義 f(i, w, v) 爲選擇物品 i 前裝入揹包的總重量爲 w,總價值爲 v,代碼以下,改動其實很小

// 最終的解:即揹包可放入的最大重量
private static int cv = Integer.MIN_VALUE;

// 每一個物品的重量
private static int[] weights = {2,2,4,6,3};

// 每一個物品的價值
private static int[] values = {3,4,8,9,6};

// 揹包能承載的最大重量
private static final int knapsackWeigth = 9;

private static void knapsack(int i, int w, int v) {
    // 物品有多少個
    int weightSize = weights.length;

    // 物品選完或者揹包裏選的物品總質量超過了揹包可承載的總重量,遞歸結束
    if (i > weightSize-1 || w >= knapsackWeigth) {
        if (w <= knapsackWeigth) {
            cv = Math.max(cv, v);
        }
        return;
    }

    // 第 i 個物品不選
    knapsack(i + 1, w, v);

    if (w + weights[i] <= knapsackWeigth) {
        // 選了第 i 個物品
        knapsack(i + 1, w + weights[i], v + values[i]);
    }
}
複製代碼

以 f(0,0,0) 爲根節點畫出遞歸樹以下

能夠看到 f(2,2,4) 與 f(2,2,3) 這兩個至關於重複子問題,由於這二者的 i 與 w 是同樣的,而 4 的價值顯然比 3 高,因此應該選 f(2,2,4)。 同理,對於 f(3,4,8) 和 f(3,4,7) 來講,顯然應該選 f(3,4,8), 但須要注意的是雖然這兩對至關於重複子問題,但卻無法用備忘錄的形式來解,對於 f(i, w, v) 這個函數來講,若是用備忘錄模式,緩存的 key 是由 i, w, v 這三個變量決定的,i,w,v 相同,key 就相同,這樣緩存纔有意義,而由以上的遞歸樹可知,i,w,v 三者沒有徹底相同的,用備忘錄模式也就失去了意義,因此針對這種沒法用備忘錄但卻存在重複子問題的題型要特別注意要特別注意!

接下來咱們考慮用動態規劃怎麼作 首先咱們仍是定義二維數組 states[i][w+1] 爲每一層所能達到的狀態,不過此時它存的不是再是 true 了,而是存了當前狀態揹包物品裏最大的總價值。

/** * * @param weights 各個物品的重量 * @param weights 各個物品的價值 * @param knapsackWeight 揹包可承受的最大質量 * @return */
public static int knapsack(int[] weights, int values[], int knapsackWeight) {
    int n = weights.length; // 物品個數
    int[][] states = new int[n][knapsackWeight+1];

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < knapsackWeight+1; j++) {
            states[i][j]  = -1;
        }
    }

    // 第一個物品不選
    states[0][0] = 0;

    if (weights[0] <= knapsackWeight) {
        // 第一個物品選了
        states[0][weights[0]] = values[0];
    }

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < knapsackWeight + 1; j++) {
            // 第 i 個物品不放入揹包中
            if (states[i-1][j] > 0) {
                states[i][j] = states[i-1][j];
            }
        }

        for (int j = 0; j <= knapsackWeight-weights[i]; ++j) {
            //把第i個物品放入揹包
            if (states[i-1][j] >= 0) {
                states[i][j+weights[i]] = Math.max(states[i-1][j] + values[i], states[i][j+weights[i]]);
            }
        }
    }

    // 求出總價值的最大值
    int max = Integer.MIN_VALUE;
    for (int j = knapsackWeight; j >= 0; j--) {
        max = Math.max(max, states[n-1][j]);
    }
    return max;
}
複製代碼

時間和空間複雜度都是 O(n2),同理,咱們能夠對空間複雜度進行優化,咱們把二維數組改用一維數組表示,即 states[i][w+1] 改成 states[w+1],代碼以下

/** * 使用一維數組來保存每一個階段的解 * @param weights 各個物品的重量 * @param weights 各個物品的價值 * @param knapsackWeight 揹包可承受的最大質量 * @return */
public static int knapsack2(int[] weights, int values[], int knapsackWeight) {
    int n = weights.length; // 物品個數

    // 改用一維數組來保存每一個階段的狀態,減小空間複雜度
    int[] states = new int[knapsackWeight+1];

    for (int j = 0; j < knapsackWeight+1; j++) {
        states[j]  = -1;
    }

    // 第一個物品不選
    states[0] = 0;

    if (weights[0] <= knapsackWeight) {
        // 第一個物品選了
        states[weights[0]] = values[0];
    }

    for (int i = 1; i < n; i++) {
        for (int j = knapsackWeight-weights[i]; j >= 0; --j) {
            //把第i個物品放入揹包
            if (states[j] >= 0) {
                states[j+weights[i]] = Math.max(states[j] + values[i], states[j+weights[i]]);
            }
        }
    }

    // 全部階段結束後求出 states 中的最大解
    int max = Integer.MIN_VALUE;
    for (int j = knapsackWeight; j >= 0; j--) {
        max = Math.max(max, states[j]);
    }
    return max;
}
複製代碼

總結

本文詳細剖析了 0-1 揹包問題的解法,先從只考慮重量的解,再逐步過濾到考慮價值的 0-1 揹包問題,由淺入深,相信你們掌握了只考慮重量的 0-1 揹包問題的求解,再考慮揹包價值問題的話問題不大。這也提醒咱們,求解複雜的難題,一開始變量比較多可能很差考慮,能夠先把變量去掉看看是否有解題思路,解完以後再加變量求解也許會更簡單一些。

文中全部代碼已更新到個人 github 地址(github.com/allentofigh…

參考

極客時間 初識動態規劃:如何巧妙解決「雙十一」購物時的湊單問題? time.geekbang.org/column/arti…

歡迎關注公號「碼海」微信交流哦

相關文章
相關標籤/搜索