“JavaScript中国象棋程序” 这一系列教程将带你从头使用JavaScript编写一个中国象棋程序。这是教程的第6节。
这一系列共有9个部分:
从这节开始,我们都是在Alpha-Beta搜索的基础上进行优化。本节将克服水平线效应、增加空步裁剪、检查重复局面。
6.1、克服水平线效应
什么是水平线效应?
之前搜索到叶子节点,都是调用评估函数,并返回估值。但有时叶子节点是一个吃子走法,这可能得到一个很好的评分,但如果是一个换子,即下一步对手又吃回来,可能局面是一个平手。在叶子节点,局面可能产生剧烈动荡,除非评估函数能非常精确的反映这一点,那么返回值不能很好的反映局面真实情况。这种现象称为水平效应。
克服水平线效应,一般可采取对叶子节点再向下搜索。但是,向下搜索多少步呢?无论多深总有返回的时候,返回的又是叶子节点。为了避免不必要的更深搜索,在叶子节点以下,只搜索吃子走法,因为吃子走法是导致局面剧烈动荡的主要原因。通常把这种思想称为静态(Quiescence)搜索。棋子是有限的,吃子走法不会无限膨胀。静态搜索与Alpha-Beta搜索很相似,主要有以下区别:
(1)、静态搜索没有深度参数,结束递归调用有两种情况,一是分值大于beta产生剪枝,二是局面不再有吃子走法。
(2)、如果是被将军的局面,生成所有走法。不是被将军的局面,只生成吃子走法。
(3)、在上一节,生成全部走法后,会使用历史表的数据对着法排序,以便提高搜索效率,这叫历史表启发(History Heuristic)。如果只生成吃子走法,使用的是MVV/LVA启发。MVV/LVA的意思是“最有价值的受害者/最没价值的攻击者”(Most Valuable Victim/Least Valuable Attacker)。这个技术假设最好的吃子是吃到最大的子。如果不止一个棋子能吃到最大的子,那么假设用最小的子去吃是最好的。排序之后,会最先搜索最好的吃子走法。
此外,我们还通过“将军延伸”的手段来客服水平线效应。也就是说,在Alpha-Beta搜索的过程中,如果遇到被将军的局面,就多向下搜索一层。
6.2、检查重复局面
之前的程序进行搜索时,重复局面的判断不是必须的,因为任何变化都只搜索固定的深度。但是静态搜索和将军延伸会带来一个问题——遇到“解将还将”的局面,搜索就会无止境地进行下去,直到程序崩溃。有两个办法可以解决这个问题:
(1)、 限制实际搜索深度。
(2)、自动识别重复局面,遇到这样的局面就根据规则返回和棋或杀棋的分数。
前者实现起来非常简单,我们的程序也这样做了,最大搜索深度是64。在这一节,我们重点把力气花在检查重复局面上。检查局面的方法很简单,每走一步就把局面记录下来,比较当前局面与前几个局面是否相同。当重复局面发生时,就要根据双方的将军情况来判定胜负——单方面长将者判负(返回杀棋分数而不必继续搜索了),双长将或双方都存在非将走法则判和(返回和棋分数)。
程序使用repStatus(recur_)函数来检查重复,如果局面存在重复,那么它的返回值将很有意思:
return 1 + (bPerpCheck ? 2 : 0) + (bOppPerpCheck ? 4 : 0);
起初bPerpCheck(本方长将)和bOppPerpCheck(对方长将)都设为TRUE,当一方存在非将走法时就改为FALSE,这样repStatus(recur_)的返回值有有这几种可能:
1、返回0,表示没有重复局面;
2、返回1,表示存在重复局面,但双方都无长将(判和);
3、返回3(=1+2),表示存在重复局面,本方单方面长将(判本方负);
4、返回5(=1+4),表示存在重复局面,对方单方面长将(判对方负);
5、返回7(=1+2+4),表示存在重复局面,双方长将(判和)。
参数recur_是局面重复次数,有两种情况:
1、在搜索算法中调用repStatus,recur_取1,表示只要出现一次局面重复,就返回检查结果。这就防止了无休止地搜索“解将还将”的局面,另外也避免了电脑走出长将的着法。
2、在用户或者电脑走完一步棋后调用repStatus,recur_取3,用于检查是否真的出现了长将。如果出现长将,游戏结束。
6.3、Zobrist校验码
刚才提到了,每走一步就把局面记录下来,还要判断局面是否重复。在程序中局面是以一维数组的形式存在的,存储这些数组再进行比较,显然很麻烦。我们通过对每个局面的计算得到一个校验码,只要校验码是相等的,局面就是一样的。Zobrist在1970年提出了一种快速计算校验码的方法,这就是Zobrist校验码。
简单的说,就是为每一个棋子在棋盘每一个位置产生一个随机数,整个局面的校验码就是棋盘上剩余棋子在棋盘相应位置随机数的异或运算。在程序中,数组PreGen_zobristKeyTable[]存储这些随机数。
异或运算(XOR)的特点:
(1)、满足结合律:Ri XOR (Rj XOR Rk) = (Ri XOR Rj) XOR Rk
(2)、满足交换律:Ri XOR Rj = Rj XOR Ri
(3)、Ri XOR Ri = 0
(4)、如果s = R1 XOR R2 …… XOR Ri,则s也是随机的,并且均匀分布。
其中,R是指某类棋子在某特定位置的随机数。第1、2项性质使得计算校验码不用考虑顺序,第3项性质使得删除棋子和棋子移动变得容易。比如黑车从51位置移动到52位置,只需将校验值K做如下两步处理:
K XOR R(黑车, 51)
K XOR R(黑车, 52)
如果52位置有对方的马,则要进一步运算:
K XOR R(红马, 52)
对于两个局面,如果棋子种类及数量完全一样但走棋方不同,这两个局面是不一样的。程序中也为走棋方设立一个随机数,也就是PreGen_zobristKeyPlayer。程序最初从FEN串始化棋局时,如果FEN串是红方走棋,那么局面校验码不用异或PreGen_zobristKeyPlayer;如果是黑方走棋,局面校验那需要异或PreGen_zobristKeyPlayer。此后只要切换走棋方,都会异或PreGen_zobristKeyPlayer。
我们并没有使用JavaScript中的Math.random()来生成随机数,而是在程序中实现了RC4加密算法,并以空密钥的RC4密码流作为随机数。如果想了解RC4,这里有篇文章还不错。
6.4、空着(Null-Move)裁剪
在前面的搜索过程中,总是本方先走一步棋,再递归调用搜索算法对对手做深度减1的搜索。如果本方的优势足够大,以至于少走一步棋,对方也不会占到任何便宜。不走棋,可以说成是执行一步空招。
开始时,只是假设本方的优势足够大,并没有具体的估值。执行一步空招,然后对对手进行搜索。对对手的搜索只是为了验证自己的优势,并不是普通的进行深度减1的搜索,而且再减去一个深度值NULL_DEPTH(通常取值为2)。还因为只是验证搜索,不需要对(vlAlpha, vlBeta)区间进行搜索,只要返回值大于等于vlBeta就行,所以搜索区间缩小为(vlBeta-1, vlBeta)。
用空着多了一次递归调用,但搜索区间很小,效率很高,在时间上的花费并不大。如果空着并没有发生剪枝,那就要重新进行一次全面的搜索。
以下三种情况不适用空着裁剪:
1、被将军的情况
2、进入残局时(自己一方的子力总价值小于某个阈值)
3、不要连续做两次空着裁剪,那等于双方都什么也没有走,空着没有起到作用
6.5、核心代码说明
本节的代码可以在 Github 下载,也可以直接clone
git clone -b step-6 https://github.com/Royhoo/write-a-chinesechess-program
Position中新增或修改的主要属性和方法:
(1)、zobristKey
zobristKey校验码
(2)、keyList[]
校验码数组。每走一步棋,都会把局面的zobristKey校验码存入该数组。
(3)、chkList[]
每走一步棋,都会把当前局面是否被将军,存入该数组。判断是否出现“长将”时会用到。
(4)、generateMoves(vls)
该方法增加了参数vls。如果vls为null,生成全部走法;否则,只生成吃子走法,并把每步棋的MVV/LVA启发值存入数组vls。
Search中新增或修改的主要属性和方法:
(1)、searchQuiesc(vlAlpha_, vlBeta)
静态搜索
(2)、searchFull(vlAlpha_, vlBeta, depth, noNull)
这是之前的Alpha-Beta搜索。为与静态搜索区分,改名为完全搜索。
参数noNull用于避免连续两次空着搜索。