代码先公布:http://download.csdn.net/source/891878
到现在为止,我只实现了一个棋盘,确切的说是在棋盘上随机走棋的速度测试程序,我借鉴了lib-ego,在上面做了一些改进,现在这个棋盘可以使用围棋规则或者五子棋规则。我的目标是让我的AI程序用同样的算法来对待围棋、五子棋甚至小时候玩过的黑白棋,它不需要任何棋类知识,你只要告诉它下棋的规则。我们的脑细胞可曾了解究竟什么是围棋?它们只是机械的执行自己的职能,而亿万个细胞堆叠在一起就使人类会下棋了。
上面说的三种棋的棋盘有一些共同的特点:棋盘是由n行n列的平行线段交叉组成的格子,棋子分黑白两种颜色,棋手分为两方,分执一种颜色的棋子。双方轮流下子,每次下一个子,棋子要下在空的交叉点上(黑白棋似乎是下在格子里,但是应该没有本质区别)。
根据这些特点我们开始设计棋盘的结构。
一、比特棋盘
很想在围棋中使用比特棋盘,就像国际象棋中那样,用一个64bit的数就描述了棋盘上的一种棋子。围棋上尽管也可以做到,例如用一个361bit的数来描述棋盘上的黑棋,另一个361bit数描述白棋,但是没见过谁这么做。
一般还是用传统的数组来描述棋盘,数组的每个元素有三个状态:黑(black)、白(white)、空(empty)。
为何计算机不是三进制的?我以前曾经这么想过,如果计算机是三进制的,会不会能更好的描述围棋?
后来我发现,其实棋盘上的点不只三个状态,还漏掉了一个off_board,也就是棋盘外的点。因此棋盘其实是4进制的,和2进制的计算机还是契合的不错的。
如何理解off_board也是一种状态?我们可以观察一下棋盘的边界,边界再往外就是off_board了,对围棋来说,通常的一颗子有4口气,但是到边界上就变成三口气或者两口气了,就仿佛边界外有敌人的子一样。对于五子棋,如果对方冲四冲到边界上,就不用挡了,就好像棋盘外有自己的棋子给它挡住了一样。
我按这种物理意义来为这些状态指派2进制数:
empty
00
black
01
white
10
off_board
11
这里empty就是没有棋子,black和white分别有一个棋子,而off_board则是同时有两个棋子,哪方的棋子靠近它,它就表现为另一方。
这样做的好处是,我可以用一个8bit的数来描述一个棋子的邻点,8bit总共256种情况,非常适合查表,通过查表,我就能得知任何情况下交叉点的“气”了。
关于计算交叉点的“气”,lib-ego中采用的另一种方法,它仅仅只增量计算交叉点周围黑、白、空三种情况的数量(off_board就分摊到黑白两种情况上了),而不管具体分布情况。目前我还没有发现我的方法表现出来的优势,但是我坚信我的方法比lib-ego中的好,因为它合乎道。
看起来,可以用一个8bit的数来存4个位置的状态,那么整个棋盘总共需要56个64bit数,比国际象棋没多太多,然而最终我没有贯彻比特棋盘的思想,因为我觉得那样不自然,我仍然选用传统的数组方式。
二、代码优化
许多人都指出优化应该晚做。但是对一份已经优化过的代码,如果不了解其优化手段,很难明白一些代码的意义。
1 使用编译期常量来代替变量。
例如棋盘的尺寸这个量,棋子的坐标计算依赖于它,为一些结构分配多大空间也与这个量相关。为了避免运行期再去计算这些东西,我们可以用宏或者const int来定义它:
- const uint board_size = 9;
但是我们希望程序可以运行在9路,13路,19路棋盘上,而且运行中可以改变棋盘,因此我采用了template。基本棋盘结构类似下面这样:
- template<uint T>
- class Vertex {
- uint idx;
- public:
- const static uint cnt = (T + 2) * (T + 2);
- };
- template<uint T>
- class Board {
- public:
- static const uint board_size = T;
- Color color_at[Vertex<T>::cnt];
- };
这里Vertex表示棋盘的交叉点,Vertex的内部实现不用类似class CPoint{int x;int y;};这样的方式实现,而只用一个整数来表示坐标,因为许多时候处理一维数组时的速度要快过二维数组,尽管理论上它们是一样的。
2 控制循环
如果在代码中看到这样的宏定义
- #define color_for_each(col) /
- for (Color col = 0; color::in_range(col); ++col)
而充斥在代码中的大片的vertex_for_each_all、vertex_for_each_nbr的使用,C++的死忠们不要急于排斥它,(我知道C++中有“优雅”的不依赖宏的方式来实现for_each,我也知道这样带来了一种方言),请先考虑一下为何需要for_each。
首先我们不希望在代码中出现大量for(;;)这样的语句,因为它会让代码行变的难看,并且以后修改困难。其次,我们有根据情况选择是否循环展开的需求。
- for(int i = 0; i < 4; i++) {code;}
- i=0;code;
- i=1;code;
- i=2;code;
- i=3;code;
循环展开的效率提升不能一概而论,它与代码块的长度和循环次数都有关系,但是宏赋予了我们控制的能力。
这两个要求我不知道除了宏还有什么简单的方法可以做到。
3 避免条件语句
因为条件语句会影响CPU的指令缓存的命中率。为人熟知的一个用位运算来取代条件语句的例子是:
- Player other(Player pl) {
- if(pl == black) return white;
- else return black;
- }
改为位运算就是这样:
- Player other(Player pl) {
- return Player(pl ^ 3);
- }
这里要假定black为1,white为2才能成立。如果black为0,white为1,则代码要改成(pl ^ 1)。
不过就这个例子来看,在我的CPU上没发现有什么效率的变化。在没有什么有说服力的例子出来之前,姑且存疑。
4 控制inline
需要清楚一点,inline不一定能提高运行速度。作为一个例子,请将代码中play_eye函数前面的no_inline修饰换成all_inline(表示总是内联),再编译运行一次看看,消耗的时间居然翻倍,为什么会这样?
这个函数的调用场景是:
- if(...) {
- return play_eye(player, v);
- } else ...
实际运行中,play_eye的调用频度不太高,如果内联的话,那么前面的if判断如果走的不是play_eye的这个分支,就会导致指令指针跳过很长一段代码到达下面的分支,因此指令缓存会失效。
你也许会说现代编译器能把这些做的很好,不用你操心这些细节了。那好吧,其实我只是建议,在瓶颈的地方手工指定一下是否内联,也许会有意想不到的性能提升。(注意inline这个关键字只是建议编译器内联,编译器不做保证,但是编译器通常都提供了额外的指令让你精确控制要不要内联。)
5 查表代替运算
不要迷信查表,因为表通常存在内存中,而你的指令放在CPU的指令缓存中,如果一两条指令能算出来的东西你去查表有可能得不偿失。
三、类的设计
一般来说,表示规则和表示棋盘的类会实现为一个类,如果把规则和棋盘分开来的话,那么应用代码可以创建一个棋盘类,再根据要求附加不同的规则类,类似下面这样写:
- Board<T> board;
- board.attach(new GoRule<T>());
- board.play(...);
看起来很优雅对不对?
但是在最终决定如何设计类结构之前,先看两点性能上的要求:
1) 不使用虚函数
原因是,除了虚函数表的空间开销,以及调用时多出来的几条机器指令外,虚函数使得编译器难以实现inline,因为虚函数是迟绑定,运行时才决定调用的函数是哪个,而C++编译器一般只能进行编译期的inline。
2) 棋盘可以快速被拷贝
记得我们的目的是让棋盘可以模拟很多盘随机对局,每一次随机对局都应该在原有棋盘的一个拷贝上进行,如果拷贝一次棋盘的代价很高的话,模拟的效率会很低。
现在,我们要否决上面的代码了,因为我们不能new一个规则类,这会破坏棋盘的快速拷贝能力,我所能想到的最快的棋盘拷贝代码是用memcpy,如果棋盘的数据成员含有指针,memcpy出来的棋盘会有问题。
继承怎么样呢?我们定义一个Board接口,也就是纯虚类,然后从这个接口继承,这是很通用的优雅解决方案,但是用到了虚函数。而且单继承会导致类数量过多,例如,我有一个基础的BasicBoard类,现在我希望能实现邻点计数功能,那么我写了一个NbrCounterBoard从BasicBoard类继承,我们的GoBoard可以从NbrCounterBoard继承。围棋还需要计算每一步棋的hash值,用以判定局面重复,那么我要实现一个ZorbistBoard,它从NbrCounterBoard继承,最终的GoBoard就从ZorbistBoard继承。黑白棋不需要计算hash,它可以直接从NbrCounterBoard继承,五子棋两个特性都不需要,那么它直接就从BasicBoard继承。一切听起来很完美,但这只是运气好而已,如果有一种棋需要hash但不需要邻点计算,这样的设计就over了。
组合可以吗?当然可以。看下面:
- class GoBoard {
- private:
- ZorbistBoard zb;
- public:
- BasicBoard bb;
- };
- void GoBoard::foo(){ return zb.foo(); }
- GoBoard board;
- board.bb.bar();
- void ZorbistBoard::foo(GoBoard* pGB) {
- pGB->bb.bar();
- }
- void GoBoard::foo(){ return zb.foo(this); }
我们看到,这样子代码显得很罗嗦。这把我由组合引向了多继承:
- class GoBoard:
- public BasicBoard<GoBoard>,
- public ZobristBoard<GoBoard>
- {
- };
我借鉴了ATL库的做法,把GoBoard当做模板参数传进去,这样,当ZobristBoard需要调用BasicBoard方法时,可以这样做:
- template<typename Derive>
- class ZobristBoard {
- public:
- void foo() {
- Derive* p = static_cast<Derive*>(this);
- p->bar();
- }
- };
四、模拟对局
我们这样来进行一场模拟对局:双方在规则的允许下,轮流下棋,当一方没有棋可下时,就pass,而双方接连pass时对局终止。对围棋和黑白棋来说,这样的过程是适应的,对于五子棋,我们需要加上中盘获胜的判断,实际上围棋中也可以用中盘胜来加快模拟速度,即一方已经明显优势的情况下,就不需要进行到双pass终局了。
首先我们看看围棋规则如何实现,围棋的三大规则,即提子(气尽棋亡)、打劫、禁同,造就了围棋的复杂性。如果没有提子,双方无论怎么下结果都是一样,如果没有打劫,双方互不相让也使得没有终局的可能。而禁同,也就是禁止全局同形,则可以看成是打劫的一般情况,也是为了防止对局无法终止。
还有一种情况也会导致对局无法终止,那就是双方都自填眼位,虽然这种情况理论上可以被禁同规则所限制,但是我是等不到对局结束的那一天了,何况这种求败并且寄希望于对方也求败的下法,在博弈程序中是不必考虑的。因此,在我们的随机模拟中,还要加上一条不填眼的规则。
在提子中还有一个分支,就是提自己的子,也即是自杀,一般比赛中是不允许自杀的,但是应氏规则中好像是允许的。模拟中肯定要禁止单个棋子的自杀行为,因为这也会导致无法终局(同上面一样,这种情况可以被禁同所限制,后面再说禁同的问题),但是多子的自杀究竟要不要在模拟中禁止?lib-ego中没有禁止,但是我发现禁止或不禁止导致的模拟胜率是有差异的,为了让模拟对局更贴近实际对局规则,我选择禁止多子自杀,尽管这需要更多的计算。
这样,在模拟中需要实现提子、打劫、不填眼、不自杀、禁同5个规则。而理论上我们只需要实现提子和禁同两个规则。
1 禁同
如果要实现禁同,我们需要为每一步棋形成的局面记录一个hash值,为了减少冲突的可能,一般使用64bit的hash值,然后如果这个hash值与以前的hash重复,则把这一步棋撤销。平均一局棋大概不超过1000步,那么进行二分查找是能够快速的判断hash重复的,但是如何撤销一步棋呢?要知道围棋是有提子的,如果这步棋出现提子,则撤销时还要将提去的子也放回来。每次提子记住那些被移走的棋子的位置,这是一个办法。lib-ego中采用了一种简单的、低效的的手段:无论是判断是否重复还是进行撤销,都根据历史棋步,把整个棋局重新下一遍。这种方法我初看时也觉得效率太低了,但是后来想通了,因为这样做,只额外存储了历史棋步,额外计算了hash。
其实这就是在表明,放弃在模拟对局中实现禁同,禁同只用到真正下棋的判断中。甚至我觉得更进一步,在模拟棋盘中,历史棋步与hash计算都不需要。因为现实对局中的全局同型是少之又少的,而检测全局同型的开销又太大,我们在模拟中设定一个棋局最大步数,凡是超过这个步数的模拟对局都弃掉不用,这样就绕开了禁同的问题。
2 提子
为了高效的判断棋子的气,这里用到了“伪气”的技巧。只要有一个空的交叉点,那么这个交叉点周围的每个棋子都能得到一口气,这就叫伪气。举图为例
├┼┼┼┼
├┼┼┼┼
○○○┼┼
●●○┼┼
└●○┴┴
上图黑棋真实的气只有一口,但是按伪气来说,就有两口,因为那个空点连着两个黑子,每个黑子都算有一口气。
按照伪气的计算法,每下一子,就减掉上下左右共4口气,每提走一子,则加上4口气。有了伪气这个工具,再来计算提子就简单多了,伪气为0的棋串就从棋盘上移走。
那么棋串怎么弄呢?我们把棋串实现为一个循环链表。一开始单个棋子就自己和自己首尾相连,并且拥有一个棋串id(就取它的位置作为id值),如果两个棋子相邻了,而棋串id不同,那么把它们合并为一个棋串,由于它们都是循环链表,合并的过程就相当于两个环扭断再对接成一个更大的环,于是合并的结果依然是循环链表。
3 打劫
打劫用了一个简单的方式来判定:如果能够在对方眼的位置下子,并且刚好只提了一个子,那么提去的那个子的位置被记录为劫争位,劫争位每次下子前被清除,也就是说只要不下在劫争位,pass也好,劫争位就被清除,下次那个位置就被允许下子。
4 填眼
下围棋的应该知道如何判断真眼和假眼,当在棋盘中间被对方占据两个“肩”或者边角处被对方占据一个“肩”,眼就是假眼了,我们随机模拟时,只要这个眼还没有确诊为假眼,我们就不往眼里下子。这里会存在误判,例如下图,白棋两个眼按照我们的规则判断是假眼,但白棋是活棋:
├┼┼┼┼┼┼┼
●●●●●●┼┼
○○○○○●●┼
├○●●○○●┼
○●●┼●○●┼
○●┼●●○●┼
○●●○○○●┼
└○○○●●●┴
不过没有关系,我们禁止填眼的目的是让大多数情况都能终局,而不是防止电脑把活棋走死。
5 自杀
单子自杀的判定是,当在对方眼中下棋时,将上下左右的棋串的气依次减1,如果没有棋串的气等于0,那么这就是一次自杀行为,我们把气加回去,然后禁止它下这一手。如下图,白棋下A点是自杀,下B点不是自杀。
├┼┼┼┼
○┼┼┼┼
●○○┼┼
B●○┼┼
●●○┼┼
A●○┴┴
多子自杀的判定是,当在一个没有气的交叉点上下子时,先把上下左右的棋串的气减1,然后判断,如果既没有让对方棋串的气为0,也没有使自己的至少一个棋串的气不为0,那么这就是一次自杀,我们再把气加回去。如下图,黑棋下A点是自杀,下B点或者c点不是自杀。
├○┼┼┼┼┼
○┼○○┼┼┼
●●B●○┼┼
○○●○○┼┼
●○○●○○┼
A●○C●○┴
这里的要点是在合并棋串之前做判断,因为棋串一旦合并后就不方便拆开了。
五子棋规则的实现相比围棋要容易很多,只用仿照围棋棋串的合并算法,在4个方向上分别建立棋串,合并棋串后,判断一下4个方向上是否有棋串的长度大于等于5。对于五子棋的职业规则,如禁手和三手交换五手两打,我暂时就不考虑了。毕竟有黑石那么牛的程序在那里。
五、下一步
自然是引入UCT算法了,也有可能是UCG,也就是UCB for Graph。