烙饼问题最初是在1975年由雅各布·e·古德曼在美国数学月刊上提出的,名为”Harry Dweighter”(或“忙碌的服务员”)。[1]在接下来的几年里它引起了相当大的关注, 其后比尔·盖茨与他的老师Christos H. Papadimitriou共同研究并写了一篇论文“前缀逆转排序的边界问题”(Bounds for Sorting by Prefixed Reversal),论文于1979年发表在《离散数学》杂志上。如今仍有许多人对其研究,改进。
Pancake Sorting是一个典型的离散数学问题,其描述为:让一摞随机顺序的烙饼通过单手翻转的方式进行排序,以达到这摞烙饼由小到大顺序放置在盘子上的目的,其特点是每次翻转都会导致第一个烙饼到所要反转的那个烙饼之间的顺序变为逆序。我们的目的是求出次数最少的翻转方案以及翻转次数。
关键字:最优化问题、分支限界、搜索树、前缀反转排序
The originalpancake problem was posed in 1975 in the AmericanMathematical Monthly by JacobE. Goodman, writing under the name "Harry Dweighter" (or"Harried Waiter"). It attracted considerable attention in subsequentyears and has since become a staple of theoretical computer science courses.Theproblem can be described as Before he delivers a stack to acustomer, he rearranges the pancakes in order of size, with the smallest one ontop and the largest on the bottom. To do so, he grabs several pancakes from thetop and flips them over. He repeats this "grab-and-flip" operation asmany times as necessary, varying the number of pancakes that he flips eachtime. If he has a stack of n pancakes,what's the maximum number of flips that he'll ever need to use to rearrangethem?
Key Words: optimization problems , branch and bound (BB) , Search tree
Bounds for Sorting by Prefixed Reversal)
一摞烙饼的排序问题是《编程之美》[2]上的一道经典问题,而且据称也是比尔.盖茨发表的唯一学术论文。作者选此课题,一方面来源于读书后的感悟与兴趣,另一方面是想实现算法,并在此基础上改进算法。
图1-1
A spatula flipsover the top three pancakes in this six-pancake stack
烙饼问题最初是在1975年由雅各布·e·古德曼在美国数学月刊上提出的,名为”Harry Dweighter”(或“忙碌的服务员”)。在接下来的几年里它引起了相当大的关注, 其后比尔·盖茨与他的老师Christos H. Papadimitriou共同研究并写了一篇论文“前缀逆转排序的边界问题”(Bounds for Sorting by Prefixed Reversal),论文于1979年发表在《离散数学》杂志上。如今仍有许多人对其研究,改进。
目前找到最大下界是[15n/14],即100个饼,至少需要15*100/14=108次翻转才能把饼翻转好——具体怎么翻还不知道。
目前找到的最小上界是[(5n+5)/3],对于100个饼,这个上界是169.
任意次数的n个烙饼数翻转排序所需的最小翻转次数称为第n个烙饼数。
N |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
P(n) |
0 |
1 |
3 |
4 |
5 |
7 |
8 |
9 |
10 |
|
10 |
11 |
12 |
13 |
14 |
|
|
|
|
|
11 |
13 |
14 |
15 |
? |
|
|
|
|
表1-1
第14个烙饼数P(n)还为找到。
一摞烙饼问题实际上是一个最优化问题:我们的目的是求出次数最少的翻转方案以及翻转次数。因而我们本能想到地是动态规划、贪心以及分支限界三种方法。这三种解决最优化问题的基本思路各有其适用范围与优缺点。
本文将从以下3个方面对该问题进行研究:
(1.)讨论动态规划、贪心以及分支限界3种算法思路对于烙饼排序问题的适用性。
(2.)使用分支限界算法实现时的改进:从1优化剪枝、2避免子问题重复计两个维度该进。
(3.)研究进一步的改进算法,讨论遍历搜索树时的改进”爬山法”、”Best-First”、”A*”
这个排序问题非常有意思,首先我们要弄清楚解决问题的关键操作——“单手每次抓几块饼,全部颠倒”。
每次我们只能选择最上方的一堆饼,一起翻转。而不能一张张地直接抽出来,然后进行插入,也不能交换任意两块饼子。由于每次操作都是针对最上面的饼,如果最底层的饼已经排序,那我们只用处理上面的n-1个烙饼。这样,我们可以再简化为n-2、n-3,直到最上面的两个饼排好序。因此我们很自然地想到使用递归算法来解决问题。
递归的退出条件有两个:
1.当煎饼有序排列。
2.当翻转次数多余2n-3。
第一个条件很好理解,现在解释第二个退出条件:
为了缩减问题规模,将n个煎饼中最大的翻转至最下面,至多需要两次翻转,将问题集压缩至(n-1)个煎饼。因此最多需要将(n-2)个饼依次翻转两次,再至多翻转一次,(因为前(n-2)个煎饼排好序后,只剩下最小、次小两个饼,至多还需一次翻转)。综上,第2个递归退出条件为2(n-2)+1=2n-3。
由此看来,此问题是有解的,接下来还需讨论翻转策略,使翻转次数最少,且搜索次数更少。
其中一种可行的解决方案是:每一次翻转,把两个本来应该相邻的煎饼尽可能地交换到一起。这样,等所有的煎饼都换到一起之后,实际上就完成排序了(从这个意义上说,上文每次翻最大饼的方案实际上是每次把最大与次大的交换到一起)。
递归算法或称之为分支限界法(即遍历+剪枝=分支限界)秉承了递归算法传统的简单、明了,但效率偏低的特点。这个问题的实质,我们在每一次反转之前其实是需要做出一种选择,这种选择必须能够导致全局最优解。递归算法就是递归的构建所有解(实际是一颗搜索树),并在遍历过程中不断刷新LowerBound和UpperBound,以及当前的最优解(剪枝),并最终找到一个最整体优解。在这种策略下,提高算法的效率只能寄希望于剪枝方法的改进。但是这种方法显然不是多项式时间的,有没有多项式时间的算法呢?
既然是最优化问题,我们还会想到用动态规划、贪心算法来求解最少翻转数。现在我们来讨论使用动态规划解题的可行性。
动态规划方法是一种自底向上的获取问题最优解的方法,它采用子问题的最优解来构造全局最优解。利用动态规划求解的问题需要满足两个条件:即(1)最优子结构(2)子结构具有重叠性。条件(1)使我们可以利用子问题的最优解来构造全局最优解,而条件(2)是我们在计算过程中可以利用子结构的重叠性来减少运算次数。此外,《算法导论》上还以有向图的无权最短路径和无权最长路径为例提出条件(3)子问题必须独立。
首先我们假定烙饼问题存在优化子结构。假如我们有N个烙饼,把他们以其半径由小到大进行编号。优化子结构告诉我们对于i个烙饼,我们只需要先排列前(i-1)个,然后再将第i个归位;或先排列第2到i个,最后将第一个归位;又或是找到一个位置k[i<=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]
[表2-1]
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]
如果再加上运算过程中我们可以淘汰超过最大反转次数的方案(剪枝),我们完成全部的运算,所经历的运算过程的时间复杂度已经不是多项式时间的,而是和先前所说的递归方法已没什么两样。
造成这种现象的原因是:某个子问题的最优解不一定是整体的最优解,所以我们在处理整个问题的时候,需要遍历所有可能的子问题,并计算它到整体问题所消耗的代价,才能最终作出有利于整体问题的选择。
综上,我们一开始的假设,即烙饼问题有优化子结构的假设是错误的。因此我们不能用动态规划,同理也不能用贪心算法。
经过第2章的讨论,我们得到结论:无法使用动态规划在多项式时间内求得最少翻转次数。于是算法的实现与优化的着重点就落在了递归法,更确切地说是分支限界法上面。
分支限界算法就是递归的构建所有解(实际是一颗搜索树),并在遍历过程中不断刷新LowerBound和UpperBound,以及当前的最优解(剪枝),并最终找到一个最整体优解。在这种策略下,提高算法的效率只能寄希望于剪枝方法的改进。该算法的优化主要在两个维度展开:
1.优化剪枝。
2.避免子问题重复计算。
//头文件 PancakeSorting.h
/**Pancake sorting program
author:Jiaqi Wang
UESTC 2013.5
[email protected]
*/
//PancakeSorting.h
class PancakeSorting{
public:
PancakeSorting(void);
~PancakeSorting(void);
//计算煎饼翻转信息
voidRun(int *pCakeArray,int nCakeCnt);
voidOutput();
//初始化数组信息
private:
int*m_CakeArray; //烙饼信息数组
intm_nCakeCnt; //烙饼个数
intm_nMaxSwap; //最大交换次数,最多为(m_nCakeCnt-1)*2;
int*m_SwapArray; //交换结果数组
int*m_ReverseCakeArray; //当前翻转烙饼信息数组
int*m_ReverseCakeArraySwap; //当前翻转烙饼交换结果数组
intm_nSearch; //当前搜索次数
voidInit(int*pCakeArray,int nCakeCnt);
//寻找当前翻转的上界
intUpperBound(int nCakeCnt);
//寻找当前翻转的下界
intLowerBound(int*pCakeArray,int nCakeCnt);
//排序的主函数
voidSearch(int step);
//排序状态方法
boolIsSorted(int *pCakeArray,int nCakeCnt);
//翻转烙饼信息
voidReverse(int nBegin,int nEnd);
};
由头文件可以得到PancakeSorting 类的成员变量与方法,这里主要展示PanckeSorting.cpp中的主要成员方法,其详细代码见附录。
//PancakeSorting.cpp
// 排序的主函数
voidPancakeSorting::Search(int step)
{
int i, nEstimate;
m_nSearch++;
// 估算这次搜索所需要的最小交换次数
nEstimate = LowerBound(m_ReverseCakeArray,m_nCakeCnt);
if((step + nEstimate >m_nMaxSwap)||(step>=m_nMaxSwap))
return;
// 如果已经排好序,即翻转完成,输出结果
if(IsSorted(m_ReverseCakeArray,m_nCakeCnt))
{
if(step < m_nMaxSwap)
{
m_nMaxSwap = step;
for(i = 0; i < m_nMaxSwap;i++)
m_SwapArray[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);
}
}
在已经构造了一个可行的翻转方案后,会得到程序的上界(UpperBound),即最优方案肯定不会比这个差。
则可以设置先m_nManxSwap为UpperBound,程序中的剪枝:
// 估算这次搜索所需要的最小交换次数
nEstimate = LowerBound(m_ReverseCakeArray,m_nCakeCnt);
if((step + nEstimate >m_nMaxSwap)||(step>=m_nMaxSwap))
return;
另外要特别注意:nEstimate可能为0,所以当step=m_nMaxSwap时,不会弹栈,如果仅是if(nEstimate+step>m_nMaxSwap),会造成下面的m_ReverseCakeArraySwap[step] =i 数组越界!
每一次翻转煎饼,最多使得一个煎饼与大小跟它响邻的煎饼拍到一起,如果当前n个煎饼中,有m对相邻的煎饼半径不相邻,那么至少要m次才能排好序。这就是本程序中的下界估计方法。
表3-1 不同上界下的搜索次数及最小翻转数
输入
UpperBound |
n=5 4,2,1,5,3 |
n=6 5,15,6,8,4,9 |
n=18 3,2,1,6,5,4,9,8,7,11,12,13,14,16,0,20,15,19 |
n=6 4,8,6,8,4,9 |
2*n |
3,4,1,3, Search Times:1233 Total Swap Times:4
|
1,3,4,1,5,4, Search Times:2026 Total Swap Times:6 |
4,8,6,8,4,8,13,16,15,14,17,16, Search Times:621334429 Total Swap Times:12
|
3,1,2,4, SearchTimes:1561 Total Swap Times:4
|
2*n-3 |
3,4,1,3, Search Times:625 Total Swap Times:4
|
1,5,4,2,3, Search Times:511 Total Swap Times:5 |
2,5,2,5,8,2,8,13,16,15,14,17,16, Search Times:1144552643 Total Swap Times:13
|
1,2,1,3,4, Search Times:371 Total Swap Times:5
|
*[3] (5*n+5)/3+1 |
3,4,1,3, Search Times:1401 Total Swap Times:4
|
1,3,4,1,5,4, Search Times:2026 Total Swap Times:6 |
4,8,6,8,4,8,13,16,15,14,17,16, Search Times:518050113 Total Swap Times:12 |
3,1,2,4, Search Times:1561 Total Swap Times:4
|
表3-1
【*注】[(5*n+5)/3]是目前研究找到的最小上界,对于100个煎饼,这个上界是169。注意系统向下取整,所以在程序中需写为[(5*n+5)/3]+1。
由表2-2可得,最小上界当煎饼数n的数值变化使,较优的UpperBound也在变化。联立2*n-3=(5*n+5)/3,得n=14。所以上界UpperBound方法可以优化为:
int PancakeSorting::UpperBound(int nCakeCnt){
if(nCakeCnt<=14) return (nCakeCnt-2)*2+1;
return (5*nCakeCnt+5)/3+1;
}
这是因为但存在相同项时,需要翻转的最大次数并不是2*n-3,而是2n,因此在剪枝过程中可能会删去最优解。从本题的描述来分析,各饼的半径应该是不同的,需要说明的是这种情形可能会影响最小上界UpperBound的选择。
3.3 避免重复计算
在搜索树中有很多重复的子问题,如何避免子问题的重复计算也是一个优化的重点。
1)对于一组数字:a1,a2,a3,a4,…,an。如果第i次翻转的是前k个,则结果是:ak,…,a1, a(k+1) ,.., an。如果第i+1次翻转的也是前k个,则结果是:a1,…,ak, a(k+1) ,.., an。出现了重复字问题。这类重复字问题的避免非常简单:
在search(int step){…} 方法中m_ReverseCakeArraySwap的赋值过程外加入一条语句即可:
for(i = 1;i < m_nCakeCnt; i ++)
{
if (i != m_ReverseCakeArraySwap[step - 1])
{
revert(0, i);
m_ReverseCakeArraySwap[step]= i;
search(step + 1);
revert(0, i);
}
}
}
输入
|
n=5 4,2,1,5,3 |
n=6 5,15,6,8,4,9 |
n=18 3,2,1,6,5,4, 9,8,7,11,12,13, 14,16,0,20,15,19 |
避重前 |
3,4,1,3, Search Times:625 Total Swap Times:4
|
1,5,4,2,3, Search Times:511 Total Swap Times:5 |
4,8,6,8,4,8,13,16,15,14,17,16, Search Times:518050113 Total Swap Times:12 |
避重后 |
3,4,1,3, Search Times:338 Total Swap Times:4
|
1,5,4,2,3, Search Times:1018 Total Swap Times:5 |
4,8,6,8,4,8,13,16,15,14,17,16, Search Times:428383218 Total Swap Times:12 |
表3-2 n<=14,UpperBound=2*n-3
sn>14,UpperBound=(5*n+5)/3
n=5,
4 2 1 5 3
|Search Times| : 338
Total Swap times = 4
针对该数组,searchTime减少了:(625-338)/625*100%=45.92%
对于一个长度为n的数组,第一层为根节点,第二层有n-1个节点,其中每个子树最起码可以减少一个分枝的计算,效率将至少提高:1/(n-1)。
n=18
Search Times:428383218
TotalSwap Times:12
减少了:(518050113-428383218)/518050113*100%=17=30%
我们观察到在表3-2第2组数据的反常:
其searchtime不降反增。
表3-3
n=5 4,2,1,5,3 |
2*n |
2*n-3 |
(5*n+5)/3 |
避重前 |
1,3,4,1,5,4, Search Times:2026 Total Swap Times:6 |
1,5,4,2,3, Search Times:511 Total Swap Times:5 |
1,3,4,1,5,4, Search Times:2026 Total Swap Times:6 |
避重后 |
1,3,4,1,5,4, Search Times:1818 Total Swap Times:6 |
1,5,4,2,3, Search Times:1018 Total Swap Times:5 |
1,3,4,1,5,4, Search Times:1818 Total Swap Times:6 |
从表3-3中来看,当上界UpperBound取2*n ,(5*n+5)/3 的搜索次数在避重后至少减少了10.26%,但可见其搜索次数仍大于程序选定的上界2*n-3,总体上来看,避重后的搜索次数有所减少。同时在“搜索次数”与“搜索结果质量”两个评价角度上,最小翻转数的优先级更高。
在本章节,我们根据前一章的解题分析,使用递归(分支限界)实现PancakeSorting的基本算法。进而讨论剪枝过程中上、下界的产生,在数学分析、程序测试的基础上提出了剪枝的优化(上界的较优选择)。最后,又对避免子问题的重复计算进行了研究,减少了搜索次数。
第4章 PancakeSorting算法的进一步改进
4.1搜索树
如前文所述,关于一摞煎饼的排序问题我们可以采用递归的方式来完成。其间我们要做的是尽量调整UpperBound和LowerBound,以减少运算次数。对于这种方法,在算法课中我们应该称之为:Tree SearchingStrategy。即整个解空间为一棵搜索树,我们按照一定的策略遍历解空间,并寻找最优解。一旦找到比当前最优解更好的解,就用它替换当前最优解,并用它来进行“剪枝”操作来加速求解过程。 [4]
第3章给出的解法就是采用深度优先的方式来遍历这棵搜索树,例如要排序[4,2,1,3],最大反转次数不应该超过(4-2)*2+1=5次,所以搜索树的深度也不应大于5,搜索树如下图所示:
图4.1 [5]
这里只列到第三层,其中被画斜线的方块由于和上层的某一节点的状态重复而无需再扩展下去(即便扩展也不可能比有相同状态的上层节点的代价少)。我们可以看到在右子树中的一个分支,只需要用3次反转即可完成,我们的目标是如何更为快速有效的找到这一分支。直观上我们可以看到:基本的搜索方法要先从左子树开始,所以要找到本例最佳的方案的代价是很高的(利用第2章的算法需要查找80次)。
既然要遍历搜索树,就有广度优先和深度优先之分,可以分别用栈和队列来实现(当然也可以用递归的方法)。那么如何能更有效地解决问题呢?我们主要考虑一下几种方法:
(1) 爬山法
该方法是在深度优先的搜索过程中使用贪心方法确定搜索方向,它实际上是一种深度优先搜索策略。爬山法采用启发式侧读来排序节点的扩展顺序,其关键点就在于测度函数f(n)的定义。我们来看一下如何为上例定制代价函数f(n),以快速找到右子树中最好的那个分支。
我们看到在[1,2,4,3]中,[1,2,3]已经相对有序,而[4]位与他们之间,要想另整体有序,需要4次反转;而[3,1,2,4]中,由于[4]已经就位,剩下的数变成了长度为3的子队列,而子队列中[1,2]有序,令其全体有序只需要2次反转。
所以我们的代价函数应该如下定义:
1. 从当前状态的最后一个饼开始搜索,如果该饼在其应该在的位置(中间断开不算),则跳过;
2. 自后向前的搜索过程中,如果碰到两个数不相邻的情况,就+1
这样我们就可以在本例中迅速找到最优分枝。因为在树的第一层
f(2,4,1,3)=3,f(1,2,4,3)=2,f(3,1,2,4)=1,所以我们选择[3,1,2,4]那一枝,而在[3,1,2,4]的下一层:
f(1,3,2,4)=2,f(2,1,3,4)=1,f(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个分支的结果和最佳结果相同,也就是说,我们目前的代价函数还不够“一击致命”,但是这已经比书中的结果要好一些,起码我们能更快地找到最佳方案,这使得我们在此后的剪枝过程更加高效。
爬山法的伪代码如下:
1 构造由根组成的单元素栈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. 若找到目标节点,则算法停止,返回优化解。
《Anveragenumber of flip in pancake sorting》 是PancakeSorting 的深入研究,除了原始PancakeSorting 问题,又加入了:有烧焦面的煎饼排序(要求烧焦的一面在下,求有序的最小翻转)。对于这篇paper,我主要利用unburnt 版本,学习、研究改进方法。其中,最主要的改进是提高了下界LowerBound。
这里选择其中确定UpperBound、LowerBound及A*算法 的源程序: [6]
// UPPER BOUND - a heuristics
// racnt = number of adjacencies thatremain to be made
// wastes = number of allowed flips thatwill not add adjacency
// Try a waste only if racnt is at most
// a) ubbt_dep
// b) or ubbt_end_dep and no join waspossible
// Returns the largest number ofadjacencies that can be created -> gives
// an upper bound only if the return valueequals racnt
int ubbt_dep, ubbt_end_dep;
int upper_bound_bt(int len, int *p, intracnt, int wastes)
{
intnp[MAXN+2];
inti,res,tmp,found=0;
intjoined=0;
res= 0;
// try adding adjacency
for(i=2;i1)
{
flip(len,p,np,len-1);
joined= 1;
tmp= 1+upper_bound_bt(len-1,np,racnt-1,wastes);
res= MAX(res, tmp);
}
// try a waste
if(wastes>0&& (racnt<=ubbt_dep || (racnt<=ubbt_end_dep && !joined)))
{
for(i=2;i
// LOWER BOUND - try if it is possible to sort the stack
// by making only joins. If not,lower bound
// is 1 larger than the number ofadjacencies
// to be made in the orig. stack
// racnt = number of adjacencies thatremain to be done
// Returns the longest sequence of joinsthat can be performed
int greedy_steps_unb(int len, int *p, intracnt)
{
intnp[MAXN+2];
inti,res,tmp,found=0;
res= 0;
for(i=2;i1)
{
flip(len,p,np,len-1);
tmp= 1+greedy_steps_unb(len-1,np,racnt-1);
res= MAX(res, tmp);
}
returnres;
}
int is_adj(int len, int *p, int a)
{
if(a==len-1)
{
if(p[a]==len-1)return 1; else return 0;
}
else
{
if(abs(p[a]-p[a+1])==1)return 1;
return0;
}
}
int count_adj(int len, int *p)
{
inti,ret = 0;
for(i=0;i
// A*SEARCH
// asres is the length of the shortestsorting sequence found so far
// we are not interested in stackssortable with < asreqmin flips
// lbcounts is just a statistics
int asres, asreqmin;
long long lbcounts;
// known_lb is set to a nonnegative numberonly when this stack was tried
// while counting the lower bound for theprevious stack (this avoids
// redundant calculations)
void count_lb_and_add_to_heap(int len, int*p, int dep, int known_lb)
{
inti,racnt;
inttmplow, tmpup;
HeapElement*newelem;
tmpup=-1;
if(known_lb>=0)
{
tmplow= known_lb;
if(tmplow>=asres)return; // this stack can be omitted
}
else
{
racnt= len-count_adj(len,p);
tmplow= racnt + dep;
if(tmplow>=asres)return;
lbcounts++;
if(greedy_steps_unb(len,p,racnt)==racnt)tmpup = tmplow;
else
{
tmplow++;
if(tmplow>=asres)return;
}
if(tmpup==-1)
{
ubbt_dep= 4; ubbt_end_dep = 6;
if(upper_bound_bt(len,p,racnt,1)==racnt)tmpup = racnt+dep+1;
}
if(tmplow>=asres)return;
if(tmpup>=0)
{
if(tmpupp[i] = (char)p[i];
newelem->dep= (char)dep;
newelem->lb= (char)tmplow;
heap_add_element(newelem);
}
void asearch(int len)
{
inti, tmp;
intp[MAXN+2];
intnp[MAXN+2];
HeapElement*el;
while((el= heap_delete_min()))
{
if(el->lb>= asres) { free(el); heap_destroy(); return; }
for(i=0;ip[i];
for(i=0;idep< asres);
asres= el->dep;
heap_destroy();
free(el);
return;
}
for(i=1;ilb;
count_lb_and_add_to_heap(len,np,el->dep+1,tmp);
if(asres
我们首先进行解题分析,发现PancakeSorting问题无法使用动态规划解决,进而使用递归(分支限界)实现PancakeSorting的基本算法。然后讨论剪枝过程中上、下界的产生,并提出了剪枝的优化(上界的较优选择)。同时,又对避免子问题的重复计算进行了研究,减少了搜索次数。最后又对煎饼排序问题的搜索树中的遍历算法进行了研究,并参考了一个GNU开源项目的PancakeSorting程序。
归根到底,烙饼问题之所以难于在多项式时间内解决的关键就在于我们无法为搜索树中的每一条边设定一个合理的权值。在这里,每条边的权值都是1,因为从上一个状态节点到下一个状态节点之需要一次翻转。所以我们不能简单地把每个节点的代价定义为翻转次数,而应该根据其距离最终解的接近程度来给出一个数值,而这恰恰就是该问题的难点。但是无论上面哪一种方法,都需要我们确定搜索树各个边的代价是多少,然后才能进行要么广度优先、要么深度优先、要么A*算法的估计代价。所以,在给出一个合理的代价之前,我们所有的努力都只能是帮忙“加速”,而无法真正在多项式时间内解决问题。
参考资料
[1]资料来源于MAA(MATHEMATICALASSOCIATION OF AMERICA)
http://www.maa.org/mathland/mathtrek_09_04_06.html
[2]《编程之美》小组著,编程之美,电子工业出版社
[3]《编程之美》1.3一摞烙饼的排序 目前的研究成果
[4][5]CSDN 薛笛的专栏 《编程之美》读书笔记三:烙饼问题与搜索树
[6] GNU开源项目 http://kam.mff.cuni.cz/~cibulka/pancakes/
[附录]
// 分支限界基本算法
PancakeSorting.h
/*
Pancake sorting program
author:Jiaqi Wang
UESTC 2013.5
[email protected]
*/
//PancakeSorting.h
class PancakeSorting{
public:
PancakeSorting(void);
~PancakeSorting(void);
//计算煎饼翻转信息
void Run(int *pCakeArray,int nCakeCnt);
void Output();
//初始化数组信息
private:
int *m_CakeArray; //烙饼信息数组
int m_nCakeCnt; //烙饼个数
int m_nMaxSwap; //最大交换次数,最多为(m_nCakeCnt-1)*2;
int *m_SwapArray; //交换结果数组
int *m_ReverseCakeArray; //当前翻转烙饼信息数组
int *m_ReverseCakeArraySwap; //当前翻转烙饼交换结果数组
int m_nSearch; //当前搜索次数
void Init(int*pCakeArray,int nCakeCnt);
//寻找当前翻转的上界
int UpperBound(int nCakeCnt);
//寻找当前翻转的下界
int LowerBound(int*pCakeArray,int nCakeCnt);
//排序的主函数
void Search(int step);
//排序状态方法
bool IsSorted(int *pCakeArray,int nCakeCnt);
//翻转烙饼信息
void Reverse(int nBegin,int nEnd);
};
pancakeSorting.cpp
/*
Pancake sorting program
author:Jiaqi Wang
UESTC 2013.5 [email protected]
*/
//PancakeSorting.cpp
#include
#include "PancakeSorting.h"
#include
PancakeSorting::PancakeSorting(void){
m_nCakeCnt=0;
m_nMaxSwap=0;
}
PancakeSorting::~PancakeSorting(void){
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;
}
}
//寻找当前翻转的上界
int PancakeSorting::UpperBound(int nCakeCnt){
if(nCakeCnt<=14) return (nCakeCnt-2)*2+1;
return (5*nCakeCnt+5)/3+1;
}
//初始化数组信息
//pCakeArray 存储烙饼索引数组
//nCakeCnt 烙饼个数
void PancakeSorting::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);
printf("%d\n",m_nMaxSwap);
// 初始化交换结果数组
m_SwapArray = new int[m_nMaxSwap+1];
assert(m_SwapArray != NULL);
// 初始化中间交换结果信息
m_ReverseCakeArray = new int[m_nMaxSwap];
for(int i = 0; i < m_nCakeCnt; i++)
{
m_ReverseCakeArray[i] = m_CakeArray[i];
}
m_ReverseCakeArraySwap = new int[m_nMaxSwap];
}
//寻找当前翻转的下界
int PancakeSorting::LowerBound(int * pCakeArray,int nCakeCnt)
{
int t,ret=0;
//根据当前数组的排序信息情况来判断最小需要交换多少次
for(int i=1;inBegin);
int i,j,t;
//翻转烙饼信息
for(i=nBegin,j=nEnd;ipCakeArray[i])
{
return false;
}
}
return true;
}
// 排序的主函数
void PancakeSorting::Search(int step)
{
int i, nEstimate;
m_nSearch++;
// 估算这次搜索所需要的最小交换次数
nEstimate = LowerBound(m_ReverseCakeArray, m_nCakeCnt);
if((step + nEstimate > m_nMaxSwap)||(step>=m_nMaxSwap))
return;
// 如果已经排好序,即翻转完成,输出结果
if(IsSorted(m_ReverseCakeArray, m_nCakeCnt))
{
if(step < m_nMaxSwap)
{
m_nMaxSwap = step;
for(i = 0; i < m_nMaxSwap; i++)
m_SwapArray[i] = m_ReverseCakeArraySwap[i];
}
return;
}
// 递归进行翻转
for(i = 1; i < m_nCakeCnt; i++)
{
if(i!=m_ReverseCakeArraySwap[step-1]){
Reverse(0, i);
m_ReverseCakeArraySwap[step] = i;
Search(step + 1);
Reverse(0,i);
}
}
}
//计算烙饼翻转信息
//pCakeArray 存储烙饼索引数组
//nCakeCnt 烙饼个数
void PancakeSorting::Run(int *pCakeArray,int nCakeCnt)
{
Init(pCakeArray,nCakeCnt);
m_nSearch=0;
Search(0);
}
//输出烙饼具体翻转的次数
void PancakeSorting::Output()
{
for(int i = 0; i < m_nMaxSwap; i++)
{
printf("%d ", m_SwapArray[i]);
}
printf("\n |Search Times| : %d\n", m_nSearch);
printf("Total Swap times = %d\n", m_nMaxSwap);
}
Test.cpp
#include "PancakeSorting.h"
int main()
{
PancakeSorting cps;
//int arr[] ={3,2,1,6,5,4,9,8,7,11,13,14,16,0,20,15,19};
// int arr[]={4,2,1,5,3};
// int arr[]={66,2,33,1,5,65,9,1,7,0};
int arr[] ={3,2,1,6,5,4,9,8,7,0};
//int arr[]={5,15,6,8,4,9};
cps.Run(arr,sizeof(arr)/sizeof(int));
cps.Output();
return 0;
}