算法笔记(III) 状态空间搜索

动态规划

在一般的算法书中,动态规划总是一个复杂的算法设计技巧。在几种普遍涉及的算法设计技巧:穷举贪心回溯分治动态规划中,动态规划往往是最复杂技巧性最强的一个技巧,但是常常最有效的算法,甚至在一些看似简单的算法中,都蕴含着动态规划的深刻思想,例如最短路径的floyd算法。

理解动态规划往往需要过程,既要阅读书本理解最优子问题结构马尔可夫性这两个理论要点,同时也要编写程序,理解动态规划的实现要点。疏于理论往往不能举一反三,如果只关心理论,疏于实践,难免流于表面,另外,算法的学习和运筹学范畴内的动态规划的学习是不一样的,真正用程序写出来,才是真的领会。

对于上述的两个理论要点不再赘述,参看wikipedia即可;关于如何实现动态规划则有必要在此归纳一番: 

递归(+记忆化)与递推

同编译原理的语法分析方法[改作: 语义分析中的属性计算]一样,基于DAG有向无环图)的算法往往有两种实现方法,即自顶向下自底向上,也可以称之为:递归与递推。这跟计算机问题的结构相关,因为对于很多问题可以分解为子问题,通过解答子问题继而获得原问题的答案。但是相对于分治法(D&C)来说,分解的子问题往往overlap,不像D&C往往是孤立的子问题(甚至有些分治法仅仅将大规模的问题转化成一个小规模的子问题,如此我们称之为decrease and conquer);而且除了理论上需要的两个要素,动态规划之所以有效就是因为子问题的重复性。重复性越高,性能越好。

上述,我们谈到动态规划的两种主要的实现形式:递归与递推;对于递归来说,其往往易于从问题的开始状态推向终止状态,就像遍历树一样,遵循自顶向下的方式遍历树结构(当然说法并不严谨,因为毕竟因为子问题重复,所以并不是树结构),同时递归实现必须与记忆化结合起来,也就是一个表结构,我们首先判别当前的状态是不是我们已经计算出结果了,如果已经遍历过这个状态节点了,我们显然不需要在遍历了,因此我们的表设计可以方便的而迅速的访问状态的结果(例如采用二进制位表示集合的状态)。上述思路是动态规划的朴素实现,即递归与记忆化;这种朴素的实现结构很方便实现以及理解,甚至我们还可以加入剪枝等操作。

其伪代码可以写作: dp(n) = opr(n) + dp(n-1); 即状态定义是到达当前状态后,还可以获得值(至于这个值是多少并不需要我们知道,我们只需明白递归回来就知道了)

递推对应的是一种自底向上的思路,也就是说我们先计算子问题,再将这些子问题逐步的组合成更大的问题,直到回到原问题;从状态的角度来理解就是从问题的终止状态推向起始状态。递推方案相对于递归方案节省了存储空间(例如只选择有用的变量进行存储)以及递归函数调用的开销,但是不好构造,思路没有递归方案好理解,同时,对于有些问题终止状态往往是很难确定的,就像一棵树的分支很多,错综复杂。就像遍历树一样:从上向下走容易,而从繁杂的叶子节点一路通过复杂的节点关联,走向根节点就难了。递推方案的状态定义往往是到达当前状态已经获得了多少,当然这并不绝对,对于揹包问题,两种状态定义均可以用递推方案实现,递归也是一样。

自顶向下与自底向上

说到这里,我们简单的提一提:自顶向下以及自底向上这两个名词。在设计学上,这是两个重要的设计思路,多见于各项领域工程设计中(自然包括软件工程学[参见 unix 设计艺术 与 代码大全2]),对于自顶向下来说,我将大系统分作各个实现独立功能的子模块,至于怎样实现,我并不关心;设计者一直重复这个过程直到模块的粒度已经够方便的实现了。这就像很多书的讲解结构一样(特别见于一些深入浅出的国外教材),首先就告诉你这本书要让你明白一件什么事,要明白这个事需要做那几个方面的准备,继而划分章节。这是一种自顶向下的讲授方式,是一种见林的方式,全局到局部的过程。这种方式比较容易让大部人接受。 

反之,对于一些设计,我们的方案是自底向上,用繁杂的小零件组合成一个大的组件。这种方式常见于CAD等等,这种方式就像递推方案一样高效,但是特别依赖于设计者的丰富经验。因此,在实践中这两种设计方法往往混合使用。 

动态规划与拓扑序 附记 2011-4-9:

上面一段关于递推方案的解释,其实有点问题,我们说并不是因为从目的状态推向起始状态难,或者说从叶子节点推到根难。本质上是因为划分阶段的困难。因为对于每一层需要等到所有的前面的状态都退出来了,才能更新当前一层。这就暗涵和一种序关系,这种序关系在递归方案中是隐式的,也就是拓扑序(DFS的顺序),因此,为了递推的顺利进行,而不会出现前面的一层还没有推完,就开始推下一层,我们必须要按着拓扑序[参见wiki]进行搜索,有了拓扑序就可以保证算法的最优性,我想这一点是很清楚的。通俗的说,拓扑序的优美就是帮我们理清节点或者对象状态之间的复杂耦合关系。

显然,递归+记忆化的方案在一些阶段性不明显,阶段之间关系复杂的情况中符合人的直觉思考,而在阶段性很明显的问题上,递推是非常的显然,做起来就非常的方便,例如数塔。

另外,阶段的表示也有很多的技巧,比如节点判别是否重复,可以采用hash并加一个验证过程,完成。状态的表示,可以采用二进制位表示子集方案,但是显然位不可以很高,例如int类型是32位(long long 是 64位),可以表示元素为32的集合的幂集,即规模是2^32,但是显然实际上这是不可能的,计算机的内存开这么大,而且状态过多,DP的效果很差。通常据lda说,超过20的位就不考虑这个方案了。

贪心算法与动态规划  附记于2011-3-20:

CLRS中,作者很有意思的提到,在关于最优子问题结构中,贪心法和动态规划的本质差异在于,贪心法是一种自顶向下利用最优子问题结构,即选择了当前看似最优的选择后,则然后求解其因为这次选择而形成的子问题。而动态规划是自底向上的利用这个最优子问题结构,即把所有子问题的结果做完之后,在作出选择。 显然,动态规划的方案是一定可以求出最优解的,而贪心法则不然。 不过要提到的是,我们上面说到 动态规划有两种实现方式:自顶向下(递归)和自底向上(递推),这和其利用子问题的结构的方式是矛盾的。 无论是递归也好和递推也好,其本质确实是 求完子问题后才做的选择(自底向上的方式),只是有时候自顶向下的方案符合思维方式一点。 这里补充一下,看了这么多的数据结构和算法书,还是觉得CLRS最好,果然不是盖得。如果你不喜欢CLRS,显然你还没有领会其中的优点:讲解透彻,详细点到,原理清晰等等。  

动态规划与Dijkstra

让我们回到动态规划的实现上,除了递归和递推外,还有一种渐进方法实现,我们称之为Reaching方法,例如Dijkstra算法:尽管在通常的书中,我们并不讲其作为动态规划的实例来讲解,而是作为贪心算法。因为,事实上Dijkstra算法并没有遍历树,比如我们设想对于最短路径问题,有一个节点介于终点与起点间,但是它离起点或终点的一方的距离非常的远,因此在实现Dijkstra的时候,显然我们不会把这个点并进我们的状态集合中去,我们不会做一个很笨的决策走了这个长路的后果是怎样(直到当前的距离都很远,有必要扩展为止),但是对于动态规划的另两个实现来说,他们是会扩展这个节点的,因为每一个决策他们都会去尝试的(这也许就是为什么Dijkstra算法为什么高效的原因了)。但是从动态规划的角度来看,其也是一种动态规划实现方法。

 

A*算法:一种新方法  2011-4-8

另外,如A*一类启发式方法相对于Dijkstra又有了优势,这一点主要体现于引入了启发性信息,我们不妨取一个极端的例子,假设你使用Dijkstra算法去寻找最短路径,假设有一段有很多节点连接起来的路,这条由很多很短(至少比其他的边都短)的边构成的路径确是导向偏离目的地的,如果使用Dijkstra算法,我们将不断的将这些节点并入集合中去,而事实上,现在所扩展的这条路径确实离目的地更远的。显然,Dijkstra算法在这种情况下,并不反映我们直觉上的最短路径寻找,我们其实会在扩展点的时候,考虑是不是离我们的目的地近了,即使一个点离我们很近,但确实背道而驰的,我们当然不会选择它。所以,A*算法出现了,A*的启发式意义就在于它帮我们估计出到底是那个点更可能离我们的目的地更近了,至少保证是向着目的地方向去走的。在最短路径问题上一个优良选择就是 节点离目的地的欧式距离,这显然也符合我们实际生活中的常识。

同时,为什么A*算法要求我们要给出一个当前点离目的地实际距离的下界而不是上界呢?

  • 首先这是一种直觉和经验,从人的角度来讲,给出一个下界要比上界更容易,也更符合直觉。例如,当我们再实际生活中间,总是约莫着一个直线距离向着目的地去搜索路径以及选择路径,这个约莫一般是 直线距离(即欧氏距离),我们常常说,望着近了,这就是一种直线距离。当然这种约莫着,也就是启发式信息并不可靠,实际生活中,我们会发现,看着近了,走过去却有一堵墙。也就是说,我们往往低估了这个距离或者代价。
  • 其次,常识之下是一种正确的思路。试想,如果我们估计一个上界,或者总是悲观的估计距离,显然,我们很可能失去真正的最优解,因为一旦一个并不好的路径的悲观估计值稍稍比真正的最短路径的估计值要大的话,我们就会失去这个选择。例如,我们还举极端例子,当一段河隔开我们和目的地时,事实上这条河是可以乘船,甚至趟过去的,只要我们走到河岸(或者码头)这个节点上就可以看到实际上过河是这么容易,但是我们悲观的预测没有船或者河很深,我们就不会走向河岸(或者码头)这个节点上。也就是说实际上,我们只需要走过这条河宽的长度就可以到达终点了。因为我们的不自信,认为这是不可能过去的,也就是无穷远的距离。于是,我们绕道而行,显然这就不再是最短路径了。那怎样才能保证我们一定能发现最短路径呢?那就是估计距离的下界,一条河算什么,也是坦途。这样我们的估计值就与实际的距离:河宽一致了。我保证一定会搜索到最优路径的(河这个例子只是一个说明,其他的问题也是一致的)。总之,上述的说明,就是想说明,乐观的估计一个下界其实是为了保证不失去最优解。

不,我们仍还有疑问:估计值既然是不准确的,假设一条路径A估计的下界低于另一路径B的下界,而实际的长度是B要比A小,看来我们一定会选择A,这样我们不是漏掉了最优路径么? 不会,因为对于那些估计值确实不准确,远比实际值小的情况,即使一时得到了我们的青睐,终会有一天暴露他的实际距离。原形毕露,就是这个意思。上面所说的“墙”就是这样一个极端的例子。只要我们对最优路径的估计是乐观的,我们最终会认清真相,终修成正果的。并且,如何我们的启发式信息取得好,我们因选择那些期望的选择,要更比dijkstra快的搜索成功,相对于DFS、BFS以及DP,显然太愚蠢了,他们会每一个策略都试一试,这个显然更低效。回顾,A*算法的历史,其就是为了提升Dijkstra算法效率而提出的。

上面就是A*思想的通俗演绎了,其实A*本身就来自人工智能,回归本源去讲解也是自然。

现在,A*算法的概况已经介绍了,其条件启发式函数条件也介绍了,也就是 h < or = h*; 其中h* 表示真实值,h表示我们给出的启发函数;还需要注意的 我们的g函数本质上也是更迭的(而且只可能越来越小),这一点与Dijkstra算法一致,也就是实质上有一个g*函数的概念,表示实际从起点到当前点的最优开销,我们使用g函数进行逼近这个最优开销g*[参考A*算法讲义],直至当前点被扩展,我们猜得到真正的g*值,也就是说 g > or = g*;  

A*算法的一致性 

A*算法的h函数设计有两个性质要考虑:可容纳性一致性;可容纳性,即h<=h*, 可以保证A*算法的最优性,即我们不会错过最优解;而对于一致性,hi <= hj + dij,其中一致性已经暗涵了可容纳性,证明很简单,只需要考虑目标节点的启发函数值为0即可 ;则可以保证一旦一个节点被扩展,就不需再对其进行更新了,这一点与Dijkstra算法类似;一致性在A*原论文中认为是最优性的必需条件(可能是受到欧氏距离自然的三角不等式的直觉影响),但是在其后给出的更正中说明其不是需要的。也就是说 可容纳性就可以保证了最优性。一致性之所以重要,是用于带有close set 的A*框架中,减少节点过多的更新(一旦放入close集合,即被扩展后无须在行更新)。


  A* 算法的框架(close set方案):
  
Best_First_Search() {
     OPEN = [起始节点]; CLOSED = [];
     while ( OPEN表非空 ) 
     {
          从OPEN表中弹出一个节点X。
          if (X是目标节点)
          {
               求得最优解;返回最优路径PATH;
          }
          foreach (X的每一个后继节点Y)
          {
               if( Y不在OPEN表或CLOSE表中 )
               {
                    求Y的估价值;并将Y插入OPEN表中;
               }
               else {
                    if( Y在OPEN表中 )
                    {
                         if( Y的估价值小于OPEN表中原来的估价值 )
                              更新OPEN表中的估价值;
                    }
                    else //Y在CLOSE表中
                    {
                         if( Y的估价值小于CLOSE表中原来的估价值 ) {// 
注意这一步,对于那些不满足一致性的h函数,是必须的。
                              更新CLOSE表中的估价值;
                              从CLOSE表中移出节点,并放入OPEN表中;
                         }
                    }          
               }//end else
               对OPEN表排序
          }//end foreach
          将X节点插入CLOSE表中;
     }//end while
}//end func

                                                                ”

  • 如果没有一致性条件,我们将必须对close set 中的点进行更新,否则会失去最优性,如下图是一个例子:

                                                                图片
  • 假若 我们对B的启发式估值是 hB = 2; 而A的估值为hA=5的话;即hA > hB + AB,违反一致性;  
  • 运行A*: 首先B点将被扩展(因为 fB = hB + ),继而,其发现目的地还是很远,然后就去扩展A,
  • 当A扩展是后,发现其离目的点更远,还没有B近;
  • 这是我们有两种做法:
  • 1. 假如我们去不再更新B,显然方案出来了,答案就是从起点走到B在走到目的地:3+10=13,显然这是错的。最佳路径是 经A到B,在到目的地;
  • 2. 再次更新已经扩展了的B点,将其离起点的距离3改成2,并将其状态改成非扩展状态,于是在下一步再次扩展了B(因为fB=hB+2 = 4),
  • 然后再一次就会将目的地扩展进来,此次是最优解了:12;

     事实上,不满足三角不等式的h函数在实际应用中并不常见(也就是说我们近乎于捏造一个这样的满足可容纳性而不满足一致性的反例),参见《人工智能:一种现代方法(中文版)》p80,另翻译不佳,可见英文版p99。

    状态空间:搜索算法的统一

    上述关于贪心算法的讲述,其实可以统一成 f = g + h 的A*算法形式,也就是说,我们可以将BFS,DFS ,贪心算法,Dijkstra 统一成A*算法的特例。首先这四种算法都是盲目搜索(也就是不利用启发式信息的搜索)[参考演算法笔记以及wikipedia];注意,我们在数据结构层次上对BFS、DFS、Dijkstra以及分支定界搜索,进行了统一,即在搜索时(或者说遍历搜索树)维护特定的数据结构,队列,栈,优先队列等而构成不同的搜索方式,这次搜索算法的统一的高度更高一层,即人工智能的角度

    • BFS:不考虑启发式函数h,并且将节点的深度作为函数g,显然其会每次优先扩展那个层数最少的节点,也就是宽度优先;
    • DFS:其也是A*的特例,其只采用h函数作为指导。首先,它有一个很大全局变量作为计数器,我们设为C;每一次扩展节点,我们就为新扩展的节点分配C值,并将C减去1,于是算法总是青睐于那些C值小的节点,也就是更深的节点;例如我们取C为100,访问一个树:
    • root — 100
    •                 — 99
    •                      — 98
    •                          ........
    •         — 100
    •         — 100
    • 贪心算法:其考虑的是,从上一个已扩展节点(而不是从目的点至当前点的距离)到当前点的长度(也就是开销)作为g函数,而不考虑h函数;
    • Dijkstra:其是只考虑了g函数的A*算法,h函数为0(而不考虑启发式信息的h函数),但是这个g函数与贪心不同,其是从目的点出发到当前点的开销。注意一下的是,尽管我们看Dijkstra的算法每次扩展的似乎是以一种贪心的方法以离上一层扩展点到当前点的开销去选择下一个扩展点的,但是由于最优子问题结构,其实是与从起点到当前点的开销:即我们常说的,一旦这个点被扩展,其到起点的最优路径已经确定了。同时注意,在A*中的g函数也类似于Dijkstra的g函数,其可能只是一个对从起点到当前点的开销“估计”,回想在Dijkstra算法中,对于只是被finger,而并没有被扩展的节点,其值是有可能被反复更新的,直至被扩展,其才真正确定了开销(最佳路径)

    上述搜索算法,在状态空间概念框架下得到了统一,参见《人工智能:一种现代方法》;其还包含以上算法的扩展版本,如分支定界(+剪枝),迭代加深,迭代加深A*,双向广度优先 等等算法[参见wiki];(注:动态规划其实是一种算法设计思想,其实现方式可以DFS以及BFS等多样,不宜归入搜索一类)

     

    rongekuta

    2011-4-9

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章