• 沒有找到結果。

新的暗棋演算法策略

翻子是這遊戲最關鍵的地方,一般人與人對弈時會覺得翻子只是純運氣的 動作,但電腦可以把所有可能出現的狀況統計起來,精準計算期望值,這是人 類玩家難以做到的事情。目前演算法的翻子時機為:得到盤面資料先做走步搜 尋,當目前無法吃到敵方子,且己方沒有子會被吃時就翻子。

暗棋遊戲的目的為:將對方所有子吃光,但不可能一次將對方的子吃光,

所以短期目標為:吃掉對方最強的那顆子。而目前暗棋遊戲的殘局非常難處理,

所以最好能在中盤時就勝出。綜合以上目標,我們使用的演算法核心策略為:

用最快的速度,將對手所有可能被我方吃掉、且子力大的子,以最小的代價吃

光,並以此前提翻子,程式碼 7.1 和 7.2 為翻子演算法之虛擬碼和實際程式碼。

for(盤面上所有未翻子的位置){

for(可能翻出之兵種){

模擬翻出該兵種();

該位置之分數 += 搜尋(六層);

結束模擬();

} }

選出最高分的位置翻開;

程式碼 7.1 翻子演算法虛擬碼

void Reveal(int turn){ //參數為換誰下,一方為 0,另一方為 1。

int maxWeight = -99999999, maxReveal = 0;

//總共會有幾種子被翻出來(DCountSum),目前有幾顆未翻子(dCounter)。

int DCountSum = 0, dCounter = 0;

U32 dark = piece[15];

while(dark){ //先計算盤面上有幾顆未翻子 U32 mask = LS1B(dark);

dark ^= mask;

dCounter++;

} //revealWeight 為 32 個位置之翻子分數,初始值為 0。

if(dCounter >= 31){ //依照個人經驗,第一手翻這四個位置較好。

revealWeight[9] = 10;

revealWeight[10] = 10;

revealWeight[21] = 10;

revealWeight[22] = 10;

}

//模擬翻子前,因為我們翻一手,所以要先將回合交換。

turn = 1 - turn; //交換回合

maxDepth = 5; //每次把子翻出來後作搜尋的層數,可自行調整。

int factor = (turn == RED) ? 1 : -1;

for(int pID=1; pID<15; pID++){

if(DCount[pID])

DCountSum += DCount[pID];

} revealWeight[src] += -( Search(turn) );

UnSimReveal(src, pID); //將模擬翻出的子復原 }

} } else{

revealWeight[src] = -99999999;

} }

for(int src=0; src<32; src++){ //找出權重最大的那個位置 if(maxWeight < revealWeight[src]){

maxWeight = revealWeight[src];

maxReveal = src;

}

revealWeight[4],之後將棋盤復原成圖 7.1 的狀態,再將該位置變成紅炮,再做 搜尋並將分數加到 revealWeight[4],4 號位置的模擬就結束了,接下來在 10 號 位置做一樣的動作,之後取 revealWeight[4]和 revealWeight[10]較大的值翻開。

卒 兵 ●

由於有使用搜尋並計算一定深度內所有可能會發生的狀況,不管盤面如何 都能準確算出吃子的期望值,這裡最重要的是配分部份,程式會想盡辦法吃掉 分數最高的子,如果那顆子尚未翻出來,程式就會以吃掉那顆子為基準來翻子。

以下為一組範例:假設審局函式的配分為:帥(6000)、仕(2700)、相(900)、

炮(2000),目前為對弈的前期盤面,大部分的子都在,假設帥已翻出,程式在 走黑方時會想辦法吃帥,吃帥有兩種吃法:1.用卒、2.用包:

1. 用卒吃:翻帥周圍的子,用卒吃帥的期望值為:能吃掉對方的卒有 5 隻,犧 牲的風險有 1 隻將、2 隻士、2 隻象、2 隻包,則獲利估為:5×6000 = 30000,

風險估為:6000+5400+1800+4000 = 17200,期望值為 30000-17200 = 12800。

2. 用包吃:獲利估為:6000×2 = 12000,風險估為:0,期望值為 12000-0 = 0。

兩組分數看起來差不多,但全場有五隻卒而包只有兩隻,所以翻帥隔壁的 位置比翻包打帥更有機會吃到對方主帥,雖然自己也有被吃的風險,以程式算 出來的分數來看,冒險是值得的。當帥被吃掉後,原本第二高分的仕會自動變 成最高分的子,翻子時會自動變成被瞄準的對象。以防守的角度來看,程式也 會以保護自己大子為前提來翻子。

開始寫程式前,我下棋也是以翻包打帥為主要戰略,但是 DarkCraft 寫出 來後程式常翻帥旁邊,原本以為哪邊有 Bug 算錯了,在大量人機測詴後,人類 玩家的大子經常會在前期被電腦以這種方法吃光,而在正式比賽上也有不錯的 表現,獲勝的局面都是在前期就將對方的大子吃光,中盤就贏了,如圖 7.5、7.6:

圖 7.5 DarkCraft 比賽獲勝盤面一

圖 7.6 DarkCraft 比賽獲勝盤面二

第二節 向上傳遞更新法

傳統暗棋程式的審局方法為:每當搜尋到葉節點時,將盤面上所有的子之 子力分數加起來。若一個盤面搜尋時大部分的葉節點都沒有發生吃子狀況,它 還是需要將盤面重新計分,同樣的加法會重複很多次,非常沒有效率,但是暗 棋有一個特性:只有在吃子時審局分數才會改變,本研究以此特性為暗棋程式

做了改良。原本的搜尋是在搜尋到葉節點時為盤面審局,如下所示:

if(depth ==0)

return Evaluate();

新的方法如程式碼 7.3:搜尋到葉節點時傳回 0,搜尋途中若發生吃子狀況,

就將該子的分數(weight[cap])加到遞迴時用到的 value 上面,並累積 value 的值

再往上傳遞,這樣可以讓程式省掉很多不必要的計算,增加程式搜尋速度。

Search(int depth){

if(depth ==0)

return 0; //這邊改為零 for(MoveLeft()){

cap = NextMove(); //如果不是吃子步則 cap 為 0,

if(cap!=0){ //如果是,cap 為被吃子的編號。

value += weight[cap]; //將被吃子的分數累積起來往上傳 }

value ‐= Search(depth - 1); //這裡是 value = value ‐ Search(depth - 1);

} }

程式碼 7.3 程式重點修改部位,其他部位與第三章的 Nega-Max Search 相同。

要注意的地方為:由於使用加法累積,我方的子被吃時對我方不利,在 weight[ ]中我方的子為負分,對方的子為正分,和傳統審局的分數相反。

第三節 同分步處理

在搜尋的過程中很容易出現同分走步的狀況,以下為簡單的範例盤面:

● ● ●

● ● ●

● ● 士 ●

● ● 相 ●

● ● ●

● ● ●

● ● ●

● ● 將 ● 圖 7.7 同分走步範例盤面

假設我方為黑方,吃掉相可以獲得 900 分,在這個盤面裡,士只需走一步 就可以吃掉相,而將必頇走四步才吃得到相,如果沒有特殊處理的話,用哪顆 子吃掉相都可以得到 900 分,但是一般 MoveGen( )產生走步時,我們都會先產

生大子的走步,在這個範例中將的走步會在士的走步前面,而演算法中:

if(value > best){

best = value;

}

這個判斷會讓程式選擇移動將,因為兩邊都是 900 分,所以程式不會選擇士的

900 分,這與我們策略裡「用最快的速度,將對手的子吃掉」相違背,我們要 用最快的速度把對手的子吃掉才行。以這個盤面為例,如果當我們花四步移動 將時,對手在相的旁邊翻出帥來,我們就較難吃掉相了。

要改善這種狀況只如程式碼 7.4 需改一行程式即可。

Search(int depth){

if(cap != 0){

value += ( weight[cap] + maxDepth – depth); //改這行即可 }

}

程式碼 7.4 同分步處理

程式碼 7.4 為第二節的 Search 使用同分步處理需要修改的部分,其他地方 跟原本一樣。幫「weight[cap]」加上額外的分數「maxDepth – depth」,假設範 例盤面搜尋八層,則 maxDepth 就是 8,士在第一層就吃掉相,depth 為 1,則 用士吃可以額外得到 8-1=7 分,而將要在第四層才可以吃到相,則用將吃只能 額外得到 8-4=4 分,這樣用比較淺層的士吃可以得到 907 分,而用比較深層的 將吃可以得到 904 分,程式就會選擇使用較近的士去吃相了。

第四節 單向搜尋

經常會有我方子離對方太遠而搜尋不到的狀況,圖 7.8 為一個簡單的範例盤面:

將 ●

● 相 ● 圖 7.8 單向搜尋範例盤面

圖 7.9 吃不到的例子

如果程式的搜尋深度是十層,搜尋時黑方只能走五步,紅方也走五步,這 個盤面黑方要走七步才能吃掉相(不考慮翻子狀況),這樣不管怎麼搜尋,黑方 都找不到能吃掉相的走法,而且黑方每個候選步的分數都是吃不到子的 0 分,

原本能吃到相的將就不會朝著相的方向前進,也就無法吃到相了。

如果每個候選走步的分數都是 0 分的話,就可以使用單向搜尋法,擴大自 己吃子的搜尋範圍。程式和原本的搜尋一模一樣,只是在遞迴呼叫時把「換邊」

的動作拿掉,如果要搜尋十層而我方是黑方,就讓黑方連續走十步,這樣就可 以解決範例盤面的問題了。

這個方法只能讓自己的大子在搜尋不到吃子步時,會有方向性的朝對方能 被自己吃的子前進,不代表一定能吃到對方。圖 7.8 中,將跟相的曼哈頓距離

第五節 寧靜搜尋

一般搜尋演算法的深度有限,在一定深度之後的狀況很有可能被忽略。例 如我們搜尋深度為八層,最好的一手為 800 分,但如果我們走這步,對方在第 九手時可以把我們的大子吃掉讓我們變 0 分,我們得到的 800 分就是錯的結果。

我們使用寧靜搜尋來解決這種問題,當搜尋到最深的地方時,如果還有吃

子狀況發生,我們就繼續搜尋直到沒有吃子狀況為止,如程式碼 7.5 所示。

Search(int depth){

if(cap != 0){ //發生吃子狀況 value += ( weight[cap] + maxDepth – depth);

if(depth == (maxDepth-1)) //倒數第二層不減少搜尋層數 value ‐= Search(depth);

else

value ‐= Search(depth - 1); //減少搜尋層數 }

}

程式碼 7.5 寧靜搜尋

程式碼 7.5 為第二節的 Search 使用寧靜搜尋需要修改的部分,其他地方跟 原本一樣。如果搜尋到倒數第二層發生吃子狀況,則遞迴呼叫時不減少搜尋層 數 depth,就可以額外多搜尋一層,達到寧靜搜尋的效果。

第八章 結論與未來方向

相關文件