昨天閱讀了程序員小灰的《什麼是動態規劃》,當時還在親戚家中,借了紙筆計算了一通,回家結合一些揹包問題文章用程序實現了一下。文章先從簡單的解決斐波那契數列入手,接着在講解工人挖礦獲取最大價值的例子中(其實就是經典的0-1揹包問題),有一些容易使你暈頭轉向的問題,本文看成算法複習,而且記錄瞭解題思路。程序員
有n件物品和容量爲cap的揹包,每件物品有本身的容量w和價值v,每件物品只能選擇放或者不放,求解讓裝入揹包的物品容量不超過揹包容量(cap)的狀況下,能得到的最大價值是多少。算法
小灰文章裏舉例的是工人挖礦,咱們仍是轉換成物品放入揹包的問題來描述,而且價值縮小10倍,只爲了減小圖片裏表格的寬度。數組
咱們把工人挖礦問題套用到揹包問題裏。容量=工人數,物品=金礦,揹包裝下最大物品價值=金庫裝下最大采集金礦價值
現有5個物品
假如揹包容量只有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 】
咱們來畫個表格試着找下規律
填上邊界值,前面分析過,邊界值就是揹包放置第一件物品的時候。第一件物品價值40,須要5的容量,那隻要揹包容量大於等於5的狀況下都只有這一件物品能夠選擇,因此從容量5到最大容量10,最大價值都是40。
咱們從上到下,從左至右,先把填充好的表格給你們看下。
直接看可能有點雲裏霧裏,我再加上每一個格子的計算過程。
能發現一個規律,每一個格子都是和本身上一行(n-1)的格子進行比較,若是揹包容量不夠放不下,那最大值就是F(n-1,c),若是放的下,那就在上一行找到能夠揹包容量爲(c-w[n])的最大值,再加上當前物品的價值v[n]。
挑一個格子來進行說明,就好理解了。如上圖,咱們看綠格子(第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)