n 个烙饼经过翻转后的状态可组成一棵树。寻找翻转最少次数,相当于在树中搜索层次最低的某个节点。
由于每层的节点数,成几何数量级增长,在n 较大时,使用广度优先遍历树,会无法有足够的内存来保存中间结果(考虑到每层的两个节点,可以通过旋转,移位等操作互相转换,也许每层的状态或许可以用一个函数来生成,这样的话也就可以采用广度优先方法。)。因而采用深度优先。但这棵树是无限深的,因而必须找到最少翻转次数的上限值 ,当深度达到该值时不再继续往下搜索。最少翻转次数,必然大等于任何一种翻转方案所需的翻转次数,因而只要构造出一种方案,取其翻转次数即可。最简单的方案就是:对最大的未就位的烙饼,将其翻转,再找到最终结果中其所在的位置,翻转一次使其就位。因此,对编号在n-1 和2 之间的烙饼,共翻转了2*(n-2) 次,剩下0 和1 号烙饼最多只要翻转1 次,因而最少翻转次数的上限值是:2*(n-2)+1=2*n-3 (最新进展:上限值为18/11*n ),当然,最好还是直接计算出采用这种方法的翻转次数做为初值 。
减少遍历次数:
1 避免出现已处理过的状态一定会减少遍历吗?答案是否定的, 深度优先遍历,必须遍历完一个子树,才能遍历下一个子树,如果一个解在某层比较靠后位置,若不允许处理已出现过的状态时,可能要经过很多次遍历才能找到这个解,但允许处理已出现过的状态时,可能会很快找到这个解,并减小“最少翻转次数的上限值”,使更多的分支能被剪掉,从而减少遍历。比如,说两个子树A 、B ,搜索子树A ,100 次后可得到一个解对应翻转次数20 ,搜索子树B ,20 次后可得到翻转次数为10 的解,不允许处理已出现过的状态,就会花100 次遍历完子树A 后,才开始遍历B ,但允许翻转回上一次状态,搜索会在A 、B 间交叉进行,就可能只要70 次找到子树B 的那个解(翻转次数为10+2=12 ),此时,翻转次数比较少,能减小更多的搜索,搜索次数明显减少。以书中的{3,2,1,6,5,4,9,8,7,0} 为例,按后面的程序,需要搜索195 次,若可以走回头路,只要搜索116 次。
2 如果最后的几个烙饼已经就位,只须考虑前面的几个烙饼。 对状态(0,1,3,4,2,5,6) ,编号为5 和6 的烙饼已经就位,只须考虑前5 个烙饼,即状态(0,1,3,4,2) 。如果一个最优解,从某次翻转开始将移动了一个已经就位的烙饼,那么,这个解法,从这次翻转开始得到的一系列状态,从中将这个烙饼,得到新的状态,可以设计出一个新的解法对应这系列新的状态。该解法所用的翻转次数不会比原来的多。
3 估计每个状态还需要翻转的最少次数(即下限值),加上当前的深度,如果大等于上限值,就无需继续遍历。这个下限值可以这样确定:从最后一个位置开始,往前找到第一个与最终结果位置不同的烙饼编号(也就是说排除最后几个已经就位的烙饼),从该位置到第一个位置,计算相邻的烙饼的编号不连续的次数,再加上1 。 每次翻转最多只能使不连续的次数减少1 ,但不少人会忽略掉这个情况:最大的烙饼没有就位时,必然需要一次翻转使其就位,而这次翻转却不改变不连续次数。(可以在最后面增加一个更大的烙饼,使这次翻转可以改变不连续数。) 如:对状态(0,1,3,4,2,5,6) 等同于状态(0,1,3,4,2) ,由于1 、3 和4 、2 不连续,因而下限值为2+1=3 。下限值也可以这样确定:在最后面增加一个已经已就位的最大的烙饼,然后再计算不连续数。 如:(0,1,3,4,2) ,可以看作(0,1,3,4,2,5) ,1 和3 、4 和2 、2 和5 这三个不连续,下限值为3 。
4 翻转次数的上限值越大,搜索次数就越多,尽快找到一个接近最优解的解,能减少大量的搜索次数。可以采用贪心算法,通过调整每次所有可能翻转的优先搜索顺序 ,来找到这样的解。比如,优先搜索使“下限值”减少的翻转,其次是使“下限值”不变的翻转,最后才搜索使“下限值”增加的翻转。对“下限值”不变的翻转,还可以根据其下次的翻转对“下限值”的影响,再重新排序。由于进行了优先排序,翻转回上一次状态能减少搜索次数的可能性进一步的降低。
5 剪枝:第m 次翻转时,“上限值”为min_swap 。
(1.3_pancake_final.cpp 用到的剪枝条件)
如果在某个位置的翻转得到一个解(即翻转次数为m ),则其它位置可以不搜索(因为在其它位置的翻转,能得到的最少翻转次数必然大等m )。
如果在某个位置的翻转后,“下限值”为k ,并且 k+m>=min_swap ,则对所有的使新“下限值”kk 大等于k 的翻转,都有 kk+m>=min_swap ,因而都可以不搜索。
另外,由于翻转时,只有两个位置的改变才对“下限值”有影响,因而可以记录每个状态的“下限值”,翻转时,通过几次比较,就可以确定新的“下限值”,没必要从新开始计算新状态的“下限值”。
针对书上的例子,最终的代码,只调用了搜索函数29 次,翻转函数66 次(这个例子比较特殊,min_swap 直接指定2*10-2=18 时,调用搜索函数29 次,翻转函数56 次)。
另外,判断不连续次数时,最好写成 -1<=x && x<=1 ,而不是x==1 || x==-1 。对于 int x; a<=x && x<=b ,编译器可以将其优化为 unsigned (x-a) <= b-a 。我最初写的代码用的类型都是size_t ,就手动优化了。后面给出两个文件(1.3_pancake.cpp 和1.3_pancake_final.cpp )完整代码。可以用前一个测试“是否允许翻转回上次状态”对搜索次数的影响。
(补充:
在网上下了“第 6 刷”的源代码,结果编译时仍是一堆错误,主要错误有:
1 Assert 应该是 assert
2 m_arrSwap 未被定义,应该改为 m_SwapArray
3 Init 函数两个 for 循环,后一个没定义变量 i ,应该将 i 改为 int i
另外,每运行一次 Run 函数,就会调用 Init 函数,就会申请新的内存,但却没有释放原来的内存,造成内存泄漏。
程序的低效主要是由于 进行剪枝判断时,没有考虑好边界条件。
1 if(step + nEstimate > m_nMaxSwap) 这个最好取大等于号。
2 判断下界时,如果最大的烙饼不在最后一个位置,则要多翻转一次,因而在 LowerBound 函数 return ret; 前插入一行 if (pCakeArray[nCakeCnt-1]!=nCakeCnt-1) ret++; 。
3 n 个烙饼,翻转最大的 n-2 烙饼最多需要 2*(n-2) 次,剩下的 2 个最多 1 次,因而上限值为 2*n-3 ,因此, m_nMaxSwap 初值可以设为 2*n-3+1=2*n-2 ,这样每步与 m_nMaxSwap 的判断就可以取大等于号。
1 和 2 两处任改一处都能使搜索次数从 172126 降到两万多,两处都改,搜索次数降到 3475 ,再改动第 3 处,搜索次数最终为 2989 。
P24 页,“目前找到的最大下界是 15n/14 ”,这句有问题,没说清楚这个“下界”是指什么,会认人误以为,任意一个排列的最少翻转次数。这应该是后面提到的“第 n 个烙饼数”的下界。
PS ,找这个代码时,无意间看到 1-17 的代码居然有 void main() 这种错误写法。)
原文链接:http://blog.csdn.net/flyinghearts/archive/2010/05/18/5605918.aspx
延伸阅读: