golang实现和讲解动态规划算法(背包问题)

imleaf · · 551 次点击 · 开始浏览    置顶
这是一个创建于 的主题,其中的信息可能已经有所发展或是发生改变。

## 前言 昨天阅读了程序员小灰的[《什么是动态规划》](https://mp.weixin.qq.com/s/3h9iqU4rdH3EIy5m6AzXsg),当时还在亲戚家中,借了纸笔计算了一通,回家结合一些背包问题文章用程序实现了一下。文章先从简单的解决斐波那契数列入手,接着在讲解工人挖矿获取最大价值的例子中(其实就是经典的0-1背包问题),有一些容易使你晕头转向的问题,本文当作算法复习,并且记录了解题思路。 ## 0-1背包问题 有n件物品和容量为cap的背包,每件物品有自己的容量w和价值v,每件物品只能选择放或者不放,求解让装入背包的物品容量不超过背包容量(cap)的情况下,能获得的最大价值是多少。 ## 问题描述 小灰文章里举例的是工人挖矿,我们还是转换成物品放入背包的问题来描述,并且价值缩小10倍,只为了减少图片里表格的宽度。 我们把工人挖矿问题套用到背包问题里。容量=工人数,物品=金矿,背包装下最大物品价值=金库装下最大采集金矿价值 现有5个物品 ![1.png](https://static.studygolang.com/200504/e54d31c810c42f06c4033e9833537e68.png) 假如背包容量只有10,应该如何选择物品才能使背包里的价值最大化?由于数据样本较少,只有5个,所以我们仅凭肉眼瞄一眼,能知道放入物品1和物品2需要5+5=10个容量,不超过背包容量,并且能获得90的最大价值。但是如果有100个物品呢,那光用眼看会瞎的。还是靠代码吧。 **简要概括:5个物品,选择放入总容量为10的背包里,价值最大化是多少?** ## 解题思路 **1、设定基础变量** 先设一下要用到的变量,在后面的程序会用上 ``` 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 ``` **2、边界:v[0]** 边界自然是只有一个物品的时候,只能把这个物品放入背包,没得选,边界是v[0]=40 **3、最优子结构:F(n,c)=Max(不放入,放入)** 我们先反过来想,放最后一个物品5的时候只有2种情况,情况1:不放入(放不下);情况2:放入。设定不放入的时候当前总价值是f1,放入的时候总价值是f2,那计算最后一个物品5的时候,就是在这2中情况总选择总价值最大的情况。设定函数F(n)表示处理第n个物品,那依据上面的反向思考,得出F(n)=Max(不放入,放入)。 **4、方程: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]。 ## 编码解题 得出方程后,怎么写代码来运算呢,比较容易想到的是用递归算法的方式。从最后一个物品开始,逐步递归到第一个物品。 #### 递归算法 ``` 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里获取已经计算出的结果,避免重复计算。我们称它为备忘录方法或者记忆方法。 #### 备忘录方法(记忆方法) ``` 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 】` #### 动态规划 我们来画个表格试着找下规律 ![2.png](https://static.studygolang.com/200504/fd9fffa5d3cbc63c39874aeeb00716fd.png) 填上边界值,前面分析过,边界值就是背包放置第一件物品的时候。第一件物品价值40,需要5的容量,那只要背包容量大于等于5的情况下都只有这一件物品可以选择,所以从容量5到最大容量10,最大价值都是40。 ![3.png](https://static.studygolang.com/200504/87bb9d0404183896d97cbe76ca83ec7a.png) 我们从上到下,从左至右,先把填充好的表格给大家看下。 ![4.png](https://static.studygolang.com/200504/af474776e2d9ae24600a44cd12dcae27.png) 直接看可能有点云里雾里,我再加上每个格子的计算过程。 ![5.png](https://static.studygolang.com/200504/9c40deefb6a46c4d627c83be0a1bbd1e.png) 能发现一个规律,每个格子都是和自己上一行(n-1)的格子进行比较,如果背包容量不够放不下,那最大值就是F(n-1,c),如果放的下,那就在上一行找到可以背包容量为(c-w[n])的最大值,再加上当前物品的价值v[n]。 ![6.png](https://static.studygolang.com/200504/e2eb6f3a001ff8817a16ca5d52663074.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次。 所以算法没有一定意义上的好坏,具体看使用场景。 ## 总结 归纳下重点: #### 解题步骤 ###### 1、边界 ###### 2、最优子结构 ###### 3、状态转移方程 #### 公式 `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) 首发地址:[《golang实现和讲解动态规划算法(背包问题)》](https://segmentfault.com/a/1190000022541438)

有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

551 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传