五子棋AI
前言:
这是一次偶然与必然的相撞,在我学习了诸多算法之后,对于极大极小值搜索这个命题依然两眼一抹黑,那天在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简陋等等
但它实际上已经得到实现,下一步的任务并不是在这个简陋的传统算法上修修补补,而是尝试用更新的,更先进的算法去翻新它!
上一篇: 第一次写博客
下一篇: JAVA趣味编程-99乘法表