针对最常见的最优化问题,动态规划如何设计求解呢?下面我们研究一个最优化问题:矿工挖矿问题。矿工挖矿问题是为了解决在给定矿产和矿工数量的前提下,能够获得最多钻石的挖矿策略。
1. 问题描述
假设某地区有 5 座钻石矿,每座钻石矿的钻石储量不同,根据挖矿难度,需要参与挖掘的工人数量也不同。假设能够参与挖矿工人的总数是 10 人,且每座钻石矿要么全挖,要么不挖,不能只派出一部分人挖取一部分矿产。要求用程序求解出,要想得到尽可能多的钻石,应该选择挖取哪几座矿产?
各矿产的钻石储量与所需挖掘工人数量如表 1 所示。
表 1:各矿产的钻石储量与所需工人数量
矿产编号
钻石储量
所需工人数量
1
400
5
2
500
5
3
200
3
4
300
4
5
350
3
2. 问题分析
1)分析原问题最优解的结构特征
首先寻找最优子结构。我们的解题目标是确定 10 个工人挖 5 座矿产时能够获得的最多的钻石数量,该结果可以从 10 个工人挖 4 个矿产的子问题中递归求解。证明不再赘述。
在解决了 10 个工人挖 4 个矿产后,存在两种选择:一种选择是放弃其中一座矿,比如第五座矿产,将 10 个工人全部投放到前4座矿产的挖掘中,如图 1 所示;
图 1:放弃第 5 座矿产的情况
另一种选择是对第 5 座矿产进行挖掘,因此需要从 10 人中分配 3 个人加入到第 5 座矿产的挖掘工作中,如图 2 所示。
图 2:挖掘第 5 座矿产的情况
因此,最终的最优解应该是这两种选择中获得钻石数量较多的那个,即为图 1 所描述的场景与图 2 所描述场景中的最大值。
为了方便描述,我们假设矿产的数量为 n,工人的数量为 m,当前获得的钻石数量为 G[n],当前所用矿工的数量为 L[n],则根据上述分析,要获得 10 个矿工挖掘第 5 座矿产的最优解 F(5,10),需要在 F(4,10)和F(4,10-L[4])+G[4] 中获取较大的值,即
F(5,10)=max{F(4,10),F(4,10-L[4])+G[4]}
因此,针对该问题而言,以上便是 F(5,10) 情况下的最优子结构。
2) 建立递归关系,写出状态转移函数
我们首先来考虑该问题的边界和初始条件。对于一个矿产的情况,若当前的矿工数量不能满足该矿产的挖掘需要,则获得的钻石数量为 0,若能满足矿工数量要求,则获得的钻石数量为 G[0]。
因此,该问题的初始边界条件可表述为:
当n=1,m≥L[0]时,F(n,m)=G[0];
当n=1,m
综上,可以得到该问题的状态转移函数为:
F(n,m)=0(n≤1,m
F(n,m)=G[0](n==1,m≥L[0])
F(n,m)=F(n-1,m)(n>1,m
F(n,m)=max(F(n-1,m),F(n-1,m-L[n-1])+G[n-1])(n>1,m≥L[n-1])
至此,我们定义了用动态规划解决该问题的几个要素。下面,我们要做的是利用边界和初始条件、最优子结构和状态转移函数对该问题进行求解。
3) 计算最优解的值
初始化阶段,我们利用表格分析求解思路。如表 2 所示,表格的第一列代表挖掘矿产数,即 n 的取值情况;表格的第一行代表占用工人数,即 m 的取值情况;中间各空白区域是我们需要通过计算填入的对应的钻石数量,即 F(n,m) 的取值。
表 2:初始钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
矿产 2
矿产 3
矿产 4
矿产 5
在挖掘第一个矿产时,由于其所需的工人数量为 5,所以当 m 的取值小于 5 时,根据公式 F(n,m)=0(n≤1,m
表 3:挖掘第1个矿产时钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
矿产 3
矿产 4
矿产 5
在挖掘第 2 个矿产时,由于其需要 5 个人进行挖掘,因此当 m 取值小于 5 时,根据公式 F(n,m)=F(n-1,m)(n>1,m1,m≥L[n-1]),在 5~9 人的区间里,获得的钻石数量为 500,即所有人都去参加第 2 个矿产的挖掘时获得的钻石量。
这是因为当 m∈{5,9}时, F(1,m)
表 4:挖掘第 2 个矿产时钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
0
0
0
0
500
500
500
500
500
500
矿产 3
矿产 4
矿产 5
同理,在挖掘第 3 个矿产时,钻石产出量为 200,需要的工人数量为 3,根据上述计算方式,可得钻石产出量如表 5 所示。
表 5:挖掘第 3 个矿产时钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
0
0
0
0
500
500
500
500
500
500
矿产 3
0
0
200
200
500
500
500
700
700
900
矿产 4
矿产 5
第 4 个矿产的钻石产出量为 300,需要的工人数量为 4,根据上述计算方式,可得钻石产出量如表 6 所示。
表 6:挖掘第 4 个矿产时钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
0
0
0
0
500
500
500
500
500
500
矿产 3
0
0
200
200
500
500
500
700
700
900
矿产 4
0
0
200
300
500
500
500
700
800
900
矿产 5
针对第 5 个矿产的钻石产出量计算与上述过程一致,具体产出量如表 7 所示。
表 7:挖掘第 5 个矿产时钻石数量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
0
0
0
0
500
500
500
500
500
500
矿产 3
0
0
200
200
500
500
500
700
700
900
矿产 4
0
0
200
300
500
500
500
700
800
900
矿产 5
0
0
350
350
500
550
650
850
850
900
通过以上的计算过程,我们不难发现,除第一个矿产的相关数据,表格中的其他数据都可以由前一行的一个或两个格子推导而来。例如,3 个矿产 8 个人挖掘的钻石量 F(3,8) 就来自 2 个矿产 8 个人挖掘的钻石量 F(2,8)和 2 个矿产 5 个人挖掘的钻石量 F(2,5),即 F(3,8)=max{F(2,8),F(2,5)+200}=max(500,500+200)=700。
再比如,4 个矿产 10 个人挖掘的钻石量 F(4,10),来自 3 个矿产 10 个人挖掘的钻石量 F(3,10)和 3 个矿产 7 个人挖掘的钻石量 F(3,7),即 F(4,10)=max{F(3,10),F(3,7)+300}=max(900, 500+300)=900,如表 8 所示。
表 8:矿产钻石产量计算依赖于之前计算量
矿产编号 n
m=1 人
m=2 人
m=3 人
m=4 人
m=5 人
m=6 人
m=7 人
m=8 人
m=9 人
m=10 人
矿产 1
0
0
0
0
400
400
400
400
400
400
矿产 2
0
0
0
0
500
500
500
500
500
500
矿产 3
矿产 4
矿产 5
根据以上思路,在用程序实现该算法的过程中,采用自底向上的方式进行计算,像填表过程一样从左至右、从上到下逐渐获得计算结果。这样,可以不需要存储整个表格的内容,仅需要存储前一行的结果,就可以推出下一行的内容,避免了重复计算。
4) 构造最优解
填表结束后,我们通过回溯的方式可以找出该矿工挖矿问题的最优解组合为所有矿工(10人)挖掘矿产 1 和矿产 2,最多可以挖得价值 900 的钻石。具体回溯过程我们之后展示。
3. 参考实现
现在我们来把这个过程转化为程序。
我们定义函数 goldMine(n,m,g,L) 来计算挖掘第 n 个矿产,有 m 个工人参与时能获得的钻石量,其中 g 和 L 分别为数组,分别存放对应各个矿产的钻石量和所需工人数。在正式迭代之前,首先界定边界的情况。当工人数量小于 L[0]时,说明目前的工人数量不能开始任何矿产的挖掘,获得的钻石数量为 0;当工人数量大于或等于 L[0]时,获得的钻石数量即为 g[0]。
之后进入循环的迭代过程,在迭代中,根据之前的分析,我们仅需要关注前一行 t_results 的取值,即可通过状态转移函数 F(n,m)=F(n-1,m)(n>1,m1,m≥L[n-1]) 获得 F(n,m) 的值,因此,整个迭代过程仅需要引入 t_results 数组保存前一行的值即可。
由此可见,该解决方案的时间复杂度为 O(ni m),而空间复杂度只有 O(m)。
具体实现时,我们首先定义边界值,确定数组 t_results 各个元素的取值,同时初始化数组 results。之后,通过函数 goldMine(n,m,g,L) 迭代计算各个矿产数量与工人数量组合所能产生的钻石量,最终获得问题的解。
矿工挖矿问题代码如下:
def goldMine(n, m, g, L):
results = [0 for _ in range(m+1)] #保存返回结果的数组
t_results = [0 for _ in range(m+1)] #保存上一行结果的数组
for i in range(1,m+1): #填充边界格子的值,从左向右填充表格第一行的内容
if i < L[0]:
t_results[i] = 0#若当前人数少于挖掘第一个金矿所需人数,黄金量为0
else:
t_results[i] = g[0] #若当前人数不少于第一个金矿所需人数,黄金量为g[0]
for i in range(1,n): #外层循环为金矿数量
results = [0 for _ in range(m+1)]
for j in range(1,m+1): #内层循环为矿工数量
if j < L[i]:
results[j] = t_results[j]
else:
results[j] = max(t_results[j], t_results[j-L[i]] + g[i])
t_results = results
return results[-1]
print(goldMine(5, 10, [400,500,200,300,350], [5,5,3,4,3]))
输出:
900