动态规划英文名Dynamic Programming,这个名称总让人有一种时曾相识的感觉,可能是因为容易和“线性规划”之类的概念搞混。
首先,适用动态规划的问题十分广泛和常见——地图路径搜索(深度优先、广度优先、A*),填充容器使价值最大化(例如背包体积固定V,有不同的物体具有各自的体积和价值),文本比较算法(常用的diff工具),以及最短路径之类的求最优解的问题,幕后都有一只叫做DP的黑手操纵着。
一个算法非常常用,说明两点:这个算法效果较好,时、空复杂度相对较好;.现实中大量问题符合这个算法的适用范围。前者并无什么特别之处,而后者正是动态规划可以称为“算法之王”的原因。
不严谨的看,世界上的算法问题有三种:特别简单的,中等的,特别复杂的。
特别复杂的问题,很不严谨的说就是只有穷举才能解决的问题。算法理论中被称为NP问题就是这种,没有“N的多项式时间内”的解法。这种问题往往不好被人们利用。
特别简单的问题,解决策略往往比较明显。要么是可以简单的用贪婪法直接做,要么就是有特定的数学方法可以很快得到结果。其中能用贪婪法的问题是这样的问题:局部最优解的简单叠加即为全局最优解。举两个例子:
1、一个小偷潜入到一个仓库里,他只能从仓库里拿三样东西,问怎样才能使小偷的利益最大化?(抢答:挑三个价值最大的货物。)
2、你去超市购物花了N元(N为正整数),你的钱包里有1、5、10、50、100五种钞票足够多,问怎样付款能让付款的钞票张数最少?(抢答:根据价格,从100元到1元从大到小付清为止。比如128元,付100+10+10+5+1+1+1即可。)
你一定会觉得上面两个例子既白痴又不实际,那么,我们把他们改的实际一点。
1、一个小偷潜入到一个仓库里,他只带了一个包,包的容量为10公斤,仓库里有单件1公斤、2公斤、3公斤的货物价值分别为60元、200元、310元。问怎样带使小偷的利益最大化?
这个问题变得非常实际了,如果小偷一着急,从3公斤的开始装,不足的空间用1公斤的补,是310+310+310+60 = 970元,还不错知足了。但是,其实装5个2公斤的,就是1000元!白赚30元。
如果把它抽象成:包裹容量为V,货物重量weight = [w1, w2, ..., wn],货物价值value = [v1, v2, ..., vn],请你编程求解,是不是就有点困难了?自古鱼和熊掌不可兼得的问题大都符合这种模型。
2、为纪念M国成立60周年,政府发行了大量6元和60元纪念钞票,并可以在市面上用于流通。具有“最少张钞票”强迫症的你头疼了,因为就算你备足了各种整钱和零钱,原来的付款策略也不再奏效。例如,如果付款12元,从大到小,付一张10元和两张1元就是3张;而其实你付2张6元就可以了。
你可能会说这个问题是瞎编的。实际上,正是为了避免这种情况,所以我们的钞票才被设计成了现在这几种面值。而现实中如果不是特别设计过,那么不能用贪婪法的问题其实占大多数。
现实中的问题,要解决的问题往往只有两、三个限制条件,而往往只要两、三个限制条件,就让它变得既不简单,又不是特别复杂。可喜的是,这样正好进入了动态规划的射程。
——————————————————————————————————————————————————————————————————
跟我看几个实例:
一片原始森林要开发为野生旅游区,一批考察人员去实地考察,规划旅游路线。森林里路线非常繁多,根据计划,从A点出发,中间必须到达中途休息区(BCD三者之一都可以,未来会选择其中一处建设成休息站)供游客休息购物,然后再出发到达E点结束。
♦ | B | ♦ |
起点A | C | 终点E |
♦ | D | ♦ |
原始森林里,考察人员必须自己行走,然后记录自己到达每个点用的时间,用以最终确定路线。
这个问题很简单,最终目标是A-E,它们的距离记作Lae,划分成几个子问题:
Lab, Lac, Lad, Lbe, Lce, Lde
Lae = Min( Lab+Lbe, Lac+Lce, Lad+Lde )
全局一看,无非是三条路线,选一条最短的即可。
就说A-B这一段,也有很多走法,对考察人员来说,他们得把这些小分支都走一遍,才能找到最短的一条作为Lab。
推广开去:
A | 1 | 4 | 7 | 9 | 12 | B=12+3 | E | ||||||||||||||||||||||
1 | 26 | ||||||||||||||||||||||||||||
5 | 25 | ||||||||||||||||||||||||||||
8 | 16 | 20 | 21 | 22 | 23 | 24 |
不管路线多么复杂,总要把所有的路走一遍才能知道最短路线。因为每一个点都有可能是最终路线的一环,要所有点全部算出来才知道。
搜索策略有很多,可以任意选择。只需要记住一个关键:当一路走来达到某一个点B时,B这一点只需要记录从起点过来最少需要x步,如果从别的途径搜索过来用了x+1步,那么这条更慢的搜索路径也没用,直接终止。如图蓝色的路线到B时,B已经记录了15,蓝色路线就不需要往上走了,可以往其他没走过的格子继续走。
可以看出,越到后来,很多搜索途径会刚开始走就停止并不很浪费,搜索策略得当的话,这个问题的时间复杂度大致就和搜索空间一致,是O(格子数N)。实际上宽度优先搜索放在这里就是最好的方法了(反正我看起来是的)。
再看小偷装东西的问题。
如果背包大小是10,我们完全可以看成0、1、2、3、……、9、10 一共11个状态,每往包里放一个货物,状态就改变一次。最终达到状态10。不同的放置策略像不像一条路径?
1、2、3公斤的三种货物价值分别是60、200、310。 每个状态的价值记作m,初始状态的价值 m0 = 0。
状态1只能通过状态0跳过来。m1 = 60
状态2可以通过状态1 或者 状态0 转移过来。 m2 = max( 60+60, 200 ) = 200
状态3有三种跳转方法,从状态2来,从状态1来,从状态0来。
状态4有三种跳转方法,从状态3来,从状态2来,从状态1来。
状态5有三种跳转方法,从状态4来,从状态3来,从状态2来。
依次类推……就可以得到状态10的值了,无非是从状态7、8、9跳转而来,只需要取max即可。
——————————————————————————————————————————————————————————————————
我曾经遇到的最有情趣的一道题:一个N*N的棋盘,蚂蚁在左上角第一个格子( 0, 0 )里的位置,棋盘的每个格子里会放0~1粒蔗糖粉末。蚂蚁只能往右走或者往下走,问蚂蚁怎么走,才能使吃到的糖粉最多?
提示:设( 0,0 )格能吃到的最大数量为m(0,0),蚂蚁每次都要从向下和向右中做出选择:
m(x,y) = 目前这一格上的糖粉 + max( m(x+1,y), m(x,y+1) )
这是个递推式子,可以递归来解。当然想明白以后从右下角开始往上算就可以避免递归了。
——————————————————————————————————————————————————————————————————
大胆的抽象描述一下动态规划问题特性:
问题:小偷的背包容量为V,仓库里有n种货物,其价值分别为(v1,v2,...,vn),重量分别为(w1,w2,...,wn),剩余数量分别为(u1,u2,...,un),求小偷获得最大利益的方法
动态规划求解的问题,不易一眼看到明确的特征。
问题必须能够划分为若干子问题,或者叫做阶段。必须给定已知的初始阶段。
每一个阶段都必然从之前的某一个阶段跳转而来,每一个阶段都要知道自己的最优值的判定方法,以便只保留最优的那个值。
初始阶段经过一系列的跳转,每一步跳转都是最优解,那么达到最终阶段的时候也是最优解。(核心条件,只有满足这一条件的问题才能用动态规划法。)
反复的讨论一下:一个状态可能是最终最优跳转路径的一部分,所以大多数状态都需要被求解,虽然有可能不是。(后面会说怎么尽可能的减少求解的次数。)
有一类简单问题:只要每一步是最优解,那么结果肯定是最优解;不需要动态规划。有一类复杂问题:就算每一个子问题找到了最优解,跳转之后也不一定能得到最优解;这种问题不适用动态规划,而且不穷举的话很难求解。
符合动态规划的问题和上面二者不同:全局最优解一定是通过一系列局部最优解得来的,但是从某个局部最优解出发不一定能达到全局最优解。(举例,上面小偷装东西的问题,最终是通过2+2+2+2+2得来的,从1或者3状态出发永远也达不到最优解。)
最后,思考上最难的一点是:状态空间不仅可以是1维的,还可以是2维、3维、4维……寻找最短路径是典型的2维问题,装包是典型的1维问题。只需要修改一下小偷装东西问题,就可以变成2维的。
如果仍然使用1维的状态空间,很容易造成求得的解超过了货物的剩余数量,得到错误的解。必须使用二维空间,两个维度分别是 当前容量、当前货物总种类。最终跳转到容量为V、种类为n的节点。
——————————————————————————————————————————————————————————————
更深入的探讨,慢慢补充:
每多一个限制条件,状态空间增加一维。有时候状态空间的意义模糊不易理解,是主要的难点所在。
动态规划问题往往有两种解法:正向和反向,正向求解往往需要递归,有时候用反向可以避免递归。比如小偷装东西问题,上面说的是反向解法,没有递归。
你还可以找到一个递推式
m10 = max(m9+60,m8+200,m7+310)
其中m9、m8、m7都是未知的,直接递归来做就可以了。
而且,你发现了吗,随着递归深入,递归方式可以跳过一部分完全不需要计算的节点。举个例子,如果输入参数里,货物重量都是偶数,那么在递归的时候,奇数状态就不会被算到啦 :)
(我在做diff算法时感觉到能跳过的节点不多,但是反向计算肯定是全都要算出来的。)
如果采用递归方式,务必记得用数组或者n维数组保存中间结果,不保存中间结果会反复的计算已经计算过的节点,在实际应用中觉得非常可怕。
比较恶搞的是,最终状态不一定能达到,比如小偷装东西的问题吧,如果货物重量是4、6、8、10等等,而你的背包容量是21,那么不可能达到21,只能达到20,而且进一步的,某种组合方式可能最大值不是出现在后面的状态,所以,你得max(状态1, 状态2 ..., 状态n)才能得到真正的极值。网上无数代码存在此漏洞,包括《编程之美——微软技术面试心得》这本书。
这又牵扯到神奇的数学,不同的数字竟然会对算法造成影响,恐怕这也是数学家的乐趣所在了 = =