吴昊品游戏核心算法 Round 17 ——(转载)八数码问题的十重境界

  

 
     

  暴力广搜+STL——此一境也

 

   开始的时候,自然考虑用最直观的广搜,因为状态最多不超过40万,计算机还是可以接受的,由于广搜需要记录状态,并且需要判重,所以可以每次图 的状态转换为一个字符串,然后存储在stl中的容器set中,通过set的特殊功能进行判重,由于set的内部实现是红黑树,每次插入或者查找的复杂度为 Log(n),所以,如果整个算法遍历了所有状态,所需要的复杂度为n*Log(n),在百万左右,可以被计算机接受,由于对string操作比较费时, 加上stl全面性导致 速度不够快,所以计算比较费时,这样的代码只能保证在10秒内解决任何问题。但,明显效率不够高。POJ上要求是1秒,无法通过,第一次的代码见 Code1.cpp。

 

  广搜+哈希——此二境也

 

   考虑到费时主要在STL,对于大规模的遍历,用到了ST的set和string,在效率上的损失是很大的,因此,现在面临一个严重的问题,必须 自己判重,为了效率,自然是自己做hash。有点麻烦,hash函数不好想,实际上是9!种排列,需要每种排列对应一个数字。网上搜索,得知了排列和数字 的对应关系。取n!为基数,状态第n位的逆序值为哈希值第n位数。对于空格,取其为9,再乘以8!。例 如,1 3 7 24 6 9 5 8 的哈希值等于:0*0! + 2*1! + 0*2! + 1*3! + 3*4! +1*5! + 0*6! + 1*7! + 0*8! <9!具体的原因可以去查查一些数学书,其中1 2 34 5 6 7 8 9 的哈希值是0 最小,9 8 7 6 54 3 2 1 的哈希值是(9!-1)最大。而其他值都在0 到(9!-1) 中,且均唯一。然后去掉一切STL之后,甚至包括String之后,得到单向广搜+Hash的代码,算法已经可以在三秒钟解决问题,可是还是不够 快!POJ时限是1秒,后来做了简单的更改,将路径记录方法由字符串改为单个字符,并记录父节点,得到解,这次提交,266ms是解决单问题的上限。当 然,还有一个修改的小技巧,就是逆序对数不会改变,通过这个,可以直接判断某输入是否有可行解。由于对于单组最坏情况的输入,此种优化不会起作用,所以不 会减少单组输入的时间上限,经过这些优化的代码见Code2.cpp。

 

  广搜+哈希+打表——此三境也

 

   好,问题可以在200—300ms间解决,可是,这里我们注 意到一个问题,最坏情况下,可能搜索了所有可达状态,都无法找到解。如果这个题目有多次输入的话,每次都需要大量的计算。其实,这里可以从反方向考虑下, 从最终需要的状态,比如是POJ 1077需要的那种情况,反着走,可达的情况是固定的。可以用上面说的那种相应的Hash的方法,找到所有可达状态对应的值,用一个bool型的表,将可 达状态的相应值打表记录,用“境界三”相似的方法记录路径,打入表中。然后,一次打表结束后,每次输入,直接调用结果!这样,无论输入多少种情况,一次成 功,后面在O(1)的时间中就能得到结果!这样,对于ZOJ的多组输入,有致命的帮助!此境界代码改动不大,不再给出,下同。

 

 双向广搜+哈希——此四境也

 


   Hash,不再赘述,现在,我们来进行进一步的优化,为了减少状态的膨 胀,自然而然的想到了双向广搜,从输入状态点和目标状态1 2 3 4 5 6 7 8 9同时开始搜索,当某方向遇到另一个方向搜索过的状态的时候,则搜索成功,两个方向对接,得到最后结果,如果某方向遍历彻底,仍然没有碰上另一方向,则无 法完成,代码不再给出,原因见上……

 

 A*+哈希+简单估价函数——此五境也

 


   用到广搜,就可以想到能用经典的A*解决,用深度作为 g(n),剩下的自然是启发函数了。对于八数码,启发函数可以用两种状态不同数字的数目。接下来就是A*的套路,A*的具体思想不再赘述,因为人工智能课 本肯定比我讲的清楚。但是必须得注意到,A*需要满足两个条件:

1.h(n)>h'(n),h'(n)为从当前节点到目标点的实际的最优代价值。

2.每次扩展的节点的f值大于等于父节点的f值小。

自 然,我们得验证下我们的启发函数,h验证比较简单不用说,由于g是深度,每次都会较父节点增1。再看h,认识上, 我们只需要将h看成真正的“八数码”,将空格看空。这里,就会发现,每移动一次,最多使得一个数字回归,或者说不在位减一个。 h最多减小1,而g认为是深度,每次会增加1。所以,f=g+h, 自然非递减,这样,满足了A*的两个条件,可以用A*了!此境界代码也不列出,因为后几个加了优化的代码,是此境界的父集,或者说升级版。既然逻辑上一 样,我们就只给出级别最高的。

  A*+哈希+曼哈顿距离——此六境也

 

   A*的核心在启发函数上,境界五若想再提升,先想到的是启发函数。这里,曼哈顿距离可以用来作为我们的启发函数。曼哈顿距离听起来神神秘秘,其 实不过是“绝对轴距总和”,用到八数码上,相当与将所有数字归位需要的最少移动次数总和。作为启发函数,自然需要满足“境界五”提到的那两个条件。现在来 看这个曼哈顿距离,第一个条件自然满足。对于第二个,因为空格被我们剥离出去,所以交换的时候只关心交换的那个数字,它至多向目标前进1,而深度作为g每 次是增加1的,这样g+h至少和原来相等,那么,第二个条件也满足了。A*可用了,而且,有了个更加优化的启发函数。因为还可以升级,所以……代码不给 出。

 

 A*+哈希+曼哈顿距离+小顶堆——此七境也

 

   经过上面优化后,我们发现了A*也有些鸡肋的地方,因为需要每次找到所谓Open表中f最小的元素,如果每次排序,那么排序的工作量可以说是很 大的,即使是快排,程序也不够快!这里,可以想到,由于需要动态的添加元素,动态的得到程序的最小值,我们可以维护一个小顶堆,这样的效果就是。每次取最 小元素的时候,不是用一个n*Log(n)的排序,而是用log(n)的查找和调整堆,好,算法又向前迈进了一大步。这里,我们终于要给出代码了,代码参 见Code3.Cpp。

 

 IDA*+曼哈顿距离——此八境也

 

   IDA*即迭代加深的A*搜索,实现代码是最简练的,无须状态判重,无需估价排序。那么就用不到哈希表,堆上也不必应用,空间需求变的超级少。 效率上,应用了曼哈顿距离。同时可以根据深度和h值,在找最优解的时候,对超过目前最优解的地方进行剪枝,这可以导致搜索深度的急剧减少,所以,这,是一 个致命的剪枝!因此,IDA*大部分时候比A*还要快,可以说是A*的一个优化版本!代码参见Code4.cpp。到此,到了我的境界八……请老师再将境 界进行升级……还有,前文中也许会有很多疏漏之处,望老师给以修正。


    遗传算法——此九境也 

 

   遗传算法是一种通过模拟自然进化过程搜索全局最优解的方法, 其本质是一种基于概率的随机搜索算法, 其方法是先采用随机的方式建立待解决问题的若干染色体( 又称个体) , 即尝试解, 所有染色体称为种群。

  先介绍适应度的概念

  F( Pi ) = ∑( 100 - 归位数字× 10)

  对种群中的每一条路径Pi , 从初始状态按该路径移动空格, 忽略掉空格不能到达的移动方式以及重复的移动方式, 得到一个状态, 对该状态使用上面的公式求值, 得到该条路径的适应度。路径的适应度越高, 说明越接近目标状态。

  例如, 假设从初始状态使用某一路径Pi 移动空格得到的状态为1 4 5 8 9 2 7 6 3 ( 注: 9代表空格) , 则归位数字为1、9, F( Pi) = 90+ 30 = 120。

  算法描述如下:

  a. 产生初始种群;

  把空格的移动方式M 作为基因, 因此共有四种基因, 采用两个二进制数表示, 如表1 所示。

  表1 

  基因编码 编码

  U 00

  D 01

  L 10

  R 11

  染色体表示为基因序列( M 1 M 2 ……Mi …… Ml) , 其中Mi 表示第i 步的移动方式。这种染色体表示一次尝试路径, 如表2 所示。其中Mi 在生成初始种群时随机产生。

  表2 染色体样例

  染色体编码路径

  P1 01 10 11 10 11…… ( DLR LR……)

  P2 10 11 00 01 10…… ( LR UDL ……)

  b. 对每个染色体判断是否为解, 并计算适应度, 如为解退出, 否则进入c;

  c. 选择;

  采用概率的方法从种群中选择一个染色体, 选中的几率正比于染色体的适应度; 若某染色体i , 其适应度为f i , 染色体个数为n, 则选择概率为:

  Pi = fi/∑fi

  算法描述如下:

  随机取0 到整个种群的适应度内的一个浮点数;

  对种群中的每一个染色体, 进行以下操作:

  累加已考察过的染色体的适应度;

  如果累加值大于随机取的浮点数, 则选择该染色体。

  d. 交叉;

  把染色体看作二进制流, 在每次应用交叉算子时, 首先在0 到2l 内随机取一个整数作为交叉点, 然后产生交叉掩码并应用。

  例如, 假设采用选择算子得到两个染色体P1:

  01 10 1 1 10 11 ……( DLRLR ……), P2: 10 11 0

  0 01 10 ……( LR UDL ……) , 交叉点为5, 则交叉掩码为1111100000 ……, 交叉后的后代为 B 1: 10 11 01 1011……( L RDLR ……) , B2: 01 10 10 01 10……( DLLDL ……) 。

  e. 变异;

  对染色体的每一位(共2l 个) , 进行以下操作:

  随机取0 到1 内的一个浮点数;

  如果该浮点数小于预设的变异率, 则对该位求反。

  例如, 对采用交叉算子得到的染色体B 1: 10 11 0110 11……( L RDLR ……) 的第一位变异, 假设该位符合变异条件, 则变异后为B1: 00 11 01 10 11……( URDLR ……) 。

  f. 返回b。

  需要注意的地方:

  如何判定无解

  盲目搜索过于耗时,可根据序列的奇偶性判断。只有当初始结点逆序与目标节点逆序的奇偶性相同时,才可能有解,否则,必无解。(根据:‘0’移动时不改变数列逆序的奇偶性)

 

  教主解法——此十境也

 

  这里的一段话摘自楼天成的回忆录(楼天成在2005年百度之星总决赛的一些经历与他对八数码问题的想法,当年总决赛就是八数码问题的AI):

   最早的国内个人程序设计比赛要回忆到 2005 年 9 月开始的第一届百度程序设计大赛了,源于宿舍走廊中的海报,我以尝试的心态报名参加了第一届百度程序设计大赛。每一届百度程序设计大赛都由初赛,复赛和现场决赛组成。

  第一届百度程序设计大赛中,印象最深的复赛题目就是那道规模巨大的最小树形图问题了, 100000 的数据规模吓退了不少选手,我鼓足勇气提交了一个理论上能够运行的程序,顺利通过了复赛进入决赛。最小树形图算法在大多图论书上就接在最小生成树算法后 面,但是其程序量远比最小生成树大,而且用途没有最小生成树广泛,在大多数竞赛中很少出现。我最早接触最小树形图算法是在 2003 年 4 月,当时正在复旦大学训练,记得关于这个问题和 xreborner 讨论了很长时间才得以证明算法的正确性并实现出高效的程序。

  现场决赛于 2005 年 10 月底在北京举行,由于当年比赛的知名度不高,时间上还和 GCJ 冲突,没有太多的顶尖高手参加。清华大学除我之外只有 superzn (张宁,我们留 shell 一个人参加 ACM 北京赛区预赛 L ),当时 OpenGL 还是以高中生身份参加的,还有复旦大学的 xreborner 和 young (李阳);中山大学的 magicpig , Savior 和张子臻(不好意思,我不记得您的 ID 了,好像杭州 2008 的时候我们还说起此事)。我一直认为,现场比赛过程的一个重要的意义在于提供了一个老朋友重逢和结实新朋友的机会,选手之间的交流是比赛中最重要的组成部 分之一,我很有幸能够在这些比赛中认识了众多牛人。

  稍微回顾一下决赛的题目吧:决赛的题目是经典的 8 数码问题,给定初始状态和结束状态,计算最短需要的转移步数。对于分数相同的情况,按照程序的运行速度排名。比较容易想到的方法有:

  (1) 单向 BFS :最坏情况需要 1s 左右。

  (2) 双向 BFS :如果先判断无解情况,这是 xreborner 使用的方法,平均情况大概 0.002 秒左右。

  (3) A* 或者 IDA* :先判断无解情况,然后通过距离启发函数搜索。平均情况大概 0.002 秒左右。我当时使用了 A* 的方法,但许多地方的实现不是很合理。

  (4) 常量表,这是最有挑战的方法,因为决赛的提交量限制在 64K 以内。

  现场比赛中, (2) 和 (3) 的使用人数比较多,速度相差无几,选手之间比拼的是各种细节和常数的处理。后来,我想出了一种速度非常快的方法:

  首先使用 A* 加上 “ 卡节点 ” 技术,就是限制 A* 算法搜索过程中每层的节点个数上限,这种算法扩展节点个数在 100 左右。然后,由于上述算法的正确性不能保证,把所有反例打成常量,程序大概 50K 左右。很容易发现,这个程序的速度远比比赛过程中所有程序的速度都快得多。

  最终我的程序以总时间 0.022 秒获得冠军, xreborner 和 Savior 以 0.026 分并列第二名。 xreborner 的程序很可惜,如果加入了无解判断,速度应该比我程序块, superzn 就更可惜了, superzn 的飘逸程序其实只有 0.020 秒,但是有一个数据错了。

  记得颁奖之后,主持人邀请获奖选手发言,选手可以通过向前走一步选择优先发言。这时,我突然感觉大家把目光都聚焦到了我身上,向右一看,由于我站在最左边没有注意到右边的情况,可谁知其他选手都后退了一步,把我留在了看似向前一步的位置。

posted on 2013-04-17 17:29  吴昊系列  阅读(541)  评论(2编辑  收藏  举报

导航