例如,阶段[3,4]中的道路可以由阶段[3,5]中的道路加一条边4—5得出,而阶段[3,5]的状态却无法由阶段[3,4]中的状态得出,因为Bitonic旅行路线要求必须严格地由左到右来旅行。
所以如果我们已经知道了阶段[3,4]中的状态,则阶段[3,5]中的状态必然已知。
因此我们可以说,Bitonic问题满足“无后效性原则”,可以用“动态规划”算法来解决,其程序可以参见Pro_3_1.Pas。
对于欧几里德货郎担问题,阶段与阶段之间没有什么必然的“顺序”。
如道路{3—2—5—7,4—6—7}属于阶段[3,4],可由属于阶段[2,4]的道路{2—5—7,4—6—7}推出;而道路{2—3—6—7,4—5—7}属于阶段[2,4],可由属于阶段[3,4]的道路{3—6—7,4—5—7}推出。
如果以顶点表示阶段,推出关系表示边,那么,阶段[3,4]与阶段[2,4]对应的关系就如图6所示。
我们可以很清晰地看出,这两个阶段的关系是“有后效性”的。
因为这个图中存在“环路”。
对于这个问题是不能像上一个问题那样来解决的,事实上,这个问题是一个NP完全问题,其解决的时间复杂度很可能是指数级的。
所以,对于一个问题能否用“动态规划”来解决的一个十分关键的判断条件就是“它是否有后效性”。
而我们在判断这个问题是否有“后效性”时,一个很有效的方法就是将这个问题的阶段作为顶点,阶段与阶段之间的关系看作有向边,判断这个有向图是否为“有向无环图”,亦即这个图是否可以进行“拓扑排序”。
通过上面的说明,我们可以总结出一些解决“动态规划”问题的基本方法与步骤:
1:
确定问题的研究对象,即确定状态。
2:
划分阶段,确定阶段之间的状态转移方程。
3:
考察此问题现在可否用“动态规划”来解决:
①:
考察此问题是否具有“最优子结构”。
②:
考察此问题是否为“无后效性”。
4:
如果发现此问题目前不能用“动态规划”来解决,则应该调整相应的定义与划分,以达到可以用“动态规划”来解决。
以上只是一般情况下的“动态规划”思维过程。
一些较为简单的问题可以“按部就班”来操作,但大多数的“动态规划”问题,特别是作为信息学竞赛中的“动态规划”问题,考察的知识是多方面的,应用的技巧是灵活多变的。
下面,对“动态规划”在应用中的一些重点、难点进行讨论。
三.动态规划的实际应用
我们衡量一个算法的标准,无外乎时间、空间两项指标。
“动态规划”算法的时间大多数为“多项式级”的,比起同样解决这个问题的搜索算法“指数级”的时间来说,“动态规划”的时间需要是很少的,所以我们在实际应用中,很少考虑“动态规划”算法的时间问题,而最经常考虑的是空间问题:
即状态的选定与存储。
1:
状态的选定:
对于一个“动态规划”算法来说,阶段的划分显得不很重要,因为阶段只是一些可以等同处理的状态的集合,我们尽可以把单个的状态定义为一个阶段。
所以,状态的选定对整个问题的处理起了决定性的作用。
我们选定的状态必须满足如下两点:
1:
状态必须完全描述出事物的性质,两个不同事物的状态是不同的;
2:
必须存在状态与状态之间的“转移方程”。
以便我们可以由“初始问题”对应的状态逐渐转化为“终结问题”对应的状态。
状态是描述事物性质的量,所以我们应该以这个标准,根据题目中的具体要求来具体分析,我们来看下面一个例子:
〖例4〗有一个奶牛运输公司,需在7个农场A,B,C,D,E,F,G之间运输奶牛,从A出发,完成任务后要回到A。
运输车每次只能运送一头奶牛,每个运输任务是由一对字母给出的,分别表示奶牛从哪个农场被运向另外一个农场。
任务总数为N(1≦N≦12)。
已知这些农场之间的距离,求出一条完成所有任务的最短路线。
我们先分析这个问题的解决方法:
我们的目的是完成一些任务,由于运输车每次只能运送一头奶牛,所以我们只能一个接一个地处理这些任务。
由于题目求的是一条完成这些任务的最短路线,所以在完成任务的过程中,我们走的路线都是最短路线,也就是说,一旦我们确定了完成任务的顺序P1—P2—……—PN,那么这条路线的最短时间也就确定了。
我们的目的就是确定这N个任务的完成顺序。
所以,这个问题的研究对象就是某些任务的集合S。
那么,状态该如何定义呢?
既然研究对象是集合,所以很自然地就想起了描述集合中元素关系的方法。
我们可以把状态定义为一个数组(
),每一个
或者为0,表示任务i不在当前集合中;或者为1,表示任务i在当前集合中。
但如果这样定义状态,我们会发现,无法写出状态转移方程,因为涉及到前后两个任务的“接口”的最短路线长度。
所以我们还需要一个量x来限定当前这个任务集合中首先要执行任务,则一个状态定义为(
),其中
必为1。
那么,一个状态对应的权值就是完成这个状态描述的任务集合中任务的最短路线长度。
则状态转移方程为:
(
)=
其中,y是不等于x且
=1的值,若i≠x,则
;
。
假设Dis(x,y)为由任务x的终点城市到任务y的起点城市的最短距离。
这个转移方程的初始条件为:
(
)={任务x的终点城市到A的距离},其中
,其余的
均为0。
在实际处理中,用这样一个最大可能为12维的数组显然是不方便的,既然每一位只能是0或1,所以我就把(
)看作一个十进制数的二进制表示,于是,状态数组为一个二维数组,具体程序见附录中的Pro_4_1.Pas。
从这个问题中我们可以看出,一个“动态规划”问题的状态选定实质上就是选择描述这个问题中事物的最贴切,最简洁的方法。
状态的选定不是一蹴而就的,而是一个在思考过程中逐步调整,逐步完善的过程。
2:
状态的存储
当状态选定后,我们面对的问题就是如何存储状态了。
从理论上讲,每一个状态都应该存储两个值,一个是此状态的最优的权值,一个是决策标识值。
但实际中,我们面对的“动态规划”问题很多都是状态数目十分庞大,这就要求我们在存储上必须做一定的优化才可以实现这个算法的程序化。
主要的优化就是:
舍弃一切不必要的存储量。
在一些问题中,题目给出了只在某一范围内的关系,所以我们只需存储这一范围内的状态即可。
下面来看这个问题:
〖例5〗一个生物体的结构可以用“基元”的序列表示,一个“基元”用一些英文字符串表示。
对于一个基元集合P,可以将字符串S作为N个基元P1,p2…pN的依次连接而成。
问题是给定一个字符串S和一个基元集合P,使S的前缀可由P中的基元组成。
求这个前缀的最大长度。
基元的长度最大为20,字符串的长度最大为500,000。
例如基元集合为{a,AB,BbC,Ca,Ba},字符串为ABABACABAABCB,则最大长度为11,具体组成见图7。
图中不同的“基元”以不同的颜色标出。
这个问题的状态十分容易确定,字符串中的每一位的性质只有两种,即:
包含这一位字符的字符串前缀是否可以由基元序列构成。
那么,我们就可以设一个数组:
Could:
Array[1..Max]OfBoolean作为表示字符串中每一位的状态。
那么,状态转移方程为:
Could[K]:
=Could[K]Or(Could[K-W]And(S[K-W+1...K]=Ji[I]))
其中,Could[k]的初始值均为False,Ji[I]表示第I个基元对应的字符串,其长度为W,S[K-W+1...K]为题中给定的字符串从K-W+1位到K位的字符组成的字符串。
写到这里,这个问题似乎已经解决了,我们只需求出使Could[I]为True最大的I即可。
但只要回过头看一看问题就会发现,字符串最长为500,000,根本无法存储,该如何是好呢?
如果再仔细观察一遍转移方程,就会发现:
Could[I]只与Could[I-W]发生关系,而W为基元的长度,基元的长度最大为20,也就是说,我们如果想推出Could[I],最多只需要知道Could[I-20]至Could[I-1]的值即可,其它的Could值我们可以不去管它。
这样,我们需要记录的Could值一下子就由500000减少到20,这个突变是巨大的。
具体程序见Pro_5.Pas。
这个问题为什么只记录很少的状态就可以呢?
这是因为它特殊的转移方程与求解顺序。
求解顺序是按照字符串中位置的先后而定的,而转移方程中,当前状态只与比它先求解的那20个状态发生关系,所以这个问题可以如此解决。
但对于大多数问题来说,这样“美丽”的“结构”不一定具备,那么我们在解决这样的问题时,只有“处心积虑”地削减存储量。
我们来看一个例子。
〖例6〗某公司运进一批箱子,总数为N(1≤N≤1000)由“传送带”依次运入,然后在仓库内至多排成P(1≤P≤4)列,(如图8所示)。
现已知运来的箱子最多为M(1≤M≤20)种,想把同一种类的箱子尽量排在一起,以便美观。
“美观程度”T定义为:
T=Σ(每列依次看到的不同种类数);所谓“依次看到的不同种类数”即为:
如果某一列中第K个箱子与第K-1个箱子种类不同,则“美观程度”的值加1。
求一种调动安排,使各列的“美观程度”值的和最小。
这个问题的思路如下:
第K个箱子需要选择一列来放,如果它放在与其种类不同的某个箱子上,则它会使总的“美观程度”值加1。
也就是说,第K个箱子的摆放只与当前P列的顶端情况有关。
又如果想求这个箱子摆放后的最小值,自然需要这个前(K-1)个箱子在此顶端情况时的最小值。
这个多阶段决策问题同时满足最优化原理与无后效性原则,所以这个问题可以用“动态规划”的方法来解决。
则状态的选定很明显,为(k,P1,P2,P3,P4),K是当前即将放入的箱子的编号,P1—P4放入第K个箱子后各列的“顶端情况”,即放入第K个箱子后顶端是什么种类的箱子(假设当前有4列)。
则阶段划分也很明显,以箱子由传送带运来的先后顺序划分阶段。
那么,状态转移方程就是:
(K,P1,P2,P3,P4)=Min(K-1,Q1,Q2,Q3,Q4)+Dis}
其中,Q1—Q4为未放入第K个箱子前各列的情况,Dis为0或1,取决与第K个箱子与其列中上一个箱子的种类有无差别。
写到这里,“动态规划”的算法各个步骤已经清楚了,似乎可以直接写程序了。
但对于本题来说,存储大量的信息,是我们所不得不面对的一个问题。
Pascal语言所允许的最大可用空间为640KB,在保护模式下,最多也不过1000多KB。
一个“动态规划”程序所需存储的一般有两个值,一是决策信息,二是状态的价值。
对于本题,若按普通的方法,最多时为1000×20
×(1+2)字节,根本无法存储。
必须对存储做一定的优化,舍弃一切不必要的量:
1.只记录每步的“决策信息”,不记录每步的状态的价值,这样可省下
的空间;
2.对每步的状态来说,所对应的P列顶端不含有两个同一种的箱子;
3.除本次要选的列以外,其它(P-1)列顶端的排列对结果没影响,所以存储的状态应为另外(P-1)列的组合,而非排列。
这样,总的存储量可由24000KB减少到1200KB,缩小了约20倍,在Pascal保护模式下完全可以操作。
具体的程序见Pro_6.Pas。
通过以上两个问题,我们可以看出,“动态规划”在实际应用中,速度几乎“不成问题”,但存储状态的“环节”却必须思考再思考,优化再优化。
近几年的试题中,大多数“动态规划”问题的难点与重点就是“动态规划”中状态的选定与存储。
这也是“动态规划”在实际应用中的重中之重。
四.动态规划的深入思考
以上讨论了“动态规划”的理论基础与在实际应用中可能遇到的问题。
那么,“动态规划”究竟好在哪里?
我们为什么要用“动态规划”呢?
从上面的一些例子可以看出,“动态规划”这种方法最大优点就是节约了时间。
这个“巨大的优点”可以从例1的解决中看出。
在实际中,搜索算法与“动态规划”算法的时间差异是巨大的,表1是例1的两个算法对应程序的执行时间。
测试数据
算法
1
2
3
4
`
5
搜索算法
1.04s
1.42s
1.05s
7.42s
22.92s
动态规划算法
<0.01s
<0.01s
0.06s
0.02s
0.01s
(表1)
从这张执行时间表可以看出,“动态规划”算法与搜索算法在实际应用中的运行时间差异是巨大的。
从理论上讲,搜索算法的时间复杂度为
,“动态规划”算法的时间复杂度是
,这两个时间根本就不是一个数量级的。
指数级的时间增长是十分可怕的,远远大于多项式级的算法。
所以,我们经常称多项式级的算法为一个“较好”的算法,称指数级的算法是一个“较差”的算法。
“动态规划”程序实质上是一种“以空间代价来换取时间代价”的技术,它在实现的过程中,不得不存储产生过程中的各种状态。
所以,它的空间复杂度要大于其它的算法。
以Bitonic旅行路线问题为例,这个问题也可以用搜索算法来解决(具体见程序Pro_3_2.Pas)。
“动态规划”的时间复杂度为
,搜索算法的时间复杂度为
,但从空间复杂度来看,“动态规划”算法为
,而搜索算法为O(N)。
搜索算法反而优于“动态规划”算法。
那么,我们为何选择“动态规划”算法来解决例3呢?
因为“动态规划”算法在空间上可以承受(事实上是很可以承受),而搜索算法在时间上却十分巨大,所以我们“舍空间而取时间”。
这个问题在例4(即奶牛运输问题)中更加明显。
例4问题实质上是一个求最优的“哈密尔顿(Hamilton)回路”问题。
这个问题是一个NP问题,人们到目前为止还没有找到这个问题的“多项式级”解法。
所以,我们在进行“动态规划”算法时不得不记录每一种状态后再进行递推。
我们在进行递推时好像是在进行着一个“多项式级”的算法。
实质上,记录的状态数目已经是“指数级”的了。
我们用的“动态规划”算法从理论上讲,与普通搜索算法的时间复杂度差不多,而空间复杂度却远远高于普通的搜索算法(“动态规划”算法的空间复杂度为
,搜索算法的空间复杂度为O(N))。
那么,为什么用“动态规划”方法解决此问题要比搜索方法快呢?
(快多少可参见下边的表2)多用的那部分空间究竟干什么了?
这两个问题的答案就是我们用“动态规划”技术的根本性目的:
“解决冗余”。
我们在用搜索算法的过程中,做了一些已经做过的工作。
以例4为例:
假设目前待解决的任务集合为[1,2,3,4],我们如果选定头两个解决的任务为1,2,那么还需要从后两个任务3,4中选择解决顺序。
而如果选定前两个解决的任务为2,1(1,2与2,1不同,解决的次序不同),那么又得重新解决一遍工作“从后两个任务3,4中选择解决顺序”,这是多么大的一个浪费呀!
这句话在文章开头分