关于棋软中国象棋程序设计探索.docx

上传人:b****5 文档编号:8576869 上传时间:2023-01-31 格式:DOCX 页数:47 大小:76.22KB
下载 相关 举报
关于棋软中国象棋程序设计探索.docx_第1页
第1页 / 共47页
关于棋软中国象棋程序设计探索.docx_第2页
第2页 / 共47页
关于棋软中国象棋程序设计探索.docx_第3页
第3页 / 共47页
关于棋软中国象棋程序设计探索.docx_第4页
第4页 / 共47页
关于棋软中国象棋程序设计探索.docx_第5页
第5页 / 共47页
点击查看更多>>
下载资源
资源描述

关于棋软中国象棋程序设计探索.docx

《关于棋软中国象棋程序设计探索.docx》由会员分享,可在线阅读,更多相关《关于棋软中国象棋程序设计探索.docx(47页珍藏版)》请在冰豆网上搜索。

关于棋软中国象棋程序设计探索.docx

关于棋软中国象棋程序设计探索

关于棋软中国象棋程序设计探索

最专业的免费象棋软件网站棋中论坛

(一)引言

 

  2005年2月我写出了象棋程序ElephantEye的第一个版本(0.90),本来它只是象棋界面ElephantBoard的调试引擎。

在设计程序的过程中,我尝试性地加入了很多算法,发现每次改进都能让程序的棋力有大幅度的提高,因此便对象棋程序的算法产生了浓厚的兴趣。

到现在我已经陆续对ElephantEye作了几十次加工(目前版本为1.05),使得它的棋力接近了顶尖商业软件的水平,在非商业的象棋程序中,ElephantEye无疑是最强的一个。

  我希望能通过公开源代码的方式,推动中国象棋程序水平的整体发展,然而根据很多网友的反馈意见,发现源代码中的很多部分并不是那么容易理解的,为此我花了大量的时间为源程序加了注释。

尽管如此,很多思想还是有必要以文字的形式保留下来,因此我才打算以《中国象棋程序设计探索》为题,写几篇详细介绍ElephantEye算法的连载,希望能让源代码充分发挥它的作用。

  总的来说,对弈程序是个系统工程,它是以下四个系统的有机结合:

(1)棋盘结构,

(2)局面评价,(3)搜索技术,(4)其他。

以ElephantEye为例,这四个部分在程序中的比例各占25%,也就是说,每个方面都很重要。

那么这四个部分应该以什么样的方式逐步建立呢?

另一个公开源代码的程序VSCCP(VerySimpleChineseChessProgram)给出了一个方向,这是本很好的对弈程序设计的入门教材。

尽管VSCCP在棋力上还有很大的提升空间,但是它的结构体系是比较完整的,参考下面一组公式,找到有待提升的空间,只要稍作改进就能成为ElephantEye。

 

棋盘结构=局面表示+着法移动+着法生成+特殊局面判断

局面评价=知识+优化的局面表示

搜索技术=完全搜索+静态搜索+启发+裁剪+选择性延伸+置换表+残局库+并行技术

其他=开局库+时间控制+后台思考+引擎协议

(二)棋盘结构和着法生成器

 最专业的免费象棋软件网站棋中论坛

  在阅读本章前,建议读者先阅读《象棋百科全书》网站中《对弈程序基本技术》专题的以下几篇译文:

  

(1)数据结构——简介(DavidEppstein);

  

(2)数据结构——位棋盘(JamesSwafford);

  (3)数据结构——旋转的位棋盘(JamesSwafford);

  (4)数据结构——着法生成器(JamesSwafford);

  (5)数据结构——0x88着法产生方法(BruceMoreland);

  (6)数据结构——Zobrist键值(BruceMoreland);

 

2.1局面和着法的表示

 

  局面是象棋程序的核心数据结构,除了要包括棋盘、棋子、哪方要走、着法生成的辅助结构、Zobrist键值等,还要包含一些历史着法,来判断重复局面。

ElephantEye的局面结构很庞大(见),其中大部分存储空间是用来记录历史局面的。

 

structPositionStruct{

 ……

 intnMoveNum;

 MoveStructmvMoveList[MAX_MOVE_NUM]; //MAX_MOVE_NUM=256

 unsignedcharucRepHash[REP_HASH_LEN];//REP_HASH_LEN=1024

 ……

}

 

  其中MoveStruct这个结构记录了四个信息:

(1)着法的起始格(Src),

(2)着法的目标格(Dst),(3)着法吃掉的棋子(Cpt),(4)着法是否将军(Chk)。

有意思的是,每个部分都只占一个字节,后两个部分(Cpt和Chk)与其说有特殊作用,不如说是为了凑一个32位整数。

在MoveStruct出现的很多地方(置换表、杀手着法表、着法生成表)里,这两项都是没作用的,而只有在PositionStruct结构的记录历史着法的堆栈中才有意义。

  Cpt一项主要用在撤消着法上,它可以用来还原被吃的棋子,而Chk一项则可以记录当前局面是否处于将军状态。

ElephantEye是用MakeMove()函数来走棋的,每走完一步棋就做两次将军判断:

第一次判断走完子的一方是否被将军,即Checked(sdPlayer),如果被将则立即撤消着法,并返回走子失败的信息;第二次判断要走的一方是否被将军,由于交换了走子方(即执行了sdPlayer=1-sdPlayer),所以仍旧是Checked(sdPlayer),如果被将则Chk置为TRUE,这个着法被压入历史着法堆栈。

因此LastMove().Chk就表示当前局面是否被将军。

 

2.2循环着法的检测

 

  Cpt和Chk的另一个作用就是判断循环着法:

ElephantEye判断循环着法时,依次从堆栈顶往前读,读到吃过子的着法(Cpt不为零)就结束;而是否有单方面长将的情况,则是通过每个着法的Chk一项来判断的。

  在循环着法的检测中,我们感兴趣的不是Cpt和Chk,而是RepHash结构,这是一个微型的置换表,用来记录历史局面。

ElephantEye在做循环着法的判断这之前,先去探测这个置换表,如果命中置换表,则说明可能存在重复局面(由于置换表可能有冲突,所以只是有这个可能),因而做循环检测;如果没有命中则肯定没有重复局面。

  ElephantEye使用“归位检测法”来判断循环着法,即从最后一个着法开始,依次向前撤消着法,并记录每个移动过的棋子所在的格子。

如果所有移动过的棋子同时归位,那么循环着法就出现了。

因此中的IsRep()函数建立了两个归位数组,第一个记录了棋子的原始位置,第二个记录了新的位置,当两个位置重合时,说明棋子归位。

 

2.3棋盘-棋子联系数组

 

  众所周知,棋盘的表示有两种方法。

一是做一个棋盘数组(例如Squares[10][9]),每个元素记录棋子的类型(包括空格);二是做一个棋子数组(例如Pieces[2][16]),每个元素记录棋子的位置(包括被吃的状态)。

如果一个程序同时使用这两个数组,那么着法生成的速度就可以快很多。

这就是“棋盘-棋子联系数组”,它的技术要点是:

  

(1)同时用棋盘数组和棋子数组表示一个局面,棋盘数组和棋子数组之间可以互相转换。

  

(2)随时保持这两个数组之间的联系,棋子移动时必须同时更新这两个数组。

  根据这两个原则,棋盘-棋子联系数组可以定义为:

 

structPositionStruct{

 intSquares[90];

 intPieces[32];

};

 

  在棋盘上删除一个棋子,需要做两个操作(分别修改棋盘数组和棋子数组)。

同样,添加一个棋子时也需要两个操作。

执行一个着法时有三个步骤:

  

(1)如果目标格上已经有棋子,就要先把它从棋盘上拿走(吃子的过程);

  

(2)把棋子从起始格上拿走;

  (3)把棋子放在目标格上。

  ElephantEye用一个函数MovePiece()来完成这项任务,它除了修改棋盘数组和棋子数组外,还修改Zobrist键值、位行和位列等信息。

  “棋盘-棋子联系数组”最大的优势是:

移动一步只需要有限的运算。

对于着法产生过程,可以逐一查找棋子数组,如果该子没有被吃掉,就产生该子的所有合理着法,由于需要查找的棋子数组的数量(每方只有16个棋子能走)比棋盘格子的数量(90个格子)少得多,因此联系数组的速度要比单纯的棋盘数组快很多。

可以说,“棋盘-棋子联系数组”是所有着法生成器的基础,位行和位列、位棋盘等其他方法都只是辅助手段。

 

2.4扩展的棋盘数组和棋子数组

 

  如今,很少有程序使用Squares[90]和Pieces[32]这样的数组了,浪费一些存储空间以换取速度是流行的做法,例如ElephantEye就用了ucpcSquares[256]和ucsqPieces[48]。

把棋盘做成16x16的大小,得到行号和列号就可以用16除,这要比用9或10除快得多。

16x16的棋盘还有更大的好处,它可以防止棋子走出棋盘边界。

00

01

02

03

04

05

06

07

08

09

0a

0b

0c

0d

0e

0f

10

11

12

13

14

15

16

17

18

19

1a

1b

1c

1d

1e

1f

20

21

22

23

24

25

26

27

28

29

2a

2b

2c

2d

2e

2f

30

31

32

33

34

35

36

37

38

39

3a

3b

3c

3d

3e

3f

40

41

42

43

44

45

46

47

48

49

4a

4b

4c

4d

4e

4f

50

51

52

53

54

55

56

57

58

59

5a

5b

5c

5d

5e

5f

60

61

62

63

64

65

66

67

68

69

6a

6b

6c

6d

6e

6f

70

71

72

73

74

75

76

77

78

79

7a

7b

7c

7d

7e

7f

80

81

82

83

84

85

86

87

88

89

8a

8b

8c

8d

8e

8f

90

91

92

93

94

95

96

97

98

99

9a

9b

9c

9d

9e

9f

a0

a1

a2

a3

a4

a5

a6

a7

a8

a9

aa

ab

ac

ad

ae

af

b0

b1

b2

b3

b4

b5

b6

b7

b8

b9

ba

bb

bc

bd

be

bf

c0

c1

c2

c3

c4

c5

c6

c7

c8

c9

ca

cb

cc

cd

ce

cf

d0

d1

d2

d3

d4

d5

d6

d7

d8

d9

da

db

dc

dd

de

df

e0

e1

e2

e3

e4

e5

e6

e7

e8

e9

ea

eb

ec

ed

ee

ef

f0

f1

f2

f3

f4

f5

f6

f7

f8

f9

fa

fb

fc

fd

fe

ff

  在中国象棋里,短程棋子(短兵器)指的是除车和炮以外的其他棋子,它们的着法都有固定的增量(行的增量,列的增量),因此处理起来非常简单,也是着法生成技术的基础。

例如马有8个着法,增量分别是±0x0e、±0x12、±0x1f和±0x21,红方的过河兵有3个着法,增量分别是-0x10和±0x01。

  16x16的扩展棋盘如上图所示,底色是红色的格子都被标上“出界”的标记,目标格在这些格子上就说明着法无效。

这样,马的着法产生就非常简单了:

 

constintcnKnightMoveTab[8]={-0x21,-0x1f,-0x12,-0x0e,+0x0e,+0x12,+0x1f,+0x21};

constintcnHorseLegTab[8]={-0x10,-0x10,-0x01,+0x01,-0x01,+0x01,+0x10,+0x10};

 

for(i=MyFirstHorse;i

 //在ElephantEye的Pieces[48]中,红方的MyFirstHorse为21,MyLastHorse为22。

 SrcSq=ucsqPieces[i];

 if(SrcSq!

=0){

  for(j=0;j<8;j++){

   DstSq=SrcSq+cnKnightMoveTab[j];

   LegSq=SrcSq+cnHorseLegTab[j];

   if(cbcInBoard[DstSq]&&(ucpcSquares[DstSq]&MyPieceMask)==0&&ucpcSquares[LegSq]==0){

    MoveList[MoveNum].Src=SrcSq;

    MoveList[MoveNum].Dst=DstSq;

    MoveNum++;

   }

  }

 }

}

 

  上面的代码是着法生成器的典型写法,用了两层循环,第一层循环用来确定要走的棋子,第二层循环用来确定棋子走到的目标格。

如果要加快程序的运行速度,第二个循环可以拆成顺序结构。

这个代码还加入了蹩马腿的判断,马腿的位置增量由ccHorseLegTab[j]给出。

  其它棋子的着法也同样处理,只要注意帅(将)和仕(士)把InBoard[DstSq]改为InFort[DstSq]就可以了。

而对于兵和象等需要考虑是否能过河的棋子,判断是否过河的方法非常简单:

红方是(SrcSq/DstSq&0x80)!

=0,黑方是(SrcSq/DstSq&0x80)==0。

  Pieces[48]这个扩展的棋子数组比较难以理解,实际上用了“屏蔽位”的设计,即1位表示红子(16),1位表示黑子(32)。

因此0到16没有作用,16到31代表红方棋子(16代表帅,17和18代表仕,依此类推,直到27到31代表兵),32到47代表黑方棋子(在红方基础上加16)。

这样,棋盘数组Squares[256]中的每个元素的意义就明确了,0代表没有棋子,16到31代表红方棋子,32到47代表黑方棋子。

这样表示的好处就是:

它可以快速判断棋子的颜色,(Piece&16)可以判断是否为红方棋子,(Piece&32)可以判断是否为黑方棋子。

  “屏蔽位”的设计不仅仅限制在判断红方棋子还是黑方棋子,如果在棋子数组上再多加7个屏蔽位,就可以对每个兵种作快速判断,例如判断是否是红兵,不需要用(Piece>=27&&Piece<=31),而只要简单的(Piece&WhitePawnBitMask)即可。

这样的话,棋子数组的大小就增加到2^12=4096个了,其中9个屏蔽位,还有3位表示同兵种棋子的编号(注意兵有5个,所以必须占据3位)。

事实上,确实有象棋程序是使用Pieces[4096]的

2.5着法预生成数组

 

  上面提到的着法生成技术,在速度上并不是最快的。

我们仍旧以马的着法为例,在很多情况下,马会处于棋盘的边缘,所以往往着法只有很少,而并不需要对每个马都作8次是否出界的判断。

因此,对于每个短程子力,都给定一个[256][4]到[256][9]不等的数组,它们保存着棋子可以到达的绝对位置,这些数组称为“着法预生成数组”。

例如,ElephantEye里用了ucsqKnightMoves[256][12]和ucsqHorseLegs[256][8],前一个数组的第二个维度之所以大于8,是因为着法生成器依次读取数组中的值,读到0就表示不再有着法(12则是为了对齐地址)。

程序基本上是这样的:

 

for(i=MyFirstHorse;i<=MyLastHorse;i++){

 SrcSq=ucsqPieces[i];

 if(SrcSq!

=0){

  j=0;

  DstSq=ucsqKnightMoves[SrcSq][j];

  while(DstSq!

=0){

   LegSq=ucsqHorseLegs[SrcSq][j];

   if(!

(ucpcSquares[DstSq]&MyPieceMask)&&ucpcSquares[LegSq]==0){

    MoveList[MoveNum].Src=SrcSq;

    MoveList[MoveNum].Dst=DstSq;

    MoveNum++;

   }

   j++;

   DstSq=ucsqHorseMoves[SrcSq][j];

  }

 }

}

 

  和前一个程序一样,这个程序也同样用了两层循环,不同之处在于第二个循环读取的是着法预生成数组,DstSq从ucsqHorseMoves[256][12]中读出,LegSq从ucsqHorseLegs[256][8]中读出。

 

2.6位行和位列

 

  车和炮的着法分为吃子和不吃子两种,这两种着法生成器原则上是分开的,因此分为车炮不吃子、车吃子和炮吃子三个部分。

不吃子的着法可以沿着上下左右四条射线逐一生成(即并列做4个循环)。

我们感兴趣的是吃子的着法,因为静态搜索只需要生成这种着法,能否不用循环就能做到?

ElephantEye几乎就做到了。

  “位行”和“位列”是目前比较流行的着法生成技术,但仅限于车和炮的着法,它是否有速度上的优势还很难说,但是设计程序时可以减少一层循环,这个思想就已经比较领先了。

以“位”的形式记录棋盘上某一行所有的格子的状态(仅仅指是否有子),就称为“位行”(BitRank),与之对应的是“位列”(BitFile),棋盘结构应该包含10个位行和9个位列,即:

 

structPositionStruct{

 ……

 unsignedshortwBitFiles[16];

 unsignedshortwBitRanks[16];

 ……

};

 

  值得注意的是,它仅仅是棋盘的附加信息,“棋盘-棋子联系数组”仍旧是必不可少的。

它的运作方式有点和“棋盘-棋子联系数组”类似:

  

(1)同时用位行数组和位列数组表示棋盘上的棋子分布信息,位行数组和位列数组之间可以互相转换;

  

(2)随时保持这两个数组之间的联系,棋子移动时必须同时更新这两个数组。

  因此,移走或放入一颗棋子时,必须在位行和位列上置位:

 

voidAddPiece(intSquare,intPiece){

 ……

 x=Square%16;

 y=Square/16;

 wBitFiles[x]=1<<(y-3);

 wBitRanks[y]=1<<(x-3);

 ……

}

 

  车和炮是否能吃子(暂时不管吃到的是我方棋子还是敌方棋子),只取决于它所在的行和列上的每个格子上是否有棋子,而跟棋子的颜色和兵种无关,因此这些信息完全反映在位行和位列中。

预置一个“能吃到的格子”的数组,以位行或位列为指标查找数组,就可以立即知道车或炮能吃哪个子了。

预置数组到底有多大呢?

 

//某列各个位置的车或炮(10)在各种棋子排列下(1024)能走到的最上边或最下边的格子

unsignedcharucsqFileMoveNonCap[10][1024][2];  //不吃子

unsignedcharucsqFileMoveRookCap[10][1024][2]; //车吃子

unsignedcharucsqFileMoveCannonCap[10][1024][2];//炮吃子

//某列各个位置的车或炮(9)在各种棋子排列下(512)能走到的最左边或最右边的格子

unsignedcharucsqRankMoveNonCap[9][512][2];

……

 

  数组中的值记录的是目标格子的偏移值,即相对于该行或列第一个格子的编号。

产生吃子着法很简单,以车吃子位例:

 

for(i=MyFirstRook;i<=MyLastRook;i++){

 SrcSq=ucsqPieces[i];

 if(SrcSq!

=-1){

  x=SrcSq%16;

  y=SrcSq/16;

  DstSq=ucsqFileMoveRookCap[y-3][wBitFiles[x]][0];//得到向上吃子的目标格

  if(DstSq!

=0){

   DstSq+=x*16;//注意:

第x列的第一个格子总是x*16。

   MoveList[MoveNum].Src=SrcSq;

   MoveList[MoveNum].Dst=DstSq;

   MoveNum++;

  }

  ……//再把FileMoveRookCap[...][...][0]替换成[...][...][1],得到向下吃子的着法

  DstSq=ucsqRankMoveRookCap[x-3][wBitRanks[y]][0];//得到向左吃子的目标格

  if(DstSq!

=0){

   DstSq+=y;//注意:

第y行的第一个格子总是y。

   MoveList[MoveNum].Src=SrcSq;

   MoveList[MoveNum].Dst=DstSq;

   MoveNum++;

  }

  ……//再把RankMoveRookCap[...][...][0]替换成[...][...][1],得到向右吃子的着法

 }

}

 

2.7着法合理性的判断

 

  ElephantEye搜索每个结点时,着法都有四个来源:

(1)置换表,

(2)吃子着法生成器,

(2)杀手着法表,(3)不吃子着法生成器。

这四种来源分别代表了四种启发式算法:

(1)置换表启发,

(2)吃子启发,(3)杀手着法启发,(4)历史表启发,这会在以后的章节中介绍。

  我们感兴趣的是杀手着法,它是以前搜索过的局面遗留下来的着法,当前的局面如果要使用这些着法,只需要做合理性的判断就可以了,如果杀手着法能产生截断,那么着法生成就没有必要了。

因此,如何快速判断着法合理性,其意义可能比着法生成器还大。

  ElephantEye判断着法合理性的程序包含在中,它分为三个步骤:

  

(1)判断棋子是否在棋盘上存在,如果不存在那么肯定不是合理着法;

  

(2)判断是否吃到自己一方的棋子,吃到自己棋子的着法肯定是不是合理着法;

  (3)分兵种作额外的判断。

  我们感兴趣的是相(象)马车炮四子的判断,其中相(象)的判断最简单,只需要满足3个条件:

  

(1)走成象步,ElephantEye里用了一个ccLegalMoveTab的数组;

  

(2)没有过河,即((SrcSq^DstSq)&0x80)==0;

  (3)没有被塞象眼,即ucpcSquares[(SrcSq+Dst

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

当前位置:首页 > 初中教育

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1