代碼先公布: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進位數:
empty00
black01
white10
off_board11
這裡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();
- // 更要命的是,
- // 如果ZorbistBoard::foo()需要調用BasicBoard::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。