看完就懂系列—動態規劃

前言

動態規劃的主要思想html

  • 將原問題分解爲更簡單的子問題(重要的事情默唸三遍),經過解決子問題來解決原問題。
  • 記憶化搜索(存儲子問題的解,解決重疊子問題屢次計算的問題)。

動態規劃的三要素:c++

  • 最優子結構:原問題最優解所包含的子問題都是最優的(子問題的最優解能組合成原問題的最優解)則該子問題爲原問題的最優子結構。
  • 狀態轉移方程:表示先後階段關係的方程(原問題與子問題的關係方程)。
  • 邊界:無需繼續分解,能夠直接獲得值的問題(沒有邊界的問題是無解的)。

這裏歸納了一下動態規劃的主要思想和要素,讓咱們暫時帶着疑問,先來看幾個經典問題。算法

動態規劃經典問題

書架放法問題

假設有一個書架,最多容納10本書。如今咱們要把這個書架放滿,條件是,每次只能放1本或者2本。問:放滿十本書總共有多少种放法?數組

下圖爲其中一種方法:每次只放1本。咱們能夠表示爲1,1,1,1,1,1,1,1,1,1 bash

下圖爲其中另一種方法:每次只放2本。咱們能夠表示爲2,2,2,2,2

顯然上面的這兩種放法都是最簡單的放法,總放法遠遠不止這兩種。還記得咱們默唸的那一句嗎,咱們如今就想這個複雜的問題能不能分解成更簡單的子問題。

文字來描述問題未免過於囉嗦,爲了更好地分析問題,咱們先對原問題進行建模。學習

問題建模

咱們須要對問題進行建模,用一個方程來普適化表達這一問題。創建方程就須要肯定變量(狀態),變量的肯定能夠看咱們在問題的每一個階段中作決策時受到什麼因素影響。題目中限制死了每次只能放1或2本,那麼在書架上能放滿書有多少种放法,只跟書架的容量有關係,所以書架的容量可做爲方程中的變量。設F(x)的解爲將容量爲x的書架放滿書的總放法數,則原問題的解爲F(10)優化

咱們反過來想一下,放滿10本書以前書架的狀態是怎麼樣的?在放滿10本書以前:ui

  • 書架已經放滿9本(最後一次放1本,可理解爲書架只有9的容量)
  • 書架已經放滿8本(最後一次放2本,可理解爲書架只有8的容量)

放滿9本和放滿8本,分別對應了最後一次放1本和2本,包含了放滿10前一階段的全部狀況。所以書架放10本書的總放法數,等同於書架放9本書的總放法數 + 書架放8本書的總放法數。原問題就能夠分解成:F(10) = F(9) + F(8),F(9)和F(8)爲F(10)的最優子結構。推廣到通常狀況可獲得一個方程F(x) = F(x-1) + F(x-2),(x>1),這個方程就是狀態轉移方程。 獲得這個方程後,咱們很容易就能夠獲得這個問題的解了,這不就是典型的遞歸問題嘛。spa

遞歸求解

const bookrackRecur = (window.tempF = (bookrack) => {
    if (bookrack < 3) {
        return bookrack;
    }
    //根據狀態轉移方程F(x) = F(x-1) + F(x-2)
    return window.tempF(bookrack - 1) + window.tempF(bookrack - 2);
})(10);
console.log(bookrackRecur); //89
複製代碼

分解問題的思想讓咱們很容易就知道了解題的思路,可是上面遞歸的解法有一種問題。會重複計算相同問題。3d

如圖能夠看到原問題分解成子問題時,會出現不少 重疊子問題,所以用遞歸自頂向下求解時,會重複求解一些問題,這個算法的時間複雜度是 O(2^n)。那麼怎麼去優化這個算法呢?如今咱們就能夠用動態規劃了。上文說了動態規劃的另一個主要思想是 記憶化搜索,咱們把以前問題的解存儲下來,那麼在求解過程當中遇到相同問題時,就能夠直接獲得值了。

動態規劃求解

下面咱們轉變下思路,嘗試着自底向上迭代求解。下面藉助一張表來講明,F(x)一樣表示爲書架放滿x本書的總放法。

x 0 1 2 3 ...
F(x) 0 1 2 3 (F(2)+F(1)) ...

該問題具備邊界:

  • F(1),只放1本書在書架上的放法只有一種:1。
  • F(2),放2本書在書架上的方法有兩種:1,1和2。

根據狀態轉移方程,咱們知道F(3)是隻依賴F(1)和F(2)的,咱們在程序中保存了前兩個問題的解,就能夠獲得目前問題的解。

const bookrackDP = ((bookrack) => {
    //爲了問題更好的對應數組下標,加入問題0, F(0) = 0
    //保存前兩個問題的解F(1) = 1, F(2) = 2;
    let record = [0, 1, 2];
    for (let i = 0; i <= bookrack; i++) {
        if (i >= 3) {
            //記錄問題的解
            record[i] = record[i - 1] + record[i - 2];
        }
    }
    //根據記錄直接獲得問題的解
    return record[bookrack];
})(10);
console.log(bookrackDP);
複製代碼

這種算法的時間複雜度爲O(n)。上面的代碼用了數組存儲子問題的解,實際上是能夠再優化一下的。咱們上面分析了,目前問題的解只依賴於前兩個問題,所以用兩個局部變量存儲前兩個問題的解就好了,這裏就再也不展現代碼了。咱們看一個複雜點的問題。

01揹包問題

假設有一個運氣爆棚的獵人進入到了彩虹洞中,地精讓這我的從寶庫中任意拿走物品,直到獵人的揹包裝不下爲止。寶庫中每一個物品都有特定的價值和重量,獵人的揹包承重有限。那麼問題來了,獵人怎麼裝物品才能得到最大價值呢?假設獵人揹包最大承重爲20公斤,物品重量及價值以下圖:

思路:這是一個多階段的決策問題,每一階段的決策都會影響到下一階段的決策(裝了物品1,可能就會裝不了物品2...)。仍是按照分解問題的思想來,獲得解題思路再說。一樣地,文字描述未免太過於囉嗦,咱們 對問題進行建模

問題建模

仍是先肯定方程的變量(狀態)。思考一下會影響咱們在各階段作決策的因素有什麼(通常問題中能直接找到)?

  1. 問題求規定揹包承重下,所裝物品的最大價值,顯然第一個變量是揹包的承重量,設爲c(capacity)。
  2. 每一個物品的價值和重量都不一樣,選了物品1極可能會影響到後面物品的選擇,同時物品的選與不選會影響到揹包的剩餘承重量,跟變量c有着關聯。所以寶庫中提供的物品爲另一個變量,設爲i。

如今就能夠對問題創建方程了:設F(i, c)的解爲,從i件物品中,挑選一些物品放入剩餘容量爲c(capacity)的揹包中,使的揹包物品價值最大。則原問題的解爲F(5, 20)。思考一下,怎麼將F(5, 20)分解呢?

選與不選神器

咱們能夠考慮面對物品5時,選不選的問題。(這個地方着重理解)

  1. 首先揹包剩餘容量必須大於物品5的重量,不然根本放不下,放不下至關於不用考慮物品5,原問題的解等同於從前4件物品中,挑選一些物品放入揹包中(目前揹包什麼都沒裝),揹包物品總價值最大。表示爲:F(4, 20)。
  2. 選了物品5,問題的解爲,選了物品5了,揹包價值爲物品5的價值,再從前4件物品中,挑選一些物品放入揹包中(目前揹包已裝物品5),揹包物品總價值最大。方程表示爲F(4, 20 - 物品5重量) + 物品5價值 = F(4, 16) + 5。
  3. 不選物品5,等同於不考慮物品5,問題的解爲,從前4件物品中,挑選一些物品放入揹包中(目前揹包什麼都沒裝),揹包物品總價值最大。表示爲:F(4, 20)。
  4. 到底選仍是不選,判斷選和不選的狀況中,哪一種狀況的揹包總價值高,選價值高的狀況。max(F(4, 16) + 5, F(4, 20))

根據上面的分析咱們就能夠獲得這個問題的狀態轉移方程了(一樣的咱們發現可能會有重疊子問題):

F(i, c) = max(F(i-1, c-weights[i]) + values[i] , F(i-1, c)), (i > 0)

很容易就能夠獲得遞歸的解法了

遞歸解法

let goods = [
    {value: 0, weight: 0}, //方便對應下標
    {value: 3, weight: 2},
    {value: 4, weight: 3},
    {value: 8, weight: 5},
    {value: 10, weight: 9},
    {value: 5, weight: 4}
];

const knapsackRecur = ((goods, capacity) => {
    let recurF = (curIdx, curCapacity) => {
        if (!curIdx) {
            return 0;
        }
        let weight = goods[curIdx].weight,
            value = goods[curIdx].value;
        if (weight > curCapacity) {
            return recurF(curIdx - 1, curCapacity);
        }
        let vPick = recurF(curIdx - 1, curCapacity - weight) + value,
            vNotPick = recurF(curIdx - 1, curCapacity);
        return Math.max(vPick, vNotPick);
    };
    return recurF(goods.length - 1, capacity);
})(goods, 20);
console.log(knapsackRecur); //26
複製代碼

咱們來看看01揹包的動態規劃解法

動態規劃解法

跟書架問題同樣,想辦法將子問題的解記憶化。因爲方程裏有兩個狀態(變量),所以咱們須要用二維數組存儲問題的解。咱們先來看看下面這一張表。在線01揹包表

能夠看到咱們將F(i, c)轉換成二維表,i爲行(從i件物品中選),c爲列(揹包容量爲c),每一個單元格的值爲F(i, c)的解。 咱們先隨便看一個單元格。
紅色框起來的單元格(第4行,第13列),意思是:F(3, 12),從前三件物品中挑選一些物品,使得容量爲12的揹包價值最大,價值爲15。下面咱們試着解釋這個值是怎麼來的。(能夠先是思考一下:選不選神器)

由狀態轉移方程可得:F(3, 12) = max(F(2, 12-5) + 8 , F(2, 12))(綠色部分和藍色部分)因此F(3, 12) = max(F(2, 7) + 8 , F(2, 12)) = max(7+8, 7) = 15。 分析到這裏你們應該明白了,如今咱們看看自底向上迭代求解的代碼實現。

let goods = [
    {value: 0, weight: 0}, //方便對應下標
    {value: 3, weight: 2},
    {value: 4, weight: 3},
    {value: 8, weight: 5},
    {value: 10, weight: 9},
    {value: 5, weight: 4}
];
const knapsackDP = ((goods, capacity) => {
    //初始化二維數組,保存子問題的最優解
    let record = [];
    for (let i = 0; i < goods.length; i++) {
        if (!record[i]) {
            record[i] = [];
        }
        let weight = goods[i].weight,
            value = goods[i].value;
        for (let c = 0; c <= capacity; c++) {
            if (!record[i][c]) {
                record[i][c] = 0;
            }
            if (i - 1 < 0) {
                continue;
            }
            //商品i的重量大於揹包容量,沒法把商品i放到揹包裏
            if (weight > c) {
                record[i][c] = record[i - 1][c];
            } else {
                let vPick = record[i - 1][c - weight] + value,  //選的狀況
                    vNotPick = record[i - 1][c];    //不選的狀況
                record[i][c] = Math.max(vPick, vNotPick);   //最優狀況
            }
        }
    }
    return record[goods.length - 1][capacity];
})(goods, 20);
console.log(knapsackDP);
複製代碼

總結

動態規劃更像是一種思想,相似分治,把複雜問題分解成多個子問題,簡單化問題。同時記憶化問題的解,爭取每一個問題只求解一次,達到優化效果。所以動態規劃適用於含有重疊子問題的問題。解決動態規劃問題的關鍵在於推導出狀態轉移方程。狀態轉移方程的推導過程能夠參考以下:

  • 在多階段決策問題中,找到影響決策的因素(定義狀態)。
  • 根據狀態對問題進行建模(用以狀態爲變量的方程來表示問題)。
  • 將原問題分解成子問題(肯定狀態轉移方程)。

本文屬我的理解,旨在互相學習,有說的不對的地方,師請糾正。轉載請註明原帖

相關文章
相關標籤/搜索