实际上现在的棋类AI都是采用了效率更高的算法(如蒙特卡洛树搜索)+Deep Learning实现。今天我们只探讨较简单的五子棋AI,大致有两种算法:五元组和博弈树。
第一节 Java 类与对象以及继承
第二节 Java 对象的保存和传递
第三节 Java 数组和列表的使用
第四节 Java 五子棋AI博弈树算法
五子棋UI界面设计和博弈树的概念在这里不做介绍(参考博客:五子棋人机对战(Java项目)),博主的UI具体效果如下:
我们先来梳理一下五子棋AI的设计流程:二者博弈,就是一场利益争夺战,那么最终结果就看博弈双方谁能够获得最大的价值。那么下棋前计算机需要遍历能够落子的点,然后找到价值最大的落子点。
那么现在又诞生一个问题:如何定义价值最大?
实际上不管是五元组还是博弈树,都需要有一个相应的“价值最大”的评分制度,我们把这个叫做权值表。
例如(只列举部分情况):
活三 | 活四 | 连五 |
---|---|---|
500 | 10000 | 50000 |
实际上,这就是建立了一种棋子状态与得分的映射关系,我们当然可以用 if - else if 语句来进行加分,也可以利用HashMap来建立这种映射关系。
HashMap是Java中最常用的集合类框架,也是Java语言中非常典型的数据结构。它的底层实现逻辑以及其特点我们不在本文介绍,现在我们只介绍一下HashMap的使用方法:
首先我们需要创建一个HashMap对象:
HashMap<K, V> hm = new HashMap<>();
显然HashMap创建时有使用到我们上一节介绍的“泛型”。(第三节 Java 数组和列表的使用)
显然,五子棋的权指表需要将其中的 K 定为String,V 定为Integer:
HashMap<String, Integer> hm = new HashMap<>();
之后就是添加权值对。与ArrayList类似,我们只需要用put方法即可添加需要的权值对:
public static void main(String[] args) {
HashMap<String, Integer> hm = new HashMap<>();
hm.put("yes",1);
hm.put("no",0);
}
之后使用get方法便可调取到映射:
public static void main(String[] args) {
HashMap<String, Integer> hm = new HashMap<>();
hm.put("yes",1);
hm.put("no",0);
String s = "no";
int a = hm.get("yes");
int b = hm.get(s);
System.out.println("a "+a);
System.out.println("b "+b);
}
a 1
b 0
得益于自动拆箱机制,a和b成功提取到对应Integer值。
学会使用HashMap后,现在我们可以着手获取落子状态了。首先我们可以把棋盘看做是15*15的二维数组,其中每个位置上黑棋为1,白棋为2,无棋为0。
对于博弈树算法,我们只需要计算每次落子时带来的收益,我们可以先假设计算机在一处落子,五子棋的规则表明,一个子可以在四个方向上影响周围子:上——下、左——右、左上——右下、右上——左下。因此我们需要获取的落子状态为落子后在四个方向上棋子的顺序。
这里介绍博主的思路:以水平方向(左——右)为例
public String[] search(String[] SearchArr, byte[][] seat, int X, int Y) {
String Evel = ""; //保存水平方向排序
String Vertical = ""; //保存竖直方向排序
String LeftCross = ""; //保存正斜向排序
String RightCross = ""; //保存反斜向排序
String Evel = "";
byte color = 0;
int i,j;
color = seat[X][Y]; //seat数组保存当前棋局状态,类型为byte
Evel += seat[X][Y]; //保存当前位置棋子颜色
//水平方向
//向左遍历
for(i=X-1,j=Y;i>=0;i--) {
if(0 == seat[i][j]) { //判断相邻是否落子
break;
}else if(seat[i][j] != color) { //判断相邻是否异色
Evel = seat[i][j] + Evel; //int、byte等类型会自动转为String类型保存
break;
}else if(seat[i][j] == color) {
Evel = seat[i][j] + Evel;
}
}
//向右遍历
for(i=X+1,j=Y;i<NUM;i++) {
if(0 == seat[i][j]) { //判断相邻是否落子
break;
}else if(seat[i][j] != color) { //判断相邻是否落子
Evel += seat[i][j];
break;
}else if(seat[i][j] == color) {
Evel += seat[i][j];
}
//竖直方向 略
//左上——右下方向 略
//右上——左下方向 略
SearchArr[0] = Evel;
SearchArr[1] = Vertical;
SearchArr[2] = LeftCross;
SearchArr[3] = RightCross;
return SearchArr;
}
之后按照相同的思路,把四个方向的String全部保存即可。
博主的思路比较简单,没有考虑将“0”也保存进棋局,因为这样会使得棋子排序种类特别繁多,构建权值对将成为难题,当然如果考虑更多更全面的情况,AI的水平也会更高。
public void setHashMap() {
hm.put("1", 0);
hm.put("2", 0);
hm.put("12",2);
hm.put("21", 2);
hm.put("212",1);
hm.put("121", 5);
hm.put("11",20);
hm.put("22", 20);
hm.put("112", 10);
hm.put("211", 10);
hm.put("122", 10);
hm.put("221", 10);
hm.put("2112", 5);
hm.put("1221", 5);
hm.put("111",200);
hm.put("222", 200);
hm.put("1112",12);
hm.put("2221", 12);
hm.put("2111",12);
hm.put("1222", 12);
hm.put("21112",5);
hm.put("12221", 5);
hm.put("1111",15000);
hm.put("2222", 15000);
hm.put("21111",120);
hm.put("12222", 120);
hm.put("11112",120);
hm.put("22221", 120);
hm.put("211112",5);
hm.put("122221", 5);
hm.put("11111",50000);
hm.put("22222",50000);
hm.put("211111",50000);
hm.put("122222",50000);
hm.put("111112",50000);
hm.put("222221",50000);
hm.put("2111112",50000);
hm.put("1222221",50000);
}
权值的设置博主没有仔细研究,权值设定仅供参考。
之后便是撰写getValue函数获取落子价值。
private int getValue(int x, int y) {
int value = 0;
String[] SearchArr = new String[4];
SearchArr = search(SearchArr, seat, x, y);
//如果有五连则直接加50000(防止六连或更多连等极端情况)
if(SearchArr[0].contains("11111") || SearchArr[0].contains("22222"))
value += 50000;
else if(SearchArr[1].contains("11111") || SearchArr[1].contains("22222"))
value += 50000;
else if(SearchArr[2].contains("11111") || SearchArr[2].contains("22222"))
value += 50000;
else if(SearchArr[3].contains("11111") || SearchArr[3].contains("22222"))
value += 50000;
else {
value += hm.get(SearchArr[0]);
value += hm.get(SearchArr[1]);
value += hm.get(SearchArr[2]);
value += hm.get(SearchArr[3]);
}
return value;
}
public int[] doAI(int AIcolor) {
int seatValue[][] = new int[NUM][NUM];
for(int n=0; n<NUM; n++) {
for(int m=0; m<NUM; m++) {
if(seat[m][n] != 0) { //检测是否有棋子
continue;
}
seat[m][n] = (byte)AIcolor;//如果没有棋子,假设下棋,并获取价值
seatValue[m][n] = getValue(m, n);
seat[m][n] = 0; //还原假设的落子
}
}
int maxValue = 0;
int X=0,Y=0;
for(int n=0;n<NUM;n++) {
for(int m=0;m<NUM;m++) {
if(maxValue<seatValue[m][n]) {
maxValue = seatValue[m][n];
X = m;
Y = n;
}
}
}
int[] bestSeat = {X,Y};
return bestSeat;
}
显然,这里AI只进行了1步预测,因此获得的落子得分只是估值,可能在几步之内有更大价值的落子点,我们可以在落子之后继续预测对方落子点,以此来获得更加精确的落子估值。这就需要通过递归算法实现,理论上,只要遍历深度足够,我们便能够得到所以落子的准确得分。但实际上遍历次数是随着深度成指数性增长的,我们必须优化算法来舍弃那些一眼就无需遍历的点:
public int[] doAI(int AIcolor, int deep) {
int[][] seatValue = new int[NUM][NUM];
for(int n=0; n<NUM; n++) {
for(int m=0; m<NUM; m++) {
if(seat[m][n] != 0) { //检测是否有棋子
continue;
}
int num = 0; //检测周围2格内是否有棋子,若没有则不考虑该落子情况
for(int i = (n-2>0? n-2:0);i<NUM && i<n+2;i++)
for(int j= (m-2>0? m-2:0);j<NUM && j<m+2;j++)
num += seat[i][j];
if(0==num) {
continue;
}
seat[m][n] = (byte)AIcolor; //如果没有棋子,假设下棋
seatValue[m][n] += getValue(m,n); //获取假设落子后的分数
int[] fore = new int[2];
if(BLACK==AIcolor) {
if(deep>=0)
fore = doAI(WHITE, deep-1); //deep不为负则继续预测
else
fore = doAI(WHITE); //deep为负便不再预测
seat[fore[0]][fore[1]] = (byte)WHITE;
seatValue[fore[0]][fore[1]] += getValue(fore[0], fore[1]);
seat[fore[0]][fore[1]] = 0;
}
else if(WHITE==AIcolor) {
if(deep>=0)
fore = doAI(BLACK, deep-1);
else
fore = doAI(BLACK);
seat[fore[0]][fore[1]] = (byte)BLACK;
seatValue[fore[0]][fore[1]] += getValue(fore[0], fore[1]);
seat[fore[0]][fore[1]] = 0;
}
seat[m][n] = 0; //还原假设的落子
}
}
int Value = 0;
int X=0,Y=0;
for(int n=0;n<NUM;n++) {
for(int m=0;m<NUM;m++) {
if(Value<seatValue[m][n]) {
Value = seatValue[m][n];
X = m;
Y = n;
}
}
}
int[] bestSeat = {X,Y};
return bestSeat;
}
到这里,我们基于博弈树的五子棋AI算法基本完成。
以上就是今天要讲的内容。
实际上,当deep=1时(往后思考3步),每一步计算的时间已经有好几秒了,甚至复杂的情况需要十几秒,这时电脑已经具备了和常人下棋的能力。
当deep=2 时需要的时间已经长得离谱了。读者可以在此基础上继续优化,提高电脑的计算效率。
最后放上源代码以及打包完的exe文件(复盘功能无效):读者可以自行游玩。
链接:https://pan.baidu.com/s/1yxRKxMCmaLhXZ7P0MEAQOg
提取码:fqwu