简介:爱因斯坦棋是一份基于Java实现的棋类AI学习资源,面向具备基础编程能力、希望理解博弈树搜索算法的开发者。项目以命令行方式运行,完整呈现了棋盘建模、规则校验、Minimax决策与Alpha-beta剪枝等核心模块,适合作为课程设计或算法练手的参考实例。压缩包共13个文件,包含4个Java源文件、8个编译后的class文件及1个备份文件,整体大小仅16KB。源文件便于阅读修改,class文件可直接运行验证,备份文件则保留了一个版本的实现对照。目前已有3449人学习下载,资源虽小但麻雀虽全。通过研读这些代码,可快速掌握棋类AI的基本架构,包括状态评估、走法生成与递归搜索的实现技巧,同时也能借鉴作者对游戏循环和输入输出处理的简化设计。
1. 爱因斯坦棋AI:一个看起来简单、跑起来全是细节的博弈搜索项目
接手一个用Java写的爱因斯坦棋AI项目时,我的第一反应是:这不就是一个人工智能入门的完美练手题吗?棋盘只有5x5,九颗编号棋子,双方轮流掷骰决定能走哪颗,规则看着简单,但等走法生成、评估函数和搜索深度真正串起来时,胜率、速度和代码结构之间全是取舍。这份资源解决的核心问题,就是教你用Java把爱因斯坦棋从桌面棋盘搬到代码里,让AI获得能打赢普通人类玩家的搜索能力。适合刚跑通过Minimax但没在带随机性的棋类上实操过的开发者,也适合想拿小型博弈项目验证算法差异的熟手。
2. 规则建模与棋盘表示:先让代码“认识”这个棋
2.1 爱因斯坦棋的常见规则与搜索空间量级
爱因斯坦棋的规则并没有全球统一的标准,我这套实现采用的变体是:双方各持编号为1到9的九颗棋子,己方放在棋盘靠近自己一侧的两行,对方对称排列。每回合掷一次六面骰,得到点数1到6。如果己方棋盘上还存在着编号与该点数相同的棋子,就必须移动那一颗;如果编号对应的棋子已经被吃掉,就可以从剩余的己方棋子中任选一颗移动。
移动本身分两种:上下左右走一格,目标格为空或敌方棋子时都能落子,落到敌方棋子上就是吃子;另外,当骰子点数恰好等于棋子编号时,这颗棋子还可以向正前方斜走一格。任何一方的任意棋子到达对方的底线,立即获胜,不需要把对方吃光。
从搜索的角度看,这个游戏的分支因子并不大。骰子有6个面,每个面下可动的棋子通常是1颗,有时是9颗任选,每颗棋子的合法落点平均3到5个。粗略估算,单层分支数在10到20之间。深度6层时,最坏节点量级在百万以上,如果递归到8层,不加剪枝的穷举在普通笔记本上是跑不完的。这也是后面选择NegaMax加Alpha-Beta剪枝的根本原因:棋类AI的棋力不只看评估函数,更看能不能在规定时间里多看几步。
2.2 Java棋盘状态:int数组、位棋盘还是枚举
棋盘状态表示是这个项目第一个要做的技术选型。常见方案有三种:二维int数组、位棋盘和状态压缩枚举。
位棋盘在五子棋和黑白棋里很流行,因为一个long就能表示整行棋形。但爱因斯坦棋的每颗棋子带编号,棋盘本身又是5x5的不对称结构,位棋盘需要拆成多段bits来存坐标和编号,读写都绕。状态压缩枚举效率最高,但调试时你要在脑子里把一段二进制解出来,对新手太不友好。
我最后选了最直观的int[5][5]。每个格子存一个整数:0表示空,正数1到9表示己方棋子,负数-1到-9表示对方棋子。这样吃子就等价于把目标格的值从负数改成己方棋子的正数,判断归属只需要看正负号,非常直接。5x5总共25个格子,一次克隆只是25个int的拷贝,性能完全够用。
public class Board { public static final int SIZE = 5; public static final int EMPTY = 0; // 正数:己方棋子(1~9),负数:对方棋子(-1~-9),0:空 public int[][] grid = new int[SIZE][SIZE]; public int turn; // 0 = 底部方(己方),1 = 顶部方(对方) public Board() { int[] home = {1,2,3,4,5,6,7,8,9}; shuffle(home); for (int c = 0; c < SIZE; c++) grid[3][c] = home[c]; for (int c = 0; c < 4; c++) grid[4][c] = home[5 + c]; int[] away = {1,2,3,4,5,6,7,8,9}; shuffle(away); for (int c = 0; c < SIZE; c++) grid[1][c] = -away[c]; for (int c = 0; c < 4; c++) grid[0][c] = -away[5 + c]; turn = 0; } public Board copy() { Board b = new Board(); for (int r = 0; r < SIZE; r++) { System.arraycopy(grid[r], 0, b.grid[r], 0, SIZE); } b.turn = turn; return b; } public boolean inside(int r, int c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; } }这段代码里,棋盘初始化用了shuffle随机打乱,对应真实棋类里"棋子编号随机放置"的变体规则。copy()方法在搜索时高频调用,我实际工程里会维护两个Board实例,用copyInto方法互相复制,减少new对象的GC压力,这个优化后面避坑章会细说。
2.3 走法生成器:骰子点数、吃子与斜前格
走法生成器是搜索引擎的地基。它不负责判断棋好不好,只负责回答"给定当前棋盘和骰子点数,哪些走法合法"。逻辑上有两个关键分支:第一,骰子点数是否对应着在场的己方棋子;第二,能走的方向有哪些。
我实现的时候用了一个forced标志位。先扫描全盘,看有没有编号等于骰子点数的己方棋子。如果有,只收集这颗棋子的走法;如果没有,收集所有己方棋子的普通走法。斜前格只在骰子点数等于棋子编号时额外加上,而且只能向前,不能向后退。
public List<Move> generateMoves(int die) { List<Move> result = new ArrayList<>(); boolean forced = false; for (int r = 0; r < SIZE; r++) { for (int c = 0; c < SIZE; c++) { if (grid[r][c] == die) { // 己方编号等于骰子点数 forced = true; collectCandidate(r, c, die, result); } } } if (!forced) { // 没有对应编号棋子,任选己方棋子移动 for (int r = 0; r < SIZE; r++) { for (int c = 0; c < SIZE; c++) { if (grid[r][c] > 0) collectCandidate(r, c, die, result); } } } return result; } private void collectCandidate(int r, int c, int die, List<Move> out) { int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}}; for (int[] d : dirs) { int nr = r + d[0]; int nc = c + d[1]; if (inside(nr, nc) && grid[nr][nc] <= 0) { out.add(new Move(r, c, nr, nc)); } } if (die == Math.abs(grid[r][c])) { int forward = (turn == 0) ? -1 : 1; for (int dc = -1; dc <= 1; dc += 2) { int nr = r + forward; int nc = c + dc; if (inside(nr, nc) && grid[nr][nc] <= 0) { out.add(new Move(r, c, nr, nc)); } } } }这里的grid[r][c] <= 0同时包含了空格和对方棋子,吃子直接在落子时发生,不需要额外判重。Move对象我在工程里建议做成纯int的不可变结构,或者直接用对象池复用,否则搜索层每秒生成成千上万个对象,GC会拖慢速度。另一个容易忽略的点是:如果generateMoves(die)返回空列表,说明己方已经无子可走,应该在外层直接判负,而不是让搜索继续空转。
3. 搜索与评估:让AI从“会走”到“会赢”
3.1 为什么选NegaMax + Alpha-Beta,而不是穷举
走法生成做好之后,最朴素的做法是穷举:枚举当前骰子点数下的所有走法,每走一步再枚举对方所有骰子面对应的走法,直到深度耗尽。这个做法在深度4时还行,到深度6就已经是百万节点量级,普通电脑一局要等几秒甚至几十秒,完全达不到"人机对弈流畅"的标准。
所以搜索引擎选了NegaMax加Alpha-Beta剪枝。NegaMax是Minimax的一种紧凑写法,两者的核心思路完全一样:假设对手永远走对它最有利、对你最不利的那步棋,你在每一层取对自己最有利的分值。Alpha-Beta剪枝在这个基础上砍掉那些"不可能被选择"的分支,在走法排序理想时,有效节点数可以从指数级降到大约开平方后的量级。
爱因斯坦棋和围棋象棋还有一点不同:它带骰子随机性。严格来说,搜索树里存在两类节点:一类是"掷骰子节点",结果是随机的,需要用六个面的平均值来评估;另一类是"走子节点",在骰子结果确定后是完全确定性的,可以用Alpha-Beta剪枝。把这两类节点混在一起用标准Minimax,棋力会明显打折,因为AI会忽略"某个骰子面下对手可能被迫走出坏棋"的概率价值。
3.2 Java实现带剪枝的搜索核心
我把搜索实现成统一的递归函数,在每一层枚举六个骰子面,对每个骰子面求出该局面下的最佳走法分值,最后取平均值返回。走子节点内部用NegaMax的符号翻转逻辑,并且把Alpha-Beta剪枝应用在走法循环里,而不是骰子循环里。
public class Search { private static final double WIN = 1e9; private static final double INF = Double.MAX_VALUE; public Move bestMove(Board board, int die, int depth) { List<Move> moves = board.generateMoves(die); Move best = null; double bestScore = -INF; for (Move m : moves) { board.apply(m); double v = -search(board, depth - 1, -INF, INF, -1); board.undo(m); if (v > bestScore) { bestScore = v; best = m; } } return best; } private double search(Board b, int depth, double alpha, double beta, int side) { if (depth == 0) return side * evaluate(b); int winner = b.winner(); if (winner != 0) return side * (winner == side ? WIN : -WIN); double total = 0; for (int die = 1; die <= 6; die++) { List<Move> moves = b.generateMoves(die); double bestForDie = -INF; for (Move m : moves) { b.apply(m); double v = -search(b, depth - 1, -beta, -Math.max(alpha, bestForDie), -side); b.undo(m); bestForDie = Math.max(bestForDie, v); if (bestForDie >= beta) break; } if (moves.isEmpty()) { double v = -search(b, depth - 1, -beta, -Math.max(alpha, bestForDie), -side); bestForDie = Math.max(bestForDie, v); } total += bestForDie; } return total / 6.0; } }这段代码有两点要特别说明。第一,winner()必须在深度判断之后、评估函数之前执行,否则AI会错过一步获胜的机会,这个坑后面还会展开。第二,骰子层严格意义上不能做Alpha-Beta剪枝,因为最终要取六个面的平均值,某个骰子面很差并不能代表整个节点被放弃。代码里保留了这个判断,属于工程近似,能在不影响大局的情况下稍微提速。如果你想要精确结果,把if (bestForDie >= beta) break;删掉即可。
apply和undo成对调用是NegaMax搜索不出错的前提。undo时必须记录被吃掉的棋子,否则回退后棋盘状态就错了。我的Move类里存了一个captured字段,专门放目标格原来的棋子值。
3.3 评估函数:推进距离、编号优先级与吃子惩罚
评估函数决定了AI的"棋感"。纯搜索只能看到终局,深度不够时,必须靠评估函数给每个中间局面打分。我给这套实现设计了三个因子:距离底线的推进距离、棋子编号的基础价值、吃子带来的数量优势。
推进距离是核心,因为爱因斯坦棋的胜利条件是抵达对方底线,不是吃光对方。一颗棋子离终点越近,威胁越大。编号的基础价值需要结合规则理解:编号7到9的棋子永远不会被骰子直接激活,它们只能在"骰子点数无对应棋子"时作为自由选择出现,因此自由度反而更高,我给了略高的权重。吃子的价值不用在静态评估里单独算,因为对方棋子被吃后自然从棋盘消失,推进分值已经间接扣掉了,搜索过程会把吃子导致的数量差距体现出来。
| 评估因子 | 权重基准 | 说明 |
|---|---|---|
| 距对方底线每格 | +12 | 越接近底线,价值越高 |
| 编号1到9基础价值 | 0,2,4,6,8,10,15,16,17 | 7到9号棋子自由度更高 |
| 到达底线 | 直接判胜 | 搜索入口处理,不参与静态打分 |
| 吃子 | 由搜索动态体现 | 吃子后对方棋子的推进分从棋盘消失 |
public double evaluate(Board b) { int me = b.turn; double score = 0; double[] pieceVal = {0, 0, 2, 4, 6, 8, 10, 15, 16, 17}; for (int r = 0; r < Board.SIZE; r++) { for (int c = 0; c < Board.SIZE; c++) { int p = b.grid[r][c]; if (p == 0) continue; int owner = (p > 0) ? 0 : 1; int num = Math.abs(p); int dist = (owner == 0) ? r : (4 - r); double advance = 12 * (4 - dist); double base = pieceVal[num]; double sign = (owner == me) ? 1.0 : -1.0; score += sign * (advance + base); } } return score; }这里dist的计算要注意坐标系:底部方的目标行是第0行,距离就是当前行号r,越往下r越大、离胜利越远;顶部方的目标行是第4行,距离是4减r。owner和me比较时,取正负号让当前方为正分、对方为负分。这套权重不是最优解,但作为起点已经能让AI下出有明显进攻意图的棋。后续调参建议先把推进距离权重调整到10到20之间,再动编号基础价值,两者混在一起调很难定位问题。
4. 调试与性能避坑:四个最常见的问题
4.1 递归深度一拉高就StackOverflow
现象:搜索深度从6调到10,程序没跑几步直接抛StackOverflowError,而且报错位置在search函数内部。
原因分两层。第一,递归方法本身每层入栈都会积压局部变量,搜索深度达到10之后,栈帧数量非常可观。第二,真正让它爆掉的往往不是深度本身,而是每个深度里又嵌套了骰子面循环和走法循环,导致节点数爆炸,GC压力陡增,程序在递归还没结束时就提前崩溃。
解决:这套实现里深度6已经够用,强行拉高到10是性价比极低的操作。如果确实需要更深,优先做两件事:一是把evaluate函数里所有局部对象改成基本类型,减少入栈数据量;二是把递归里的List复用掉,不每次new。我一般会把深度上限设为8,再用迭代加深配合时间控制,而不是直接给一个固定大深度。
4.2 Alpha-Beta剪枝失效,深度加了棋力反而降
现象:深度从4加到6,AI走出明显更差的棋,甚至把一个明显的吃子机会给放过了。
原因:Alpha-Beta剪枝严重依赖走法排序。如果每次生成的走法顺序是乱的,搜索只能剪掉很少的分支,深度加上去反而在同样时间里看不到更深的有效分支。更隐蔽的问题在于评估函数:如果它返回的值几乎恒定,搜索会认为所有走法都差不多,最终选到哪步全看列表顺序。
解决:我在bestMove里加了一步排序,把吃子走法和推进类走法排到前面,这一步对剪枝效率的提升立竿见影。另外,调完评估函数后一定要做一次自对弈验证,而不是用一两盘肉眼判断棋力。曾经有一次我把编号价值权重调反了,AI死活不愿推进,就是被自对弈脚本抓出来的。
4.3 棋盘克隆开销大,搜索速度直接腰斩
现象:depth=4时一回合要等十几秒,进程内存占用持续上涨,日志里大量GC记录。
原因:最初版在search里频繁调用board.copy(),每次递归都new一个Board对象,同时Move对象也在大量生成。5x5棋盘看起来小,但搜索树节点量基数大,积少成多就成了性能瓶颈。
解决:改成复用Board实例,搜索开始前准备两个棋盘,apply时把一个棋盘的状态复制到另一个,undo则通过记录captured棋子来回退。Move也改成轻量对象,用int编码表示起点和终点,避免List里塞满堆对象。这套改完,同样深度的搜索时间能缩短一半以上。
4.4 终局判定优先级错误:AI“赢在门前却回头”
现象:棋盘上己方一颗棋子下一步就能落到对方底线,但AI选了一个明显无关的走法,白白错失胜机。
原因:search函数里把depth == 0的判断放在了winner()之前。深度已经耗尽时,递归直接返回静态评估值,而没有检查当前局面是否已经获胜。静态评估给不出足够大的信号,因为一颗棋子在底线前一格和底线后一格的分值差异虽然存在,但不足以压过其他棋子的综合分。
解决:把winner()判断提前到search函数的最前面,一旦检测到胜负立即返回WIN或-WIN。这个值必须远大于任何静态评估可能给出的分数,我用的是1e9。同时注意side符号,当前方获胜要返回正分,对方获胜返回负分,符号搞反会让AI主动送死。从那以后我每次new一个搜索函数,都强制先写胜负判断,再写评估逻辑。
5. 进阶技巧:迭代加深、开局库与自对弈验证
5.1 迭代加深与时间控制
固定深度搜索有一个天然缺陷:你不知道在当前机器上算不完。我的做法是迭代加深,从深度1开始逐层增加搜索深度,每层算完后记录当前最佳走法。如果下一层超时,就返回上一层已经算好的结果。这样AI既能在快棋模式下快速出招,又能在宽松时间里尽量多算几步。
Move best = null; long deadline = System.currentTimeMillis() + 3000; for (int depth = 1; depth <= 8; depth++) { Move m = searchRoot(board, die, depth); if (System.currentTimeMillis() > deadline) break; best = m; }5.2 开局库:前四手不再裸奔
搜索深度再高,开局阶段的随机落子也很容易把自己阵型搞散。我给前四手准备了一个简单的开局库:用棋盘Zobrist哈希做Key,把预置的最佳走法存成Map。程序对局时先查库,命中就直接执行,不进入搜索流程。开局库的走法不需要多复杂,保证前几手能维持阵型就够用。
5.3 用自对弈脚本验证“棋力”
验证AI棋力最有效的手段是让两个相同引擎自对弈。我会让AI执先手和后手各下25局,统计胜率、平均步数和到达底线所需步数。改完权重后第一件事就是跑这个脚本,而不是自己去下一盘。曾经有一版评估函数自我感觉很合理,自对弈50局下来胜率却不到两成,掀开一看才发现编号价值权重符号写反了。从那以后我每次动评估函数都强制走一遍自对弈流程,先让数据说话,再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取