前几天 GGTalk 发了一期关于算法类的播客,主持人磊子和嘉宾 WAMaker 都分享了很有趣的算法经历。这一系列文会帮你梳理一下在这期电台中,你应该知道的知识点。

这一篇来聊聊博客中 WAMaker 提及到的 01 背包问题。

[11:25] WAMaker:项目里面有这样一个功能。从各个学科中抽取题目组成一道模拟试卷...我们如何抽取题目从而刚好令其分值凑够满分 10 分?

其实 WAMaker 具体的场景就是:有一个题库假设有 N 道题,每一道题都会有他的分值 m[i] ,现在我需要用这些题目来凑成一个 total 分数的试卷,那么我该如何选择。

为什么说这是一道 01 背包问题,我们来对比一下原题目的场景:有 N 件物品和一个容量为 V 的背包,第 i 件物品的费用是 c[i] ,体积是 w[i] ,求解将哪些物品装入背包可使价值总和最大。

是不是和问题场景题目非常像?唯一不同的就是在题目凑分问题中,需要将总分凑到等于 total ,而下面的背包问题中,只是上限是 V 求一个最大值。

统一一下问题场景,我们把凑分的问题也当做是背包装物品,其体积和价值相等,这时我们要背一个总和为 V 价值的物品。好了,统一了场景之后,我们来看这两个情况。

背最大值情况

首先来看如何背一个体积上限为 V 但是价值总和最大呢?

先来考虑题目中有这么一个特点:每个物品只有一件,可以选择放或者不放。那么我就来考虑每个东西是否要放!

我们从一个中间状态来入手,假设背包中还有 V0 的容量剩余,那么我们放入第 i 个物品后,其容量就变为 V1 = V0 - w[i] ,并且我们原来背包中的总价值 C0 也就变成了 C1 = C0 + c[i]

我们将 C1C0 当做自变量,将 i 也当做自变量,来思考一个函数:

F(i, w) 表示背包中在前 i 个物品中选择了一些东西放入到容积是 w 的背包所产生的最大价值。

由于这个中间情况我们已经遍历到了第 i 个物品,那么其前面的状态就是考虑了 i - 1 个物品。所以我们从已经考虑了 i - 1 个物品出发,假设这时候我们要决定第 i 个物品是否要放入背包,如果不放的话我们的价值就不会变化,通过上述的二元函数中就有如下式子:

翻译成文字就是对于容积是 w 的背包来说,考虑前 i - 1 个物品的最大值和考虑前 i 个物品包中价值和的最大值是一样的。

那么第 i 个物品如果放进去了呢?我们要怎么表示递推关系呢?如果能放下第 i 个物品,则肯定说明 w > w[i],此时我们要求放入第 i 个物品的价值,由于我们计算的都是在 w 容积下的最大值,那么放了 w[i] 容积的物品之后,其实是一个 w - w[i] 容积的背包。那其实,我们只要知道我们在考虑前 i - 1 的物品放入 w - w[i] 容积的背包的最大价值,再加上第 i 个物品的价值,其实就是我们要求的 F(i, w) 了“

如此,我们通过 i 和容积 w 为这个中间情况得到了两个递推式,下面我们将其做一下结合。考虑到我们的 F 函数代表的是包中每种状态下能放入的最大值,那么结合以上两个式子,可以推导出完整的 F(i, w) 表达式:

在上面两个式子取最大值,就可以完整的将 i - 1 状态推导到 i 状态。其中我们借用了包的最大容量 w 也做为一个状态的影响因素,所以这复合一个二维动态规划的模型。这个用来表示两次状态的递推式,在动态规划中就叫做状态转移方程,这也就是动态规划问题的核心要素,一般只要确定了状态转移,基本上依照这个思路就能编程解决了。

但是这个式子要怎么理解呢?之前为也用了很长一段时间来理解这个 01 背包的状态转移方程,最后发现最好的理解方法是来画一张表。

这里我举一个例子,有五个物品,属性如下,背包的总体积是 10

来看动态规划的状态转移,红色的地方就是状态转换的地方:

给出一个描述吧,例如第二行标红的价值为 10 的位置,推导式如下:

这个例子表示了当考虑了 ab 物品的取舍时,如果背包的容量最大是 9,可取得最大的物品价值是 10

那么要如何写代码来实现?可以把上表中的行和列作为而一个二维数组的下标,利用以上规律来迭代完成。

# 物品容积w = [0, 4, 5, 5, 2, 2]# 物品价值c = [0, 6, 4, 6, 3, 6]# 背包大小W = 10# 初始化 dp 状态数组dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]

for i in range(1, len(w)):for j in range(0, W + 1):if (j < w[i]):            dp[i][j] = dp[i - 1][j]else:            dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + c[i])

# debugfor i in range(1, len(w)):    print(dp[i])

print(f'可以装最大价值是 {dp[-1][-1]}')

"""[0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6][0, 0, 0, 0, 6, 6, 6, 6, 6, 10, 10][0, 0, 0, 0, 6, 6, 6, 6, 6, 12, 12][0, 0, 3, 3, 6, 6, 9, 9, 9, 12, 12][0, 0, 6, 6, 9, 9, 12, 12, 15, 15, 15]可以装最大价值是 15"""

背准确值情况

回归到 WAMaker 所说的,如果我们想对一个背包背指定大小的容量总和,这个要怎么实现呢?

我们继续从上面的背包来看这个问题。如果我们将物品的体积当做题目的分数,物体的价值等于物体的体积,也就是分数在这个场景下既是体积,也是价值。背包的大小等于试卷的总分值,就转化成为了一道 01 背包问题。

但是背包问题中,我们最后的求解是最大能获得多少价值,在我们这个场景下,是需要获得准确的分数,这个要怎么处理?

还是需要从上方的状态转移来寻找思路?

我们观察到第二行的状态值都是由第一行对应相同颜色的状态值转移过来的。而第一行无物品的状态都是由我们定义的。当无物品状态时,背包大小再怎么扩大,其价值都是 0。这种定义在背包问题下看起来是合适的。但是如果我们不把它当做背包容量,而是当做试卷满分,那么当试卷满分是 0 分的时候,无题目才是有效的(题目分数相加总和等于总分数),否则我们做为无效状态

从这个角度出发,我们定义一个负无穷 -∞ 状态,并且规定负无穷加上任何正数都是负无穷。如此,我们将有意义的初始状态填 0,而无意义的都用负无穷来填充。我们来看看上述的状态转移变成了什么?

通过这种方式,我们剩下的都是刚好填满背包的情况。总结下来,我们只需要修改无物品时的初始状态,就可以解决背准确值问题。使用代码来实现一下:

# 物品容积w = [0, 4, 5, 5, 2, 2]# 物品价值c = [0, 6, 4, 6, 3, 6]# 背包大小W = 10# 初始化 dp 状态数组dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]for i in range(1, len(dp[0])):    dp[0][i] = -1e5

for i in range(1, len(w)):for j in range(0, W + 1):if (j < w[i]):            dp[i][j] = dp[i - 1][j]else:            dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + c[i])if dp[i][j] < 0:                dp[i][j] = -1e5

for i in range(1, len(w)):for j in range(len(dp[i])):        print('-∞' if dp[i][j] < 0 else dp[i][j], '\t', end='')    print('\n')

"""表中所有的数字都是可以正好装满时候的价值总和0       -∞      -∞      -∞      -∞      -∞      -∞      -∞      -∞      -∞      -∞0       -∞      -∞      -∞      6       -∞      -∞      -∞      -∞      -∞      -∞0       -∞      -∞      -∞      6       4       -∞      -∞      -∞      10      -∞0       -∞      -∞      -∞      6       6       -∞      -∞      -∞      12      100       -∞      3       -∞      6       6       9       9       -∞      12      100       -∞      6       -∞      9       6       12      12      15      15      10"""

路径记录

上面所有的记录和解法,我们虽然了解决背包中能装的最大价值以及题目能广告凑出的总分值情况,但是我们无法求得在取得最大价值中我们具体装了哪些物品、在凑得指定总分时我们收录了哪几道题目。这其实就是 dp 中的记录路径

我们继续使用上述的状态转移表,以 dp[5][10] 这个最终结果为例:

其实这里的路径分成两种情况

  1. 同列相当于没有增加物品;

  2. 非同列相当于之前的状态增加新的物品;

我们注意第二种情况,在状态转移的时候来记录这个一个关系标记 col[i][j] ,之后从后向前将每个物品的选用状况就可以了。

# 物品容积w = [0, 4, 5, 5, 2, 2]# 物品价值c = [0, 6, 4, 6, 3, 6]# 背包大小W = 10# 初始化 dp 状态数组dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]# 标记数组col = [[0 for _ in range(W + 1)] for _ in range(len(w))]

for i in range(1, len(w)):for j in range(0, W + 1):# 容量不够放if (j < w[i]):            dp[i][j] = dp[i - 1][j]else:            dp[i][j] = dp[i - 1][j]# 如果发现从上一状态放入当前物品价值更大,则放入记录价值并记录路径if dp[i - 1][j - w[i]] + c[i] > dp[i][j]:                dp[i][j] = dp[i - 1][j - w[i]] + c[i]                col[i][j] = 1 

for i in range(0, len(w)):for j in range(len(dp[i])):        print('-∞' if dp[i][j] < 0 else dp[i][j], '\t', end='')    print('')print("总价值", dp[-1][-1])

# 从最后一个物品向上查询路径,即装入物品print("选择的物品有:")i, j = len(dp) - 1, len(dp[0]) - 1while i > 0 and j > 0:if col[i][j] == 1:        print(f'{i}({w[i]}, {c[i]}) ', end="")        j -= w[i]    i -= 1

"""0       0       0       0       0       0       0       0       0       0       00       0       0       0       6       6       6       6       6       6       60       0       0       0       6       6       6       6       6       10      100       0       0       0       6       6       6       6       6       12      120       0       3       3       6       6       9       9       9       12      120       0       6       6       9       9       12      12      15      15      15总价值 15选择的物品有:5(2, 6) 4(2, 3) 1(4, 6)"""

代码实现 WAMaker 试卷问题

上面讲了这么多的背包问题,接下来我们用 WAMaker 的实际场景来写一个试卷凑分问题的 Demo。假设我们要凑够一个 100 分的试卷,我们手上的题目有 5 分、10 分、16 分、20 分、14 分、30 分、36 分、40 分、45 分的题目各一道,那么我们要如何选择题目凑足成一个 100 分的试卷呢?

我们将上述的的问题直接套入到前面 01 背包问题中背准确值情况来计算:

# 题目分数w = [0, 5, 10, 16, 20, 14, 30, 36, 40, 45]c = w# 总分W = 100# 初始化 dp 状态数组dp = [[0 for _ in range(W + 1)] for _ in range(len(w))]for i in range(1, len(dp[0])):    dp[0][i] = -1e5# 标记数组col = [[0 for _ in range(W + 1)] for _ in range(len(w))]

for i in range(1, len(w)):for j in range(0, W + 1):if (j < w[i]):            dp[i][j] = dp[i - 1][j]else:            dp[i][j] = dp[i - 1][j]if dp[i - 1][j - w[i]] + c[i] > dp[i][j]:                dp[i][j] = dp[i - 1][j - w[i]] + c[i]                col[i][j] = 1             dp[i][j] = -1e5 if dp[i][j] < 0 else dp[i][j]

print("总分", dp[-1][-1])

print("选择题目有:")i, j = len(dp) - 1, len(dp[0]) - 1while i > 0 and j > 0:if col[i][j] == 1:        print(f'题目{i}({c[i]}分) ', end="")        j -= w[i]    i -= 1

"""总分 100选择题目有:题目7(36分) 题目6(30分) 题目5(14分) 题目4(20分)"""

做到这里,我们已经解决了 WAMaker 问题。但是其实是无法覆盖全部场景的。这里我提几个延伸问题:

  1. 如果有些习题类型相同,所以造成有问题 A 就不能出现问题 B 的场景;

  2. 有些习题是一种延伸习题(思考题),例如 C 问题出现的前提是有 D 问题已经出现;

  3. 有些习题的分数是泛化的,这些习题只是有会随着某些因素而变化分值,从而造成了这些习题的分值是某个区间;

当然可以猜测的需求还有很多,但是以上三个需求分别对应背包问题中的三个子类型:分组背包有依赖背包泛化物品。有兴趣的读者可以去看 Tianyi Cui 大牛写的《背包问题九讲》来针对性的看背包问题。

空间复杂度优化 - 滚动数组

在上面所有的代码中,为了方便大家理解我全部都使用了二维的 dp 状态数组来推导所有的子状态。但其实,背包问题由于我们只需要一个待求的子状态,所以中间状态在递推的规律中只为下一个状态使用一次。所以我们可以将 dp 数组使用一维数组来做空间上的优化。

for i in range(1, len(w)):for j in range(W, w[i] - 1, -1):        dp[j] = max(dp[j], dp[j - w[i]] + c[i])

可以继续利用前文状态数组的推理的方法,大家自行去演绎一下。这里我就不再赘述了。

对于 bitset 还有一点要说的

在 WAMaker 的试卷问题中,其实如果我们不需要求具体凑分的每一道题,而是只判断是否已知题库中的题目可以凑成一个指定 X 分的试卷的可行性时,我们没有必要去维护一个这么重的 dp 数组。在这个情况下,又一个数据结构叫做 bitset 就可以完成判断凑数可行的方法。

例如我们用上面的实例来计算是否可以凑成 100 分,103 分和 105 分(Python 中 bitset 不是很了解,换成 C++)。

#include using namespace std;

int a[10] = {0, 5, 10, 16, 20, 14, 30, 36, 40, 45};bitset<120> bs;

int main() {    bs[0] = 1;for (int i = 1; i < 10; ++ i) {        bs |= bs << a[i];    }cout << bs[100] << endl; // 1cout << bs[103] << endl; // 0cout << bs[105] << endl; // 1}

我们可以考虑一下 bs |= bs << a[i] 这个操作,其实他把每一位都当做是否可表示的数,1 表示可以凑出,0 表示不能。每一次的左移就是在让当前的答案都增加 a[i] ,位或运算则代表增加新的可表示数。

是不是很巧妙?

但是有一个弊端,就是不能记录路径,从而得到具体的情况。但其实也是有方法的,只不过又要开一个数组来表示。这个留给大家思考。

总结

01 背包问题是背包问题的基础,也是动态规划问题的经典范例问题。根据笔者的经验,在学习背包问题的时候是最容易找到动态规划的解题感觉的。希望通过这类题目也给你有发散性的思维。它在面试中和业务中都不常用,但是一旦遇到只有学习过的人才有思路。

运筹学是应用数学中接近生活且有趣的分支学科,强大的动态规划就是其中的研究方向之一。希望这篇小的知识点也能帮助你激发学习的乐趣,并且知道我们的计算机是能解决更多数学问题的强大工具。?

c语言贪心算法背包问题_GGTalk 中的算法知识 01背包问题相关推荐

  1. 探讨与研究——动态规划算法、回溯法、分支限界法解0-1背包问题

    一个人终归是要成长的,是要不断历练的,没有人可以安安稳稳一辈子.就算是最有地位最有钱的人也要不断追求.不断历练.不断提升自己. 人的学问少时在不断学习,青年时期不断实践.随着时间推移,到了老年终有所成 ...

  2. o-1背包问题迭代_经典动态规划:01背包问题的变体

    点击上方蓝字设为星标 东哥带你手把手撕力扣~ 作者:labuladong   公众号:labuladong 若已授权白名单也必须保留以上来源信息 上篇文章 经典动态规划:0-1 背包问题 详解了通用的 ...

  3. 使用ga算法解决背包问题_我如何使用算法解决现实生活中的手提背包的背包问题

    使用ga算法解决背包问题 I'm a nomad and live out of one carry-on bag. This means that the total weight of all m ...

  4. 语言高精度算法阶乘_JavaScript中的算法(附10道面试常见算法题解决方法和思路)...

    https://juejin.im/post/6844903811505455118 Introduction 面试过程通常从最初的电话面试开始,然后是现场面试,检查编程技能和文化契合度.几乎毫无例外 ...

  5. 动态规划背包问题详解(二)---0-1背包问题

    /**  * 对于技术面试,你还在死记硬背么?  * 快来"播"沙糖橘吧,  * 用视频案例为你实战解读技术难点  * 聚焦Java技术疑难点,实战视频为你答疑解惑  * 越&qu ...

  6. 0-1背包问题 动态规划java_C#使用动态规划解决0-1背包问题实例分析

    // 利用动态规划解决0-1背包问题 using System; using System.Collections.Generic; using System.Linq; using System.T ...

  7. java蛮力法背包问题_[算法课]五种蛮力法解决01背包问题

    文章目录 注明:题目要求只能使用蛮力法 算法标签:全排列,枚举,二进制,dfs,数组 题目简介 思路 AC代码 方法一:字符串蛮力 方法二:二进制枚举 方法三:DFS 三.2闫老板思考角度 方法四:全 ...

  8. java数组查找算法_JAVA数组中查找算法中equals和==的问题

    importjava.util.*;publicclassTest1{publicstaticvoidmain(String[]args){ScannerS=newScanner(System.in) ...

  9. c语言 用回溯算法解决01背包问题,回溯法解决01背包问题

    <回溯法解决01背包问题>由会员分享,可在线阅读,更多相关<回溯法解决01背包问题(21页珍藏版)>请在人人文库网上搜索. 1.回溯法解决01背包问题,回溯法解决01背包问题, ...

最新文章

  1. mysql杠杆加号什么意思_对tb_book表中的数据,按ID序号进行升序排列,查询语句是什么?_学小易找答案...
  2. ThreadLocalMap的enrty的key为什么要设置成弱引用
  3. echarts学习文档
  4. TJU 2248. Channel Design 最小树形图
  5. cloudstack centOS安装(一)
  6. Rust-Cargo(3)
  7. 美团在Redis上踩过的一些坑-2.bgrewriteaof问题
  8. Hadoop-2.6.0NodeManager Restart Recover实现分析(一)
  9. Mac系统安装Windows系统
  10. TinyMind 和机器之心收藏
  11. 用AI「驯服」人类幼崽,手头有娃的可以试试
  12. Spring Boot项目JSP页面中文乱码解决
  13. 笔记本计算机怎么进入安全模式启动,笔记本电脑如何进入安全模式
  14. sklearn预测员工离职率
  15. winscp使用教程 linux,WinSCP使用方法教程
  16. 事关健康、教育和工资 | 1月起,这些事有变化 |
  17. 物联网卡开启养老新模式
  18. 3GPP TS 23501-g51 中英文对照 | 4.4.7 MSISDN-less MO SMS Service
  19. 机器人门禁控制盒怎么接线方法_门禁电锁如何接线
  20. 棋盘放芝麻:有一个棋盘,有64个方格,在第一个方格里面放1粒芝麻重量是0.00001kg,第二个里面放2粒,第三个里面放4,棋盘上放的所有芝麻的重量(代码)

热门文章

  1. Objective-C 和 Swift 混编项目的小 Tips(一)
  2. [Elasticsearch2.x] 多字段搜索 (三) - multi_match查询和多数字段 译
  3. Tasker文件夹说明
  4. linux 构建 无线网络 过程
  5. Win7安装VC++6.0已知的兼容性问题的解决方法
  6. JS正则表达式详解(转)
  7. php __tostring 与 tostring
  8. javascript获取url参数的代码
  9. php.ini 配置详解
  10. 如何修改webbrowser里的JS函数