1.3 一摞烙饼的排序
星期五的晚上,一帮同事在希格玛大厦附近的“硬盘酒吧”多喝了几杯。程序员多喝了几杯之后谈什么呢?自然是算法问题。有个同事说:
“我以前在餐馆打工,顾客经常点非常多的烙饼。店里的饼大小不一,我习惯在到达顾客饭桌前,把一摞饼按照大小次序摆好——小的在上面,大的在下面。由于我一只手托着盘子,只好用另一只手,一次抓住最上面的几块饼,把它们上下颠倒个个儿,反复几次之后,这摞烙饼就排好序了。”
我后来想,这实际上是个有趣的排序问题:假设有n块大小不一的烙饼,那最少要翻几次,才能达到大小有序的结果呢?”如图1-6所示。
图1-6 翻烙饼的顺序
你能否写出一个程序,对于n块大小不一的烙饼,输出最优化的翻饼过程呢?
分析与解法
这个排序问题非常有意思,首先我们要弄清楚解决问题的关键操作——“单手每次抓几块饼,全部颠倒”。
具体参看图1-7。
图1-7 烙饼的翻转过程
每次我们只能选择最上方的一堆饼,一起翻转。而不能一张张地直接抽出来,然后进行插入,也不能交换任意两块饼。这说明基本的排序办法都不太好用。那么怎么把这n个烙饼排好序呢?
由于每次操作都是针对最上面的饼,如果最底层的饼已经排序,那我们只用处理上面的n-1个烙饼。这样,我们可以再简化为n-2、n-3,直到最上面的两个饼排好序。
解法一
我们用图1-8演示一下,为了把最大的烙饼摆在最下面,我们先把最上面的和最大的烙饼之间的翻转(1~4之间),这样,最大的烙饼就在最上面了。接着,我们把所有烙饼翻转(4~5之间),最大的烙饼就摆在最下面了。
图1-8 两次翻转烙饼,调整最大的烙饼到最底端
之后,我们对上面n-1、n-2个饼重复这个过程。
那么,我们一共需要多少次翻转才能实现结果呢?
经过两次翻转可以把最大的烙饼翻转到最下面。因此,最多需要把上面的n-1个烙饼依次翻转两次。那么,我们至多需要2(n-1)次翻转就可以把所有烙饼排好序(因为第二小的烙饼排好的时候,最小的烙饼已经在最上面了)。
这样看来,单手翻转的想法是肯定可以实现的。我们进一步想想怎么减少翻转烙饼的次数吧。
怎样才能通过程序来搜索到一个最优的方案呢?
通过每次找出最大的烙饼进行翻转是一个可行的解决方案。这个方案是最好的一个吗?考虑这样一种情况,假如这堆烙饼中有好几个不同的部分相对有序,凭直觉来猜想,我们可以先把小一些的烙饼进行翻转,让其有序。这样会比每次翻转最大的烙饼要更快。
既然如此,有类似的方案可以达到目的吗?比如说,考虑每次翻转的时候,把两个本来应该相邻的烙饼尽可能地换到一起。这样,等所有的烙饼都换到一起之后,实际上就是完成排序了。(从这个意义上来说,每次翻最大烙饼的方案实质上就是每次把最大的和次大的交换到一起。)
在这个基础之上,本能的想法就是穷举。只要穷举出所有可能的交换方案,那么,我们一定能够找到最优的一个。
沿着这个思路去考虑,我们自然就会使用动态规划或递归的方法来实现了。可以从不同的翻转策略开始,比如说第一次先翻最小的,然后递归把所有的可能全部翻转一遍。这样,最终肯定是可以找到一个解的。
但是,既然是递归就一定有退出的条件。在这个过程中,第一个退出的条件肯定是所有的烙饼已经排好序。那么,有其他的吗?大家仔细想想就会发现到,既然2(n-1)是一个最多的翻转次数。如果在算法中,需要翻转的次数多于2(n-1),我们就应该放弃这个翻转算法,直接退出。
另外,既然这是一个排序问题。我们也应该利用排序的信息来处理。同样,在翻转的过程中,我们可以看看当前的烙饼数组排序情况如何,然后利用这些信息来减少翻转次数。
代码清单1-8是在前面讨论的基础之上形成的一个粗略的搜索最优方案的程序。
代码清单1-8
/********************************************************************/ // // 烙饼排序实现 // /********************************************************************/ class CPrefixSorting { public: CPrefixSorting() { m_nCakeCnt=0; m_nMaxSwap=0; } ~CPrefixSorting() { if(m_CakeArray !=NULL) { delete m_CakeArray; } if(m_SwapArray !=NULL) { delete m_SwapArray; } if(m_ReverseCakeArray !=NULL) { delete m_ReverseCakeArray; } if(m_ReverseCakeArraySwap !=NULL) { delete m_ReverseCakeArraySwap; } } // // 计算烙饼翻转信息 // @param // pCakeArray 存储烙饼索引数组 // nCakeCnt 烙饼个数 // void Run(int* pCakeArray, int nCakeCnt) { Init(pCakeArray, nCakeCnt); m_nSearch=0; Search(0); } // // 输出烙饼具体翻转的次数 // void Output() { for(int i=0; i<m_nMaxSwap; i++) { printf("%d ", m_arrSwap[i]); } printf("\n |Search Times| : %d\n", m_nSearch); printf("Total Swap times=%d\n", m_nMaxSwap); } private: // // 初始化数组信息 // @param // pCakeArray 存储烙饼索引数组 // nCakeCnt 烙饼个数 // void Init(int* pCakeArray, int nCakeCnt) { assert(pCakeArray !=NULL); assert(nCakeCnt > 0); m_nCakeCnt=nCakeCnt; // 初始化烙饼数组 m_CakeArray=new int[m_nCakeCnt]; assert(m_CakeArray !=NULL); for(int i=0; i<m_nCakeCnt; i++) { m_CakeArray[i]=pCakeArray[i]; } // 设置最多交换次数信息 m_nMaxSwap=UpperBound(m_nCakeCnt); // 初始化交换结果数组 m_SwapArray=new int[m_nMaxSwap+1]; assert(m_SwapArray !=NULL); // 初始化中间交换结果信息 m_ReverseCakeArray=new int[m_nCakeCnt]; for(i=0; i<m_nCakeCnt; i++) { m_ReverseCakeArray[i]=m_CakeArray[i]; } m_ReverseCakeArraySwap=new int[m_nMaxSwap]; } // // 寻找当前翻转的上界 // // int UpperBound(int nCakeCnt) { return nCakeCnt*2; } // // 寻找当前翻转的下界 // // int LowerBound(int* pCakeArray, int nCakeCnt) { int t, ret=0; // 根据当前数组的排序信息情况来判断最少需要交换多少次 for(int i=1; i<nCakeCnt; i++) { // 判断位置相邻的两个烙饼,是否为尺寸排序上相邻的 t=pCakeArray[i] - pCakeArray[i-1]; if((t==1) || (t==-1)) { } else { ret++; } } return ret; } // 排序的主函数 void Search(int step) { int i, nEstimate; m_nSearch++; // 估算这次搜索所需要的最小交换次数 nEstimate=LowerBound(m_ReverseCakeArray, m_nCakeCnt); if(step+nEstimate > m_nMaxSwap) return; // 如果已经排好序,即翻转完成,输出结果 if(IsSorted(m_ReverseCakeArray, m_nCakeCnt)) { if(step<m_nMaxSwap) { m_nMaxSwap=step; for(i=0; i<m_nMaxSwap; i++) m_arrSwap[i]=m_ReverseCakeArraySwap[i]; } return; } // 递归进行翻转 for(i=1; i<m_nCakeCnt; i++) { Reverse(0, i); m_ReverseCakeArraySwap[step]=i; Search(step+1); Reverse(0, i); } } // // true : 已经排好序 // false : 未排序 // bool IsSorted(int* pCakeArray, int nCakeCnt) { for(int i=1; i<nCakeCnt; i++) { if(pCakeArray[i-1] > pCakeArray[i]) { return false; } } return true; } // // 翻转烙饼信息 // void Revert(int nBegin, int nEnd) { assert(nEnd > nBegin); int i, j, t; // 翻转烙饼信息 for(i=nBegin, j=nEnd; i<j; i++, j--) { t=m_ReverseCakeArray[i]; m_ReverseCakeArray[i]=m_ReverseCakeArray[j]; m_ReverseCakeArray[j]=t; } } private: int* m_CakeArray; // 烙饼信息数组 int m_nCakeCnt; // 烙饼个数 int m_nMaxSwap; // 最多交换次数。根据前面的推断,这里最多为 // m_nCakeCnt*2 int* m_SwapArray; // 交换结果数组 int* m_ReverseCakeArray; // 当前翻转烙饼信息数组 int* m_ReverseCakeArraySwap; // 当前翻转烙饼交换结果数组 int m_nSearch; // 当前搜索次数信息 };
当烙饼不多的时候,我们已经可以很快地找出最优的翻转方案。
还有优化方案吗?
我们已经知道怎么构造一个可行的翻转方案,所以最优方案肯定不会比这个差。这个就是我们程序中的上界(UpperBound),就是说,我们感兴趣的最优方案最差也就是上面的方案了。如果我们能够找到一个更好的构造方案,搜索空间就会继续缩小,所以我们一开始就设m_nMaxSwap为UpperBound,而程序中有一个剪枝:
nEstimate=LowerBound(m_ReverseCakeArray, m_n); if(step+nEstimate > m_nMaxSwap) return;
m_nMaxSwap越小,这个剪枝条件就越容易满足,更多的情况就不需要再去搜索。当然,程序也就能更快地找出最优方案。
仔细分析上面的剪枝条件,在到达m_ReverseCakeArray状态之前,我们已经翻转了step次,nEstimate是在当前这个状态我们至少还要翻转多少次才能成功的次数。如果step+nEstimate大于m_nMaxSwap,也就说明从当前状态继续下去,一定会超过上界。当然就没有必要再继续了。
显然,nEstimate越大,剪枝条件越容易被满足。而这正是我们希望的。
结合上面两点,我们希望UpperBound越小越好,而下界(LowerBound)越大越好。假设有神仙指点,只要告诉神仙当前的状态,他就能告诉你最少需要多少次翻转。这样,我们可以花费O(N2)的时间得到最优的方案。但是,现实中,没有这样的神仙。我们只能尽可能地减小UpperBound,增加LowerBound,从而减少需要搜索的空间。
利用上面的程序,做一个简单的比较。
对于一个输入,10个烙饼,从上到下,烙饼半径分别为3, 2, 1, 6, 5, 4, 9, 8, 7, 0。对应上面程序的输入为
10
3 2 1 6 5 4 9 8 7 0
如果LowerBound在任何状态都为0,也就是我们太懒了,不想考虑那么多。当然任意状态下,你至少需要0次翻转才能排好序。这样,上面的程序Search函数被调用了575225 200次。
但是如果把LowerBound稍微改进一下(如上面程序中所计算的方法估计),程序则只
需要调用172126次Search函数便可以得到最优方案:
6 4 8 6 8 4 9
程序中的下界怎么估计呢?
每一次翻转我们最多使得一个烙饼与大小跟它相邻的烙饼排到一起。如果当前n个烙饼中,有m对相邻的烙饼半径不相邻,那么我们至少需要m次才能排好序。
从上面的例子,大家都会发现改进上界和下界,好处可不少。
除了上界和下界的改进,还有什么办法可以提高搜索效率吗?如果我们翻了若干次之后,又回到一个已经出现过的状态,还值得继续从这个状态开始搜索吗?我们怎样去检测一个状态是否出现过呢?
读者也许不相信,比尔·盖茨在上大学的时候也研究过这个问题,并且发表过论文。你不妨跟盖茨的结果1比比吧。
扩展问题
1.有一些服务员会把上面的一摞饼子放在自己头顶上(放心,他们都戴着洁白的帽子),然后再处理其他饼子,在这个条件下,我们的算法能有什么改进?
2.事实上,饭店的师傅经常把烙饼烤得一面非常焦,另一面则是金黄色。这时,服务员还得考虑让烙饼大小有序,并且金黄色的一面都要向上。这样要怎么翻呢?
3.有一次师傅烙了三个饼,一个两面都焦了,一个两面都是金黄色,一个一面是焦的,一面是金黄色,我把它们摞一起,只能看到最上面一个饼,发现是焦的,问这个饼的另一面是焦的概率是多少?
4.每次翻烙饼的时候,上面的若干个烙饼会被翻转。如果我们希望在排序过程中,翻转烙饼的总个数最少,结果会如何呢?
5.对于任意次序的n个饼的排列,我们可以研究把它们完全排序需要大致多少次翻转,目前的研究成果如下。
■ 目前找到的最大下界是‘15n/14’,就是说,如果有100个烙饼,那么我们至少需要15×100/14=108次翻转才能把烙饼翻好——而且具体如何翻还不知道。
■ 目前找到的最小的上界是‘(5n+5)/3’,对于100个烙饼,这个上界是169。
■ 任意次序的n个烙饼翻转排序所需的最小翻转次数被称为第n个烙饼数,现在找到的烙饼数为
第14个烙饼数P14还没有找到,读者朋友们,能否在吃烙饼之余考虑一下这个问题?