前言:
這是一次偶然與必然的相撞,在我學習了諸多算法之後,對於極大極小值搜索這個命題依然兩眼一抹黑,那天在51nod上刷題映入眼簾第一題就是 [...在一個3*4的棋盤上下三子棋,問第一步的走法和輸贏結論...] 對搜索算法的執念和做題的偶然遇見促使我使用這個從未接觸過的算法去解答它.在花費兩天時間作出這道題之後,既然三子棋有了,爲什麼不做做五子棋呢?
以上,就是一位拖延症患者手擼五子棋遊戲的動機.
搜索算法:
從算法而不是編程的角度來看,一個五子棋引擎其最關鍵的東西是引擎,這個引擎要實現局面的判斷和評分,要實現思考和走子,最終將一切內涵封裝爲幾個簡單的命令,供UI使用,那我們就從真空中的理想球形雞開始,什麼是搜索算法
在解集空間中尋找最優解的方法就是搜索算法
解集空間通常被描述爲一棵樹,其葉子結點描述爲一個int,值越大越好,搜索算法的目的就是在所有的葉子結點中找到最大值
然而對弈問題並不是一個簡單的搜索,因爲你想贏對手也想贏,雙方都希望自己取得的值最大,反過來看,就都希望對方取得的值最小,基於這種問題的搜索叫 [...極大極小值搜索...]
對於上面那個問題的解決,代碼並不長,只有一百多行,就直接貼在這裏好了
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <stdlib.h>
#include <iostream>
#include <windows.h>
int run(int, int, int);
int run_next(int);
int source(int, int, int);
using namespace std;
int table[3][4] = {0};
int main(void) {
for(int i=0; i<3; i++) {
for(int j=0; j<4; j++) {
int result = run(i, j, 1);
printf("i:%d, j:%d, run::%d\n", i, j, result<0 ? -13-result : 13-result);
}
}
return 0;
}
// 判斷當前局面是否結束
int source(int x, int y, int test) {
// 由當前打入點構成勝負手的情況
// 橫
for(int j=max(0, y-2); j<=min(3, y+2); j++) {
if(j==y or table[x][j]==test) continue;
goto GOTO1;
}
// 如果都符合就退出並返回1,只要有任一不符合,跳至下一個判定
return 1;
GOTO1:
// 豎
for(int i=0; i<=2; i++) {
if(i==x or table[i][y]==test) continue;
goto GOTO2;
}
// 如果都符合就退出並返回1,只要有任一不符合,跳至下一個判定
return 1;
GOTO2:
// 正斜
if(y-x<0 or y-x>1) goto GOTO3;
for(int i=0; i<=2; i++) {
if(i==x or table[i][y-x+i]==test) continue;
goto GOTO3;
}
// 如果都符合就退出並返回1,只要有任一不符合,跳至下一個判定
return 1;
GOTO3:
// 反斜
if(x+y<2 or x+y>3) goto GOTO4;
for(int i=0; i<=2; i++) {
if(i==x or table[i][x+y-i]==test) continue;
goto GOTO4;
}
// 如果都符合就退出並返回1,只要有任一不符合,跳至下一個判定
return 1;
GOTO4:
return 0;
}
// 求解主觀審局結果
int run_next(int myturn) {
int best = -13;
for(int i=0; i<3; i++) {
for(int j=0; j<4; j++) {
// 棋盤格已經被佔用,跳過
if(table[i][j]) continue;
// 獲得主觀評價並取反
int next = run(i, j, myturn);
// 只要有一種走法能贏,就不用看其它走法了
// 現在要找最短步數
// if(next==1) return 1;
// 否則修正最佳值
best = max(best, next);
}
}
// 否則返回最佳結果
return best;
}
int dept = 1;
// 走子並進行主觀審局
int run(int x, int y, int myturn) {
// 此函數中myturn用於剛剛落子的這一步,因此直接代表當前步
// 如果走完直接取勝
if(source(x, y, myturn)) return 13-dept;
// 棋盤佔滿了未分勝負判定爲平局
if(dept == 12) return 0;
// 否則進一步探索
table[x][y] = myturn;
dept ++;
// 當前局面的主觀評定
int result = -run_next(-myturn);
dept --;
table[x][y] = 0;
return result;
}
當然,上述代碼中出現了不文雅的goto語句,這主要是因爲我淺薄的C++姿勢水平不知道該怎樣實現while...else結構,總之註釋很清晰,注意到其中只有四個函數,main函數不必說,run和run_next互相調用共同實現搜索,source函數則用於判定局面是否已經結束(某方取勝)
五子棋
很容易發現,上述三子棋的搜索邏輯可以輕鬆擴展到五子棋中,我們只需要解決這樣的兩個問題就好:
1.如何在勝負未分的情況下評價一個局面得分
2.如何避免無意義的搜索
這種時候就要學習別人的先進經驗了.
第一個問題,我們可以採取這樣的方式:搜索整個棋盤,檢查我有多少活三,衝四甚至活四,活躍的鏈接點越多,評分越高,這既符合我們的直覺,實測也很有效.
第二個問題,則需要對當前落子做一個即時評估,優選那些落子價值更高的位置,這也稱爲是一種"啓發式算法"
那麼這兩者能不能融合到一起去呢?我看行.
評分設計
可以設定三張棋盤,一張用於表示棋盤當前狀態,取值'A','B','\0',爲了便於處理邊界問題,用四圈'X'包起來,看起來就像這樣:
另外兩個表格則分別對應A的位置評分圖和B的位置評分圖
同樣是爲了便於處理,已有棋子的位置分值直接取反,這樣便於回退局面時復原分數,例如上述棋型,我給出的位置評價分爲:
A:
B:
越靠近戰鬥羣的位置評分越高,也就是說只要將算力集中在兩個棋盤共同的高分區域,就可以避免無用功.同時,兩個棋盤的分數之差可以作爲局面的最終評分.
評分實現
// 評分
void dir_check(int add_flag, char mine, int x, int y, int dx, int dy, int board[][MAX_MN]) {
// 先找空位
int count = 1;
int a = x + dx;
int b = y + dy;
while(fullboard[a][b] == mine) {
a += dx;
b += dy;
count ++;
}
// 封死退出
if(fullboard[a][b] != '\0') return;
// 反向探測
int count_before = 1;
// 反向最大連續
while(fullboard[x - dx*count_before][y - dy*count_before] == mine) {
count_before++;
}
// 反向是否堵死
bool lin = fullboard[x - dx*count_before][y - dy*count_before] == '\0';
// 正向探測
char next = fullboard[a + dx][b + dy];
if(next == '\0') {
// 空位
if(count == 2) { // 很詭異,但似乎只有 count == 2 的時候有必要進行平衡, 這是因爲我只給兩格以內的位置進行打分
board[a][b] += add_flag * scores[0];
}
while(count_before--) {
// 去重, 我也不知道爲什麼
if(count_before > count) board[a][b] -= add_flag * scores[count + count_before - 2];
board[a][b] += add_flag * scores[count + count_before];
board[a+dx][b+dy] += add_flag * scores[count + count_before - 1];
}
// board[a][b] += add_flag * scores[count + count_before];
} else if(next != mine) {
// 死位
// 注意,此處判定的是 count + count_before - 1 == 4
if(count + count_before == 5) board[a][b] += add_flag * scores[4];
else if(lin and count + count_before == 4) board[a][b] += add_flag * scores[2];
} else {
// 續位
// cout << board[a][b] << endl;
int p = a + dx;
int q = b + dy;
while(fullboard[p][q] == mine) {
count++;
p+=dx;
q+=dy;
}
if(fullboard[p][q] == '\0') {
while(count_before--) {
board[a][b] += add_flag * scores[count + count_before];
}
} else {
// 注意,此處判定的是 count + count_before - 1 == 4
if(count + count_before == 5) board[a][b] += add_flag * scores[4];
else if(lin and count + count_before == 4) board[a][b] += add_flag * scores[2];
}
}
}
// 步數記錄
static int steps_x[MAX_MN*MAX_MN] = {0};
static int steps_y[MAX_MN*MAX_MN] = {0};
static int step = -1;
static const int dir_x[] = {1, 0, 1, 1, -1, 0, -1, -1};
static const int dir_y[] = {0, 1, 1, -1, 0, -1, -1, 1};
// 走子並修改分值
void move(bool myturn, int x, int y) {
// 落子
fullboard[x][y] = myturn ? 'A' : 'B';
steps_x[++step] = x;
steps_y[step] = y;
// 計分板扣置,用於恢復
myboard[x][y] = -myboard[x][y];
hisboard[x][y] = -hisboard[x][y];
int (*board)[MAX_MN]= myturn ? myboard : hisboard;
char mine = myturn ? 'A' : 'B';
// 八個方向
for(int d=0; d<8; d++) {
dir_check(1, mine, x, y, dir_x[d], dir_y[d], board);
}
}
// 返回上一步走子
void back() {
int x = steps_x[step];
int y = steps_y[step--];
bool myturn = fullboard[x][y] == 'A';
fullboard[x][y] = '\0';
myboard[x][y] = -myboard[x][y];
hisboard[x][y] = -hisboard[x][y];
int (*board)[MAX_MN]= myturn ? myboard : hisboard;
char mine = myturn ? 'A' : 'B';
for(int d=0; d<8; d++) {
dir_check(-1, mine, x, y, dir_x[d], dir_y[d], board);
}
}
這個評分函數稍顯複雜,並且並不完美,但其核心思想是簡單的:每落下一個棋子,修改棋盤上相關聯位置的評分,以期修改後的評分能夠很好地同時反應當前局面優劣,和當前關鍵的落子位置
TopK
避免無意義搜索的核心在於優選那些有意義的搜索位置.現在棋盤上有兩百個座標,每個座標都對應一個分值,唯一要做的是對這些分值進行排序,很容易用一個堆排序算法實現:
class Topk
{
public:
Topk(int k) :k(k), size(0), min(0), values(new int[2*k]){
}
~Topk(){
delete this->values;
}
// 帶着座標一塊push
void push(int value, int pos) {
// 已滿
if(size == k) {
// 此值更大
if(value > values[0]) {
// 覆蓋頂值重排
retop(value, pos);
}
} else if(size == 0) {
// 沒元素直接加進去
values[k+size] = pos;
values[size++] = value;
} else {
// 插入並自下向上重排
int t = size++;
// this->values[t] = value;
int p;
do {
p = (t-1)/2;
// 前K大,使用小頂堆
if(values[p] <= value) break;
values[k+t] = values[k+p];
values[t] = values[p];
t = p;
} while(t > 0);
values[k+t] = pos;
values[t] = value;
}
}
// 返回座標
int pop_back() {
int result = values[k];
// 減小規模
int value = values[--size];
int pos = values[k+size];
// 覆蓋頂值重排
retop(value, pos);
return result;
}
bool empty() {
return this->size == 0;
}
int nums() {
return this->size;
}
// 消費技能,消費過後數據結構將消失
int* take() {
int find = 0;
while(++find < size) {
int val = values[find];
int pos = values[k+find];
int here = find;
while(here and val > values[here-1]) {
values[here] = values[here-1];
values[k + here] = values[k + here];
here--;
}
values[k + here] = pos;
values[here] = val;
}
return values;
}
private:
const int k;
int size;
int min;
int* const values;
void retop(int value, int pos) {
// 自上向下重排
int p = 0;
int s = p*2 + 1;
while(s < size) {
// 從在右子節點中選擇更小的那個
if(s+1<size and values[s] > values[s+1]) s += 1;
// value比左右子節點都小,則結束
if(values[s] > value) break;
values[k+p] = values[k+s];
values[p] = values[s];
p = s;
s = p*2 + 1;
}
values[k+p] = pos;
values[p] = value;
}
};
由於我們的topk要同時處理權值(評分)和信息(座標),我用前k位記錄評分,後k位記錄座標,並且對座標的x和y進行狀態壓縮...在將來對搜索效率進行進一步優化時,我們可以將棋盤座標從二維改成一維的,於是我們的topk標準類就不需要再做調整.
THINK
其實到這裏,我們的五子棋算法引擎就算是搞定了,回顧上面的過程:
三子棋遊戲爲我們提供了極大極小值搜索的實現; 走子引擎直接爲我們提供了局面評價; topk算法爲我們提供了基於局面評價的快速優選.
最後一步只剩下整合:
// 遞歸思考
int loop_think(bool myturn, int h, int score_limit) {
if(h == 0) {
return min(score_limit, judge(myturn));
}
int (*board)[MAX_MN] = myturn ? myboard : hisboard;
int (*other_board)[MAX_MN] = myturn ? hisboard : myboard;
// 從價值網絡中取topk
Topk tp(K);
for(int i=E; i<N-E; i++) {
for(int j=E; j<M-E; j++) {
if(board[i][j] <= 0) continue;
// 直接取勝,返回WIN並使用VWIN計算步數,此處所需步數爲1
if(board[i][j] >= TO_WIN) {
// 設定STEP是爲了屏蔽背景分
return VWIN - STEP;
}
// 敵之要道即我之要道, 但我之要道分數更高
int val = board[i][j]*2 + other_board[i][j];
// 座標肯定小於64,2的6次方已經足夠
int pos = (i<<6) | j;
// 我不確定在這裏是否有必要對提前退出循環進行優化,簡單起見,先不優化
tp.push(val, pos);
}
}
if(tp.nums() == 0) {
cout << "WHAT???" << endl;
return 0;
}
int *p = tp.take();
// 我的絕殺步已經返回,這裏還能比TO_WIN大肯定是他的絕殺步
// 也可能是我的多重絕殺? 恐怕不行...
if(*p >= TO_WIN) {
move(myturn, p[K]>>6, p[K]&63);
// 必走招法的遞歸不降低遞歸層數, score_limit是全局打分,在誰的回合都能打,所以不管
int score = -loop_think(not myturn, h%2==0?h-1:h+1, -score_limit);
back();
// 減小絕對值
return score>0 ? score-STEP : score+STEP;
}
// 梯隊評估分, 單步評分永遠是正數
int good = (*p) / 2;
// 全局評分
int max_score = -VWIN;
for(int* end=p+tp.nums(); p!=end; p++) {
// 優先梯隊已經算出優質解,無需繼續評估
if(*p < good and max_score >= CAN_WIN) break;
move(myturn, p[K]>>6, p[K]&63);
int score = -loop_think(not myturn, h-1, -max_score);
back();
// 阿爾法貝塔剪枝
if(score >= score_limit) {
// 此時我返回去的數取反一定比對手的預期要低,因此對手不會選擇這個分支
return score_limit;
}
// 一個 score 達到 WIN 時會立即返回, 因此score_limit比WIN大隻有兩種情況
// 1.第一輪深搜,預設定爲 -VWIN
// 2.怎麼走都輸,尋找最強應對
if(score >= WIN) {
return score - STEP;
}
if(score > max_score) {
max_score = score;
}
}
// 分數向零靠近
return max_score>0 ? max_score-STEP : max_score+STEP;
}
至此五子棋引擎基本完成,其實還有一些問題,比如人類玩家走了一步棋,衝四,此時AI有必要思考嗎?無論如何都必須要堵住這個衝四對不對?那就沒有必要再去思考其它招法了.對這些必選招法的剪枝並不適合放在loop函數中,因此在獨立的excute函數中描述它.
然後既然是引擎,還需要與UI層進行交互,對交互命令進行封裝等等.完整的代碼參見:
https://gitee.com/jassor/my_code/tree/master/%E4%BA%94%E5%AD%90%E6%A3%8B
目前仍然有很多問題待解決如:剪枝不合理,計算層數淺,評分不合理,不支持規則,UI簡陋等等
但它實際上已經得到實現,下一步的任務並不是在這個簡陋的傳統算法上修修補補,而是嘗試用更新的,更先進的算法去翻新它!