1.3 一摞烙饼的排序

    星期五的晚上,一帮同事在希格玛大厦附近的“硬盘酒吧”多喝了几杯。程序员多喝了几杯之后谈什么呢?自然是算法问题。有个同事说:

    “我以前在餐馆打工,顾客经常点非常多的烙饼。店里的饼大小不一,我习惯在到达顾客饭桌前,把一摞饼按照大小次序摆好——小的在上面,大的在下面。由于我一只手托着盘子,只好用另一只手,一次抓住最上面的几块饼,把它们上下颠倒个个儿,反复几次之后,这摞烙饼就排好序了。

    我后来想,这实际上是个有趣的排序问题:假设有n块大小不一的烙饼,那最少要翻几次,才能达到最后大小有序的结果呢?

    你能否写出一个程序,对于n块大小不一的烙饼,输出最优化的翻饼过程呢?

    分析与解法

    这个排序问题非常有意思,首先我们要弄清楚解决问题的关键操作——“单手每次抓几块饼,全部颠倒”。

    具体参看图1-6:

    alt

    图1-6 烙饼的翻转过程

    每次我们只能选择最上方的一堆饼,一起翻转。而不能一张张地直接抽出来,然后进行插入,也不能交换任意两块饼子。这说明基本的排序办法都不太好用。那么怎么把这n个烙饼排好序呢?

    由于每次操作都是针对最上面的饼,如果最底层的饼已经排序,那我们只用处理上面的n-1个烙饼。这样,我们可以再简化为n-2、n-3,直到最上面的两个饼排好序。

    【解法一】

    我们用图1-7演示一下,为了把最大的烙饼摆在最下面,我们先把最上面的烙饼和最大的烙饼之间的烙饼翻转(1~4之间),这样,最大的烙饼就在最上面了。接着,我们把所有烙饼翻转(4~5之间),最大的烙饼就摆在最下面了。

    alt

    图1-7 两次翻转烙饼,调整最大的烙饼到最底端

    之后,我们对上面n-1、n-2个饼重复这个过程就可以了。

    那么,我们一共需要多少次翻转才能把这些烙饼给翻转过来呢?

    首先,经过两次翻转可以把最大的烙饼翻转到最下面。因此,最多需要把上面的n-1个烙饼依次翻转两次。那么,我们至多需要2(n-1)次翻转就可以把所有烙饼排好序(因为第二小的烙饼排好的时候,最小的烙饼已经在最上面了)。

    这样看来,单手翻转的想法是肯定可以实现的。我们进一步想想怎么减少翻转烙饼的次数吧。

    怎样才能通过程序来搜索到一个最优的方案呢?

    首先,通过每次找出最大的烙饼进行翻转是一个可行的解决方案。那么,这个方案是最好的一个吗?考虑这样一种情况,假如这堆烙饼中有好几个不同的部分相对有序,凭直觉来猜想,我们可以先把小一些的烙饼进行翻转,让其有序。这样会比每次翻转最大的烙饼要更快。

    既然如此,有类似的方案可以达到目的吗?比如说,考虑每次翻转的时候,把两个本来应该相邻在烙饼尽可能地换到一起。这样,当所有的烙饼都换到一起之后,实际上就是完成排序了。(从这个意义上来说,每次翻最大烙饼的方案实质上就是每次把最大的和次大的交换到一起。)

    在这样的基础之上,本能的一个想法就是穷举。只要穷举出所有可能的交换方案,那么,我们一定能够找到一个最优的方案。

    沿着这个思路去考虑,我们自然就会使用动态规划或者递归的方法来进行实现了。可以从不同的翻转策略开始,比如说第一次先翻最小的,然后递归把所有的可能全部翻转一遍。这样,最终肯定是可以找到一个解的。

    但是,既然是递归就一定有退出的条件。在这个过程中,第一个退出的条件肯定是所有的烙饼已经排好序。那么,有其他的吗?如果大家仔细想想就会发现到,既然2(n-1)是一个最多的翻转次数。如果在算法中,需要翻转的次数多于2(n-1),那么,我们就应该放弃这个翻转算法,直接退出。这样,就能够减少翻转的次数。

    从另外一个层面上来说,既然这是一个排序问题。我们也应该利用到排序的信息来进行处理。同样,在翻转的过程中,我们可以看看当前的烙饼数组的排序情况如何,然后利用这些信息来帮助减少翻转次数的判断过程。

    下面是在前面讨论的基础之上形成的一个粗略的搜索最优方案的程序:

    代码清单1-8

    alt

    alt

    alt

    alt

    alt

    当烙饼不多的时候,我们已经可以很快地找出最优的翻转方案。

    我们还有办法来对这个程序进行优化,使得能更快地找出最优方案吗?

    我们已经知道怎么构造一个可行的翻转方案,所以最优的方案肯定不会比这个差。这个就是我们程序中的上界(UpperBound),就是说,我们感兴趣的最优方案最差也就是我们刚构造出来的方案了。如果我们能够找到一个更好的构造方案,我们的搜索空间就会继续缩小,因为我们一开始就设m_nMinSwap为UpperBound,而程序中有一个剪枝:

    alt

    m_nMinSwap越小,那么这个剪枝条件就越容易满足,更多的情况就不需要再去搜索。当然,程序也就能更快地找出最优方案。

    仔细分析上面的剪枝条件,在到达m_tArr状态之前,我们已经翻转了step次,nEstimate是在当前这个状态我们至少还要翻转多少次才能成功的次数。如果step+nEstimate大于m_nMinSwap,也就说明从当前状态继续下去,m_nMinSwap次我们也不能排好所有烙饼。那么,当然就没有必要再继续了。因为继续下去得到的方案不可能比我们已经找到的好。

    显然,如果nEstimate越大,剪枝条件越容易被满足。而这正是我们希望的。

    结合上面两点,我们希望UpperBound越小越好,而下界(LowerBound)越大越好。假设如果有神仙指点,你只要告诉神仙你当前的状态,他就能告诉你最少需要多少次翻转。这样的话,我们可以花费O(N2)的时间得到最优的方案。但是,现实中,没有这样的神仙。我们只能尽可能地减小UpperBound,增加LowerBound,从而减少需要搜索的空间。

    利用上面的程序,做一个简单的比较。

    对于一个输入,10个烙饼,从上到下,烙饼半径分别为3,2,1,6,5,4,9,8,7,0。对应上面程序的输入为:

    10

    3216549870

    如果LowerBound在任何状态都为0,也就是我们太懒了,不想考虑那么多。当然任意状态下,你至少需要0次翻转才能排好序。这样,上面的程序Search函数被调用了575225200次。

    但是如果把LowerBound稍微改进一下(如上面程序中所计算的方法估计),程序则只需要调用172126次Search函数便可以得到最优方案:

    6

    486849

    程序中的下界是怎么估计出来的呢?

    每一次翻转我们最多使得一个烙饼与大小跟它相邻的烙饼排到一起。如果当前状态n个烙饼中,有m对相邻的烙饼它们的半径不相邻,那么我们至少需要m次才能排好序。

    从上面的例子,大家都会发现改进上界和下界,好处可不少。我想不用多说,大家肯定想继续优化上界和下界的估计吧。

    除了上界和下界的改进,还有什么办法可以提高搜索效率吗?如果我们翻了若干次之后,又回到一个已经出现过的状态,我们还值得继续从这个状态开始搜索吗?我们怎样去检测一个状态是否出现过呢?

    读者也许不相信,比尔盖茨在上大学的时候也研究过这个问题,并且发表过论文。你不妨跟盖茨的结果比比吧。

    扩展问题

    1.有一些服务员会把上面的一摞饼子放在自己头顶上(放心,他们都戴着洁白的帽子),然后再处理其他饼子,在这个条件下,我们的算法能有什么改进?

    2.事实上,饭店的师傅经常把烙饼烤得一面非常焦,另一面则是金黄色。这时,服务员还得考虑让烙饼大小有序,并且金黄色的一面都要向上。这样要怎么翻呢?

    3.有一次师傅烙了三个饼,一个两面都焦了,一个两面都是金黄色,一个一面是焦的,一面是金黄色,我把它们摞一起,只能看到最上面一个饼的上面,发现是焦的,问最上面这个饼的另一面是焦的概率是多少?

    4.每次翻烙饼的时候,上面的若干个烙饼会被翻转。如果我们希望在排序过程中,翻转烙饼的总个数最少,结果会如何呢?

    5.对于任意次序的n个饼的排列,我们可以研究把它们完全排序需要大致多少次翻转,目前的研究成果是:

    (1)目前找到的最大的下界是15n/14,就是说,如果有100个烙饼,那么我们至少需要15×100/14=108次翻转才能把烙饼翻好——而且具体如何翻还不知道。

    (2)目前找到的最小的上界是(5n+5)/3,对于100个烙饼,这个上界是169。

    (3)任意次序的n个烙饼反转排序所需的最小反转次数被称为第n个烙饼数,现在找到的烙饼数为:

    alt

    第14个烙饼数P14还没有找到,读者朋友们,能否在吃烙饼之余考虑一下这个问题?