收藏 分享(赏)

中国象棋算法.doc

上传人:hwpkd79526 文档编号:9057756 上传时间:2019-07-22 格式:DOC 页数:37 大小:247.50KB
下载 相关 举报
中国象棋算法.doc_第1页
第1页 / 共37页
中国象棋算法.doc_第2页
第2页 / 共37页
中国象棋算法.doc_第3页
第3页 / 共37页
中国象棋算法.doc_第4页
第4页 / 共37页
中国象棋算法.doc_第5页
第5页 / 共37页
点击查看更多>>
资源描述

1、解剖大象的眼睛中国象棋程序设计探索 黄晨 * 2005 年 6 月 ( * 联系地址:复旦大学化学系表面化学实验室,eMail:morning_) (一) 引言 我在今年 2 月写出了象棋程序 ElephantEye 的第一个版本(0.90),本来它只是象棋界面 ElephantBoard 的调试引擎。在设计程序的过程中,我尝试性地加入了很多算法,发现每次改进都能让程序的棋力有大幅度的提高,因此便对象棋程序的算法产生了浓厚的兴趣。到现在我已经陆续对 ElephantEye 作了几十次加工( 目前版本为 0.94),使得它的棋力接近了中等商业软件的水平,在公开源代码的象棋程序中,Elephant

2、Eye 是最强的一个。我希望能通过公开源代码的方式,推动中国象棋程序水平的整体发展,然而根据很多网友的反馈意见,发现源代码中的很多部分并不是那么容易理解的。因此我才打算以中国象棋程序设计探索为题,写几篇详细介绍 ElephantEye 算法的连载,希望能让的源代码充分发挥它的作用。 下面我先简要谈一下我自己对 ElephantEye 的体会。 1.1 ElephantEye 用到了哪些算法? 在我写本次连载以前,我已经完成了象棋百科全书网站上对弈程序基本技术专题中所有文章的翻译,ElephantEye 的大部分算法都参考了这些文章,这些算法我会在连载中一笔带过,详细的内容希望读者参考这些译文,

3、那里还有我加的很多译注,希望它们能够加深读者对这些算法的体会。 当然,仅根据这些文章所提供的算法,是写不出很好的程序的,我参考了王小春的PC 游戏编程人机博弈一书,也参考了一些国际象棋的源程序,并通过自己的探索,在 ElephantEye 中加入了另外的非常重要的算法,尤其是启发算法,我认为它们在程序中发挥了关键性的作用,而且很多细节在绝大多数文字资料中没有详细给出,我会在我的连载中重点介绍。 我猜读者最感兴趣的内容是 ElephantEye 的着法生成器,这应该算是象棋程序的核心部分,同时也是各个程序差异最大的部分。在写 ElephantEye 以前,我在象棋百科全书网站上刊登了大量介绍“位

4、棋盘”的文章,这是个非常有吸引力的思想,但是我试验下来觉得它的速度并不快,在 ElephantEye 的程序里我只把位棋盘运用在将军判断上。尽管如此,ElephantEye 短短 10 行的将军判断也许是程序的一个亮点吧,那么这部分内容我将尽量介绍得详细一点。 此外,一些看似和棋力关系不大的技术,诸如开局库、长将检测、后台思考、时间策略、引擎协议等等,其实也直接影响着象棋程序的稳定性,因此也有必要逐一讲解。 总之,每个技术都很重要,我的连载虽然不能面面俱到,但我会尽我所能来作详细阐述的。 1.2 如何正确评价 ElephantEye 目前的棋力? ElephantEye 是“蛮力型”象棋程序,

5、与大多数商业程序的不同之处在于,它没有审局能力,那么它的棋力到底有多强?网友对这个问题众说纷纭,有人认为它无法跟一流的商业软件相比,毕竟 ElephantEye 是免费程序,其源代码又是公开的,为什么非要去和顶尖程序去比呢?也有人认为它能战胜中等商业软件,但电脑对电脑和电脑对人类根本就不是一回事,这么一个不懂得防守空头炮的程序怎能说它厉害呢?还有人喜欢在同一搜索水平(比如 6 层、8 层或 10 层)上比较两个不同的程序,这种标准去比较“蛮力型”程序和“知识型”程序,这有意义吗? 要正确认识这个问题,我想说明几点: (1) 测试标准要合理,这个标准只能是 “时限” ,即给两个程序以同样多的时间

6、,可以对每步都限定时间,也可以是比赛所采用的时段制或加时制,而不能以同样的搜索水平作标准。另外,如果两个程序运行在同一台电脑上,那么不能启用后台思考功能。 (2) 某几盘对局并不能说明问题,我以 “浅红象棋”为平台用 ElephantEye 对阵“梦入神蛋” ,ElephantEye 遗憾地以 2:3 败北。我有充分的信心表明 ElephantEye 的棋力比梦入神蛋强得多,因为两者用了相同的评价函数,但同样时间 ElephantEye 通常要比梦入神蛋多搜索一层以上,那么 2:3 的比分又能说明什么问题呢? (3) 跟人类比和跟电脑比是两回事,每个电脑程序都有弱点,这些弱点很容易被人类棋手抓

7、住,但其他电脑程序则不会抓住你的弱点。一般认为,知识缺乏的程序弱点也多(例如 ElephantEye 不懂得防守空头炮),因此对阵人类棋手失败的几率要比对阵其他程序高得多。 1.3 ElephantEye 对象棋有哪些认识? 要说 ElephantEye 一点象棋知识都不具备,这种观点我是无法接受的。很多搜索算法确实只能用在象棋上,这一点 ElephantEye 做得比很多商业程序都好,这些算法体现在以下几个方面: (1) 杀棋局面在置换表中的特殊处理,这使得 ElephantEye 识别杀棋的速度快了很多; (2) 将军扩展,这使得 ElephantEye 对可能有杀棋的线路特别感兴趣,它会

8、在搜索上增加对这些路线的投入; (3) 带检验的适应性空着裁剪,这个算法首先由一个以色列学者发表于 2002 年( 不是“适应性”的),最近我对该算法作了改进,使得它能正确处理残局中的等着杀和连等着杀,速度也快了很多。 这些算法使得 ElephantEye 有很强的处理杀局和残局的能力,我相信绝大多数商业软件都没它做得好。如果一个程序能在很短的时间内告诉你,几步之后必定有一方会被将死,或者几步之后优势一方就可以破士或破象,那么这个程序的实用价值还算小吗?(二) 棋盘结构和着法生成器 在阅读本章前,建议读者先阅读象棋百科全书网站中对弈程序基本技术专题的以下几篇译文: (1) 数据结构简介(Dav

9、id Eppstein) ; (2) 数据结构位棋盘(James Swafford); (3) 数据结构旋转的位棋盘(James Swafford); (4) 数据结构着法生成器(James Swafford); (5) 数据结构 0x88 着法产生方法(Bruce Moreland); (6) 数据结构 Zobrist 键值(Bruce Moreland) ; (7) 其他策略重复检测(Bruce Moreland)。 2.1 局面和着法的表示 局面是象棋程序的核心数据结构,除了要包括棋盘、棋子、哪方要走、着法生成的辅助结构、Zobrist 键值等,还要包含一些历史着法,来判断重复局面。El

10、ephantEye 的局面结构很庞大(见),其中大部分存储空间是用来记录历史局面的。 struct CchessPosition int MoveNum; MoveStruct MoveListMaxMoveNum; / MaxMoveNum = 256 char LoopHashLoopHashMask + 1; / LoopHashMask + 1 = 1024 其中 MoveStruct 这个结构记录了四个信息:(1) 着法的起始格(Src),(2) 着法的目标格(Dst) ,(3) 着法吃掉的棋子 (Cpt),(4) 着法是否将军(Chk)。有意思的是,每个部分都只占一个字节,后两个部

11、分(Cpt 和 Chk)与其说有特殊作用,不如说是为了凑一个 32 位整数。在 MoveStruct 出现的很多地方(置换表、杀手着法表、着法生成表) 里,这两项都是没作用的,而只有在 CchessPosition 结构的记录历史着法的堆栈中才有意义。 Cpt 一项主要用在撤消着法上,它可以用来还原被吃的棋子,而 Chk 一项则可以记录当前局面是否处于将军状态。ElephantEye 是用 MovePiece()函数来走棋的,每走完一步棋就做两次将军判断:第一次判断走完子的一方是否被将军,即 Checked(Player),如果被将则立即撤消着法,并返回走子失败的信息;第二次判断要走的一方是否

12、被将军,由于交换了走子方(即执行了 Player = 1 Player),所以仍旧是 Checked(Player),如果被将则 Chk 置为 1,这个着法被压入历史着法堆栈。因此 LastMove().Chk 就表示当前局面是否被将军。 2.2 循环着法的检测 Cpt 和 Chk 的另一个作用就是判断循环着法: ElephantEye 判断循环着法时,依次从堆栈顶往前读,读到吃过子的着法(Cpt 不为零)就结束;而是否有单方面长将的情况,则是通过每个着法的 Chk 一项来判断的。 在循环着法的检测中,我们感兴趣的不是 Cpt 和 Chk,而是 LoopHash 结构,这是一个微型的置换表,用

13、来记录历史局面。ElephantEye 在做循环着法的判断这之前,先去探测这个置换表,如果命中置换表,则说明可能存在重复局面(由于置换表可能有冲突,所以只是有这个可能),因而做循环检测;如果没有命中则肯定没有重复局面。 ElephantEye 使用“ 归位检测法 ”来判断循环着法,即从最后一个着法开始,依次向前撤消着法,并记录每个移动过的棋子所在的格子。如果所有移动过的棋子同时归位,那么循环着法就出现了。因此中的 IsLoop()函数建立了两个归位数组,第一个记录了棋子的原始位置,第二个记录了新的位置,当两个位置重合时,说明棋子归位。 2.3 棋盘- 棋子联系数组 众所周知,棋盘的表示有两种方

14、法。一是做一个棋盘数组(例如 Squares109),每个元素记录棋子的类型(包括空格 );二是做一个棋子数组(例如 Pieces32),每个元素记录棋子的位置(包括被吃的状态)。如果一个程序同时使用这两个数组,那么着法生成的速度就可以快很多。这就是“棋盘- 棋子联系数组”,它的技术要点是: (1) 同时用棋盘数组和棋子数组表示一个局面,棋盘数组和棋子数组之间可以互相转换。 (2) 随时保持这两个数组之间的联系,棋子移动时必须同时更新这两个数组。 根据这两个原则,棋盘-棋子联系数组可以定义为: struct CchessPosition int Squares90; int Pieces32;

15、 ; 棋子数组 Pieces48是 ElephantEye 的一个特点,0 到 16 没有作用,16 到 31 代表红方棋子(16 代表帅,17 和 18 代表仕,依此类推,直到 27 到 31 代表兵) ,32 到 47 代表黑方棋子(在红方基础上加 16)。这样,棋盘数组 Squares90中的每个元素的意义就明确了, 0 代表没有棋子,16 到 31 代表红方棋子,32 到 47 代表黑方棋子。这样表示的好处就是:它可以快速判断棋子的颜色,(Piece const int HorseMovDeltaMaxHorseMove = -0x21, -0x1f, -0x12, -0x0e, +0

16、x0e, +0x12, +0x1f, +0x21; const int HorseLegDeltaMaxHorseMove = -0x10, -0x10, -0x01, +0x01, -0x01, +0x01, +0x10, +0x10; for (i = MyFirstHorse; i 中,则是 KnightMoves9012)和 HorseLegs908,前一个数组的第二个维度之所以是 9,是因为着法生成器依次读取数组中的值,读到1 就表示不再有着法。程序基本上是这样的: for (i = MyFirstHorse; i 提供,着法预产生数组则在程序初始化时生成,初始化过程由提供。 2.5

17、 位行和位列 车和炮的着法分为吃子和不吃子两种,这两种着法生成器原则上是分开的,因此分为车炮不吃子、车吃子和炮吃子三个部分。不吃子的着法可以沿着上下左右四条射线逐一生成(即并列做 4 个循环)。我们感兴趣的是吃子的着法,因为静态搜索只需要生成这种着法,能否不用循环就能做到?ElephantEye 几乎就做到了。 “位行”和“ 位列”是目前比较流行的着法生成技术,但仅限于车和炮的着法,它是否有速度上的优势还很难说,但是设计程序时可以减少一层循环,这个思想就已经比较领先了。以“位”的形式记录棋盘上某一行所有的格子的状态(仅仅指是否有子 ),就称为“位行”(BitRank),与之对应的是 “位列”(

18、BitFile),棋盘结构应该包含 10 个位行和 9 个位列,即: struct CchessPosition int BitFiles9; int BitRanks10; ; 值得注意的是,它仅仅是棋盘的附加信息, “棋盘-棋子联系数组 ”仍旧是必不可少的。它的运作方式有点和“棋盘- 棋子联系数组”类似: (1) 同时用位行数组和位列数组表示棋盘上的棋子分布信息,位行数组和位列数组之间可以互相转换; (2) 随时保持这两个数组之间的联系,棋子移动时必须同时更新这两个数组。 因此,移走或放入一颗棋子时,必须在位行和位列上置位: void SetPiece(int Square, int Pi

19、ece) x = Square / 10; y = Square % 10; BitFilesx = 1 中,它分为三个步骤: (1) 判断棋子是否在棋盘上存在,如果不存在那么肯定不是合理着法; (2) 如果棋子是帅(将)、仕(士) 或兵(卒) ,那么肯定是合理着法; (3) 相(象)、马、车和炮需要作额外的判断。 我们感兴趣的是相(象)马车炮四子的判断,其中相(象)的判断最简单,由于象眼的位置总能用(SrcSq+DstSq)/2 表示(不管是 9x10 还是 16x16 的棋盘),所以判断合理性只需要一条语句: return !Squares(SrcSq + DstSq) 1; Elepha

20、ntEye 在马的判断上用了一个诀窍:构造了一个巧妙的数组: const int HorseLegTab43 = -10, 0, -10, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 10, 0, 10, ; 上面的数组中,正中心的数代表马步的起始格(红色表示) ,21、19、12 和8 是马步的增量(蓝色表示)( 能被程序读到的就是这些位置 ),它们记录了马腿的增量(马腿的位置用绿色表示)。这样,判断合理性也只需

21、要一条语句: return !SquaresSrcSq + HorseLegTabDstSq - SrcSq + 21; 车和炮的判断就需要用到前面所说的位行和位列,对于吃子着法很容易理解,而不吃子着法只需要跟车吃子的着法相比较。方法很简单,如果不吃子着法的增量(绝对值) 不超过不吃子着法的最大增量,那么着法就是合理的。这就需要首先要判断是否是吃子着法,然后根据不同情况作相应的判断。ElephantEye 中有专门的判断着法合理性的数组,也在中生成,结构跟前面提到的着法预产生数组类似,只是把FileRookCapMoves1010243拆成了 FileRookCapMax101024和 Fil

22、eRookCapMin101024了,它们和 FileRookCapMoves1010243是一起生成的。 2.7 位棋盘与将军判断 将军判断是 ElephantEye 的一大特色,但这已经是时髦的“位棋盘”在 ElephantEye 中的最后一块阵地了。与大多数象棋程序不同,ElephantEye 每执行一个着法就进行一次将军判断,这使得将军判断占用了很多程序运行的时间。更普遍的做法是延迟将军的判断,即当帅(将)被吃掉后程序才认识到前一个局面是将军,这会使得程序多花一层搜索才能判断出杀棋,但是毕竟将军不是经常发生的。笔者很早就考虑把将军判断从 ElephantEye 的代码中移走,但是担心这

23、样会影响程序解杀局的速度,所以这使得位棋盘直到现在还保留在ElephantEye 的代码中。 ElephantEye 判断将军的方法,不是让每个子都走到帅(将) 的位置看看是否能走,而是反过来问一个问题:敌方的子在哪些位置上,自己的将(帅) 会被将军? 对于每个兵种,ElephantEye 都用了 BitBoard .Check18.的结构,代表将(帅) 的 18个位置(双方的两个城池)上可能被这些棋子攻击的格子。判断将军时,只要用这个位棋盘“与”上该兵种所占格子的位棋盘,不为零就说明被将军了。对于兵来说,这样做具有非常高的效率,因为五个兵所占的格子被一张位棋盘表示,所以只需要做一次判断,不必

24、五个兵逐一判断。而对于车马炮,这样做也不浪费时间,尤其是车,它的位棋盘可以和帅(将)合并在一起,被车将军和双王照脸可以一起判断。 从中看出,判断车(连同双王照脸) 和炮将军的位棋盘数组,包括了纵线上将军的数组181024和横线上将军的数组18512 ,查找数组时分别要调用将所在的位置的位行和位列。而最有意思马将军的位棋盘数组18256,显然第二个指标代表蹩马腿的信息。可是为什么是 256 呢?这里面蕴涵着“折叠位棋盘”的思想。 “折叠位棋盘” 的思想是由湖北襄樊铁路分局计算机中心的章光华提出的,首先于 2004年底发表在象棋百科全书网站的论坛上。这个思想的要点是: (1) 棋盘必须按照列的方式

25、对每个格子编号(如下图所示 ),格子的编号代表 96 位位棋盘中的第几位; 09 19 29 39 49 59 69 79 8908 18 28 38 48 58 68 78 8807 17 27 37 47 57 67 77 8706 16 26 36 46 56 66 76 8605 15 25 35 45 55 65 75 8504 14 24 34 44 54 64 74 8403 13 23 33 43 53 63 73 8302 12 22 32 42 52 62 72 8201 11 21 31 41 51 61 71 8100 10 20 30 40 50 60 70 80(2

26、) 初始化数组一个“马腿数组”,以表示某个格子上的马可能存在的马腿,例如: BitBoard HorseLegTable90; HorseLegTable0 = BitMask1 | BitMask10; HorseLegTable1 = BitMask11 | BitMask20; / BitMask0没必要加上去,而加上去也没错。 注意,这里仅仅是拿两个格子举例子,写在程序里的时候要用循环语句来生成马腿数组,以精简代码。 (3) 产生绊马腿的棋子的位棋盘,以红方左边未走过的马为例: HorseLeg = HorseLegTable10 (4) 根据马的位置和绊马腿的位棋盘,就可以知道马可以

27、走的格子,因此可以构造这样的函数: BitBoard KnightPinMove(int KnightSquare, BitBoard HorseLeg); 为了增加运算速度,应该用查表代替运算,即把函数变成数组。那么位棋盘 HorseLeg如何转化成整数呢?这就引出了“折叠位棋盘”的技术,这可以称得上是一个炫技。折叠位棋盘实际上就是位棋盘的 8 位校验和(CheckSum),有了折叠位棋盘后,上面的函数就可以用数组表示: BitBoard KnightPinMove90256; 例如第 10 个格子上马的所有着法,用位棋盘表示为: KnightMoveBitBoard = KnightPin

28、Move10CheckSum(HorseLegTable10 相(象) 的着法可以采用同样的原理,首先初始化象眼数组: BitBoard ElephantEyeTable90; 随后折叠象眼的位棋盘 ElephantEye,再从相(象)的着法预产生数组BishopPlugMove90256中找到着法。 1 3 5 7 1 3 5 7 10 2 4 6 0 2 4 6 07 1 3 5 7 1 3 5 76 0 2 4 6 0 2 4 65 7 1 3 5 7 1 3 54 6 0 2 4 6 0 2 41 2 3 4 5 6 7 0 10 1 2 3 4 5 6 7 07 0 1 2 3 4

29、5 6 76 7 0 1 2 3 4 5 65 6 7 0 1 2 3 4 54 5 6 7 0 1 2 3 43 5 7 1 3 5 7 1 32 4 6 0 2 4 6 0 21 3 5 7 1 3 5 7 10 2 4 6 0 2 4 6 03 4 5 6 7 0 1 2 32 3 4 5 6 7 0 1 21 2 3 4 5 6 7 0 10 1 2 3 4 5 6 7 0按纵线编号的棋盘 按横线编号的棋盘看到这里,可能有的读者就会怀疑“折叠位棋盘”的合理性,如果折叠成 4 位的整数( 甚至更少),把马的着法预产生数组了缩小到 90x16,这显然是不合理的,为什么 8 位就一定合理呢?

30、 要说明这个问题,首先来看折叠的本质校验和(CheckSum),棋盘上的很多格子对应着校验和上固定的一位。根据这个性质,我们对棋盘重新编号,就如左图所示。假如河界下面蓝色的格子是马,那么相邻的红色格子就是马腿,4 条马腿对应 4 个不同的编号,所以任何一种组合(一共有 24 =16 种组合) 是不重复的。需要指出的是,这个棋盘当中所有的格子,其相邻的四个格子都有不同的编号。有趣的是,象眼同样符合这个规律(注意河界上面蓝色的格子,斜相邻的四个格子是象眼)。 折叠位棋盘的唯一性是建立在“按纵线编号”的基础上的。如果按横线编号,情况就不那么幸运了,如右图所示,无论是河界下面的马,还是河界上面的象,马

31、腿或象眼都存在编号重复的格子。 位棋盘必须按纵线编号,就是这个原因。 ElephantEye 并没有用“ 折叠位棋盘 ”的技术来做马和相( 象)的着法生成器,但它在将军判断的过程中展示了风采。由于将军判断是从帅(将) 出发的,所以将军的马腿CheckLegTable18实际上是象眼的位棋盘。 初始化 KnightPinCheck18256这个庞然大物, 似乎处理起来一点头绪也没有。其实折叠位棋盘使用起来需要“折叠”,生成起来却需要“展开”(即 CheckSum()函数对应的 Duplicate()函数) 。当 8 位的整数展开成位棋盘后,再和某个格子的 CheckLegTable 作“ 与 ”

32、运算,就可以还原为 CheckLeg 了。 2.8 小节 棋盘结构和着法生成是众多象棋程序中区别最大的部分,以上只是介绍了几种比较流行的思路,具体的实现方法需要读者自己体会。ElephantEye 在着法生成方面并不是做得最好的,但它主要体现了“着法预产生数组”的思想,ElephantEye 产生了三种不同功能的数组:(1) 着法产生;(2) 着法合理性判断;(3) 将军判断。数组的生成方法在这里就不介绍了,建议读者先完全了解中的着法生成技术,回过头来再剖析中的代码。 解剖大象的眼睛中国象棋程序设计探索 黄晨 * 2005 年 6 月 ( * 联系地址:复旦大学化学系表面化学实验室,eMail

33、:morning_ ) (三) 搜索与置换表 在阅读本章前,建议读者先阅读象棋百科全书网站中对弈程序基本技术专题的以下几篇译文: (1) 基本搜索方法 简介( 一)(David Eppstein); (2) 基本搜索方法 简介( 二)(David Eppstein); (3) 基本搜索方法 简介( 三)(David Eppstein); (4) 基本搜索方法 最小- 最大搜索(Bruce Moreland) ; (5) 基本搜索方法 Alpha-Beta 搜索(Bruce Moreland); (6) 基本搜索方法迭代加深(Bruce Moreland); (7) 基本搜索方法置换表(Bruc

34、e Moreland); (8) 高级搜索方法 简介( 二)(David Eppstein); (9) 高级搜索方法期望窗口(Bruce Moreland); (10) 高级搜索方法主要变例搜索(Bruce Moreland); (11) 高级搜索方法搜索的不稳定性(Bruce Moreland); (12) 其他策略主要变例的获取(Bruce Moreland); (13) 其他策略胜利局面(David Eppstein) 。 3.1 搜索技术概述 搜索算法是象棋程序的核心算法,在众多搜索算法中,如何选择适合自己的算法,并有效地把它们组合起来,是决定搜索效率的关键所在。要做好这点,首先必须明

35、确搜索的概念,把各种搜索算法作一下分类。 象棋程序的搜索算法都是基于“最小-最大”策略的,因此衍生出以 Alpha-Beta 为基础的完全搜索算法以及带裁剪的搜索算法。尽管 Alpha-Beta 算法也有裁剪的过程,但是这种裁剪被证明是绝对可靠的,没有无负面作用,即通常所说的“截断”(Cut-off),它属于申朗所说的 A 策略。 我们现在所说的“裁剪”(Pruning),特指“向前裁剪”(Forward Pruning),即需要承担风险地对某些线路作的裁剪,也就是申朗所说的 B 策略。尽管它是完全搜索算法的对立面,但为了克服完全搜索中的“水平线效应”(Horizon Effect),它是程序

36、中必不可少的部分。把裁剪反过来,对某些重要线路进行“延伸”(Extension),这种思想和裁剪有异曲同工之妙。 如今, “带置换表的启发式 Alpha-Beta 搜索”成了象棋程序的主流,这种思想强调对着法排序的重要性,排序着法是由“启发”(Heuristic)算法来完成的,它同“置换表”(Transposition Table)一起,使搜索效率有大幅度的提高。 因此,搜索算法大致可以分为以下四类: (1) 完全搜索算法,即 Alpha-Beta 搜索及其变种,诸如期望窗口、PVS 和 MTD(f)等; (2) 裁剪和延伸算法,常用的有空着裁剪、选择性延伸和静态搜索; (3) 启发算法,常用

37、的有内部迭代加深、杀手着法和历史表。 (4) 置换表。 以上算法中,置换表被独立归为一类,因为它不但功能特殊,而且值得研究问题最多。置换表的初衷是利用置换现象来减少搜索,然而在象棋的中局阶段,置换现象并不那么普遍,因此它的主要功效在于迭代加深。另外,置换现象会导致搜索的不稳定性,因此如果要让程序绝对没有错误,可以在使用置换表时避免置换现象(例如让 Zobrist 键值包含最近几步着法的信息)。当然,还有其他千奇百怪的做法,我们可以去尝试,但要彻底研究清楚并非那么容易。 3.2 超出边界的 Alpha-Beta 搜索 置换表的大部分问题出在边界上,直到目前笔者还无法彻底明白该如何设置边界,因此想

38、把这个问题留给读者。首先我们从不带置换表的超出边界(Fail-Soft)的 Alpha-Beta 搜索说起: int AlphaBeta(int n, int Alpha, int Beta) if (GameOver() | n = Beta) return Beta; / if (Value Best) if (Value = Beta) / return Beta; return Value; Best = Value; if (Value Alpha) Alpha = Value; / return Alpha; return Best; 以上代码中,蓝色的被注释掉的部分是不超出边界的

39、 Alpha-Beta 搜索,红色的代码就是超出边界的 Alpha-Beta 搜索了。如此一个小的改动就会导致搜索效率的变化,因为函数的返回值跟原来不一样了。很多人认为这样改动会使搜索效率降低,因为超出边界后窗口宽度边大了。其实这个问题很难定论,我们来看 Alpha 结点,由于 Best 值比 Alpha 小,因此返回后就更容易超过上一层结点的 Beta(注意返回上一层结点时要取负值) 。 使用置换表时,探索置换表的形式(是否超出边界) 应该与 Alpha-Beta 的形式保持一致:int ProbeHash(int n, int Alpha, int Beta) if (Hash.Flag

40、= HashBeta) if (Hash.Value = Beta) / return Beta; return Hash.Value; else if (Hash.Flag = HashAlpha) if (Hash.Value = Beta) return Beta; / if (Hash.Value = Beta) / RecordHash(n, Beta, HashBeta); RecordHash(n, Value, HashBeta); return ; / RecordHash(n, Alpha, HashFlag); RecordHash(n, Best, HashFlag);

41、 return ; 以上代码中,蓝色或红色部分是记录置换表的操作,那么到底是记录边界还是记录超出边界的值呢?它是否要跟后面的返回相一致呢?ElephantEye 就采用了不一致的做法,但是笔者并不知道这样做是对是错。 3.3 胜利局面的特殊处理 胜利局面就是程序能搜索到的有杀局的局面,它具有特殊的分值最大值减去“根结点”到“将死结点” 的步数。而当这个数值记录到置换表的时候,就必须转化为最大值减去“当前结点”到“ 将死结点”的步数。 除此以外杀局还有一个显著的特点对一个局面进行某一深度的搜索后,如果已经证明它是杀局,那么就不必进行更深层次的搜索了。利用这点,可以改进探索置换表的程序,提高置换表

42、的命中率,从而提高搜索效率: int ProbeHash(int n, int Alpha, int Beta) MateNode = 0; if (Hash.Value MateValue) Hash.Value -= Ply; MateNode = 1; else if (Hash.Value n) 这样做似乎在中局阶段并不能起到很好的效果,但是在处理杀局和残局时,搜索的结点数大幅度降低了,ElephantEye 在使用了这种方法以后,杀局和残局的处理能力有了很大的提高。 3.4 MTD 和 PVS 一般的 Alpha-Beta 搜索可以用函数 AlphaBeta(n, Alpha, Be

43、ta)的形式表现,而当窗口宽度为零时,就可以只用两个参数,我们把它用 MTD(n, f)表示,简称 MTD(f)。MTD( f)算法是对 MTD 函数的极端运用,即调用的全部搜索例程都是这种零窗口搜索。MTD 得名于“存储器增强型的测试驱动器”(Memory-enhanced Testing Driver),由于窗口宽度为零,因此使用置换表(它需要消耗大量存储器 )就可以大幅度提高效率。 MTD 函数有以下特点: (1) 由于零窗口中找不到 PV 着法,因此不需要改变 Alpha,这点让它非常适合作并行计算; (2) 由于没有 Alpha 的变化,因此该函数做递归时始终调用 MTD,且窗口位置

44、不会变化; (3) 使用前必须注意参数 f 的含义,到底是上边界还是下边界。 因此,MTD 函数可以写成以下形式: int MTD(int n, int f) / f 是上边界,即窗口是(f - 1, f) Value = MTDProbeHash(n, f); if (Value != UnknownValue) return Value; if (GameOver() | n = f) MTDRecordHashBeta(n, f); return Value; / 如果不是高出边界的,则返回 f MTDRecordHashAlpha(n, f); return f - 1; 那么在哪些情

45、况下需要用到 MTD 函数呢? 首先,如果 Alpha-Beta 搜索时发现 Beta = Alpha + 1,那么 Alpha-Beta 搜索就自动进化为 MTD 函数,这样我们就可以用 MTD 函数来改进 Alpha-Beta 搜索: int AlphaBeta(int n, int Alpha, int Beta) if (Beta = Alpha + 1) return MTD(n, Beta); / 常规的 Alpha-Beta 搜索 其次,可以只用 MTD 函数就获得搜索值,例如下面的“MTD-二分搜索”代码: while (UpperBound LowerBound + 1) f

46、 = (UpperBound + LowerBound) / 2; if (MTD(n, f) = Beta) return Value; / 如果不是高出边界的,则返回 Beta 而 PVS 算法则充分利用了这个思想,对第一个着法作常规窗口搜索,而后面的着法都首先尝试它是否好比第一个着法差,代码可以这样写: int AlphaBeta(int n, int Alpha, int Beta) GenMoves(); MoveSearched = 0; while (MovesLeft() MakeNextMove(); if (MoveSearched) Value = -MTD(n - 1,

47、 -Alpha); if (Value Alpha / 如果不是高出边界的,则返回 Beta if (Value Alpha) Alpha = Value; 看到这里读者可能要问,搜索使用的是完全的 Alpha-Beta 窗口,为什么说空着启发是窗口启发?因为当搜索完空着后,窗口的宽度就有可能改变了,变成(NullMove_Value, Beta)。在象棋的大多数局面中,几乎一半的着法都比“ 什么都不走”要坏( 例如起始局面,帅五进一、象三进一、车一进一、炮二进一、兵五进一都比不走还糟,而中残局里这样的着法更多),因此空着启发在着法排序做得并不好的情况下大出风头。 当然,如果着法排序足够好,那

48、么空着启发的效果就显示不出来了,因为第一个着法通常都比什么都不走要好。因此现在的象棋程序中,空着启发被空着裁剪所替代,而空着裁剪失败后,不会改变窗口的宽度。 4.3 内部迭代加深 迭代加深是时间控制的手段,然而很多介绍迭代加深的文章中都提到,深一层搜索的着法排序可以依据浅一层的结果,这样可提高搜索效率。那么具体该怎么做呢?这就需要通过“内部迭代加深”(Internal Iterative Deepening) 来实现。 如果 Alpha-Beta 搜索是可以返回主要变例的,那么这个程序将非常简单: int AlphaBeta(PvLineStruct if (ThisPvLine.HaveMove() MakeMove(ThisPvLine.FirstMove(); Value = -AlphaBeta(ThisPvLine, -Beta, -Alpha, Depth - 1); UndoMakeMove(); 这里要提几个技巧: (1) 内部迭代加深是很消耗时间的,因此可以作一些改进,例如测试浅两

展开阅读全文
相关资源
猜你喜欢
相关搜索
资源标签

当前位置:首页 > 企业管理 > 管理学资料

本站链接:文库   一言   我酷   合作


客服QQ:2549714901微博号:道客多多官方知乎号:道客多多

经营许可证编号: 粤ICP备2021046453号世界地图

道客多多©版权所有2020-2025营业执照举报