圍棋AI之路(二)棋盤的實現

代碼先公佈: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來定義它:
  1. const uint board_size = 9;
但是我們希望程序可以運行在9路,13路,19路棋盤上,而且運行中可以改變棋盤,因此我採用了template。基本棋盤結構類似下面這樣:
  1. template<uint T> 
  2. class Vertex {
  3.         uint idx;
  4. public:
  5.         const static uint cnt = (T + 2) * (T + 2);
  6. };
  7. template<uint T> 
  8. class Board {
  9. public:
  10.         static const uint board_size = T;
  11.         Color color_at[Vertex<T>::cnt];
  12. };

這裏Vertex表示棋盤的交叉點,Vertex的內部實現不用類似class CPoint{int x;int y;};這樣的方式實現,而只用一個整數來表示座標,因爲許多時候處理一維數組時的速度要快過二維數組,儘管理論上它們是一樣的。

2 控制循環
如果在代碼中看到這樣的宏定義
  1. #define color_for_each(col) /
  2.         for (Color col = 0; color::in_range(col); ++col)

而充斥在代碼中的大片的vertex_for_each_all、vertex_for_each_nbr的使用,C++的死忠們不要急於排斥它,(我知道C++中有“優雅”的不依賴宏的方式來實現for_each,我也知道這樣帶來了一種方言),請先考慮一下爲何需要for_each。

首先我們不希望在代碼中出現大量for(;;)這樣的語句,因爲它會讓代碼行變的難看,並且以後修改困難。其次,我們有根據情況選擇是否循環展開的需求。

  1. //所謂循環展開就是,正常代碼這樣:
  2. for(int i = 0; i < 4; i++) {code;}
  3. //循環展開的代碼是:
  4. i=0;code;
  5. i=1;code;
  6. i=2;code;
  7. i=3;code;
循環展開的效率提升不能一概而論,它與代碼塊的長度和循環次數都有關係,但是宏賦予了我們控制的能力。
這兩個要求我不知道除了宏還有什麼簡單的方法可以做到。

3 避免條件語句
因爲條件語句會影響CPU的指令緩存的命中率。爲人熟知的一個用位運算來取代條件語句的例子是:
  1. Player other(Player pl) {
  2.     if(pl == black) return white;
  3.     else return black;
  4. }
改爲位運算就是這樣:
  1. Player other(Player pl) {
  2.     return Player(pl ^ 3);
  3. }
這裏要假定black爲1,white爲2才能成立。如果black爲0,white爲1,則代碼要改成(pl ^ 1)。
不過就這個例子來看,在我的CPU上沒發現有什麼效率的變化。在沒有什麼有說服力的例子出來之前,姑且存疑。

4 控制inline
需要清楚一點,inline不一定能提高運行速度。作爲一個例子,請將代碼中play_eye函數前面的no_inline修飾換成all_inline(表示總是內聯),再編譯運行一次看看,消耗的時間居然翻倍,爲什麼會這樣?
這個函數的調用場景是:
  1. if(...) {
  2.     return play_eye(player, v);
  3. else ...
實際運行中,play_eye的調用頻度不太高,如果內聯的話,那麼前面的if判斷如果走的不是play_eye的這個分支,就會導致指令指針跳過很長一段代碼到達下面的分支,因此指令緩存會失效。

你也許會說現代編譯器能把這些做的很好,不用你操心這些細節了。那好吧,其實我只是建議,在瓶頸的地方手工指定一下是否內聯,也許會有意想不到的性能提升。(注意inline這個關鍵字只是建議編譯器內聯,編譯器不做保證,但是編譯器通常都提供了額外的指令讓你精確控制要不要內聯。)

5 查表代替運算
不要迷信查表,因爲表通常存在內存中,而你的指令放在CPU的指令緩存中,如果一兩條指令能算出來的東西你去查表有可能得不償失。


三、類的設計

一般來說,表示規則和表示棋盤的類會實現爲一個類,如果把規則和棋盤分開來的話,那麼應用代碼可以創建一個棋盤類,再根據要求附加不同的規則類,類似下面這樣寫:
  1. Board<T> board;
  2. board.attach(new GoRule<T>());
  3. 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了。

組合可以嗎?當然可以。看下面:

  1. class GoBoard {
  2. private:
  3.     ZorbistBoard zb;
  4. public
  5.     BasicBoard bb;
  6. };
  7. // 如果把組合類設爲私有,許多功能你還需要中轉一下
  8. void GoBoard::foo(){ return zb.foo(); }
  9. // 如果設成公有,那麼需要很囉嗦的調用形式
  10. GoBoard board;
  11. board.bb.bar();
  12. // 更要命的是,
  13. // 如果ZorbistBoard::foo()需要調用BasicBoard::bar(),你會這樣寫代碼嗎?
  14. void ZorbistBoard::foo(GoBoard* pGB) {
  15.     pGB->bb.bar();
  16. }
  17. void GoBoard::foo(){ return zb.foo(this); }

我們看到,這樣子代碼顯得很羅嗦。這把我由組合引向了多繼承:
  1. class GoBoard:
  2. public BasicBoard<GoBoard>,
  3. public ZobristBoard<GoBoard>
  4. {
  5. };

我借鑑了ATL庫的做法,把GoBoard當做模板參數傳進去,這樣,當ZobristBoard需要調用BasicBoard方法時,可以這樣做:
  1. template<typename Derive>
  2. class ZobristBoard {
  3. public:
  4.     void foo() {
  5.         Derive* p = static_cast<Derive*>(this);
  6.         p->bar();
  7.     }
  8. };


四、模擬對局

我們這樣來進行一場模擬對局:雙方在規則的允許下,輪流下棋,當一方沒有棋可下時,就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。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章