golang實現和講解動態規劃算法(揹包問題)

前言

昨天閱讀了程序員小灰的《什麼是動態規劃》,當時還在親戚家中,借了紙筆計算了一通,回家結合一些揹包問題文章用程序實現了一下。文章先從簡單的解決斐波那契數列入手,接着在講解工人挖礦獲取最大價值的例子中(其實就是經典的0-1揹包問題),有一些容易使你暈頭轉向的問題,本文看成算法複習,而且記錄瞭解題思路。程序員

0-1揹包問題

有n件物品和容量爲cap的揹包,每件物品有本身的容量w和價值v,每件物品只能選擇放或者不放,求解讓裝入揹包的物品容量不超過揹包容量(cap)的狀況下,能得到的最大價值是多少。算法

問題描述

小灰文章裏舉例的是工人挖礦,咱們仍是轉換成物品放入揹包的問題來描述,而且價值縮小10倍,只爲了減小圖片裏表格的寬度。數組

咱們把工人挖礦問題套用到揹包問題裏。容量=工人數,物品=金礦,揹包裝下最大物品價值=金庫裝下最大采集金礦價值

現有5個物品
image.png
假如揹包容量只有10,應該如何選擇物品才能使揹包裏的價值最大化?因爲數據樣本較少,只有5個,因此咱們僅憑肉眼瞄一眼,能知道放入物品1和物品2須要5+5=10個容量,不超過揹包容量,而且能得到90的最大價值。可是若是有100個物品呢,那光用眼看會瞎的。仍是靠代碼吧。
簡要歸納:5個物品,選擇放入總容量爲10的揹包裏,價值最大化是多少?函數

解題思路

一、設定基礎變量
先設一下要用到的變量,在後面的程序會用上優化

var w [5] int = [5] int{5, 5, 3, 4, 3}           //物品佔用容量數組
var v [5] int = [5] int{40, 50, 20, 30, 30} //物品價值數組
var cap int = 10    //揹包容量10

二、邊界:v[0]
邊界天然是隻有一個物品的時候,只能把這個物品放入揹包,沒得選,邊界是v[0]=40編碼

三、最優子結構:F(n,c)=Max(不放入,放入)
咱們先反過來想,放最後一個物品5的時候只有2種狀況,狀況1:不放入(放不下);狀況2:放入。設定不放入的時候當前總價值是f1,放入的時候總價值是f2,那計算最後一個物品5的時候,就是在這2中狀況總選擇總價值最大的狀況。設定函數F(n)表示處理第n個物品,那依據上面的反向思考,得出F(n)=Max(不放入,放入)。spa

四、方程:F(n,c)=Max(F(n-1,c),F(n-1,c-w[n])+v[n])
想想不放入的狀況,若是不放入,是否是就是等於處理上一個物品的狀況,此次是n,上一次就是n-1,揹包容量是c,因此不放入是F(n-1,c)。那放入的狀況呢,那上一次的揹包容量 就不是c了,要減去當前這個物品的容量w[n],最後得出的上一次的總價值
再加上此次物品的價值v[n],那結果是F(n-1,c-w[n])+v[n]。3d

編碼解題

得出方程後,怎麼寫代碼來運算呢,比較容易想到的是用遞歸算法的方式。從最後一個物品開始,逐步遞歸到第一個物品。code

遞歸算法

var w [5] int = [5] int{5, 5, 3, 4, 3}           //重量數組
var v [5] int = [5] int{400, 500, 200, 300, 350} //價值數組

func main() {
    var cap int = 10
    var n int = len(w)
    max := computer(n-1, cap)
    fmt.Println("【", cap, "容量的揹包在", n, "個物品裏選擇能裝下的最大價值是", max, "】")
}
//遞歸
func computer(nIndex int, cap int) int {
    //基準條件:若是索引無效或者容量不足,直接返回當前價值0
    if nIndex < 0 || cap <= 0 {
        return 0
    }
    //不放第n個物品所得價值
    res := computer(nIndex-1, cap)
    //放第n個物品所得值(前提是要放的下)
    if w[nIndex] <= cap {
        var f1 int = res
        //計算放的下的方案值    v[n]是當前物品的價值,computer(n-1, cap-w[n])是計算前一個物品,在減去這個物品容量後的容量下的最大價值方案
        var f2 int = v[nIndex] + computer(nIndex-1, cap-w[nIndex])
        //取兩種方案最大價值那個
        maxRes := maxForInt(f1, f2)
        res = maxRes
    } else {
        //fmt.Println(i, "|nIndex=", nIndex, ",", w[nIndex], ">", cap, "放不下")
    }
    return res
}

運行結果
【 10 容量的揹包在 5 個物品裏選擇能裝下的最大價值是 90 】
處理每一物品/金礦的時候都有2個最優子結構,遞歸的執行流程相似一個高度爲N的二叉樹,因此時間複雜度:O(2^N)。看看有什麼能夠優化的嗎,能發現計算結果是有重複的,那咱們用一個map把計算的結果存儲下來,每次計算以前從map裏獲取已經計算出的結果,避免重複計算。咱們稱它爲備忘錄方法或者記憶方法。blog

備忘錄方法(記憶方法)

var memo map[string]int = make(map[string]int) //備忘錄算法存儲使用
//備忘錄
func computer2(nIndex int, cap int) int {
    //基準條件:若是索引無效或者容量不足,直接返回當前價值0
    if nIndex < 0 || cap <= 0 {
        return 0
    }
    //若是此子問題已經求解過,則直接返回上次求解的結果
    var key string = strconv.Itoa(nIndex) + "_" + strconv.Itoa(cap)
    res, ok := memo[key]
    if ok && res != 0 {
        return res
    }

    //不放第n個物品所得價值
    res = computer2(nIndex-1, cap)
    //放第n個物品所得值(前提是要放的下)
    if w[nIndex] <= cap {
        var f1 int = res
        //計算放的下的方案值    v[n]是當前物品的價值,computer(n-1, cap-w[n])是計算前一個物品,在減去這個物品容量後的容量下的最大價值方案
        var f2 int = v[nIndex] + computer2(nIndex-1, cap-w[nIndex])
        //取兩種方案最大價值那個
        maxRes := maxForInt(f1, f2)
        res = maxRes
        //計算的結果存入備忘錄,便於下次直接使用
        memo[key] = res
        fmt.Println("保存計算結果", key, "=", res)
    } else {
        //fmt.Println(i, "|nIndex=", nIndex, ",", w[nIndex], ">", cap, "容量不足")
    }
    return res
}

運行結果
【 10 容量的揹包在 5 個物品裏選擇能裝下的最大價值是 90 】

動態規劃

咱們來畫個表格試着找下規律
image.png
填上邊界值,前面分析過,邊界值就是揹包放置第一件物品的時候。第一件物品價值40,須要5的容量,那隻要揹包容量大於等於5的狀況下都只有這一件物品能夠選擇,因此從容量5到最大容量10,最大價值都是40。
image.png
咱們從上到下,從左至右,先把填充好的表格給你們看下。
image.png
直接看可能有點雲裏霧裏,我再加上每一個格子的計算過程。
image.png
能發現一個規律,每一個格子都是和本身上一行(n-1)的格子進行比較,若是揹包容量不夠放不下,那最大值就是F(n-1,c),若是放的下,那就在上一行找到能夠揹包容量爲(c-w[n])的最大值,再加上當前物品的價值v[n]。
image.png
挑一個格子來進行說明,就好理解了。如上圖,咱們看綠格子(第4行,第9列)最大價值80是怎麼得出的,是根據上一行的2個黃格子得出的,首先根據公式F(n,c)=Max(不放入,放入),狀況1不放入的f1=70(上一行同一列的最大值),若是放入的話,當前物品的價值是v[n]=30,放入物品以前有多少容量呢,9-w[n]=3,那在第二行找3容量的揹包最大值是f2=50,因此,max(70,50+30),80較大,那這個格子填入80。每一個格子均可以套用這個思路去理解,那整個表格的最後一行的最後一格就是整個題目的最大化價值90。
來看下動態規劃代碼,代碼變量拆分的比較細,是爲了更好理解每一個步驟的做用:

//動態規劃
func computer3(nIndex int, cap int) int {
    size := cap + 1//+1是由於把第一個索引0表示爲0個容量,雖然沒有實際意義,可是讓從索引1開始的位置表明1容量,便於理解。
    preRes := make([]int, size) //上一輪最大價值存儲
    res := make([]int, size) //這一輪最大價值存儲

    //填充邊界格子,把第一個物品放入能容納下的第一行格子中
    for i := 0; i <= cap; i++ {
        if i < w[0] {
            preRes[i] = 0
        } else {
            preRes[i] = v[0]
        }
    }
    fmt.Println(1, preRes)

    //填充其餘格子,外層循環是物品數量,內層循環是容量
    for i := 1; i <= nIndex; i++ {
        for j := 0; j <= cap; j++ {
            vCurrent := v[i] //當前物品價值
            wCurrent := w[i] //當前物品容量
            f1 := preRes[j]  //上一個不裝的最大值
            //判斷是否裝的下
            if j < wCurrent {
                res[j] = f1
                //fmt.Println("----", j, "裝不下", wCurrent, "取值=", f1)
            } else {
                capCurrent := j - wCurrent //裝下後的剩餘容量
                vPre := preRes[capCurrent] //獲取上一輪剩餘容量能存的最大價值
                f2 := vPre + vCurrent
                biger := maxForInt(f1, f2)
                //fmt.Println("----", j, ">=", wCurrent, "裝的下", f1, "vs", f2, "(", vPre, "+", vCurrent, ")", "=", biger)
                res[j] = biger
            }
        }
        //用深拷貝,把res賦值給上一個數組preRes,若是用preRes=res,則是操做一個數組
        copy(preRes, res)
        fmt.Println(i+1, res)
    }

    return res[cap]
}

運行結果

1 [0 0 0 0 0 40 40 40 40 40 40]
2 [0 0 0 0 0 50 50 50 50 50 90]
3 [0 0 0 20 20 50 50 50 70 70 90]
4 [0 0 0 20 30 50 50 50 70 80 90]
5 [0 0 0 30 30 50 50 60 80 80 90]
【 10 容量的揹包在 5 個物品裏選擇能裝下的最大價值是 90 】

時間複雜度:O(N*C),空間複雜度:O(C)。

算法比較

若是cap=10,n=5,遞歸算法的計算次數是2^N=32,動態規劃算法的計算次數是N*C=50,遞歸算法更少。
但若是cap=100,n=50,遞歸算法的計算次數是2^50=1.1259e+15,動態規劃算法的計算次數是100*50=5000,遞歸算法須要計算1億屢次,量級至關可怕,動態規劃算法只要5000次。
因此算法沒有必定意義上的好壞,具體看使用場景。

總結

概括下重點:

解題步驟

一、邊界
二、最優子結構
三、狀態轉移方程

公式

F(n,c) = max(F(n-1,c),  F(n-1,c-w[n-1])+v[n-1])

時間複雜度

遞歸算法

時間:O(2^N),空間:O(1)。計算次數隨,物品n數量成指數增加,數量n一多效率就低下。

動態規劃算法

時間:O(N*C),空間:O(C)

相關文章
相關標籤/搜索