烙饼问题与搜索树

转自:http://blog.csdn.net/kabini/article/details/2276723

早在一年前,当时我的一个很牛的胖师兄受邀参加Google中国的面试,一开始问他考什么问题他就用签了保密协议打发我们。但当最后他得知无缘Google的时候,终于打开话匣子,跟我们这些小字辈滔滔不绝地传授了一些“面经”。我记得其中就有一道题就是这个一摞烙饼问题,还有一道概率题在我面试MSRA的时候也被问到,可恨我当时没在意,后来面试吃了亏。不过如此的巧合说明微软和Google面试题库相同?抑或是两个互为竞争对手的公司选择的标准惊人的一致?只可惜Google今年没来哈尔滨招聘,我没法证实答案到底是A还是B。但如果是后者,我相信明年参加校园招聘的朋友还是有必要把这些经典的问题搞清楚。只可惜我当时对微软的面试不甚了解导致准备不足,而《编程之美》又出得晚了半年,否则说不定....。算了,还是关注问题本身吧。

     言归正传,一摞烙饼问题其实是一个很有意思的问题,它的描述是让一摞随机顺序的烙饼通过单手翻转的方式进行排序,以达到这摞烙饼由小到大顺序放置在盘子上的目的,其特点是每次翻转都会导致第一个烙饼到所要反转的那个烙饼之间的顺序变为逆序。我们的目的是求出次数最少的翻转方案以及翻转次数,很显然这是一个最优化问题,我们本能想到的是动态规划、贪心以及分支限界三种方法。
  
    书中给出的递归算法或称之为分支限界法(即遍历+剪枝=分支限界)秉承了递归算法传统的简单、明了,但效率偏低的特点。这个问题的实质,我们在每一次反转之前其实是需要做出一种选择,这种选择必须能够导致全局最优解。递归算法就是递归的构建所有解(实际是一颗搜索树),并在遍历过程中不断刷新LowerBound和UpperBound,以及当前的最优解(剪枝),并最终找到一个最整体优解。在这种策略下,提高算法的效率只能寄希望于剪枝方法的改进。但是这种方法显然不是多项式时间的,有没有多项式时间的算法呢?

    书中P22页提到动态规划,但最后却给出了解决最优化问题普遍适用但效率可能是最差的递归方法。这不禁让人疑惑:这也不美啊!?如果我们能证明该问题满足动态规划或贪心算法的使用条件,解决问题的时间复杂度将会降到多项式时间甚至N^2。但书中提到动态规划却最终没有使用,又没有讲明原因,我觉得是一种疏失(应该不算错误)。那我们就来想一下为什么没有动态规划或贪心算法的原因。

    我们知道动态规划方法是一种自底向上的获取问题最优解的方法,它采用子问题的最优解来构造全局最优解。利用动态规划求解的问题需要满足两个条件:即(1)最优子结构 (2)子结构具有重叠性。条件(1)使我们可以利用子问题的最优解来构造全局最优解,而条件(2)是我们在计算过程中可以利用子结构的重叠性来减少运算次数。此外,《算法导论》上还以有向图的无权最短路径和无权最长路径为例提出条件(3)子问题必须独立。
  
  首先我们假定烙饼问题存在优化子结构。假如我们有N个烙饼,把他们以其半径由小到大进行编号。优化子结构告诉我们对于i个烙饼,我们只需要先排列前(i-1)个,然后再将第i个归位;或先排列第2到i个,最后将第一个归位;又或是找到一个位置k[i<=k<j]像矩阵乘法加括号那样,使得我们先排列前k个,再排列后j-k个,最后再将二者合并,以找到一个最佳翻转策略等等...
  
    根据动态规划算法的计算过程,我们需要一个N*N矩阵M,其中M[i][j]表示将编号i至编号j的烙饼排序所需要的翻转次数。但我们真的能从M[0][0..j-1]和M[1][j+1],或与M[i][j]同行同列的值来计算M[i][j]吗?如果能,我们就能获得多项式时间的算法。  


    我们来看书中给出的例子:(顶端)3,2,1,6,5,4,9,8,7,0(底端),我们最终的目标是计算M[0][9]。
这里我们以计算M[0][4]为例,计算的矩阵我已经在下面给出:  
  0 1  2  3  4  5  6  7  8  9
  ------------------------
0|0 1 (1){1}[?]
1|  0  1 (1){1}  
2|     0  1 (1) 
3|        0  0
4|           0
  ------------------ 
  
  实际上如果我们要向将0-4号烙饼(注意:烙饼编号也等同于其半径)排为正序(中间有其他烙饼也没关系),按照程序给出的结果, 我们需要进行3次翻转,分别为[2,5,9](即分别翻转队列中第二(从零开始)、五、九个烙饼,这里的数字不是烙饼的编号):  
  [1]  [2]  [3]  6   5  [4]  9  8  7  [0]
  [4]   5    6  [3] [2] [1]  9  8  7  [0]
  [0]   7    8   9  [1] [2] [3] 6  5  [4]
  
      我们知道,该矩阵中每一个数的背后都隐含着一个烙饼的排列,例如M[0][4]就应该对应0,7,8,9,1,2,3,6,5,4
  所以,每一个M[i][j]的选取都蕴含着其子排列的顺序的变化。
      在计算M[i][j]的时候,我们需要计算i-j号饼的全部划分(不包括全部为1的划分)所能构成的翻转结构,并取其翻转 次数最少的哪一个最为M[i][j]的最终值。例如,我们在计算M[0][4]的时候,需要查看:
  
   /**先将0和1-4号分别排序,最后将二者合并为有序所需要的翻转次数*/
   M[0][0],M[1][4] 
   
   /** 同上 */
   M[0][1],M[2][4]
   
   /** 同上 */
   M[0][2],M[3][4]
   
   /** 同上 */
   M[0][3],M[4][4]
   
   /* 先将0、1、2、3-4号分别排序,最后将4者合并为有序所需要的翻转次数.
    * 注意这里又包含将4个分组再次进行划分的问题! 
    */
   M[0][0],M[1][1],M[2][2],M[3][4]
   .....//中间略
   M[0][3],M[4][4]
  
   如果再加上运算过程中我们可以淘汰超过最大反转次数的方案(剪枝?),我们完成全部的运算,所经历的运算过程的时间复杂度已经不是多项式时间的,而是和先前所说的递归方法已没什么两样。

     造成这种现象的原因是:某个子问题的最优解不一定是整体的最优解,所以我们在处理整个问题的时候,需要遍历所有可能的子问题,并计算它到整体问题所消耗的代价,才能最终作出有利于整体问题的选择。

   所以我们一开始的假设,即烙饼问题有优化子结构的假设是错误的。因此我们不能用动态规划,同理也不能用贪心算法。


前面已经写了一些关于烙饼问题的简单分析,但因为那天太累有些意犹未尽,今天再充实一些内容那这个问题研究透。我想,通过这篇文章,我们就可以把这一类问题搞懂。再遇到优化问题,如果我们想不到别的办法,就可以采用搜索树算法来解决,至少我们不至于拿不出解决方案。前面我们已经知道,关于一摞烙饼的排序问题我们可以采用递归的方式来完成。其间我们要做的是尽量调整UpperBoundLowerBound,已减少运算次数。对于这种方法,在算法课中我们应该称之为:Tree Searching Strategy。即整个解空间为一棵搜索树,我们按照一定的策略遍历解空间,并寻找最优解。一旦找到比当前最优解更好的解,就用它替换当前最优解,并用它来进行“剪枝”操作来加速求解过程。

书中给出的解法就是采用深度优先的方式来遍历这棵搜索树,例如要排序[4,2,1,3],最大反转次数不应该超过(4-1)*2=6次,所以搜索树的深度也不应大于6,搜索树如下图所示:

烙饼问题与搜索树_第1张图片

这里只列到第三层,其中被画斜线的方块由于和上层的某一节点的状态重复而无需再扩展下去(即便扩展也不可能比有相同状态的上层节点的代价少)。我们可以看到在右子树中的一个分支,只需要用3次反转即可完成,我们的目标是如何更为快速有效的找到这一分支。直观上我们可以看到:基本的搜索方法要先从左子树开始,所以要找到本例最佳的方案的代价是很高的(利用书中的算法需要查找292次)。

既然要遍历搜索树,就有广度优先和深度优先之分,可以分别用栈和队列来实现(当然也可以用递归的方法)。那么如何能更有效地解决问题呢?我们主要考虑一下几种方法:

(1)       爬山法

该方法是在深度优先的搜索过程中使用贪心方法确定搜索方向,它实际上是一种深度优先搜索策略。爬山法采用启发式侧读来排序节点的扩展顺序,其关键点就在于测度函数f(n)的定义。我们来看一下如何为上例定制代价函数f(n),以快速找到右子树中最好的那个分支(很像贪心算法,呵呵)。

我们看到在[1,2,4,3]中,[1,2,3]已经相对有序,而[4]位与他们之间,要想另整体有序,需要4次反转;而[3,1,2,4]中,由于[4]已经就位,剩下的数变成了长度为3的子队列,而子队列中[1,2]有序,令其全体有序只需要2次反转。

所以我们的代价函数应该如下定义:

从当前状态的最后一个饼开始搜索,如果该饼在其应该在的位置(中间断开不算),则跳过;

自后向前的搜索过程中,如果碰到两个数不相邻的情况,就+1

这样我们就可以在本例中迅速找到最优分枝。因为在树的第一层

f(2,4,1,3)=3f(1,2,4,3)=2f(3,1,2,4)=1,所以我们选择[3,1,2,4]那一枝,而在[3,1,2,4]的下一层:

f(1,3,2,4)=2f(2,1,3,4)=1f(4,2,1,3)=2,所以我们又找到了最佳的路径。

上面方法看似不错,但是数字比较多的时候呢?我们来看书中给出的10个数的例子:

[3,2,1,6,5,4,9,8,7,0]程序给出的最佳翻转序列为{ 4,8,6,8,4,9}(从0开始算起)

那么,对于搜索树的第一层,按照上面的算法我计算的结果如下:

f(2,3,1,6,5,4,9,8,7,0)=4

       f(1,2,3,6,5,4,9,8,7,0)=3

       f(6,1,2,3,5,4,9,8,7,0)=4

       f(5,6,1,2,3,4,9,8,7,0)=3

       f(4,5,6,1,2,3,9,8,7,0)=3

       f(9,4,5,6,1,2,3,8,7,0)=4

       f(8,9,4,5,6,1,2,3,7,0)=4

f(7,8,9,4,5,6,1,2,3,0)=3

f(0,7,8,9,4,5,6,1,2,3)=3

我们看到有4个分支的结果和最佳结果相同,也就是说,我们目前的代价函数还不够“一击致命”,但是这已经比书中的结果要好一些,起码我们能更快地找到最佳方案,这使得我们在此后的剪枝过程更加高效。

爬山法的伪代码如下:

构造由根组成的单元素栈S

2 IF Top(s)是目标节点 THEN 停止;

3 Pop(s);

4 S的子节点按照启发式测度,由小到大的顺序压入S

5 IF 栈空 Then 失败

Else 返回2

 

              如果有时间我会把爬山法解决的烙饼问题贴在后面。

(2)       Best-First搜索策略

最佳优先搜索策略结合了深度优先和广度优先二者的优点,它采取的策略是根据评价函数,在目前产生的所有节点中选择具有最小代价值的节点进行扩展。该策略具有全局优化的观念,而爬山法则只具有局部优化的能力。具体用小根堆来实现搜索树就可以了,这里不再赘述。

(3)       A*算法

如果我们把下棋比喻成解决问题,则爬山法和Best-First算法就是两个只能“看”未来一步棋的玩家。而A*算法则至少能够“看”到未来的两步棋。

我们知道,搜索树的每一个节点的代价f*(n)=g(n)+h*(n)。其中,g(n)为从根节点到节点n的代价,这个值我们是可求的;h*(n)则是从n节点到目标节点的代价,这个值我们是无法实际算出的,只能进行估计。我们可以用下一层节点代价的最小者来替代h*(n),这也就是“看”了两步棋。可以证明,如果A*算法找到了一个解,那它一定是优化解。A*算法的描述如下:

1.  使用BestFirst搜索树

2.  按照上面所述对下层点n进行计算获得f*(n)的估计值f(n),并取其最小者进行扩展。

3.  若找到目标节点,则算法停止,返回优化解

总结:归根到底,烙饼问题之所以难于在多项式时间内解决的关键就在于我们无法为搜索树中的每一条边设定一个合理的权值。在这里,每条边的权值都是1,因为从上一个状态节点到下一个状态节点之需要一次翻转。所以我们不能简单地把每个节点的代价定义为翻转次数,而应该根据其距离最终解的接近程度来给出一个数值,而这恰恰就是该问题的难点。但是无论上面哪一种方法,都需要我们确定搜索树各个边的代价是多少,然后才能进行要么广度优先、要么深度优先、要么A*算法的估计代价。所以,在给出一个合理的代价之前,我们所有的努力都只能是帮忙“加速”,而无法真正在多项式时间内解决问题。

你可能感兴趣的:(面试题,编程之美,烙饼问题)