博主在学习动态规划的过程中,有幸读到几篇文章,大有裨益,与大家分享,通过金矿模型介绍动态规划,动态规划之背包问题(一),P01: 01背包问题。第一篇文章是从评论中发掘的,生动形象,小白力荐!
学过算法的同学,可能都知道动态规划,它不是一个具体的算法,而是一种很有用的解决问题的思想。本文是博主的总结性文章,除了借鉴上述两篇文章之外,还参考其他例子,来加深理解。
什么是动态规划
奇文共欣赏!(故事有适当精简)
有一个国家,某天他们在自己的国家发现了十座金矿,并且这十座金矿在地图上排成一条直线,国王知道这个消息后非常高兴,他希望能够把这些金子都挖出来造福国民,首先他把这些金矿按照在地图上的位置从西至东进行编号,依次为0、1、2、3、4、5、6、7、8、9,然后他命令他的手下去对每一座金矿进行勘测,以便知道挖取每一座金矿需要多少人力以及每座金矿能够挖出多少金子,然后动员国民都来挖金子。
题目补充1:挖每一座金矿需要的人数是固定的,多一个人少一个人都不行。国王知道每个金矿各需要多少人手,金矿i需要的人数为peopleNeeded[i]。
题目补充2:每一座金矿所挖出来的金子数是固定的,要么全挖出来,要没一个都不挖。当第i座金矿有peopleNeeded[i]人去挖的话,就一定能恰好挖出gold[i]个金子。否则一个金子都挖不出来。
题目补充3:一个人最多只能使用一次。开采一座金矿的人完成开采工作后,他们不会再次去开采其它金矿。
题目补充4:国王在全国范围内仅招募到了10000名愿意为了国家去挖金子的人,因此这些人可能不够把所有的金子都挖出来,但是国王希望挖到的金子越多越好。
题目补充5:有很多人拿到这个题后的第一反应就是对每一个金矿求出平均每个人能挖出多少金子,然后从高到低进行选择,这里要强调这种方法是错的,如果你也是这样想的,请考虑背包模型,当有一个背包的容量为10,共有3个物品,体积分别是3、3、5,价值分别是6、6、9,那么你的方法取到的是前两个物品,总价值是12,但明显最大值是后两个物品组成的15。
题目补充6:我们只需要知道最多可以挖出多少金子即可,而不用关心哪些金矿挖哪些金矿不挖。(如果要关心的话,计算的时候增开变量进行存储)
那么,国王究竟如何知道在只有10000个人的情况下最多能挖出多少金子呢?国王是如何思考这个问题的呢?
国王首先来到了第9个金矿的所在地(注意,第9个就是最后一个,因为是从0开始编号的,最西边的那个金矿是第0个),他的臣子告诉他,如果要挖取第9个金矿的话就需要1500个人,并且第9个金矿可以挖出8888个金子。听到这里国王哈哈大笑起来,因为原先他以为要知道十个金矿在仅有10000个人的情况下最多能挖出多少金子是一件很难思考的问题,但是,就在刚才听完他的臣子所说的那句话时,国王已经知道总共最多能挖出多少金子了,国王是如何在不了解其它金矿的情况下知道最多能挖出多少金子的呢?他的臣子们也不知道这个谜,因此他的臣子们就问他了:“最聪明的国王陛下,我们都没有告诉您其它金矿的情况,您是如何知道最终答案的呢?”
得意的国王笑了笑,然后把他最得意的“左、右手”叫到跟前,说到:“我并不需要考虑最终要挖哪些金矿才能得到最多的金子,我只需要考虑我面前的这座金矿就可以了,对于我面前的这座金矿不外乎仅有两种选择,要么挖,要么不挖,对吧?”
“当然,当然”大臣们回答倒。
国王继续说道:“如果我挖取第9座金矿的话那么我现在就能获得8888个金子,而我将用去1500个人,那么我还剩下8500个人。我亲爱的左部下,如果你告诉我当我把所有剩下的8500个人和所有剩下的其它金矿都交给你去开采你最多能给我挖出多少金子的话,那么我不就知道了在第9个金矿一定开采的情况下所能得到的最大金币数吗?”
国王的左部下听后回答道:“国王陛下,您的意思是如果我能用8500个人在其它金矿最多开采出x个金币的话,那您一共就能够获得 x + 8888个金子,对吗?”
“是啊,是啊……如果第9座金矿一定开采的话……”大臣们点头说到。
国王笑着继续对着他的右部下说到:“亲爱的右部下,也许我并不打算开采这第9座金矿,那么我依然拥有10000个人,如果我把这10000个人和剩下的金矿都给你的话,你最多能给我挖出多少个金子呢?”
国王的右部下聪明地说道:“尊敬的国王陛下,我明白您的意思了,如果我回答最多能购开采出y个金币的话,那您就可以在y和x+8888之间选择一个较大者,而这个较大者就是最终我们能获得的最大金币数,您看我这样理解对吗?”
国王笑得更灿烂了,问他的左部下:“那么亲爱的左部下,我给你8500个人和其余金矿的话你能告诉我最多能挖出多少金子吗?”
“请您放心,这个问题难不倒我”。左部下向国王打包票说到。
国王高兴地继续问他的右部下:“那右部下你呢,如果我给你10000个人和其余金矿的话你能告诉我最多能挖出多少金子吗?”
“当然能了!交给我吧!”右部下同左部下一样自信地回答道。
“那就拜托给你们两位了,现在我要回到我那舒适的王宫里去享受了,我期待着你们的答复。”国王说完就开始动身回去等消息了,他是多么地相信他的两个大臣能够给他一个准确的答复,因为国王其实知道他的两位大臣要比他聪明得多。
故事发展到这里,你是否在想国王的这两个大臣又是如何找到让国王满意的答案的呢?他们为什么能够如此自信呢?事实上他们的确比国王要聪明一些,因为他们从国王的身上学到了一点,就是这一点让他们充满了自信。
国王走后,国王的左、右部下来到了第8座金矿,早已在那里等待他们的金矿勘测兵向两位大臣报道:“聪明的两位大臣,您们好,第8座金矿需要1000个人才能开采,可以获得7000个金子”。
因为国王仅给他的左部下8500个人,所以国王的左部下叫来了两个人,对着其中一个人问到:“如果我给你7500个人和除了第8、第9的其它所有金矿的话,你能告诉我你最多能挖出多少金子吗?”
然后国王的左部下继续问另一个人:“如果我给你8500个人和除了第8、第9的其它所有金矿的话,你能告诉我你最多能挖出多少金子吗?”
国王的左部下在心里想着:“如果他们俩都能回答我的问题的话,那国王交给我的问题不就解决了吗?哈哈哈!”
因为国王给了他的右部下10000个人,所以国王的右部下同样也叫来了两个人,对着其中一个人问:“如果我给你9000个人和除了第8、第9的其它所有金矿的话,你能告诉我你最多能挖出多少金子吗?”
然后国王的右部下继续问他叫来的另一个人:“如果我给你10000个人和除了第8、第9的其它所有金矿的话,你能告诉我你最多能挖出多少金子吗?”
此时,国王的右部下同左部下一样,他们都在为自己如此聪明而感到满足。
当然,这四个被叫来的人同样自信地回答没有问题,因为他们同样地从这两个大臣身上学到了相同的一点,而两位自认为自己一样很聪明的大臣得意地笑着回到了他们的府邸,等着别人回答他们提出来的问题,现在你知道了这两个大臣是如何解决国王交待给他们的问题了吗?
那么你认为被大臣叫去的那四个人又是怎么完成大臣交给他们的问题的呢?答案当然是他们找到了另外八个人!
没用多少功夫,这个问题已经在全国传开了,更多人的人找到了更更多的人来解决这个问题,而有些人却不需要去另外找两个人帮他,哪些人不需要别人的帮助就可以回答他们的问题呢?
很明显,当被问到给你z个人和仅有第0座金矿时最多能挖出多少金子时,就不需要别人的帮助,因为你知道,如果z大于等于挖取第0座金矿所需要的人数的话,那么挖出来的最多金子数就是第0座金矿能够挖出来的金子数,如果这z个人不够开采第0座金矿,那么能挖出来的最多金子数就是0,因为这唯一的金矿不够人力去开采。让我们为这些不需要别人的帮助就可以准确地得出答案的人们鼓掌吧,这就是传说中的底层劳动人民!
引用文章一的这篇故事,详细的阐述了动态规划时问题的切分,每个子问题的解决流程。我们来吧过程抽象成数学函数,先定义几个要用的变量:
gold[mineNum]:第mineNum个金矿能够挖出的金子数peopleNeeded[mineNum]:挖第mineNum个金矿需要的人数
函数f(people,mineNum):表示people个人和mineNum+1个金矿时能够得到的最大金子数
从上述叙述中,我们得到f(people,mineNum)函数的状态转移函数为:
f(people,mineNum)=max(f(people,mineNum-1),f(people-peopleNeeded[mineNum],mineNum-1)+gold[mineNum]);
而函数的边界(终止)条件是啥呢?那就只剩一个矿的时候:
if(people>peopleNeeded[0]){
f(people,0)=gold[0];
}else{
f(people,0)=0;
}
结合状态转移函数,我们就可以求解了。状态转移函数要注意一个问题:
if(people>peopleNeeded[mineNum]){
f(people,mineNum)=max(f(people,mineNum-1),f(people-peopleNeeded[mineNum],mineNum-1)+gold[mineNum]);
}else{
f(people,mineNum)=f(people,mineNum-1);//人数不够开挖当前矿的时候
}
流程捋清楚了,来分析下这个问题本身,从数学的角度看,N个人挖M个矿,是一个排列组合问题,对每个矿来说有两种状态,挖和不挖,所以一共是2的M次方中组合方式,每种组合下面再考虑人员的安排,算法的复杂度是O(2的M次方),指数级的复杂度!!!考虑下动态规划下的复杂度,粗糙来看,是个两层的嵌套循环,获取每种n下m个矿时,执行的结果。复杂度O(M*N)。当N较大时,实际计算次数高于组合的次数。但是我们可以发现,实际应用中,嵌套循环的N的步长不可能是1,因为一个矿peopleNeeded>>>1。比如每个矿neededPeople==1000,对于N=10000的情况,如果M=10,则计算100次就够了。
从算法复杂度的分析过程可以看出,除了递归外,也可以用嵌套循环的方式实现。
从这个问题思考动态规划的几点特质:
- 最优子结构:
国王相信,只要他的两个大臣能够回答出正确的答案(对于考虑能够开采出的金子数,最多的也就是最优的同时也就是正确的),再加上他的聪明的判断就一定能得到最终的正确答案。我们把这种子问题最优时母问题通过优化选择后一定最优的情况叫做“最优子结构”。 - (父)子问题重叠:
实际上国王也好,大臣也好,所有人面对的都是同样的问题,即给你一定数量的人,给你一定数量的金矿,让你求出能够开采出来的最多金子数。我们把这种母问题与子问题本质上是同一个问题的情况称为“(父)子问题重叠”。然而问题中出现的不同点往往就是被子问题之间传递的参数,比如这里的人数和金矿数。 - 边界:
想想如果不存在前面我们提到的那些底层劳动者的话这个问题能解决吗?永远都不可能!我们把这种子问题在一定时候就不再需要提出子子问题的情况叫做边界,没有边界就会出现死循环。 - 子问题独立:
要知道,当国王的两个大臣在思考他们自己的问题时他们是不会关心对方是如何计算怎样开采金矿的,因为他们知道,国王只会选择两个人中的一个作为最后方案,另一个人的方案并不会得到实施,因此一个人的决定对另一个人的决定是没有影响的。我们把这种一个母问题在对子问题选择时,当前被选择的子问题两两互不影响的情况叫做“子问题独立”。
0-1背包问题
一般来说具有这四个特性的问题就可以用动态规划解决。看个更常见的例子,0-1背包问题:有个容量为C的背包,现有N个物品,对应的体积和价值分别是V[i]个W[i],给出一种总价值最大的装法。
结合上述的挖金矿,很容易写出边界条件和状态转移函数d(i, j)=max{ d(i-1, j), d(i-1,j-V[i-1]) + W[i-1] },d(i,j)表示前i个物品装入容量为j的背包中的最大价值。
显然可以用上述递归的思路写出过程,这里我们用循环嵌套的方式给出过程:
for(int i=0;i<=n;i++){
for(int j=0;j<=C;j++){
if(i==0){
d[i][j]=0;//前0个物品,值为0
}else if(j>V[i-1]){//当前容量能够存放第i-1个物品
d[i][j]=d[i-1][j]<d[i-1][j-V[i-1]]+W[i-1]?d[i-1][j-V[i-1]]+W[i-1]:d[i-1][j];
}else{//当前容量无法容纳第i-1个物品
d[i][j]=d[i-1][j];
}
}
}
程序的流程如图所示:
开辟d(i,j)二维数组记录前i个元素放进容量为j的背包中得到的最大价值。这样在计算d[i][j]的时候,直接取出已有的d[i-1][j]和d[i-1][j-V[i-1]]。
对于第一层嵌套循环比较好理解,从没有物品开始,到所有物品都添加进背包。那第二层的0~C的循环,代表的又是什么含义呢?每次二层循环结束得到d[i][0~C]一维数组,前i个元素放入到容量为0~C的背包中时,得到的价值最大值。但是此处为什么要以j++的形式变化,背包容量不是应该以V[i-1]的程度变化吗?这个问题我们先放一边,后面再解释。
功能基本实现,到了考虑优化的时候,时空复杂度O(nC)。先看空间复杂度,使用了三个数组:V[]、W[]、d[]。V记录了所有物品的体积、W[]记录了所有物品的价值,在某些输入条件下可以优化,比如:从键盘读入或者从文件读入数据时,可以只采用V和W变量,记录当前读入的值,做完操作,读取下一组值的时候覆盖之前的V、W值。d[N][C]的数组有法优化吗?每一次的i循环,完成是计算当前i个物品的放入到C容量的背包中价值,最终我们需要的N个物品放入容量为C的背包中的最大价值存在数组的d[N-1][C-1]元素中,i的作用就是记录当前的物品数量,而这个是我们不关心的,能只保留d[C]数组,循环N遍之后的d[C-1]的值就是最终需要的结果。这样的话,状态转移函数就可以变为:
d[j]=max(d[j],d[j-V[i-1]]+W[i-1]);//先不考虑V[]、W[]的优化
或许有些同学能从上面那张图中,感觉到如此存储数据带来的问题:
d[i,j]=max(d[i-1,j],d[i-1,j-V[i-1]]+W[i-1]);计算完成后d[j]对应的数据从d[i-1,j]变成了d[i][j]。此时j++,准备计算d[i,j+1],需要d[i-1,j+1]和d[i-1][j+1-V[i-1]]的值,如果此时恰巧V[i-1]==1,也就是需要d[i-1][j]的值,但是d[j]中存放的值已经是d[i][j]而不是d[i-1][j]了。怎么办呢?问题的出在,后面的d[i]j+k会用到前面的d[i-1][j]值,而d[i-1][j]可能会被d[i][j]覆盖掉,所以要保证先进行的操作,不能改变后执行需要的变量。于是乎,我们就从C往0开始计算,每次改变的d[j]都是后执行的d[i]j-k用不到的变量。
这样代码可以写成:
for(int i=0; i<=n; i++){
for(int j=C; j>=0; j--){
if(i==0){
d[j]=0;
}else if(j>=V[i-1] ){
d[j] =d[j]>d[j-V]+W?d[j]:d[j-V]+W;
}else{
d[j]=d[j];
}
}
}
再来看下前面说的C–优化的问题,每次C的变化步长为1,而我们推导d[j]只要知道d[j-V]即可,很难去一次性让C变化到对应的j-V处,但我们可以改变循环的下界,减少比较次数。比如第n-1个(从0开始编号)物品,只要知道 d[C-V[n-1]]即可。第k个物品,需要d[C-sum(V[k],……,V[n-1])]。那么循环就可以改为:
for(int i=0;i<=n;i++){
bound=C-sum{V[i],...,V[n-1]};
for(int j=C;j>bound;j--){
......
}
}
减少第二层嵌套的循环次数。
最后补充下一种0-1背包情况:要求恰好装满容量为C的背包。这个和上面的区别仅仅是从能装变成恰好装满,该如何改动就可以了呢?只要在初始化的时候,把d[0]赋为0,d[1]到d[n-1]赋为-INFINIT即可。为什么这样就可以了呢?这个就留给大家自己思考,啊哈哈~
总结
动态规划的概念,还有01背包的详述就说完了,相信大家会有一个更清晰的认识。
很惭愧,做了一点微小的贡献!