1. 程式人生 > 實用技巧 >《資料結構與演算法之美》27——初識動態規劃

《資料結構與演算法之美》27——初識動態規劃

前言

今天開始學習動態規劃,一共有三節,分別是:初識動態規劃、動態規劃理論、動態規劃實戰。今天這一節就是初識動態規劃。

動態規劃比較適合用來求解最優問題,比如最大值、最小值等等。它可以非常顯著地降低時間複雜度,提高程式碼的執行效率。

下面會通過兩個非常經典的動態規劃問題模型來展示為什麼需要動態規劃,以及動態規劃解題方法是如何演化出來的。

0-1揹包問題

對於一組不同重量、不同分割的物品,我們需要選擇一些裝入揹包,在滿足揹包最大重量限制的前提下,揹包中物品總重量的最大值是多少呢?

這問題可以通過回溯演算法來解決,程式碼如下:

public class Soluction {
    private int maxW = int.MinValue; // 結果
    private int[] weight = new int[] { 2, 2, 4, 6, 3 }; // 物品重量
    private int n = 5; // 物品個數
    private int w = 9; // 揹包最大承受

    public void F (int i, int cw) {
        if (cw == w || i == n) { // cw==w表示揹包滿了,i==n表示物品都考察完了
            if (cw > maxW) maxW = cw;
            return;
        }

        F (i + 1, cw); // 選擇不裝第i個物品
        if (cw + weight[i] <= w) {
            F (i + 1, cw + weight[i]); // 選擇裝第i個物品
        }
    }

    public int GetResult () {
        return maxW;
    }
}

回溯演算法可以通過遞迴樹來分析,如下圖:

通過遞迴樹可計算回溯演算法的時間複雜度,時間複雜度是指數級的\(O(2^n)\),非常高。

通過遞迴樹可知,回溯演算法中存在大量的重複計算,這個可以通過“備忘錄”的方法來優化,優化後的程式碼如下:

// 回溯演算法優化,增加“備忘錄”
public class Soluction2 {
    private int maxW = int.MinValue; // 結果
    private int[] weight = new int[] { 2, 2, 4, 6, 3 }; // 物品重量
    private int n = 5; // 物品個數
    private int w = 9; // 揹包最大承受
    private bool[][] mem = new bool[5][]; // 備忘錄,預設值false

    public Soluction2 () {
        for (int i = 0; i < 5; i++) {
            mem[i] = new bool[10];
        }
    }

    public void F (int i, int cw) {
        if (cw == w || i == n) { // cw==w表示揹包滿了,i==n表示物品都考察完了
            if (cw > maxW) maxW = cw;
            return;
        }
        if (mem[i][cw]) return; // 重複狀態
        F (i + 1, cw); // 選擇不裝第i個物品
        if (cw + weight[i] <= w) {
            F (i + 1, cw + weight[i]); // 選擇裝第i個物品
        }
    }

    public int GetResult () {
        return maxW;
    }
}

下面我們來看一下動態規劃是如何解決這個問題的。

  1. 把整個求解過程分為n個階段,每個階段會決策一個物品是否放到揹包中。
  2. 把每一層重複的狀態合併,只記錄不同的狀態,然後基於上一層的狀態集合來推導下一層的狀態集合。
  3. 以此類推,直到考察完所有物品。只需要在最後一層,找一個值為true的最接近w的值,就是揹包中物品總重量的最大值。

整個過程如下圖:

翻譯成程式碼如下:

public int Knapsack (int[] weight, int n, int w) {
    bool[][] states = new bool[n][]; // 預設值false
    for (int i = 0; i < n; i++) states[i] = new bool[w + 1];

    states[0][0] = true; // 第一行的資料要特殊處理,可以利用哨兵優化
    if (weight[0] <= w) states[0][weight[0]] = true;

    for (int i = 1; i < n; i++) // 動態規劃狀態轉移
    {
        for (int j = 0; j <= w; j++) // 不把第i個物品放入揹包,則把上一輪的狀態移下來
            if (states[i - 1][j]) states[i][j] = states[i - 1][j];

        for (int j = 0; j <= w - weight[i]; j++) // 放第i個物品,在不超出揹包最大承重w的範圍內,再判斷上一輪的狀態,來設定
            if (states[i - 1][j]) states[i][j + weight[i]] = true;
    }

    for (int i = w; i >= 0; i--) // 從最後一行(下標n-1)找到值為true的位置,即為結果
        if (states[n - 1][i]) return i;

    return 0;
}

這個程式碼的時間複雜度是O(n * w),其中n表示物品個數,w表示揹包可以承載的總重量。空間複雜度也是O(n * w),使用了個二維陣列來儲存狀態。

實際上空間複雜度可以進一步程式碼,只需要w + 1的空間即可。程式碼優化如下:

public int Knapsack2 (int[] weight, int n, int w) {
    bool[] states = new bool[w + 1];
    states[0] = true;
    if (weight[0] <= w) states[weight[0]] = true;

    // 對比上面的程式碼,主要是不再儲存每一輪的狀態,而是合併儲存狀態。
    for (int i = 1; i < n; i++) {
        for (int j = w - weight[i]; j >= 0; --j) // 對比上面的程式碼,這裡是從大到小遍歷,目的是為了避免狀態的修改,影響後續的結果。
            if (states[j]) states[j + weight[i]] = true; 
    }

    for (int i = w; i >= 0; --i)  
        if (states[i]) return i;

    return 0;
}

對比上面的程式碼,上面程式碼是儲存每一輪的狀態,而實際上這個問題只需要最後一輪的狀態即可。但是要注意第8行程式碼,j是從大到小遍歷的,這裡主要是因為只有一輪狀態儲存,為了不影響其他狀態。

0-1揹包問題升級版

剛剛的揹包只涉及揹包重量和物品重量,我們改造一下剛剛的揹包問題,引入物品價值這一變數。

對於一組不同重量、不同價值、不可分割的物品,選擇將某些物品裝入揹包,在滿足揹包最大重量的限制的前提下,揹包中可裝入物品的總價值最大是多少?

這個問題依舊可以使用回溯演算法來解決,但是就沒辦法通過備忘錄來優化了。

我們來看一下動態規劃如何解決這個問題,直接上程式碼:

public int Knapsack3 (int[] weight, int[] value, int n, int w) {
    int[][] states = new int[n][];
    for (int i = 0; i < n; i++) states[i] = new int[w + 1];
    states[0][0] = 0;
    if (weight[0] <= w) states[0][weight[0]] = value[0];

    for (int i = 1; i < n; i++) {
        for (int j = 0; j <= w; j++) // 不放第i個物品
            if (states[i - 1][j] > 0) states[i][j] = states[i - 1][j];

        for (int j = 0; j <= w - weight[i]; j++) // 放第i個物品
            if (states[i - 1][j] > 0) states[i][j + weight[i]] = states[i - 1][j] + value[i];
    }

    for (int i = w; i >= 0; i--) if (states[n - 1][i] > 0) return states[n - 1][i];

    return 0;
}

時間複雜度和空間複雜度一樣是O(n * w)。同理,一樣可以優化空間,程式碼如下:

public int Knapsack4 (int[] weight, int[] value, int n, int w) {
    int[] states = new int[w + 1];
    states[0] = 0;
    if (weight[0] <= w) states[weight[0]] = value[0];

    for (int i = 1; i < n; i++) {
        for (int j = w - weight[i]; j >= 0; j--) // 放第i個物品
            if (states[j] > 0) states[j + weight[i]] = states[j] + value[i];
    }

    for (int i = w; i >= 0; i--) 
        if (states[i] > 0) return states[i];

    return 0;
}

案例:如何巧妙解決“雙十一”購物時的湊單問題

淘寶的“雙十一”購物節有各種促銷活動,比如“滿200減50”。假設你女朋友的購物車中有n個(n>100)想買的商品,她希望從裡面選幾個,在湊夠滿減條件的前提下,讓選出來的商品價格總和最大程度地接近滿減條件(200元)。

直接上程式碼:

public class Soluction4 {
    // items商品價格,n商品個數, w表示滿減條件,比如200
    public void Double11advance (int[] items, int n, int w) {
        int[][] states = new int[n][];
        for (int i = 0; i < n; i++) {
            states[i] = new int[3 * w + 1];
        }
        states[0][0] = 0;
        if (items[0] <= 3 * w) states[0][items[0]] = items[0];

        for (int i = 1; i < n; i++) {
            for (int j = 0; j <= 3 * w; j++) { // 不放第i個物品
                if (states[i - 1][j] > 0) states[i][j] = states[i - 1][j];
            }

            for (int j = 0; j <= 3 * w - items[i]; j++) { // 放第i個物品
                if (states[i - 1][j] > 0) states[i][j + items[i]] = states[i - 1][j] + items[i];
            }
        }

        int maxW = 0; // 找出大於等於w的最小值
        for (maxW = w; maxW < 3 * w + 1; ++maxW) {
            if (states[n - 1][maxW] > 0) break;
        }

        if (maxW == 3 * w + 1) return; // 沒有可行解

        Console.WriteLine ($"w的最小值:{maxW}");
        Console.Write ("購買組合:");
        for (int i = n - 1; i >= 1; --i) { // i表示二維陣列中的行,j表示列
            if (maxW - items[i] >= 0 && states[i - 1][maxW - items[i]] > 0) { // states[i][j]有值,要麼是不購買i,
                Console.Write (items[i] + " "); // 購買這個商品
                maxW = maxW - items[i];
            }
        } // else 沒有購買這個商品,j不變。
        if (maxW != 0) Console.Write (items[0]);
    }
}

class Program {
    static void Main (string[] args) {
        Soluction4 soluction = new Soluction4 ();
        soluction.Double11advance (new int[] { 15, 93, 23, 843, 74, 82, 39, 46, 5, 26, 32, 37 }, 12, 200);
    }
}

輸出結果是:37 32 26 5 46 39 15

上面的程式碼有一點要注意的,如何根據狀態集合來判斷是否購買此商品。

狀態(i, j)只有可能從(i - 1, j)或者(i - 1, j - value[i])兩個狀態推匯出來。所以就檢查這兩個狀態是否可達的,也就是states[i - 1][j]或者states[i - 1][j - value[i]]是否為true。

如果states[i - 1][j]可達,就說明我們沒有選擇購買第i個商品,如果states[i - 1][j - value[i]]可達,那就說明我們選擇了購買第i個商品。