摘 要:随着人工智能及计算机硬件的发展,计算机象棋程序的下棋水平也不断地得到提高。20世纪60年代初,麦卡锡提出了alpha-beta修剪算法,把为决定下一个走步而需对棋盘状态空间的搜索量从指数级减少为指数的平方根,大大地提高了机器下棋的水平。IBM的超级计算机“Deep Blue”更是一个神话,让棋迷们神往。本文根据国际象棋程序设计的一些成功经验,提出中国象棋程序设计的一些思路和方法。
关 键 词:中国象棋,位棋盘,Zobrist键值,alpha-beta搜索,置换表,局面评价
-
-
- bstract:Along with the development of the Artificial Intelligence and computer hardware, the capability of computer chess program have advanced continually.At the beginning of 60s,20th century, McCaxi brought forword alpha-beta pruning algorism which made the chess program advanced more by reducing the order of magnitude of the number of searching nodes deciding next step,named “State Space” from O(Xn) to O(Xn/2). IBM’s super-computer “Deep Blue” is more like a myth for all computer chess fans. In my article, I will describe some ideas and methods of designing Chinese Chess program along with some successful experiences and cases of the Chess.
-
Keywords: Chinese Chess, bit board, zobrist keys, alpha-beta search, transposition table,
Evaluation
象棋水平的发展是需要靠信息技术来推动的,国际象棋有两个很好的范例,一个是象棋棋谱编辑和对弈程序的公共平台——WinBoard平台,另一个是商业的国际象棋数据库和对弈软件——ChessBase,他们为国际象棋爱好者和研究者提供了极大的便利。国际象棋软件有着成功的商业运作,已发展成一种产业。然而,电脑在中国象棋上的运用还刚刚起步,尽管国内涌现出一大批中国象棋的专业网站和专业软件,但是由于缺乏必要的基础工作,电脑技术在中国象棋上的应用优势还无法体现出来。
在设计中国象棋软件过程中,国际象棋软件有很多值得借鉴的成功经验和优秀的思想。例如B. Moreland,微软(Microsoft)的程序设计师,业余从事国际象棋引擎Ferret的开发,他的一系列关于国际象棋程序设计的文章非常值得其他棋类程序设计人员借鉴。然而,中国象棋与国际象棋存在着很大的差异,因此国际象棋的某些成熟技术,无法直接应用于中国象棋,需要对其加以改进和创新。
本文针对中国象棋程序设计的一系列问题,总结出一些搜索引擎的设计方法,并给出java语言的实现。
中国象棋是由两人下的。一方称为红方(或白方),一方称为黑方。对局时由红方先走,黑方后走,一次一着,双方轮流走棋,直到对局结束为止。棋子的走法及详细规则见《中国象棋竞赛规则》,本章只描述计算机实现象棋对弈程序时所涉及的一些概念及相关的表示方法。
象棋的着法表示,简而言之就是某个棋子从什么位置走到什么位置。通常,表示方法可以分为“纵线方式”和“坐标方式”两种,现在作简要说明:
这是中国象棋常用的表示方法,即棋子从棋盘的哪条线走到哪条线。中国象棋规定,对于红方来说的纵线从右到左依次用“一”到“九”表示,黑方则是“1”到“9”(如图1所示),这种表示方式体现了古代中国象棋研究者的智慧。
这是套用国际象棋中的表示方法,即把每个格子按坐标编号(如图二所示),只要知道起始格子和到达格子,就确定了着法,这种表示方式更方便也更合理,而且还可以移植到其他棋类游戏中。采用这种方法来表示,棋盘的纵线从左到右(红方)依次为a b c d e f g h i,横线从下到上(红方)依次为0 1 2 3 4 5 6 7 8 9(如图2所示)。
|
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
(图1) | (图2) |
为方便表示,中国象棋的棋子名称除了用汉字以外,还可以用字母,字母可从国际象棋中稍加改动得到,而数字是为了方便着法的输入(以便用在数字小键盘上)(见表1):
红方 | 黑方 | 字母 | 英文单词 | 数字 |
帅 | 将 | K | King | 1 |
仕 | 士 | A | Advisor | 2 |
相 | 象 | B | Bishop | 3 |
马 | 马 | N | Knight | 4 |
车 | 车 | R | Rook | 5 |
炮 | 炮 | C | Cannon | 6 |
兵 | 卒 | P | Pawn | 7 |
(表1) |
现以如下局面为例说明各种记谱方法
对于右图的局面,记法如下: 1.炮二平五 炮8平5 2.炮五进四 士4进5 3.马二进三 马8进7 4.炮八平五 马2进3 5.前炮退二 车9平8 | |
坐标格式包括:棋子的名称、起点和终点的位置(起点和终点用‘-’连接),对上面的走法,记录如下(省略了吃子、将军等信息):
1、Ch2-e2(炮二平五) | Ch7-e7(炮8平5) |
2、Ce2-e6(炮五进四) | Ad9-e8(士4进5) |
3、Nh0-g2(马二进三) | Nh9-g7(马8进7) |
4、Cb2-e2(炮八平五) | Nb9-c7(马2进3) |
5、Ce6-e4(前炮退二) | Ri9-h9(车9平8) |
这种格式是中国象棋棋谱的常规记法,在各类出版物中最为普遍。但是这里还是要说明两个重要的细节。
1、仕(士)和相(象)如果在同一纵线上,不用“前”和“后”区别,因为能退的一定在前,能进的一定在后。
2、兵要按情况讨论:
(1) 三个兵在一条纵线上:用“前”、“中”和“后”来区别;
(2) 三个以上兵在一条纵线上:最前面的兵用“一”代替“前”,以后依次是“二”、“三”、“四”和“五”;
(3) 在有两条纵线,每条纵线上都有一个以上的兵:按照“先从右到左,再从前到后”(即先看最左边一列,从前到后依次标记为“一”和“二”,可能还有“三”,再看右边一列)的顺序,把这些兵的位置标依次标记为“一”、“二”、“三”、“四”和“五”,不在这两条纵线上的兵不参与标记。
如右图局面,四个兵分别位于四线和六线,下表列举了几种走法的坐标格式和纵线格式。
| |
另外需要注意的是:
(1) 如果黑方出现数字,不管数字代表纵线标号还是前进或后退的格数,都用阿拉伯数字表示,在计算机中显示全角的数字。但是代表同一纵线上不同兵的“一二三四五”(它们类似于“前中后”的作用)例外,例如例局面红黑互换,那么某步着法就应该写成“一卒平5”。
(2) 在传统的象棋记谱中,如果发生以上这种情况,通常用五个字来表示,例如“前兵四平五”等,在计算机处理过程中就比较麻烦,因为4个汉字(一个汉字占16位)的着法可以储存在一个64位的字当中(在C语言中数据类型为__int64或long long),而增加到5个汉字就比较麻烦了。黑方用全角的数字是同一个道理。
这种格式仅仅是用字母和数字代替汉字,其中“进”、“退”和“平”分别用符号“+”、“-”和“.”表示,“前”、“中”和“后”也分别用符号“+”、“-”和“.”表示,并且写在棋子的后面(例如“前炮退二”写成“C+-2”而不是“+C-2”),多个兵位于一条纵线时,代替“前中后”的“一二三四五”分别用“abcde”表示(这种情况极少发生)。 另外,代表棋子名称的第一个字母,还可以用数字1到7表示,这是为了方便数字小键盘的输入,例如“炮二平五”可以记作“62.5“(6代表炮)选用符号“+”、“-”和“.”也是出于这个考虑。
-
-
- 历史局面的表示及存储
-
中国象棋的一个局面可以用一个“FEN格式串”来表示。FEN格式串是由4段ASCII字符串组成的代码(彼此3个空格隔开),这4段代码的意义依次是:
(1) 棋盘上的棋子,这是FEN格式串的主要部分;
(2) 当前局面轮到哪一方走子;
(3) 最近一次吃子或者进兵后棋局进行的步数(半回合数),用来判断“50回合自然限着”;
(4) 棋局的回合数。
现以最初局面为例详细说明如下:
rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w 0 1
- 第一部分(w前面的字符):表示棋盘布局,小写表示黑方棋子,大写表示红方棋子。例如前九个字母rnbakabnr表示棋盘第一行的棋子分别为黑方的“车马象士将士象马车”,“/”为棋盘行与行之间的分割;数字“9(5,1)”表示该行从该位置起连续9(5,1)个位置无棋子。
- 第二部分(w):表示轮到哪一方走棋,如果是w表示轮到红方(白方)走,是b表示轮到黑方走。
- 第三部分(w后的数字0):表示自然限着。
- 第四部分(w后的数字1):表示当前局面的回合数。
存放棋谱的文件分为两个部分:标签部分和棋谱部分,现分述如下:
有如下标签
1、Event:比赛名;
2、Site:比赛地点;
3、Date:比赛日期,格式统一为"yyyy.mm.dd";
4、Red:红方棋手;
5、Black:黑方棋手;
6、Result:比赛结果,“红先胜”用“1-0”表示,“黑先胜”用“0-1”表示,和棋用“1/2-1/2”
7、FenStr:起始局面。如果空缺,表示起始局面是最初局面。
棋谱记录部分必须在标签部分的后面,棋谱部分当中不能插入标签; 每一回合都由“回合数”、“红方着法”和“黑方着法”三部分组成,回合数后面要跟“.”(句点),三者之间用两个分隔符隔开(回合数后面的句点也不例外),回合之间也用分隔符隔开。
现举一个例子如下:
例1:
[Event "第19届五羊杯全国象棋冠军邀请赛"]
[Date "1998.12.??"]
[Site "广州"]
[Red "徐天红"]
[Black "许银川"]
[Result "1/2-1/2"]
1. 炮二平五 马8进7 2. 马二进三 车9平8
3. 车一平二 马2进3 4. 兵七进一 卒7进1
5. 车二进六 炮8平9 6. 车二平三 炮9退1
7. 马八进九 车8进5 8. 兵五进一 马3退5
9. 炮八进四 炮2平5 10. 马九进七 炮9平7
例2:
[Event "全国象棋冠军邀请赛"]
[Date "1999.12.15"]
[Site "广州"]
[Red "吕钦"]
[Black "许银川"]
[FEN "3k1ab2/4a4/8b/6NPP/9/4n1P2/6p2/4B4/4A4/2BAK4 w - - 0 46"]
[Result "1-0"]
46. 兵一进一 卒7进1
47. 兵一平二 卒7进1
48. 前兵平三 卒7平6
49. 马三退五 马5退3
50. 兵二平三 马3进2
51. 马五退六
<game>
<event>全国象棋冠军邀请赛</event>
<site>广州</site>
<date>1999.12.09</date>
<red>许银川</red>
<black>聂卫平</black>
<fen>rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/9/1C5C1/9/RN2K2NR r - - 0 1</fen>
<result>1-0</result>
<move> 炮八平五 </move>
<move> 炮8平5 </move>
</game>
在中国象棋中,棋盘有90个交叉点。位棋盘其实就是一个长度为90位的变量,每个位对应一个交叉点,用来记录棋盘上的某些布尔值。在Java中,用3 个int类型数据(每个32位,共96位多余的6位不用)表示一个位棋盘。
位棋盘的作用,可从回答下面的问题开始:
“哪些格子上面有棋子?”
“哪些格子上面有白棋(黑棋)棋子?”
“哪些格子上面有车(马,炮等)?”
“哪些格子受到a7格上的棋子的攻击?”(不用管格子上是否有棋子或是什么颜色的棋子。)
“如果有马在a3格上,哪些格子会受到它的攻击?”
……
通过回答上面的问题,可设置如下一些位棋盘
1、记录所有棋子位置的位棋盘AllPieces。AllPieces告诉我们棋盘上哪些格子有棋子,哪些没有。当棋子处于最初位置的时候,“AllPieces”看上去是这个样子的(以下描述中,格子的下标从0开始,坐标的图示见第一章“棋盘的标记”):
(Hi,89,a9)111111111 000000000 101010101 000000000 000000000 101010101 000000000 010000010 000000000 111111111(Low,0,i0)
其最高位对应第89格(a9格,左上角),最低位对应第0格(a8格,右下角)。
这样显示位棋盘可能更形象一点:
黑棋
111111111
000000000
010000010
101010101
000000000
000000000
101010101
000000000
010000010
000000000
111111111
红棋
2、记录所有黑棋棋子初始位置的位棋盘BlackPieces如下(记录红棋棋子初始位置的位棋盘RedPieces与此类似,在此不再列出)
111111111
000000000
010000010
101010101
000000000
000000000
000000000
000000000
000000000
000000000
3、记录各兵种如红棋车的初始位置的位棋盘“redRooks”如下:
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
100000001
4、假如我们创建了一个位棋盘数组knight[90],用“knight[0]”位棋盘记录当马在0格(即i0格)时,棋盘上所有受到它攻击的格子,knight[89]记录当马在第89格(a9格)时,棋盘上所有受到它攻击的格子,那么,“knight[20]”就是这个样子的(为了直观,用“x”标记出马的位置,实际上该位置是“0”):
000000000
000000000
000000000
000000000
000000000
000001010
000010001
000000x00
000010001
000001010
5、用另外一个位棋盘数组HorseLeg[90]记录蹩马腿位置的位棋盘。与Knight[20]对应的HorseLeg[20]如下(在实际系统中,根据马的行走方向单独设置只有一个蹩腿位的位棋盘,knight数组更改为knight[90][8]):
000000000
000000000
000000000
000000000
000000000
000000000
000000100
000001x10
000000100
000000000
6、建立全局数组“BitBoard bitMask[90]” ,用来屏蔽或设置位棋盘中的某位。bitMask[0]是这样的:
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000001
mask[89] 是这样的:
100000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
000000000
1、与(&)
0 1 0 1
1 0 0 1
————
0 0 0 1
2、或(|)
0 1 0 1
1 0 0 1
————
1 1 0 1
3、异或(^)
0 1 0 1
1 0 0 1
————
1 1 0 0
4、取补(~) a = 0001,~a = 1110。
Java中,位棋盘用3个int型的数据表示,最高六位不用。Java中“与、或、非、异或、左位移,右位移(注意,位棋盘的右位移是无符号位移)”分别是“&、|、^、<<、>>>”。代码摘要(详细代码见附件)及相关说明如下:
public class BitBoard{
private int Low,Mid,Hi//用3个int字段表示位棋盘,最高位Hi的高//6位不用
public BitBoard(int Arg1, int Arg2, int Arg3) {//构造函数
Low = Arg1;
Mid = Arg2;
Hi = Arg3;
}
public static BitBoard opAnd(BitBoard arg1,BitBoard arg2) {
//位棋盘的“与”操作,保存结果。
int low=arg1.Low & arg2.Low;
int mid=arg1.Mid & arg2.Mid;
int hi=arg1.Hi & arg2.Hi;
return new BitBoard(low,mid,hi);
}
public static BitBoard opOr(BitBoard arg1,BitBoard arg2)
//位棋盘的“或”操作,保存结果。
public static BitBoard opXor(BitBoard arg1,BitBoard arg2)
//位棋盘的“异或”操作,保存结果。
public static int count(BitBoard arg) //计算位棋盘中非零位的个数
public static BitBoard duplicate(int arg) //复制位棋盘
public static boolean equals(BitBoard arg1,BitBoard arg2)
//位棋盘是否相等(所有90位对应的位相同即//为相等)
public static BitBoard leftShift(BitBoard arg,int num)
//位棋盘arg左位移num位
public static rightShift(BitBoard,int num) //位棋盘右位移num位
public static int LSB(BitBoard Arg) //位棋盘最低非0位的位置(从0开始计数)
public static int MSB(BitBoard Arg) //位棋盘最高非0位的位置(从0开始计数)
public static boolean notZero(BitBoard Arg) //是否非“0”。当90位中有非0位时返回true。
}
某些位棋盘从程序开始运行到结束都不会改变。例如上面所述的那个位棋盘数组“knight[90]”。(他实际上记录了当马在任意格子上时,它下一步可以走的格子。)这个数组将在程序开始执行的时候被初始化并且不再改变。其余的位棋盘将不断变化。例如“AllPieces”位棋盘。当中国象棋棋盘变化时,它也跟着变化。然而,他们的初始化方式相同。对于诸如knight[90]这样不变化的位棋盘的初始化,将在“伪着法生成”章节详述。此处叙述走棋过程中随棋局变化的诸多位棋盘的初始化及相关操作。
首先,初始化“BitBoard bitMask[90]”数组:
BitBoard b = new BitBoard(0,0,1);
for (int c = 0; c < 90; c ++) {
mask[c] = BitBoard.leftShift(b,c);
}
其次,用一个叫 ChessPosition类记录棋盘上某一状态的所有有用信息。它包含了一个整型数组 int piece_in_square[90],还包含了一些位棋盘。
public class ChessPosition {
int piece_in_square[90];
int player; //轮到哪方走棋,0:红方走,1:黑方走
BitBoard allPieces;
BitBoard redKing;//红帅
BitBoard blackKing;//黑将
BitBoard redRooks;//红车
BitBoard blackRooks;//黑车
BitBoard redKnights;//红马
BitBoard blackKnights;//黑马
BitBoard redCannon;//红炮
BitBoard redCannon;//黑炮
BitBoard redBishops;//红相
BitBoard blackBishops;//黑象
BitBoard redAdvisor;//红仕
BitBoard blackAdvisor;//黑士
BitBoard redPawns;//红兵
BitBoard blackPawns;//黑卒
BitBoard redPieces;//所有红棋子
BitBoard blackPieces;//所有黑棋子
};
初始化“piece_in_square[]”数组。
piece_in_square[0] = RED_ROOK;
piece_in_square[1] = RED_KNIGHT;
piece_in_square[2] = RED_BISHOP;
…
piece_in_square[89] = BLACK_ROOK;
现在初始化其他一些位棋盘:
for (c = 0; c < 90; c ++) {
switch (piece_in_square[c]) {
case : RED_ROOK
position.redPieces = BitBoard.opOr(position.redPieces,bitMask[c]);
position.redRooks
BitBoard.opOr(position.redRooks,bitMask[c]);
break;
…
}
}
当棋盘局面变动后,某些位棋盘就需要被更新。例如记录白子所在位置的“WhitePieces”位棋盘。假如我们把h2格的红炮移动到h9格(炮二进七),吃掉黑棋的一个马,需要更新如下位棋盘:
allPieces
redPieces
redCannons
blackpieces
blackKnights
首先,要把redPieces,redCannons位棋盘的“h2”位清零,然后把他们的“h9”位置1。
/* clear a bit with the "XOR" operation */
position.allPieces = BitBoard.opXor(position.allPieces,bitMask[h2];
position.redPieces = BitBoard.opXor(position.redPieces,bitMask[h2]);
position.redCannons = BitBoard.opXor(position.redCannons,bitMask[h2];
/* set a bit with the "OR" operation */
position.redPieces = BitBoard.opOr(position.redPieces,bitMask[h9]);
position.redCannons = BitBoard.opOr(position.redCannons,bitMask[h9]);
现在我们要将blackPieces和blackKnights位棋盘的h9位清除,因为那里的黑马被吃掉了。
/* clear the captured piece */
position.blackPieces = BitBoard.opXor(position.blackPieces,bitMask[h9]);
position.blackKnight = BitBoard.opXor(position.blackPieces,bitMask[h9]
在写中国象棋程序时,需要比较两个局面看它们是否相同。如果比较每个棋子的位置,或许不需要花很多时间,但是实战中每秒种需要做成千上万次比较,因此这样会使比较操作变成瓶颈的。另外,需要比较的局面数量多得惊人,要存储每个棋子的位置,需要占用非常大的空间。
一个解决方案是建立一个标签,通常是64位。由于64位不足以区别每个局面,所以仍然存在冲突的标签,但实战中这种情况非常罕见。
实现Zobrist必须从多维的64位数组开始,每个数组含有一个随机数。在Java中,“rand.nextLong()”函数返回一个64位的随机数值。
这个函数用来填满一个long型(64位)的三维数组:棋子的类型、棋子的颜色和棋子的位置:
long zobrist[pcMAX][coMAX][sqMAX];
程序启动时就把这个数组用随机数填满。要为一个局面产生Zobrist键值,首先把键值设成零,然后找棋盘上的每个子,并且让键值跟“zobrist[pc][co][sq]”做异或(通过“^”运算符)运算。
如果局面由白方走,那么别去动它,如果是黑方走,你还要在键值上异或一个64位的随机常数。
用Zobrist技术产生的键值,表面上跟局面没什么关系。如果一个棋子动过了,就会得到完全不同的键值,所以这两个键值不会挤在一块儿或者冲突。当把它们用作散列表键值的时候会非常有效。
另一个优点在于,键值的产生是可以逐步进行的。例如,红马在e5格,那么键值里一定异或过一个“zobrist[KNIGHT][RED][E5]”。如果再次异或这个值,那么根据异或的工作原理,这个马就从键值里删除了。
这就是说,如果有当前局面的键值,并且需要把红马从e5移到f7,你只要异或一个“红马在e5”的键值,把马从e5格移走,并且异或一个“红马在f7”的键值,把红马放在f7上。比起从头开始一个个棋子去异或,这样做可以得到同样的键值。
如果要改变着子的一方,只要异或一个“改变着子方”的键值就可以了。用这种方法,可以在搜索根结点的时候构造一个Zobrist键值,在搜索时通过走子函数“MakeMove()”来更新键值,一直让它保持和当前局面同步。
Zobrist键值通常用在散列键值当中,而散列键值在象棋程序里有以下几个作用:
1、用Zobrist键值来实现置换表。置换表是一个巨大的散列表,来保存以前搜索过的局面,这样可以节省很多搜索的时间。如果需要对某个局面搜索9层,可以从置换表中查找该局面,如果它已经搜索过9层,那么不必去重复搜索。置换表的另一个并不起眼的作用是,它可以帮助我们改善着法的顺序。
2、可以用Zobrist键值制造一个很小的散列表,来检测当前着法路线中有没有重复局面,以便发现长将或其他导致和局的着法。
3、可以用Zobrist键值创建支持置换的开局库。
本系统使用一个key和一个lock结合来区分每个局面,这样发生冲突(即两个局面对应的key和lock一样)的概率几乎为0。示例代码及相关说明如下
1、填充数组。
上述的三维数组现在改变为二维(将颜色与棋子兵种类型合并)
……
public static long ZobristKeyPlayer;//改变走子方的key
public static long ZobristLockPlayer;//改变走子方的lock
public static long[][] ZobristKeyTable = new long[14][90];
public static long[][] ZobristLockTable = new long[14][90];
……
static{
……
zobristGen();
}
public static void zobristGen() {
int i, j;
Random rand = new Random();
long RandSeed;
RandSeed = 1;
rand.setSeed(RandSeed);
ZobristKeyPlayer = rand.nextLong();
for (i = 0; i < 14; i ++) {
//0:红帅1:红仕2:红相3:红马4:红车5:红炮6:红兵
//7:黑将8:黑士9:黑象10:黑马11:黑车12:黑炮13:黑卒
for (j = 0; j < 90; j ++) {
ZobristKeyTable[i][j] = rand.nextLong();
}
}
ZobristLockPlayer = rand.nextLong();
for (i = 0; i < 14; i ++) {
for (j = 0; j < 90; j ++) {
ZobristLockTable[i][j] = rand.nextLong();
}
}
}
2、移子函数
当移动(添加、删除)一个棋子时,将当前局面的Zobrist键值与键值表中该棋子的键值进行异或操作,同时也与改变走子方的键值进行异或操作。
public class ChessPosition{
long ZobristKey, ZobristLock;
//当前局面的zobrist键值
public ChessPosition{
……
ZobristKey=0;//初始化为0
ZobristLock=0;
……
}
……
public void makeMove(int Square, int Piece, boolean IsAdd) {
……
ZobristKey^=ZobristKeyTable[PieceType][Square];
ZobristLock^=ZobristLockTable[PieceType][Square];
ZobristKey ^= ZobristKeyPlayer;//改变走子方
ZobristLock ^= ZobristLockPlayer;
……
}
}
3、开局库:开局库的实现,将在“搜索方法”中说明。
着法生成在不同的象棋引擎中差异较大。本章使用位棋盘生成着法的基本原理。高级的国际象棋引擎通常具备一次只生成一小部分着法的能力。例如,仅生成象走的着法,马走的着法,“将”的着法,所有的吃子着法等等,这正是位棋盘的强项。那为什么用这种方式生成着法呢?原因是生成着法耗费一定的时间。如果引擎在检查了一部分着法后发现了必须走的棋,那它就无需生成余下的棋步了。因此,可能先生成所有吃子的着法,如果没有满意的棋再生成余下的着法。(用来减少耗时的着法生成策略很多——发挥你的想象力吧)。
大名鼎鼎的免费国际象棋引擎Crafty(其作者是Robert Hyatt博士)使用三个着法生成函数。一个用来生成所有伪合法吃子着法,一个生成所有伪合法不吃子着法,最后一个生成所有摆脱被将军状态的着法。注意前两个函数生成的是伪合法的着法。就是说,这些函数生成的着法并非都是合法的。例如,你要生成所有将军的着法并且发现了一步你想走的棋,但随后发现这步不合法再把它抛弃。这看起来很奇怪,但它确实比那种在所有局面下都严格生成合法着法的策略更快!Hyatt博士曾经这样解释:当国王被将时,你需要生成摆脱被将的着法,这时大部分生成的着法是不合法的,在这种局面中你使用生成所有合法着法的策略会帮你节省时间;但在大多数局面中,生成的着法都是合法的,推迟验证合法性会更有效率。
中国象棋的着法生成与此类似,先生成所有伪合法的着法,存入静态数组中。在对局中可以用“查表”的方式查找生成的伪着法,并对其合法性作出判断。这样可以节省大量的时间。
伪合法着法包含几类:
- 各兵种的不吃子着法
- 各兵种的吃子着法
- “将”和摆脱“将”的着法
其中,马、相(象)、兵、帅(将)、仕(士)的吃子着法与其对应的不吃子着法规则相同。(伪合法着法并不考虑被吃的棋子的颜色——该棋子是对方的棋子还是己方的棋子,也不考虑该子是否能动,例如动了该子,双方的帅将会面。)炮和车的不吃子着法规则相同,但分为纵向横向行走两类。炮的吃子着法分为纵向和横向两类,车的吃子着法也分为纵向和横向两类。马和象的着法要考虑蹩马腿和塞象眼。将军的着法单独作为一类。
本程序使用静态数组存储生成的位合法着法,先对其作一些说明。
1、保存帅(将)、仕(士)、相(相)、马、兵的伪合法静态数组如下:
public static final int[][] KingMoves=new int[90][8];
public static final int[][] AdvisorMoves=new int[90][8];
public static final int[][] BishopMoves=new int[90][8];
public static final int[][] ElephantEyes=new int[90][4];
public static final int[][] KnightMoves=new int[90][12];
public static final int[][] HorseLegs=new int[90][8];
public static final int[][][] PawnMoves=new int[90][2][4];
第一个下标说明棋子所在的格,第二个下标含义不尽相同。帅(将)在某个位置最多有4种走法,例如KingMoves[13][0]=12表示帅在13格(e1格)时可以走到12格(当然,也可以走到14、4、22格,保存到其他几个数组元素中)。第5种(如果前面只有3种着法,则此处是第4种)保存的是非法着法即KingMoves[13][4]=-1,其作用作为查询算法的“哨兵”,提高查询算法的速度。为了速度(以位移运算取代除法运算),第2个坐标值用2的整次方幂。(在后面所讲的开局库和置换表的大小设置是2的整次方幂也是这个道理。)兵的走棋规则需要分颜色,红色的垂直走棋方向和黑色的垂直走棋方向是相反的。兵最多有三种走棋方法。AdvisorMoves[90][8]保存的是士的着法。BishopMoves[90][8]保存的是相(象)的着法,ElephanEyes[90][4]保存的是相(象)着法对应的塞象眼的位置。KnightMoves和HorseLegs是马的着法和蹩马腿的位置。
2、车、炮的伪合法着法静态数组如下:
public static final int[][][] FileNonCapMoves=new int[10][1024][12];
//共十条横线,FileNonCapMoves[y][bitWordY][index]=newY,进
public static final int[][][] FileRookCapMoves=new int[10][1024][4];
public static final int[][][] FileCannonCapMoves=new int[10][1024][4];
public static final int[][][] RankNonCapMoves=new int[9][512][12];
//RankNonCapMoves[x][bitWordX][index]=newX,平
public static final int[][][] RankRookCapMoves=new int[9][512][4];
public static final int[][][] RankCannonCapMoves=new int[9][512][4];
public static final int[][] FileNonCapMax=new int[10][1024];
//FileNonCapMax[y][bitwordY]=MaxY//进退
public static final int[][] FileNonCapMin=new int[10][1024];
//FileNonCapMax[y][bitwordY]=MinY
public static final int[][] FileRookCapMax=new int[10][1024];
public static final int[][] FileRookCapMin=new int[10][1024];
public static final int[][] FileCannonCapMax=new int[10][1024];
public static final int[][] FileCannonCapMin=new int[10][1024];
public static final int[][] RankNonCapMax=new int[9][512];//平
public static final int[][] RankNonCapMin=new int[9][512];
public static final int[][] RankRookCapMax=new int[9][512];
public static final int[][] RankRookCapMin=new int[9][512];
public static final int[][] RankCannonCapMax=new int[9][512];
public static final int[][] RankCannonCapMin=new int[9][512];
车、炮吃子着法与它们的不吃子着法规则不同,因此需要分开保存。再将着法分为水平和垂直两种(也就是进、退与平)。车炮的不吃子着法是相同的,因此,分别保存到FileNonCapMoves[10][1024][12]和RankNonCapMoves[9][512][12]中。棋盘的横线合纵线分别有10条和9条,这就是数组第一个下标10和9的含义,用来指示车炮在哪条横线或纵线上。第二个坐标的含义,以横线走法示例如下:
例如,第二条横线上的棋子如下(有棋子的用1表示,炮或车的位置用x表示,实际上x也是1):
001100x01
那么,
RankNonCapMoves[2][101][0]=-1
RankNonCapMoves[2][101][1]=1
RankNonCapMoves[2][101][2]=2
RankNonCapMoves[2][101][3]=0
上面的下标101就是001100101对应的二进制值,数组元素的值-1、1、2表示可行走格子的增量。
继续……
RankRookCapMoves[2][101][0]=-2
RankRookCapMoves[2][101][1]=3
RankRookCapMoves[2][101][2]=0
以上是车吃子的走法。下面是炮吃子的着法:
RankCannonCapMoves[2][101][0]=-2
RankCannonCapMoves[2][101][1]=4
RankCannonCapMoves[2][101][2]=0;
下面是最大的位移量和最小的位移量,用来生成合法着法时初步判断着法的合法性:
RankNonCapMax[2][101]=2 //不吃子着法中最大的格子增量
RankNonCapMin[2][101]=-1 //不吃子着法中最小的格子增量
RankRookCapMax[2][101]=3//车吃子着法中最大的格子增量
RankRookCapMin[2][101]=-2//车吃子着法中最小的格子增量
RankCannonCapMax[2][101]=4//炮吃子着法中最大的格子增量
RankCannonCapMin[2][101]=-2//炮吃子着法中最小的格子增量
以上是横向(平)着法的静态数组,纵向着法的表示与此类似,在此不再赘述。
3、“照将”着法
public static final BitBoard[] CheckLegs=new BitBoard[18];
//帅将每个位置蹩马腿的位棋盘
public static final BitBoard[][] KnightPinCheck=new BitBoard[18][256];
//除去蹩腿位置有子的将军位置
public static final BitBoard[][] FileRookCheck=new BitBoard[18][1024];
//车纵线照将
public static final BitBoard[][] FileCannonCheck=new BitBoard[18][1024];
//炮纵线照将
public static final BitBoard[][] RankRookCheck=new BitBoard[18][512];
//车横线照将
public static final BitBoard[][] RankCannonCheck=new BitBoard[18][512];
//炮纵线照将
public static final BitBoard[] PawnCheck=new BitBoard[18];
//兵照将
帅、将的位置共有18个,每个位置有一个编号,从0到17,对应数组的第一个下标。KnightPinCheck是马“照将”的着法,CheckLegs是马“照将”蹩马腿的位棋盘。
以下是车炮横线—纵移(横线定位,纵向移动)的算法:
// Generate FilePreMove for Rooks and Cannons
for (i = 0; i < 10; i ++) {//10条横线
for (j = 0; j < 1024; j ++) {//一条纵线位棋盘的二进制值
Index = 0;//着法种类下标
FileNonCapMax[i][j] = i;
for (k = i + 1; k <= 9; k ++) {
if ((j & (1 << k))!=0) {
break;
}
FileNonCapMoves[i][j][Index] = k;
Index ++;
FileNonCapMax[i][j] = k;
}
FileNonCapMin[i][j] = i;
for (k = i - 1; k >= 0; k --) {
if ((j & (1 << k))!=0) {
break;
}
FileNonCapMoves[i][j][Index] = k;
Index ++;
FileNonCapMin[i][j] = k;
}
FileNonCapMoves[i][j][Index] = -1;
Index = 0;
FileRookCapMax[i][j] = i;
for (k = i + 1; k <= 9; k ++) {
if ((j & (1 << k))!=0) {
FileRookCapMoves[i][j][Index] = k;
Index ++;
FileRookCapMax[i][j] = k;
break;
}
}
FileRookCapMin[i][j] = i;
for (k = i - 1; k >= 0; k --) {
if ((j & (1 << k))!=0) {
FileRookCapMoves[i][j][Index] = k;
Index ++;
FileRookCapMin[i][j] = k;
break;
}
}
FileRookCapMoves[i][j][Index] = -1;
Index = 0;
FileCannonCapMax[i][j] = i;
for (k = i + 1; k <= 9; k ++) {
if ((j & (1 << k))!=0) {
k ++;
break;
}
}
for (; k <= 9; k ++) {
if ((j & (1 << k))!=0) {
FileCannonCapMoves[i][j][Index] = k;
Index ++;
FileCannonCapMax[i][j] = k;
break;
}
}
FileCannonCapMin[i][j] = i;
for (k = i - 1; k >= 0; k --) {
if ((j & (1 << k))!=0) {
k --;
break;
}
}
for (; k >= 0; k --) {
if ((j & (1 << k))!=0) {
FileCannonCapMoves[i][j][Index] = k;
Index ++;
FileCannonCapMin[i][j] = k;
break;
}
}
FileCannonCapMoves[i][j][Index] = -1;
}
}
合法着法的生成,是在已生成的伪合法着法的基础之上,增加一些判断合法性的条件。例如,判断炮吃子的伪合法着法是否是合法着法,需要判断被吃子的颜色(在生成伪合法时并不考虑棋子的颜色,前面一提到),同时需要判断己方是否正在被“将”,如果正在被“将”,炮吃子是否能解除被“将”的状态。
现列出生成合法着法的算法并附简要说明如下:
1、伪合法着法的合法性判断:
以炮为例,算法如下:
public boolean LeagalMove(MoveStruct Move){
int Piece, Attack, x, y, BitWord;
Piece = Squares[Move.src];
if ((Piece & (Player!=0 ? 32 : 16))==0) {
return false;//所选的棋子是否是当前Player的
}
Attack = Squares[Move.dst];//被攻击的棋子
if ((Attack & (Player!=0 ? 32 : 16))!=0) {
return false;//有被攻击的棋子,但不是对方的。
}
switch (PieceTypes[Piece] - (Player!=0 ? 7 : 0)) {//判断棋子类型
case 5://炮,吃子时中间要有炮架
x = File[Move.src];//棋子所在的列
y = Rank[Move.src];//棋子所在的行
if (x == File[Move.dst]) {//与目标位置在同一列,也就是进退
BitWord = BitFiles[x];//该列的二进制位棋盘数值,共十位
if (Move.src < Move.dst) {//进
if ((Attack & (Player!=0 ? 16 : 32))!=0) {//吃子
return
Move.dst== PreMoves.FileCannonCapMax[y][BitWord]
+Bottom[x];
} else {//不吃子
return
Move.dst <= PreMoves.FileNonCapMax[y][BitWord]
+ Bottom[x];
}
} else {//Move.Src > Move.Dst,退
if ((Attack & (Player!=0 ? 16 : 32))!=0) {
return
Move.dst == PreMoves.FileCannonCapMin[y][BitWord]
+ Bottom[x];
} else {
return
Move.dst >= PreMoves.FileNonCapMin[y][BitWord]
+ Bottom[x];
}
}
} else {//与目标位置的列位置不同,也就是“平”
BitWord = BitRanks[y];//行位棋盘数值,9位二进制数
if (Move.src < Move.dst) {
if ((Attack & (Player!=0 ? 16 : 32))!=0) {
return
Move.dst == PreMoves.RankCannonCapMax[x][BitWord] + y;
} else {
return
Move.dst <= PreMoves.RankNonCapMax[x][BitWord] + y;
}
} else {
if ((Attack & (Player!=0 ? 16 : 32))!=0) {
return
Move.dst == PreMoves.RankCannonCapMin[x][BitWord] + y;
} else {
return
Move.dst >= PreMoves.RankNonCapMin[x][BitWord] + y;
}
}
}
以上对炮伪合法着法的合法性判断。File[90]和Rank[90]保存的是格子对应的列和行,这比“%、/”(取余和除法)的运算速度快。BitFiles[9]和BitRanks[10]分别保存的是位棋盘中9列(每列共10位)和10行(每行共9位)的二进制值。PreMoves是生成伪合法的类,保存有所有伪合法着法的静态数组。
2、合法着法的生成
代码如下:
public class MoveStruct {
public int src, cap, dst;//位置a0=0,b0=10
public boolean chk;
public MoveStruct(){
src = dst = cap = -1;
chk = false;
}
public MoveStruct(int s,int d){
src = s; dst = d;
chk=false;
}
public MoveStruct(String moveStr){
move(moveStr);chk=false;
}
public void move(String moveStr) {
src = (char) (ChessPosition.Bottom[moveStr.charAt(0) - 'a'] + moveStr.charAt(1) - '0');
dst = (char) (ChessPosition.Bottom[moveStr.charAt(2) - 'a'] + moveStr.charAt(3) - '0');
if (src < 0 || src >= 90 || dst < 0 || dst >= 90) {//invalid move
src = dst = -1;
}
}
}
MoveStruct是着法的数据结构,src和dst分别是移动前后的位置(格子)
public void GenMoves(final ChessPosition Position, final int HistTab[][]) {
GenKingMoves(Position, HistTab);//帅将
GenAdvisorMoves(Position, HistTab);//仕士
GenBishopMoves(Position, HistTab);//相象
GenKnightMoves(Position, HistTab);//马
GenRookMoves(Position, HistTab);//车
GenCannonMoves(Position, HistTab);//炮
GenPawnMoves(Position, HistTab);//兵
}
GenMoves根据当前局面(作为参数的final ChessPosition Position)生成合法着法,并保存在一个数组MoveStruct moveList[MAX_MOVENUM]中,同时也将每个着法的局面评价值存入数组int valueList[MAX_MOVENUM]中,这样以便对着法进行排序,在搜索算法中提高算法的效率。以上算法调用的子函数再此给出为代码,详细代码见附件。
GenKnightMoves(ChessPosition Position,HistTab){
根据Position,得出当前局面player马的伪合法着法;
for(all马的伪合法着法){
if (ChessPosition.LeagalMove(其中一个着法)){
ChessPosition.MakeMove();
If(走子方不是处于被“将”状态)
saveToMoveList;
ChessPosition.UnMove();
}
}
}
在中国象棋里,双方棋手都知道每个棋子在哪里,他们轮流走并且可以走任何合理的着法。下棋的目的就是将死对方,或者避免被将死,或者有时争取和棋是最好的选择。
中国象棋程序通过使用“搜索”函数来寻找着法。搜索函数获得棋局信息,然后寻找对于程序一方来说最好的着法。
一个浅显的搜索函数用“树状搜索”(Tree-Searching)来实现。一个中国象棋棋局通常可以看作一个很大的n叉树(“n叉树”意思是树的每个结点有任意多个分枝通向其他结点),棋盘上目前的局面就是“根局面”(Root Position)或“根结点”(Root Node)。从根局面走一步棋,局面就到达根局面的“分枝”(Branch),这些局面称为“后续局面”(Successor Position)或“后续结点”(Successor Nodes)。每个后续局面后面还有一系列分枝,每个分枝就是这个局面的一个合理的着法。
中国象棋的树非常庞大(通常每个局面有45个分枝),又非常深。
每盘棋局都是一棵巨大的n叉树,如果能通过树状搜索找到棋局中对双方来说都最好的着法就好了。这个浅显的算法在这里称为“最小-最大搜索”(Min-max Search)。
用最小-最大搜索来解诸如井字棋的简单棋局是可行的(即完全了解每一种变化)。井字棋的博弈树既不烦琐也不深,所以整个树可以遍历,棋局的所有变化都可以知道,任何局面都可以保证找到一步最佳着法。
数学上用这种方法处理中国象棋也是可以的,但是目前和不久的将来用计算机去实现,却是不可行的。即便如此,我们仍然可以用基于最小-最大搜索的程序来下象棋。相比最小-最大地搜索整个树,在一个给定的局面下搜索前几步则是可能的。由于叶子结点的局面没能搜索出杀棋或和棋,所以要用一个称为“评价”(Evaluate)的启发函数给这些局面赋值。
评价函数的细节,在后面再说。这里我只说明它是怎样确定的,在以后的章节中会详细展开。评价函数首先应该返回局面的准确值,在没办法得到准确值的情况下,如果可能的话启发值也可以。它可以由两种方法来决定:
1、如果黑方被将死了,那么评价函数返回一个充分大的正数;如果红方被将死了,那么返回一个充分大的负数;如果棋局是和棋,那么返回一个常数,通常是零或接近零。如果不是棋局结束局面,那么它返回一个启发值。确定启发值,子力平衡是首先要考虑的(如果红方盘面上多子的话,这个值就大),而其他位置上的考虑(帅、将的安全性,重要的子力的位置等等)也需要加上。如果红方是赢棋或者很有希望赢,那么启发函数通常会返回正数;如果黑方是赢棋或者很有希望赢,那么返回负数;如果棋局是均势或者是和棋,那么返回在零左右的数值。
2、这个函数的工作原理跟第一个一样,只是如果当前局面要走子的一方优势,那么它返回正数,反之是负数。
-
-
-
- 最小-最大搜索
-
-
最小-最大搜索是一对几乎一样的函数,或者说两个逻辑上重复的函数。纯粹的(不完美的)最小-最大函数,代码如下:
int MinMax(int depth) {
if (SideToMove() == RED) { // 红方是“最大”者
return Max(depth);
} else { // 黑方是“最小”者
return Min(depth);
}
}
int Max(int depth) {
int best = -INFINITY;
if (depth <= 0) {
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = Min(depth - 1);
UnmakeMove();
if (val > best) {
best = val;
}
}
return best;
}
int Min(int depth) {
int best = INFINITY; // 注意这里不同于“最大”算法
if (depth <= 0) {
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = Max(depth - 1);
UnmakeMove();
if (val < best) { // 注意这里不同于“最大”算法
best = val;
}
}
return best;
}
上面的代码可以这样调用:
val = MinMax(5);
这样可以返回当前局面的评价,它是向前看5步的结果。
这里的“评价”函数用的是我上面所说第一种定义,它总是返回对于红方来说的局面。
我简要描述一下这个函数是如何运作的。假设根局面(棋盘上当前局面)是红方走,那么调用的是“Max”函数,它产生红方所有合理着法。在每个后续局面中,调用的是“Min”函数,它对局面作出评价并返回。由于现在是白走,因此白方需要让评价尽可能地大,能得到最大值的那个着法被认为是最好的,因此返回这个着法的评价。
“Min”函数正好相反,当黑方走时调用“Min”函数,而黑方需要尽可能地小,因此选择能得到最小值的那个着法。
这两个函数是互相递归的,即它们互相调用,直到达到所需要的深度为止。当函数到达最底层时,它们就返回“Evaluate”函数的值。
如果在深度为1时调用“MinMax”函数,那么“Evaluate”函数在走完每个合理着法之后就调用,选择一个能达到最佳值的那个着法导致的局面。如果层数大于1,那么另一方有权选择局面,并找一个最好的。
负值最大只是对最小-最大的优化,“评价”函数返回我所说的第二种定义,对于当前结点上要走的一方,占优的情况返回正值,其他结点也是对于要走的一方而言的。这个值返回后要加上负号,因为返回以后就是对另一方而言了。代码如下:
int NegaMax(int depth) {
int best = -INFINITY;
if (depth <= 0) {
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = -NegaMax(depth - 1); // 注意这里有个负号。
UnmakeMove();
if (val > best) {
best = val;
}
}
return best;
}
在这个函数里,当走子一方改变时就要对返回值取负值,以反映当前局面评价的更改。就根结点是红先走的情况,如果没有剩下的层数,那么“评价”返回的值是就红方而言的,如果有剩下的层数,就产生后续局面,函数对这些局面逐一做递归,每个次递归都得到就黑方而言的评价,黑方走得越好值就越大。当评价值返回时,它们被取负数,变成就白方而言的评价。
该函数在遍历时结点的顺序同“最小-最大”搜索的函数是一样的,产生的返回值也一样。它的代码更短,同时减少了移植代码时出错的可能,代码维护起来也比较方便。
Alpha-Beta 同“最小-最大”非常相似,事实上只多了一条额外的语句。“最小-最大”运行时要检查整个博弈树,然后尽可能选择最好的线路。这是非常好理解的,但效率非常低。每次搜索更深一层时,树的大小就呈指数式增长。
通常一个中国象棋局面平均都有45个左右的合理着法(有效分支因子),所以用最小-最大搜索来搜索一层深度,就有45个局面要检查,如果用这个函数来搜索两层,就有452个局面要搜索,搜索局面的数量一指数级增长,非常迅速。要想通过检查搜索树的前面几层,并且在叶子结点上用启发式的评价,那么做尽可能深的搜索是很重要的。最小-最大搜索无法做到很深的搜索,因为有效的分枝因子实在太大了。
Alpha-Beta修剪算法可以有效地减少分支因子,它是建立在这样一个思想之上:如果你已经有一个不太坏的选择了,那么当你要作别的选择并知道它不会更好时,你没有必要确切地知道它有多坏。有了最好的选择,任何不比它更好的选择就是足够坏的,因此你可以撇开它而不需要完全了解它。只要你能证明它不比最好的选择更好,你就可以完全抛弃它。Alpha-Beta对搜索树有两种修剪
1、浅的修剪
假设用最小-最大搜索(前面讲到的)来搜索下面的树:
当搜索到F,发现子结点的评价分别是11、12、7和9,在这层是棋手甲走,我们希望他选择最好的值,即12。所以,F的最小-最大值是12。
现在开始搜索G,并且第一个子结点就返回15。一旦如此,就知道G的值至少是15,可能更高(如果另一个子结点比G更好)。这就意味着我们不指望棋手乙走G这步了,因为就棋手乙看来,F的评价12要比G的15(或更高)好,因此我们知道G不在主要变例上。我们可以裁剪(Prune)结点G下面的其他子结点,而不要对它们作出评价,并且立即从G返回,因为对G作更好的评价只是浪费时间。一般来说,像G一样只要有一个子结点返回比G的兄弟结点更好的值(对于结点G要走棋的一方而言),就可以进行裁剪。
2、深的裁剪
我们来讨论更复杂的可能裁剪的情况。例如在同一棵搜索树中,我们评价的G、H和I都比12好,因此12就是结点B的评价。现在我们来搜索结点C,在下面两层我们找到了评价为10的结点N:
我们能用更为复杂的路线来作裁剪。我们知道N会返回10或更小(轮到棋手乙走棋,需要挑最小的)。我们不知道J能否返回10或更小,也不知道J的哪个子结点会更好。如果从J返回到C的是10或者更小的值,那么我们可以在结点C上作裁剪,因为它比兄弟结点B要好。因此在这种情况下,继续找N的子结点就毫无意义。考虑其他情况,J的其他子结点返回比10更好的值,此时搜索N也是毫无意义的。所以我们只要看到10,就可以放心地从N返回。
Alpha-Beta修剪算法在搜索中传递两个值,第一个值是Alpha,即搜索到的最好值,任何比它更小的值就没用了,因为策略就是知道Alpha的值,任何小于或等于Alpha的值都不会有所提高。
第二个值是Beta,即对于对手来说最坏的值。这是对手所能承受的最坏的结果,因为我们知道在对手看来,他总是会找到一个对策不比Beta更坏的。如果搜索过程中返回Beta或比Beta更好的值,那就够好的了,走棋的一方就没有机会使用这种策略了。
在搜索着法时,每个搜索过的着法都返回跟Alpha和Beta有关的值,它们之间的关系非常重要,或许意味着搜索可以停止并返回。
如果某个着法的结果小于或等于Alpha,那么它就是很差的着法,因此可以抛弃。因为我前面说过,在这个策略中,局面对走棋的一方来说是以Alpha为评价的。
如果某个着法的结果大于或等于Beta,那么整个结点就作废了,因为对手不希望走到这个局面,而它有别的着法可以避免到达这个局面。因此如果我们找到的评价大于或等于Beta,就证明了这个结点是不会发生的,因此剩下的合理着法没有必要再搜索。
如果某个着法的结果大于Alpha但小于Beta,那么这个着法就是走棋一方可以考虑走的,除非以后有所变化。因此Alpha会不断增加以反映新的情况。有时候可能一个合理着法也不超过Alpha,这在实战中是经常发生的,此时这种局面是不予考虑的,因此为了避免这样的局面,我们必须在博弈树的上一个层局面选择另外一个着法。算法代码如下,醒目斜体部分是在最小-最大算法上改过的:
int AlphaBeta(int depth, int alpha, int beta) {
if (depth == 0) {
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = -AlphaBeta(depth - 1, -beta, -alpha);
UnmakeMove();
if (val >= beta) {
return beta;
}
if (val > alpha) {
alpha = val;
}
}
return alpha;
}
把醒目的部分去掉,剩下的就是最小-最大函数。可以看出现在的算法没有太多的改变。
这个函数需要传递的参数有:需要搜索的深度,负无穷大即Alpha,以及正无穷大即Beta:
val = AlphaBeta(5, -INFINITY, INFINITY);
这样就完成了5层的搜索。最终出现的情况是,在搜索树的很多地方,Beta是很容易超过的,因此很多工作都免去了。
这个算法严重依赖于着法的寻找顺序。如果你总是先去搜索最坏的着法,那么Beta截断就不会发生,因此该算法就如同最小-最大一样,效率非常低。该算法最终会找遍整个博弈树,就像最小-最大算法一样。
如果程序总是能挑最好的着法来首先搜索,那么数学上有效分枝因子就接近于实际分枝因子的平方根。这是Alpha-Beta算法可能达到的最好的情况。
由于中国象棋的分枝因子在45左右,这就意味着Alpha-Beta算法能使国际象棋搜索树的分枝因子变成7左右。
这是很大的改进,在搜索结点数一样的情况下,可以使你的搜索深度达到原来的两倍。这就是为什么使用Alpha-Beta搜索时,着法顺序至关重要的原因。
如果准备开始搜索一个中国象棋的局面,那么要搜索多深?事先预测搜索将进行多少时间,这有些困难,因为完成D层搜索所需要的时间取决于很多不确定的因素。在复杂的中局局面里,你可能不会搜索得很深,而在残局中你可能会搜索得非常深。
有一个思想,就是一开始只搜索一层,如果搜索的时间比分配的时间少,那么搜索两层,然后再搜索三层,等等,直到你用完时间为止。
这足以保证很好地运用时间了。如果你可以很快搜索到一个深度,那么你在接下来的时间可以搜索得更深,或许你可以完成。如果局面比你想象的复杂,那么你不必搜索得太深,但是至少有合理的着法可以走了,因为你不太可能连1层搜索也完不成。
这个思想称为“迭代加深”(Iterative Deepening),因为使用迭代搜索,每次都比一次前一次加深1层(多1层没有什么奥妙的,当然你可以试试多两层,但是1层比较好)。
代码如下:
for (depth = 1; ; depth ++) {
val = AlphaBeta(depth, -INFINITY, INFINITY);
if (TimedOut()) {
break;
}
}
这是一个非常有效的搜索方法。如果能增强Alpha-Beta使得它返回一条“主要变例”,那么便可以用主要变例中的着法来做下一次迭代搜索。这样做之所以有好的效果,是因为第一次搜索的线路通常是好的,而Alpha-Beta对着法的顺序特别敏感。如果着法顺序很坏,那么在中国象棋中“分枝因子”将接近45。如果着法很好,那么分枝因子将接近于7。前一次迭代的搜索函数得到的主要变例通常是非常好的着法。
迭代加深的思想给了你一个简单的方法,它可以在时间用完时中断搜索,并且会提高你的搜索效率。
中国象棋的搜索树可以用图来表示,而置换结点可以引向以前搜索过的子树上。置换表可以用来检测这种情况,从而避免重复劳动。置换表还有另一个更为重要的作用:每个散列项里都有局面中最好的着法,在“迭代加深”中,首先搜索好的着法可以大幅度提高搜索效率。因此如果在散列项里找到最好的着法,那么首先搜索这个着法,这样会改进着法顺序,减少分枝因子。
主置换表是一个散列数组,每个散列项如下:
class HashRecord {
private static final int BookUnique = 1;//指示结点类型,就是下面Flag的值。
private static final int BookMulti = 2;
private static final int HashAlpha = 4;
private static final int HashBeta = 8;
private static final int HashPv = 16;
public HashRecord(){
Flag=0;
Depth=0;
Value=0;
ZobristLock = 0;
BestMove = new MoveStruct();
}
long ZobristLock;
int Flag, Depth;
int Value;
MoveStruct BestMove;
};
这个散列数组是以“Zobrist键值”为指标的。当求得局面的键值,除以散列表的项数得到余数,这个散列项就代表该局面。由于很多局面都有可能跟散列表中同一项作用,因此散列项需要包含一个校验值,它可以用来确认该项就是要找的。通常校验值是一个64位的数,也就是上面的ZobristLock。
从搜索中得到结果后,要保存到散列表中。用散列表来避免重复工作,最重要的是记住搜索有多深。如果在一个结点上搜索了3层,后来又打算做10层搜索,就不能认为散列项里的信息是准确的。因此子树的搜索深度也要记录。
在Alpha-Beta搜索中,很少能得到搜索结点的准确值。Alpha和Beta的存在有助于裁剪掉没有用的子树,但是用Alpha-Beta有个小的缺点,就是通常不会知道一个结点到底有多坏或者有多好,只是知道它足够坏或足够好,从而不需要浪费更多的时间。
当然,这就引发了一个问题,散列项里到底要保存什么值,并且当你要获取它时怎样来做。答案是储存一个值,另加一个标志来说明这个值是什么含义。在我上面的例子中,比方说你在评价域中保存了16,并且在标志域保存了“HashPV”,这就意味着该结点的评价是准确值16;如果你在标志域中保存了“HashAlpha”,那么结点的值最多是16;如果保存了“HashBeta”,这个值就至少是16。
散列项的最后一个域BtestMove,保存着上次搜索到这个局面时的最佳着法。有时没有得到最佳着法,比如任何低出边界的情况(返回一个小于或等于Alpha的值),而其他情况必定有最佳着法,比如高出边界的情况(返回一个大于或等于Beta的值)。代码如下:
int AlphaBeta(int depth, int alpha, int beta) {
int hashf = HashAlpha;
if ((val = ProbeHash(depth, alpha, beta)) != valUNKNOWN) {
// valUNKNOWN必须小于-INFINITY或大于INFINITY,否则会跟评价值混淆。
return val;
}
if (depth == 0) {
val = Evaluate();
RecordHash(depth, val, hashfEXACT);
return val;
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = -AlphaBeta(depth - 1, -beta, -alpha);
UnmakeMove();
if (val >= beta) {
RecordHash(depth, beta, HashBETA);
return beta;
}
if (val > alpha) {
hashf = hashfEXACT;
alpha = val;
}
}
RecordHash(depth, alpha, hashf);
return alpha;
}
以下就是两个新的函数的代码:
int ProbeHash(MoveStruct HashMove, int Alpha, int Beta, int Depth) {
boolean MateNode;
HashRecord TempHash;
int tmpInt = (int) (Position.ZobristKey & HashMask);
long tmpLong1 = Position.ZobristLock,tmpLong2;
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
if (TempHash.Flag!=0 && TempHash.ZobristLock == Position.ZobristLock) {
MateNode = false;
if (TempHash.Value > MaxValue - MaxMoveNum / 2) {
TempHash.Value -= Position.MoveNum - StartMove;
MateNode = true;
} else if (TempHash.Value < MaxMoveNum / 2 - MaxValue) {
TempHash.Value += MoveNum - StartMove;
MateNode = true;
}
if (MateNode || TempHash.Depth >= Depth) {
if ((TempHash.Flag & HashBeta)!=0) {
if (TempHash.Value >= Beta) {
HitBeta ++;
return TempHash.Value;
}
} else if ((TempHash.Flag & HashAlpha)!=0) {
if (TempHash.Value <= Alpha) {
HitAlpha ++;
return TempHash.Value;
}
} else if ((TempHash.Flag & HashPv)!=0) {
HitPv ++;
return TempHash.Value;
} else {
return UnknownValue;
}
}
if (TempHash.BestMove.src == -1) {
return UnknownValue;
} else {
HashMove = TempHash.BestMove;
return ObsoleteValue;
}
}
return UnknownValue;
}
void RecordHash(MoveStruct HashMove, int HashFlag, int Value, int Depth) {
HashRecord TempHash;
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
if ((TempHash.Flag!=0) && TempHash.Depth > Depth) {
return;
}
TempHash.ZobristLock = Position.ZobristLock;
TempHash.Flag = HashFlag;
TempHash.Depth = Depth;
TempHash.Value = Value;
if (TempHash.Value > MaxValue - MaxMoveNum / 2) {
TempHash.Value += Position.MoveNum - StartMove;
} else if (TempHash.Value < MaxMoveNum / 2 - MaxValue) {
TempHash.Value -= Position.MoveNum - StartMove;
}
TempHash.BestMove = HashMove;
HashList[(int) (Position.ZobristKey & HashMask)] = TempHash;
}
源码网整理:www.codepub.com
当向散列表中记录新的散列项时,如果该位置已被占用,那么是否覆盖该散列项?如何选择覆盖策略?“始终替换”的策略即简单地覆盖已经存在的值。这或许不是最好的策略,但很容易实现。另一个策略是“同样深度或更深时替换”。除非新局面的深度大于或等于散列表中已经有的值,否则已经存在的结点将被保留。
如果使用“同样深度或更深时替换”的策略,那么散列表可能最终会被过期的但很深的结点所占满。解决方案就是每次你走棋时都清除散列表,或者在散列项中加入“顺序”这个域,从而使这个策略变成变成“同样深度,或更深,或原来是旧的搜索,才替换”。
上面的代码使用了“同样深度或更深时替换”的策略。
中国象棋中会有很多强制的应对。如果有人用马吃掉你的炮,那么你最好吃还他的马。Alpha-Beta搜索不是特别针对这种情况的。你把深度参数传递给函数,当深度到达零就做完了,即使一方被“将死”。
一个应对的方法称为“静态搜索”(Quiescent Search)。当Alpha-Beta用尽深度后,通过调用静态搜索来代替调用“Evaluate()”。这个函数也对局面作评价,只是避免了在明显有对策的情况下看错局势。
简而言之,静态搜索就是应对可能的动态局面的搜索。
典型的静态搜索只搜索吃子着法。这会引发一个问题,因为在中国象棋中吃子通常不是强制的。如果局势很平静,而且面对的吃子只有(车吃兵,导致丢车),不会强迫车去吃兵的,所以静态搜索不应该强迫吃子。
伪代码如下:
int Quies(int alpha, int beta) {
val = Evaluate();
if (val >= beta) {
return beta;
}
if (val > alpha) {
alpha = val;
}
GenerateGoodCaptures();
while (CapturesLeft()) {
MakeNextCapture();
val = -Quies(-beta, -alpha);
UnmakeMove();
if (val >= beta) {
return beta;
}
if (val > alpha) {
alpha = val;
}
}
return alpha;
}
这看上去和“Alpha-Beta”非常相似,但是区别是很明显的。这个函数调用静态评价,如果评价好得足以截断而不需要试图吃子时,就马上截断(返回Beta)。如果评价不足以产生截断,但是比Alpha好,那么就更新Alpha来反映静态评价。
然后尝试吃子着法,如果其中任何一个产生截断,搜索就中止。可能它们没有一个是好的,这也没问题。
这个函数有几个可能的结果:可能评价函数会返回足够高的数值,使得函数通过Beta截断马上返回;也可能某个吃子产生Beta截断;可能静态评价比较坏,而任何吃子着法也不会更好;或者可能任何吃子都不好,但是静态评价只比Alpha高一点点。
注意这里静态搜索中没有传递“深度”这个参数。正因为如此,如果找到好的吃子,或者有一系列连续的强制性吃子的着法,那么搜索可能会非常深。
“好的”吃子的界定也是仁者见仁智者见智的。如果允许任何吃子,并且以任何原始的顺序来搜索,那么你会降低搜索效率,并且导致静态搜索的膨胀,从而大幅度降低搜索深度,并使程序崩溃。
有一些简单的做法可以避免静态搜索的膨胀,最简单的就是MVV/LVA。MVV/LVA 意思是“最有价值的受害者/最没价值的攻击者”(Most Valuable Victim/Least Valuable Attacker)。这是一个应用上非常简单的着法排序技巧,从而最先搜索最好的吃子着法。这个技术假设最好的吃子是吃到最大的子。如果不止一个棋子能吃到最大的子,那么假设用最小的子去吃是最好的。
这就意味着PxR(兵吃车)首先考虑(假设“将军”另外处理)。接下来是AxR或BxR,等等。这个工作总比不做要好,但是很明显有很严重的问题。即使车被保护着,RxR仍旧排在PxN的前面。
MVV/LVA 可以解决静态搜索膨胀的问题,但是它仍然留给你比较庞大的静态搜索树。MVV/LVA 的优势在于它实现起来非常方便,而且可以达到很高的NPS值(每秒搜索的结点数)。它的缺点是搜索效率低——要花大量的时间来评估吃亏的吃子,所以搜索不会很深。
静态搜索不是完美的,静态搜索极有可能会犯错误。这是一个不幸的现实,但是它更多地在搜索看上去非常愚蠢(例如一层的搜索,它根本不会是非常好的)的情况下会犯错误,因此这不是一个严重的问题。
如果可能用更准确的静态搜索而不降低速度,那么我肯定这个程序会比以前更强。但是我们必须明白的是,你在耗费时间的前提下试图让静态搜索更准确,需要找到平衡点。如果为了让静态搜索更聪明,花费了几层完全搜索的时间,那么这就不值得让它更聪明了
空着向前裁剪(Null-Move Forward Pruning),运用可能忽视重要路线的冒险策略,使得中国象棋的分枝因子锐减,它导致搜索深度的显著提高,因为大多数情况下它明显降低了搜索的数量。它的工作原理是裁剪大量无用着法而只保留好的。
试想中国象棋搜索树中的某个局面,程序将以D层搜索这个局面的每个着法。如果其中任何一个着法的分数超过Beta,你就会马上返回Beta。如果任何一个超过Alpha,但是没有超过Beta,你就要记住着法和分值,因为这有可能是主要变例的一部分。如果它们全部小于或等于Alpha,你就要返回Alpha。
空着向前裁剪是在搜索任何着法之前首先要做的事。“如果我在这里什么都不做,对手能做什么?”这就好比像打架时,根据自己的能力给对手一个出击的机会,来增加自己的信心。如果任凭对手攻击也无法击倒自己,那么自己攻击他的时候他会输掉。
在搜索着法以前(事实上在生成着法以前),你做一个减少深度的搜索,让对手先走,如果这个搜索的结果大于或等于Beta,那么你简单地返回Beta而不需要搜索任何着法。
这个思想就给了对手出击的机会,如果你的局面仍然好到超过Beta的程度,你就假设如果你搜索了所有的着法也会超过Beta。这个方法能节省时间的原因是,开始时用了减少深度的搜索。深度减少因子称为R,因此跟你用深度D搜索所有的着法相比,现在你是先以D - R搜索对手的着法。一个比较好R是2,如果你要对所有的着法搜索6层,你最终只对对手所有的着法搜索了4层。这就使得很多时间节约下来了,实践证明可以使搜索增加一到两层。效果真的非常可观!
代码如下,醒目的文字是在Alpha-Beta搜索的基础上增加的部分:
static final R=2;
int AlphaBeta(int depth, int alpha, int beta) {
if (depth == 0) {
return Evaluate();
}
MakeNullMove();
val = -AlphaBeta(depth - 1 - R, -beta, -beta + 1);
UnmakeNullMove();
if (val >= beta) {
return beta;
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
val = -AlphaBeta(depth - 1, -beta, -alpha);
UnmakeMove();
if (val >= beta) { // 把这部分去掉,就用纯粹的最小-最大代替了Alpha-Beta。
return beta;
}
if (val > alpha) {
alpha = val;
}
}
return alpha;
}
在这个代码中我用了一个诀窍。我需要知道空着搜索的值是否是Beta或更好,如果还不如Beta,我不关心它到底比Beta有多糟,因此我用了极小窗口,试图让裁剪做得更快。实际上我用(Beta - 1, Beta)调用了搜索,但是由于递归时必须把Alpha和Beta颠倒并取负数,这就变成源代码中的样子了。
不用说,这个代码在一方被将军时不能发挥作用(因为对手立刻把王吃掉了)。什么地方允许调用空着向前裁剪,必须掌握好分寸,因为如果你允许一次连续地这么做,那么搜索将退化成什么都不做了。一个很简单的尝试,就是当中没有间隔一个实在着法的时候,不要让两个空着搜索连在一起。另一个思想是在一个实在着法之前,允许连续两个空着裁剪。实践证明这两个方法都做得很好。
主要变例搜索(PVS, Principal Variation Search)是提高“Alpha-Beta”算法效率的一种方法。
在Alpha-Beta搜索中,任何结点都属于以下三种类型:
1、Alpha结点。每个搜索都会得到一个小于或等于Alpha的值,这就意味着这里没有一个着法是好的,可能是因为这个局面对于要走的一方太坏了。
2.、Beta结点。至少一个着法会返回大于或等于Beta的值。
3、主要变例(PV)结点。有一个或多个着法会返回大于或等于Alpha的值(即PV着法),但是没有着法会返回大于或等于Beta的值。
有些时候可以很早地判断出要处理的是哪类结点。如果搜索的第一个着法高出边界(返回一个大于或等于Beta的值),那么很明显会得到Beta结点。如果低出边界(返回一个小于或等于Alpha的值),假设着法顺序非常好,那么有可能得到Alpha结点。如果返回值在Alpha和Beta之间,可能得到PV结点。
当然,有两种情况可能会判断错误。当高出边界时,返回Beta,因此不会犯错误,但是如果第一个着法低出边界或者是PV着法时,仍然有可能在下一个着法得到更高的值。
主要变例搜索作了假设,如果你在搜索一个结点时找到一个PV着法,那么你就得到PV结点。也就是说假设你的着法排序已经足够好了,使得你不必在其余的着法中找更好的PV着法或者高出边界的着法(这就会使结点变成Beta结点)。
你找到一个着法其值在Alpha和Beta之间,那么对其余的着法,搜索的目标就是证明他们都是坏的。跟要搜索出更好的着法相比,这种搜索也许要快一些。
如果这个算法发现判断是错的,即其中一个后续着法比第一个PV着法好,那么它会被再一次搜索,这次使用正常的Alpha-Beta搜索方法。这种情况有时会发生,这样就浪费时间了,但是这些时间通常不会超过面所说的“证明是坏着法”所节约下来的时间。
算法如下,是从Alpha-Beta算法改过来的,改过的地方用醒目的字标出:
int AlphaBeta(int depth, int alpha, int beta) {
boolean fFoundPv = FALSE;
if (depth == 0) {
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft()) {
MakeNextMove();
if (fFoundPv) {
val = -AlphaBeta(depth - 1, -alpha - 1, -alpha);
if ((val > alpha) && (val < beta)) { // 检查失败
val = -AlphaBeta(depth - 1, -beta, -alpha);
}
} else
val = -AlphaBeta(depth - 1, -beta, -alpha);
}
UnmakeMove();
if (val >= beta) {
return beta;
}
if (val > alpha) {
alpha = val;
fFoundPv = TRUE;
}
}
return alpha;
}
算法的核心部分就是函数中间醒目的“if”块中的内容。如果没有找到PV结点,“AlphaBeta()”函数就正常调用,如果找到了一个,那么情况就变了。不是用常规的窗口(Alpha, Beta),而是用(Alpha, Alpha + 1)来搜索。这样做的前提是,搜索必须返回小于或等于Alpha的值,如果确实这样,那么把窗口的上面部分去掉就会导致更多的截断。当然,如果前提是错的,返回值是Alpha + 1或更高,那么搜索必须用宽的窗口重做。
评价函数综合了大量跟象棋有关的知识。我们从以下两个基本假设开始:
(1) 我们能把局面的性质量化成一个数字。例如,这个数字可以是对取胜的概率作出的估计;但是大多数程序不给这个数字以如此确定的含义,因此这仅仅是一个数子而已。
(2) 我们衡量的这个性质应该跟对手衡量的性质是一样的(如果我们认为我们处于优势,那么反过来对手认为他处于劣势)。真实情况并非如此,但是这个假设可以让我们的搜索算法正常工作,而且在实战中它跟真实情况非常接近。
评价可以是简单的或复杂的,这取决于在程序中加了多少知识。评价越复杂,包含知识的代码就越多,程序就越慢。通常,程序的质量(它棋下得怎样)可以通过知识和速度的乘积来估计:
因此,如果程序快速而笨拙,可以加一些知识让它慢下来,使它工作得更好。但是同样是增加知识让程序慢下来,对一个比较聪明但很慢的程序来说,可能会更糟;知识对棋力的增长作用会减少的。类似地,增加程序的速度,到一定程度后,速度对棋力的提高作用也会减少,我们需要在速度和知识上寻求平衡,达到图表中间的位置。平衡点也会随着面对的对手而改变;对于击败其他电脑,速度的表现更好,而人类对手则善于寻找程序中对于知识的漏洞,从而轻松击败基于知识的程序。
就评价方法而言主要有两个类型。第一个是“终点评价”(End-Point Evaluation),即用擅长的评价算法,简单地评价每个局面,而不受其他局面的影响。这通常会给出好的结果,但是非常慢。因此一些程序设计师用了下面的诀窍,称为预先计算(Pre-Computation),一阶评价(First-Order Evaluation),或棋子-格子数组(Piece-Square Tables)。
在我们对一个局面搜索最佳着法之前,我们认真检查棋局本身,在数组T[格子,棋子类型]中保存计算值。在搜索过程中评价任何局面,只要简单地把棋子在数组中的值加起来就行了。我们不必每一步都重新计算它们的和,在把棋子从一个格子移到另一个格子时,可以用下面的公式更新评价值:
score += T[新的格子,棋子] - T[旧的格子,棋子]
用棋子-格子数组的程序通常需要结合终点评价。另一个建立棋子-格子数组的策略,就是把数组的建立延迟到后面的搜索中。例如,我们要搜索9步后续着法,那么可以在5步的后续着法下建立数组,为剩下的4步搜索作准备。如果这么做,就应该使一个5步着法产生的数组和其他着法产生的数组保持一致,使得所有的评价值都有可比性。
把评价要素组合起来,通常就和上面所说的一阶评价一样,评价函数是很多项的和,每一项是一个函数,它负责找到局面中的某个特定因素。我认我,象棋程序应该充分尝试各种可能的评价函数:把各种胜利的可能性结合起来,包括很快获胜(考虑进攻手段),很多回合以后能获胜,以及在残局中获胜的可能性,然后把这些可能性以适当的方式结合起来。如果黑方很快获胜的可能性用bs表示而红方用rs,在很多回合以后获胜(即不是很快获胜)的可能性是bm或rm,而在残局中获胜的可能性是be或re,那么整个获胜的可能性就是:
bs + (1 - bs - rs) * bm + (1 - bs - rs - bm - rm) * be,或者
rs + (1 - bs - rs) * rm + (1 - bs - rs - bm - rm) * re
通过和类似上面的公式把若干单独概率结合起来,在评价函数中或许是个很好的估计概率的思路。每种概率是否估计得好,这就需要用程序的估计来和数据库中棋局的真实结果来作比较,这就需要让程序具有基本判断的能力(判断某种攻击是否能起到效果)。
典型的评价函数,要把下列不同类型的知识整理成代码,并组合起来:
(1) 子力(Material):在国际象棋中,它是子力价值的和,在围棋或黑白棋中,它是双方棋盘上棋子的数量。这种评价通常是有效的,但是黑白棋有个有趣的反例:棋局只由最后的子数决定,而在中局里,根据子力来评价却是很差的思路,因为好的局势下子数通常很少。其他像五子棋一样的游戏,子力是没有作用的,因为好坏仅仅取决于棋子在棋盘上的位置,看它是否能发挥作用。
(2) 空间(Space):在某些棋类中,棋盘可以分为一方控制的区域,另一方控制的区域,以及有争议的区域。例如在围棋中,这个思想被充分体现。而包括国际象棋在内的一些棋类也具有这种概念,某一方的区域包括一些格子,这些格子被那一方的棋子所攻击或保护,并且不被对方棋子所攻击或保护。在黑白棋中,如果一块相连的棋子占居一个角,那么这些棋子就不吃不掉了,成为该棋手的领地。空间的评价就是简单地把这些区域加起来,如果有说法表明某个格子比其他格子重要的话,那么就用稍复杂点的办法,增加区域重要性的因素。
(3) 机动(Mobility):每个棋手有多少不同的着法?有一个思想,即你有越多可以选择的着法,越有可能至少有一个着法能取得好的局势。这个思想在黑白棋中非常有效,国际象棋中并不那么有用。(它也曾被使用,但现在国际象棋程序设计师们把它从程序中去掉了,因为它看起来对整个局面的评价质量没什么提高。)
(4) 着法(Tempo):这和机动性有着密切的联系,它指的是在象棋残局中,某方被迫作出使局面变得不利的着法。和机动性不同的是,起决定作用的是着法数的奇偶而不是数量。
这里有一个中国象棋的排局,包括这种奇偶性的主动权问题:
在这个局面中,双方的兵(卒)都不能离开原位,否则对方平帅(将)即可造成铁门栓杀。双方的中炮不能离开中线,而三七路炮也不能离开该线,否则对方就会有闷宫杀。这样的棋型只能有一种取胜方法——用自己的两个炮顶住对方的两个炮,迫使对方让开兵(卒)或三七路炮。
这就衍生出一个数学游戏:有两堆石子,双方轮流从石子中拿去几颗,每次只能从一堆石子中拿走至少一颗石子,先拿完最后一堆者获胜。这个游戏的诀窍是:始终让对方面临两堆石子一样多的窘境。上面这个象棋局面中,两路炮之间的空格就好比两堆石子的数量,现在先走一方占有主动,因为两堆石子数量不一样多,他只要走一步让两堆石子数目一样就可以了。以红方先走为例,红方杀法及其黑方最顽强的抵抗如下:
1. 炮七进四 炮3进1
2. 炮五进一 炮3进1
3. 炮五进一 炮3进1
4. 炮五进一 炮3进1
5. 炮五进一 炮3退1
若黑走炮3平5,则仕五进六、前炮平2、炮七平五做杀无解(若黑走炮2平5解杀则构成长将)。
6. 炮七进一 炮3退1
7. 炮七进一 炮3退1
8. 炮七进一 炮3退1
9. 炮七进一 卒6平7
10. 帅五平四 卒7进1
11. 帅四进一 卒7平8
12. 兵四进一
红方第一步若不走炮七进四,不管进哪个炮,主动权都让给了黑方,走炮七进八可以守和,其他着法都会让黑取胜。可见,主动权这一问题在很多棋类中都是存在的,然而这个知识写入象棋程序中很有难度。
(5) 威胁(Threat)。对手是否会有很恶劣的手段?你有什么很好的着法?例如在国际象棋或围棋中,有什么子可能要被吃掉?在五子棋中,某一方是否有可以连起来的子?在国际象棋或西洋棋中,有没有子将会变后或变王?这个因素必须根据威胁的远近和强度来考虑。
(6) 形状(Shape)。在中国象棋中,空头炮(指被对手摆空头炮)和窝心马是不好的形状,不太深的搜索不会察觉到它们的坏处,但是长远来看是这些形状会存在严重弊端,大多数程序的评价函数会直接对空头炮和窝心马罚分。
本系统主要有以下4个模块,每个模块对应一个程序包:
1、engine:搜索引擎包,系统的核心部分。
2、message:网络对战过程中各种消息及其传递机制的类实现包。
3、main:主界面实现包。
4、pieces:棋子及其相关类实现包。
现就各个包中的要点给与说明。
(1) BitBoard.java:位棋盘的实现,见2.4节。
(2) CCEvalue.java:评价函数知识类。
本程序使用开源软件“梦入神蛋”的快速评价函数。该函数包含子力价值和棋子所在位置的奖励值。子力价值分别是:帅-0, 仕- 40, 象-40, 马-88, 车-200, 炮-96, 兵-9。帅是无价的,用0表示。以马为例,位置的奖励值如下:
0, -3, 5, 4, 2, 2, 5, 4, 2, 2,
-3, 2, 4, 6,10,12,20,10, 8, 2,
2, 4, 6,10,13,11,12,11,15, 2,
0, 5, 7, 7,14,15,19,15, 9, 8,
2,-10, 4,10,15,16,12,11, 6, 2,
0, 5, 7, 7,14,15,19,15, 9, 8,
2, 4, 6,10,13,11,12,11,15, 2,
-3, 2, 4, 6,10,12,20,10, 8, 2,
0, -3, 5, 4, 2, 2, 5, 4, 2, 2
上面的每行代表棋盘的一条纵线。其中,-10所在的位置是“窝心马”,所以要罚10分。
(3) ChessPosition.java:动态局面类
包含对局过程中的动态信息,主要实现的是2.4节的各类位棋盘和移子函数。
(4) MoveStruct.java:着法表示类。
(5) PreMove.java:伪合法着法生成模块,见4.1。
(6) MoveSortStruct.java:合法着法的生成及其排序算法,见4.2。
(7) SearchMove.java:搜索算法,实现如下功能:
- 主置换表及开局库
- Alpha-Beta搜索算法
- 针对吃子着法的静态搜索算法
- 适应性空着裁剪算法:见5.5.2,根据不同情况来调整R值的做法,称为“适应性空着裁剪”(Adaptive Null-Move Pruning),它首先由Ernst Heinz发表在1999年的ICCA杂志上。其内容可以概括为:
a. 深度小于或等于6时,用R = 2的空着裁剪进行搜索
b. 深度大于8时,用R = 3;
c. 深度是6或7时,如果每方棋子都大于或等于3个,则用 R = 3,否则用 R = 2。
在对弈过程中(主要是网络对弈)需要在对弈双方之间传输各类信息,抽象为各类消息。如时间规则的协定、各方的走子信息等。每方都有消息接收、消息处理和消息发送程序(OuterMsgReceiver、LocalMsgReceiver,QzMessageHandler,MessageSender)。己方的MessageSender与对方的OuterMsgReceiver通过接口SrConnection连接。所有接收的消息放入消息队列QzMsgQueue中,等待消息处理进程QzMessageHandler来处理。所有的消息都封装在QzMessage类对象中,消息的类型通过消息的Header类型(以静态常量存放在MsgHeader类中)来区分。
Qizi.java包含棋子的信息,如棋子的(在棋盘上的)位置、图片、名称、类型、状态等。PiecesFactory.java以“工厂”模式提供根据棋子类型或其他信息生成相关Qizi对象的方法。
实现了程序界面与消息传递、搜索引擎的集成。
(1) NewBoard.java:棋盘坐标系统及其界面的实现。
(2) CChessApp.java:主界面类,以内部类实现了QzMessageHandler接口、计时规则TimeRule接口以及事件的处理程序,根据需要生成其他的并发线程如消息接收、处理和发送,机器思考(启动搜索引擎),计时显示等。
(3) SetRuleDialog.java:设置规则的对话框。
(4) SetSysInfoDialog.java:设置系统的一些属性如对战模式、连接端口等。
(5) Translation.java:提供了一系列实用方法主要有:
- FEN串与局面ChessPosition对象之间的转换
- 不同着法表示(见第一章)之间的转换。如“炮二平五”与“Ch2-e2”及“62.5或C2.5“(C和6代表炮)这几种表示法之间的转换
- 棋谱文件的读入和存储。
- 开局库的生成:将近年实战的棋谱文件(可能有几种格式)整理生成开局库。
package engine;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Calendar;
import java.util.Random;
public class SearchMove {
private static final int MaxBookMove = 40;//使用开局库的最大步数
private static final int MaxKiller = 4;//搜索杀着的最大步数
private static final int BookUnique = 1;//指示结点类型,下同
private static final int BookMulti = 2;
private static final int HashAlpha = 4;
private static final int HashBeta = 8;
private static final int HashPv = 16;
private static final int ObsoleteValue = -CCEvalue.MaxValue - 1;
private static final int UnknownValue = -CCEvalue.MaxValue - 2;
private static final int BookUniqueValue = CCEvalue.MaxValue + 1;
private static final int BookMultiValue = CCEvalue.MaxValue + 2;
public static final int CLOCK_S = 1000;//1秒=1000毫秒
public static final int CLOCK_M = 1000*60;//1分=60秒
private static final Random rand = new Random();
private int Depth;
private long ProperTimer, LimitTimer;
class BookRecord {
int MoveNum;
MoveStruct[] MoveList;//[MaxBookMove];
public BookRecord(){
MoveList = new MoveStruct[MaxBookMove];
MoveNum=0;
}
};
class KillerStruct {
int MoveNum;
MoveStruct[] MoveList;//[MaxKiller];
public KillerStruct(){
MoveList = new MoveStruct[MaxKiller];
for (int i=0;i<MaxKiller;i++)
MoveList[i]= new MoveStruct();
MoveNum=0;
}
};
class HashRecord {
public HashRecord(){
Flag=0; Depth=0; Value=0;
ZobristLock = 0;
BestMove = new MoveStruct();
}
long ZobristLock;
int Flag, Depth, Value;;
MoveStruct BestMove;
};
// Global variables in search procedures, including:
// 1. Search tree and history table
ChessPosition Position;
int HistTab[][];
// 2. Search options set by user
int SelectMask,Style;//下棋风格 default = EngineOption.Normal;
boolean WideQuiesc, Futility, NullMove;
//SelectMask:随机性 , WideQuiesc(保守true if Style == EngineOption.solid)
//Futility(true if Style == EngineOption.risky冒进)
//NullMove 是否空着剪裁
boolean Ponder;
// 3. Timer, controll and miscellaneous options set by engine
long StartTimer, MinTimer, MaxTimer;
int StartMove;
boolean Stop;
BufferedWriter OutFile;//调试信息输出
// 4. Main Search Nodes, Quiescence Search Nodes and Hash Nodes
int Nodes, NullNodes, HashNodes, KillerNodes, BetaNodes, PvNodes, AlphaNodes,
MateNodes, LeafNodes;
int QuiescNullNodes, QuiescBetaNodes, QuiescPvNodes, QuiescAlphaNodes,
QuiescMateNodes;
int HitBeta, HitPv, HitAlpha;
// 5. Search results
int LastScore, PvLineNum;
MoveStruct PvLine[]=new MoveStruct[ChessPosition.MaxMoveNum];
// 6. Hash and Book Structure
int HashMask, MaxBookPos, BookPosNum;
HashRecord[] HashList;
BookRecord[] BookList;
long BookRand;
public SearchMove(ChessPosition chessP){
this();
Position = chessP;
}
public SearchMove(){
int i;
//Position = new ChessPosition();
HistTab=new int[90][90];
Nodes=NullNodes=HashNodes=KillerNodes=BetaNodes=PvNodes=AlphaNodes=MateNodes=LeafNodes=0;
SelectMask=0;//1<<10-1;//随机性
Style=EngineOption.Normal;
WideQuiesc=Style==EngineOption.Solid;
Futility=Style==EngineOption.Risky;
NullMove=true;
try {
Calendar c = Calendar.getInstance();
String s = "./data/tmp"+c.get(Calendar.MINUTE)+"_"+
c.get(Calendar.SECOND)+".log";
OutFile= new BufferedWriter(new FileWriter(s));
} catch (IOException e) {
System.out.println("cannot create log file!"+e.getMessage());
}
// 5. Search results
LastScore=0; PvLineNum=0;
MoveStruct PvLine[]=new MoveStruct[ChessPosition.MaxMoveNum];
for (i=0;i < ChessPosition.MaxMoveNum;i++){
PvLine[i]=new MoveStruct();
}
BookRand=1;//rand seed????????????
NewHash(17,14);
Depth = 8; ProperTimer = CLOCK_M * 1 ; LimitTimer = CLOCK_M *20;
}
//Begin History and Hash Table Procedures
public void NewHash(int HashScale, int BookScale) {
HistTab = new int[90][90];
HashMask = (1 << HashScale) - 1;
MaxBookPos = 1 << BookScale;
HashList = new HashRecord[HashMask+1];
for (int i=0; i< HashMask+1; i++){
HashList[i]=new HashRecord();
}
BookList = new BookRecord[MaxBookPos];
ClearHistTab();
ClearHash();
//BookRand = rand.nextLong();//(unsigned long) time(NULL);
}
public void DelHash() {
HistTab=null;
HashList=null;
BookList=null;
}
public void ClearHistTab() {
int i, j;
for (i = 0; i < 90; i ++) {
for (j = 0; j < 90; j ++) {
HistTab[i][j] = 0;
}
}
}
public void ClearHash() {
int i;
for (i = 0; i <= HashMask; i ++) {
HashList[i].Flag = 0;
}
}
private int ProbeHash(MoveStruct HashMove, int Alpha, int Beta, int Depth) {
boolean MateNode;
HashRecord TempHash;
int tmpInt = (int) (Position.ZobristKey & HashMask);
long tmpLong1 = Position.ZobristLock,tmpLong2;
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
tmpLong2 = TempHash.ZobristLock;
if (TempHash.Flag!=0 && TempHash.ZobristLock == Position.ZobristLock) {
MateNode = false;
if (TempHash.Value > CCEvalue.MaxValue - ChessPosition.MaxMoveNum / 2) {
TempHash.Value -= Position.MoveNum - StartMove;
MateNode = true;
} else if (TempHash.Value < ChessPosition.MaxMoveNum / 2 –
CCEvalue.MaxValue) {
TempHash.Value += Position.MoveNum - StartMove;
MateNode = true;
}
if (MateNode || TempHash.Depth >= Depth) {
if ((TempHash.Flag & HashBeta)!=0) {
if (TempHash.Value >= Beta) {
HitBeta ++;
return TempHash.Value;
}
} else if ((TempHash.Flag & HashAlpha)!=0) {
if (TempHash.Value <= Alpha) {
HitAlpha ++;
return TempHash.Value;
}
} else if ((TempHash.Flag & HashPv)!=0) {
HitPv ++;
return TempHash.Value;
} else {
return UnknownValue;
}
}
if (TempHash.BestMove.src == -1) {
return UnknownValue;
} else {
HashMove = TempHash.BestMove;
return ObsoleteValue;
}
}
return UnknownValue;
}
private void RecordHash(MoveStruct HashMove, int HashFlag, int Value, int Depth) {
HashRecord TempHash;
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
if ((TempHash.Flag!=0) && TempHash.Depth > Depth) {
return;
}
TempHash.ZobristLock = Position.ZobristLock;
TempHash.Flag = HashFlag;
TempHash.Depth = Depth;
TempHash.Value = Value;
if (TempHash.Value > CCEvalue.MaxValue - ChessPosition.MaxMoveNum / 2) {
TempHash.Value += Position.MoveNum - StartMove;
} else if (TempHash.Value < ChessPosition.MaxMoveNum / 2 - CCEvalue.MaxValue) {
TempHash.Value -= Position.MoveNum - StartMove;
}
TempHash.BestMove = HashMove;
HashList[(int) (Position.ZobristKey & HashMask)] = TempHash;
}
private void GetPvLine() {
HashRecord TempHash;
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
if ((TempHash.Flag!=0) && TempHash.BestMove.src != -1 && TempHash.ZobristLock
== Position.ZobristLock) {
PvLine[PvLineNum] = TempHash.BestMove;
Position.MovePiece(TempHash.BestMove);
PvLineNum ++;
if (Position.IsLoop(1)==0) {//???????
GetPvLine();
}
Position.UndoMove();
}
}
//record example:
// i0h0 4 rnbakabr1/9/4c1c1n/p1p1N3p/9/6p2/P1P1P3P/2N1C2C1/9/R1BAKAB1R w - - 0 7
//i0h0:Move , 4: evalue, other: FEN String
public void LoadBook(final String BookFile) throws IOException{//开局库
int BookMoveNum, Value ,i;
BufferedReader inFile;
String LineStr;
// LineStr;
int index=0;
MoveStruct BookMove=new MoveStruct();//note:wrong
HashRecord TempHash;
ChessPosition BookPos=new ChessPosition();//note:wrong
inFile = new BufferedReader(new FileReader(BookFile));
if (inFile == null) return;
BookPosNum = 0;
int recordedToHash = 0;//for test
while ((LineStr=inFile.readLine())!=null) {
BookMove = new MoveStruct();
BookMove.move(LineStr);
index=0;
if (BookMove.src != -1) {
index += 5;
while(LineStr.charAt(index)==' '){
index++;
}
//tmpPos.LoadFen(LineStr.substring(index+1));
String tmpStr = LineStr.substring(index);
BookPos.LoadFen(LineStr.substring(index));
long tmpZob = BookPos.ZobristKey;
int tmp = BookPos.Squares[BookMove.src];//for test
if (tmp==0) //for test
System.out.print("invalid");//for test
if (BookPos.Squares[BookMove.src]!=0) {
TempHash = HashList[(int) (BookPos.ZobristKey & HashMask)];
if (TempHash.Flag!=0) {//占用
if (TempHash.ZobristLock == BookPos.ZobristLock){//局面相同
if ((TempHash.Flag & BookMulti)!=0) {//多个相同走法
BookMoveNum =
BookList[TempHash.Value].MoveNum;
if (BookMoveNum < MaxBookMove) {
BookList[TempHash.Value].MoveList[BookMoveNum]= BookMove;
BookList[TempHash.Value].MoveNum ++;
recordedToHash++;//for test
}
}else{
if(BookPosNum < MaxBookPos) {
TempHash.Flag = BookMulti;
BookList[BookPosNum] = new BookRecord();
BookList[BookPosNum].MoveNum = 2;
BookList[BookPosNum].MoveList[0] = TempHash.BestMove;
BookList[BookPosNum].MoveList[1] = BookMove;
TempHash.Value = BookPosNum;
BookPosNum ++;
HashList[(int) (BookPos.ZobristKey & HashMask)] = TempHash;
recordedToHash++;//for test
}
}
}
} else {
TempHash.ZobristLock = BookPos.ZobristLock;
TempHash.Flag = BookUnique;
TempHash.Depth = 0;
TempHash.Value = 0;
TempHash.BestMove = BookMove;
HashList[(int) (BookPos.ZobristKey & HashMask)] = TempHash;
recordedToHash++;
}
}
}
}
inFile.close();
}
// End History and Hash Tables Procedures
// Begin Search Procedures
// Search Procedures
private int RAdapt(int Depth) {
//根据不同情况来调整R值的做法,称为“适应性空着裁剪”(Adaptive Null-Move Pruning),
//它首先由Ernst Heinz发表在1999年的ICCA杂志上。其内容可以概括为
//a. 深度小于或等于6时,用R = 2的空着裁剪进行搜索
//b. 深度大于8时,用R = 3;
//c. 深度是6或7时,如果每方棋子都大于或等于3个,则用 R = 3,否则用 R = 2。
if (Depth <= 6) {
return 2;
} else if (Depth <= 8) {
return Position.Evalue[0] < CCEvalue.EndgameMargin || Position.Evalue[1] < CCEvalue.EndgameMargin ? 2 : 3;
} else {
return 3;
}
}
// Quiescence Search (with Fail-Soft Alpha-Beta and Check Extension)
private int Quiesc(int Alpha, int Beta) {//只对吃子和将军
int i, BestValue, ThisAlpha, ThisValue;
boolean InCheck, Movable;
MoveStruct ThisMove;
MoveSortStruct MoveSort=new MoveSortStruct();
// A Quiescence Alpha-Beta Search always does the following procedures:
// 1. Return if a Loop position is detected
if (Position.MoveNum > StartMove) {
ThisValue = Position.IsLoop(1);//note:wrong
if (ThisValue!=0) {
return Position.LoopValue(ThisValue, Position.MoveNum - StartMove);
}
}
// 2. Initialize
InCheck = Position.LastMove().chk;
Movable = false;
BestValue = -CCEvalue.MaxValue;
ThisAlpha = Alpha;
// 3. For non-check position, try Null-Move before generate moves
if (!InCheck) {
Movable = true;
ThisValue = Position.Evaluation() + (SelectMask!=0 ? (rand.nextInt() & SelectMask) - (rand.nextInt() & SelectMask) : 0);
if (ThisValue > BestValue) {
if (ThisValue >= Beta) {
QuiescNullNodes ++;
return ThisValue;
}
BestValue = ThisValue;
if (ThisValue > ThisAlpha) {
ThisAlpha = ThisValue;
}
}
}
// 4. Generate and sort all moves for check position, or capture moves for non-check position
MoveSort.GenMoves(Position, InCheck ? HistTab : null);
for (i = 0; i < MoveSort.MoveNum; i ++) {
MoveSort.BubbleSortMax(i);
ThisMove = MoveSort.MoveList[i];
if (InCheck || Position.NarrowCap(ThisMove, WideQuiesc)) {
if (Position.MovePiece(ThisMove)) {
Movable = true;
// 5. Call Quiescence Alpha-Beta Search for every leagal moves
ThisValue = -Quiesc(-Beta, -ThisAlpha);
//for debug
String tmpStr="";
for (int k=0;k<Position.MoveNum;k++){
tmpStr = tmpStr + Position.MoveList[k]+",";
}
tmpStr = tmpStr+"Value:"+ThisValue+"\n";
Position.UndoMove();
// 6. Select the best move for Fail-Soft Alpha-Beta
if (ThisValue > BestValue) {
if (ThisValue >= Beta) {
QuiescBetaNodes ++;
return ThisValue;
}
BestValue = ThisValue;
if (ThisValue > ThisAlpha) {
ThisAlpha = ThisValue;
}
}
}
}
}
// 7. Return a loose value if no leagal moves
if (!Movable) {
QuiescMateNodes ++;
return Position.MoveNum - StartMove - CCEvalue.MaxValue;
}
if (ThisAlpha > Alpha) {
QuiescPvNodes ++;
} else {
QuiescAlphaNodes ++;
}
return BestValue;
}
// Search Routine, with the following algorithm:
// 1. Hash Table;
// 2. Fail-Soft Alpha-Beta;
// 3. Adaptive Null-Move Pruning;
// 4. Selected / Extended Futility Pruning and Limited Razoring;
// 5. Iterative Deepening Heuristics via Hash Table;
// 6. Killer Table Heuristics;
// 7. Check Extension;
// 8. Principal Variation Search;
// 9. History Table Heuristics.
private int Search(KillerStruct KillerTab, int Alpha, int Beta, int Depth) {
int i, j, ThisDepth, FutPrune, HashFlag;
boolean InCheck, Movable, Searched;
int HashValue, BestValue, ThisAlpha, ThisValue, FutValue=0;
MoveStruct ThisMove=new MoveStruct();
MoveStruct BestMove=new MoveStruct();
MoveSortStruct MoveSort=new MoveSortStruct();
KillerStruct SubKillerTab=new KillerStruct();
// A Normal Alpha-Beta Search always does the following procedures:
// 1. Return if a Loop position is detected
if (Position.MoveNum > StartMove) {
ThisValue = Position.IsLoop(1);//
if (ThisValue!=0) {
return Position.LoopValue(ThisValue, Position.MoveNum - StartMove);
}
}
// 2. Test if the search depth should be extended
InCheck = Position.LastMove().chk;
ThisDepth = Depth;
if (InCheck) {
ThisDepth ++;
}
// 3. Return if hit the Hash Table
HashValue = ProbeHash(ThisMove, Alpha, Beta, ThisDepth);
if (HashValue >= -CCEvalue.MaxValue && HashValue <= CCEvalue.MaxValue) {
return HashValue;
}
// 4. Return if interrupted or timeout
if (Interrupt()) {
return 0;
};
// 5. Initialize for Normal Search
if (ThisDepth > 0) {
Movable = false;
Searched = false;
BestValue = -CCEvalue.MaxValue;
ThisAlpha = Alpha;
HashFlag = HashAlpha;
SubKillerTab.MoveNum = 0;
// 6. Decide about the following Razorings / Prunings:
FutPrune=0;
if (Futility) {//无用的
// a. Limited Razoring at Pre-Pre-Frontier Nodes
if (ThisDepth == 3 && !InCheck && Position.Evaluation() +
CCEvalue.RazorMargin <= Alpha && Position.Evalue[1 - Position.Player] > CCEvalue.EndgameMargin) {
ThisDepth = 2;
}
// b. Extended Futility Pruning at Pre-Frontier Nodes
// c. Selective Futility Pruning at Frontier Nodes
if (ThisDepth < 3) {
FutValue = Position.Evaluation() + (ThisDepth == 2 ? CCEvalue.ExtFutMargin : CCEvalue.SelFutMargin);
if (!InCheck && FutValue <= Alpha) {
FutPrune = ThisDepth;
BestValue = FutValue;
}
}
}
// 7. Try to cut-off by Adaptive Null-Move Pruning
if (NullMove && FutPrune==0 && !InCheck && Position.LastMove().src != -1 && Position.Evalue[Position.Player] > CCEvalue.EndgameMargin) {
Position.NullMove();
ThisValue = -Search(SubKillerTab, -Beta, 1 - Beta, ThisDepth - 1 - RAdapt(ThisDepth));
Position.UndoNull();
if (ThisValue >= Beta) {
NullNodes ++;
return Beta;
}
}
// 8. Try to cut-off by the moves stored in Hash Table
if (HashValue == ObsoleteValue) {
//System.out.println(ThisMove.Coord());
if (Position.MovePiece(ThisMove)) {
Movable = true;
if (FutPrune!=0 && -Position.Evaluation() + (FutPrune == 2 ? CCEvalue.ExtFutMargin : CCEvalue.SelFutMargin) <= Alpha && Position.LastMove().chk) {
Position.UndoMove();
} else {
ThisValue = -Search(SubKillerTab, -Beta, -ThisAlpha, ThisDepth - 1);
Searched = true;
Position.UndoMove();
if (Stop) {
return 0;
}
if (ThisValue > BestValue) {
if (ThisValue >= Beta) {
HistTab[ThisMove.src][ThisMove.dst] += 1 << (ThisDepth - 1);
RecordHash(ThisMove, HashBeta, Beta, ThisDepth);
HashNodes ++;
return ThisValue;
}
BestValue = ThisValue;
BestMove = ThisMove;
if (ThisValue > ThisAlpha) {
ThisAlpha = ThisValue;
HashFlag = HashPv;
if (Position.MoveNum == StartMove) {
RecordHash(BestMove, HashFlag, ThisAlpha, ThisDepth);
PopInfo(ThisAlpha, Depth);
}
}
}
}
}
}
// 9. Try to cut-off by the moves stored in Killer Table
// The procedures are similar to (12) and (13) in searching generated moves
for (i = 0; i < KillerTab.MoveNum; i ++) {
ThisMove = KillerTab.MoveList[i];
if (Position.LeagalMove(ThisMove)) {
if (Position.MovePiece(ThisMove)) {
Movable = true;
if (FutPrune!=0 && -Position.Evaluation() + (FutPrune == 2 ? CCEvalue.ExtFutMargin : CCEvalue.SelFutMargin) <= Alpha && Position.LastMove().chk) {
Position.UndoMove();
} else {
if (Searched) {
ThisValue = -Search(SubKillerTab, -ThisAlpha - 1, -ThisAlpha, ThisDepth - 1);
if (ThisValue > ThisAlpha && ThisValue < Beta) {
ThisValue = -Search(SubKillerTab, -Beta, -ThisAlpha, ThisDepth - 1);
}
} else {
ThisValue = -Search(SubKillerTab, -Beta, -ThisAlpha, ThisDepth - 1);
Searched = true;
}
Position.UndoMove();
if (Stop) {
return 0;
}
if (ThisValue > BestValue) {
if (ThisValue >= Beta) {
KillerNodes ++;
HistTab[ThisMove.src][ThisMove.dst] += 1 << (ThisDepth - 1);
RecordHash(ThisMove, HashBeta, Beta, ThisDepth);
return ThisValue;
}
BestValue = ThisValue;
BestMove = ThisMove;
if (ThisValue > ThisAlpha) {
ThisAlpha = ThisValue;
HashFlag = HashPv;
if (Position.MoveNum == StartMove) {
RecordHash(BestMove, HashFlag, ThisAlpha, ThisDepth);
PopInfo(ThisAlpha, Depth);
}
}
}
}
}
}
}
// 10. Generate and sort all moves
MoveSort.GenMoves(Position, HistTab);
Nodes+=MoveSort.MoveNum;
for (i = 0; i < MoveSort.MoveNum; i ++) {
MoveSort.BubbleSortMax(i);
ThisMove = MoveSort.MoveList[i];
if (Position.MovePiece(ThisMove)) {
Movable = true;
// 11. Call Alpha-Beta Search (with Principal Variation Search) of every leagal moves
if (FutPrune!=0 && -Position.Evaluation() + (FutPrune == 2 ? CCEvalue.ExtFutMargin : CCEvalue.SelFutMargin) <= Alpha && Position.LastMove().chk) {
Position.UndoMove();
} else {
if (Searched) {
ThisValue = -Search(SubKillerTab, -ThisAlpha - 1, -ThisAlpha, ThisDepth - 1);
if (ThisValue > ThisAlpha && ThisValue < Beta) {
ThisValue = -Search(SubKillerTab, -Beta, -ThisAlpha, ThisDepth - 1);
}
} else {
ThisValue = -Search(SubKillerTab, -Beta, -ThisAlpha, ThisDepth - 1);
Searched = true;
}
Position.UndoMove();
if (Stop) {
return 0;
}
// 12. Select the best move for Fail-Soft Alpha-Beta
if (ThisValue > BestValue) {
if (ThisValue >= Beta) {
BetaNodes ++;
HistTab[ThisMove.src][ThisMove.dst] += 1 << (ThisDepth - 1);
RecordHash(ThisMove, HashBeta, Beta, ThisDepth);
if (KillerTab.MoveNum < MaxKiller) {
KillerTab.MoveList[KillerTab.MoveNum] = ThisMove;
KillerTab.MoveNum ++;
}
return ThisValue;
}
BestValue = ThisValue;
BestMove = ThisMove;
if (ThisValue > ThisAlpha) {
ThisAlpha = ThisValue;
HashFlag = HashPv;
if (Position.MoveNum == StartMove) {
RecordHash(BestMove, HashFlag, ThisAlpha, ThisDepth);
PopInfo(ThisAlpha, Depth);
}
}
}
}
}
}
// 13. Return a loose value if no leagal moves
if (!Movable) {
MateNodes ++;
return Position.MoveNum - StartMove - CCEvalue.MaxValue;
}
// 15. Update History Tables and Hash Tables
if (FutPrune!=0 && BestValue == FutValue) {
BestMove.src = BestMove.dst = -1;
}
if ((HashFlag & HashAlpha)!=0) {
AlphaNodes ++;
} else {
PvNodes ++;
HistTab[BestMove.src][BestMove.dst] += 1 << (ThisDepth - 1);
if (KillerTab.MoveNum < MaxKiller) {
KillerTab.MoveList[KillerTab.MoveNum] = BestMove;
KillerTab.MoveNum ++;
}
}
RecordHash(BestMove, HashFlag, ThisAlpha, ThisDepth);
return BestValue;
// 16. Call Quiescence Search if a Leaf Node
} else {
ThisValue = Quiesc(Alpha, Beta);
ThisMove.src = BestMove.dst = -1;
if (ThisValue <= Alpha) {
RecordHash(ThisMove, HashAlpha, Alpha, 0);
} else if (ThisValue >= Beta) {
RecordHash(ThisMove, HashBeta, Beta, 0);
} else {
RecordHash(ThisMove, HashPv, ThisValue, 0);
}
LeafNodes ++;
return ThisValue;
}
}
// End Search Procedures
// Start Control Procedures
public boolean Interrupt(){
if (Stop)
return true;
return false;
}
private void PopInfo(int Value, int Depth) {
int i, QuiescNodes, Nps, NpsQuiesc;
char[] MoveStr;
long TempLong;
if (Depth!=0) {
try {
OutFile.write("PVNode: depth=" + Depth + ",score=" + Value +",Move: "); PvLineNum = 0;
GetPvLine();
for (i = 0; i < PvLineNum; i ++) {
MoveStr = PvLine[i].location();
OutFile.write(" " + String.copyValueOf(MoveStr));
}
OutFile.write("\n");
OutFile.flush();
if (Ponder && System.currentTimeMillis() > MinTimer && Value +
CCEvalue.InadequateValue > LastScore) {
Stop = true;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void setupControl(int depth,long proper,long limit){
this.Depth = depth;
this.ProperTimer = proper;
this.LimitTimer = limit;
}
MoveStruct bestMove=null;
public void Control(){
//int Depth, int ProperTimer, int LimitTimer) throws IOException {
int i, MoveNum, ThisValue;
char[] MoveStr;
try{
bestMove=null;
MoveStruct ThisMove=new MoveStruct(), UniqueMove=new MoveStruct();
HashRecord TempHash;
MoveSortStruct MoveSort=new MoveSortStruct();
KillerStruct SubKillerTab=new KillerStruct();
// The Computer Thinking Procedure Contains three parts:
// 1. Search the move in Book
int tmpInt = (int) (Position.ZobristKey & HashMask);
TempHash = HashList[(int) (Position.ZobristKey & HashMask)];
if (TempHash.Flag!=0 && TempHash.ZobristLock == Position.ZobristLock) {
if ((TempHash.Flag==BookUnique)) {
MoveStr = TempHash.BestMove.location();
bestMove = new MoveStruct(String.copyValueOf(MoveStr));
return;
} else if (TempHash.Flag == BookMulti) {
ThisValue = 0;
System.out.println("MoveNum(for test):"+BookList[TempHash.Value].MoveNum);
i = Math.abs(rand.nextInt())%(BookList[TempHash.Value].MoveNum);
MoveStr = BookList[TempHash.Value].MoveList[i].location();
bestMove = new MoveStruct(String.copyValueOf(MoveStr));
return;
}
}
// 2. Initailize Timer and other Counter
StartTimer = System.currentTimeMillis();
MinTimer = StartTimer + (ProperTimer >> 1);
MaxTimer = ProperTimer << 1;
if (MaxTimer > LimitTimer) {
MaxTimer = LimitTimer;
}
MaxTimer += StartTimer;
Stop = false;
StartMove = Position.MoveNum;
Nodes = NullNodes = HashNodes = KillerNodes = BetaNodes = PvNodes =
AlphaNodes = MateNodes = LeafNodes = 0;
QuiescNullNodes = QuiescBetaNodes = QuiescPvNodes = QuiescAlphaNodes =
QuiescMateNodes = 0;
HitBeta = HitPv = HitAlpha = 0;
PvLineNum = 0;
// 3. Stop if an illeagal or draw position is detected
if (Position.Checked(1 - Position.Player)) {
return;
}
ThisValue = Position.IsLoop(3);
if (ThisValue!=0) {
//OutFile.write("score " + Position.LoopValue(ThisValue, Position.MoveNum - StartMove));
//OutFile.flush();
throw new LostException("不可常捉!");
}
if (Position.MoveNum > ChessPosition.MaxConsecutiveMoves) {
//OutFile.write("score 0\n");
//OutFile.flush();
throw new LostException("最大步数,和棋!");
}
// 4. Test every leagal move in a check position,
// stop if a mate position (no leagal moves) is detected
// or return a move immediately if the position has only one move
if (Position.LastMove().chk) {
MoveNum = 0;
MoveSort.GenMoves(Position, HistTab);
for (i = 0; i < MoveSort.MoveNum; i ++) {
ThisMove = MoveSort.MoveList[i];
if (Position.MovePiece(ThisMove)) {
Position.UndoMove();
UniqueMove = ThisMove;
MoveNum ++;
if (MoveNum > 1) {
break;
}
}
}
if (MoveNum==0) {
OutFile.write("score " + -CCEvalue.MaxValue +"\n");
OutFile.flush();
return;
}
if (MoveNum == 1) {
MoveStr = UniqueMove.location();
OutFile.write("bestmove " + String.copyValueOf(MoveStr) + "\n");
OutFile.flush();
bestMove = new MoveStruct(String.copyValueOf(MoveStr));
return;
}
}
// 5. Do Iterative Deepening Search or Return a score if no depth
if (Depth==0) {
//OutFile.write("score " + Quiesc(-CCEvalue.MaxValue, CCEvalue.MaxValue) + "\n");
//OutFile.flush();
return;
}
for (i = 4; i <= Depth; i ++) {
OutFile.write("info depth " + i +"\n");
OutFile.flush();
SubKillerTab.MoveNum = 0;
ThisValue = Search(SubKillerTab, -CCEvalue.MaxValue, CCEvalue.MaxValue, i);
PopInfo(ThisValue,Depth);
if (Stop) {
break;
}
LastScore = ThisValue;
// 6. Stop thinking if timeout or solved
if (!Ponder && System.currentTimeMillis() > MinTimer) {
break;
}
if (ThisValue > CCEvalue.MaxValue - ChessPosition.MaxMoveNum / 2 || ThisValue < ChessPosition.MaxMoveNum / 2 - CCEvalue.MaxValue) {
break;
}
}
// 7. Pop Best Move and its Ponder Move after thinking
if (PvLineNum!=0) {
MoveStr = PvLine[0].location();
bestMove = new MoveStruct(String.copyValueOf(MoveStr));
OutFile.write("bestmove: " + String.copyValueOf(MoveStr) + "\n");
if (PvLineNum > 1) {
MoveStr = PvLine[1].location();
OutFile.write("ponder:" + String.copyValueOf(MoveStr) + "\n");
}
} else {
OutFile.write("score:" + ThisValue);
return;
}
OutFile.write("\n");
OutFile.flush();
}catch (Exception e){
e.printStackTrace();
try {
OutFile.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
}//End Control Procedures
public MoveStruct getBestMove(){
Control();
MoveStruct retVal = bestMove;
return bestMove;
}
}
-
2:程序运行界面及功能说明
1、主程序界面如下:
(图1:主界面)
Save按钮用来保存对局,Read按钮读入棋谱,Setting按钮设置一些选项见图2,resetAll按钮复位选项到默认值,Reverse按钮翻转棋盘,Computer按钮手动让计算机走一步,set rule按钮设置时间规则,display Rule按钮显示以设定的规则,Connect按钮根据设置的选项进行网络连接,start 按钮开始对弈。
2、初始设置
有三种对战模式。如果选择网络对战,需要设置IP地址和端口。
(图2:设置选项)
3、设置时间规则
(图3:设置时间规则)
源码网整理:www.codepub.com