ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java五子棋AI算法:四方向扫描与Minimax搜索实战

Java五子棋AI算法:四方向扫描与Minimax搜索实战

简介:这是一份面向高校计算机专业学生的Java五子棋课程设计完整项目,适合正在学习面向对象编程、数据结构与算法、GUI开发及人工智能入门的人群,可用于课程作业提交、项目实战练习或算法学习参考。压缩包共56个文件,约5.16MB,包含12个java源码、19个class编译文件、6个png与5个jpg界面素材、5个xml配置、2个jar依赖库及dat数据文件等,覆盖源码、资源、配置与打包产物,结构完整。项目重点在于胜负判断的算法解析,围绕水平、垂直及两条对角线四个方向遍历棋盘数组,检查落子后是否形成连续五子,并鼓励引入Minimax与α-β剪枝实现简单AI对弈。界面部分采用Swing或JavaFX构建棋盘交互,实时反馈轮次与胜负状态。目前已有46人学习,读者可借此理解搜索策略、评估函数与代码结构优化,为软件开发或人工智能方向积累实践基础。

1. 五子棋课程设计里,算法解析为什么要按四个方向拆

很多同学做 Java 五子棋课程设计,棋盘画完、落子能下、胜负也能判,但一到答辩就被问住:你的 AI 到底怎么算的?为什么横着能赢、斜着就漏判?我当年第一次交这个作业,就是栽在“只判了一个方向”上,老师随手摆了条斜线,程序毫无反应,那场面相当尴尬。

这个标题里的“分四个方向”,说的不是把棋盘转四圈,而是胜负判定和 AI 估值都要沿横、竖、正斜、反斜四条线扫描。它解决的是两个核心问题:一是落子后如何快速判断五连,二是 AI 如何给每个空位打分选出最优点。适合正在做 Java 课程设计、想拿高分又不想只交个“能跑就行”版本的同学,也适合想借五子棋练手搜索算法的初学者。下面我按自己踩过的路,把选型、实现、参数和坑一次讲清。

2. 棋盘数据结构与四方向扫描的底层逻辑

2.1 为什么用二维数组而不是 List 存棋盘

五子棋棋盘是标准的 15×15 网格,最自然的存储就是int[][] board。我见过有人用ArrayList<Point>存已落子位置,判断胜负时遍历列表找相邻点,代码写起来好像很灵活,实际每次判定都要做距离计算和集合查找,复杂度直接上去。二维数组的好处是索引即坐标,board[row][col]一步到位,四个方向的偏移量用常量数组就能统一处理。

选型上还有一个细节:用0表示空、1表示黑、2表示白,比用char或枚举更省事,因为后续估值函数要做数值累加,整数直接参与运算,不用来回转换。棋盘大小建议做成常量BOARD_SIZE = 15,别到处写死 15,否则改尺寸时满屏找数字。

public class Board { public static final int SIZE = 15; public static final int EMPTY = 0; public static final int BLACK = 1; public static final int WHITE = 2; private final int[][] grid = new int[SIZE][SIZE]; public boolean isEmpty(int row, int col) { return grid[row][col] == EMPTY; } public void place(int row, int col, int player) { grid[row][col] = player; } public int get(int row, int col) { return grid[row][col]; } }

这段代码把棋盘状态和基本操作封装起来,SIZE、EMPTY、BLACK、WHITE都是常量,后续所有方向扫描和估值都依赖它们。参数上唯一要留意的是行列范围,落子前必须校验0 <= row < SIZE,否则数组越界直接抛异常,AI 搜索时尤其容易在边界外试探。

2.2 四个方向的偏移量到底怎么定义

所谓四个方向,指的是从当前落子点出发的四条轴线:水平(0,1)、垂直(1,0)、正斜(1,1)、反斜(1,-1)。每条轴线要往正负两个方向同时延伸,才能覆盖整条连线。很多人只写一个方向,比如只往右和往下数,结果就是开头说的斜线漏判。

我一般把方向定义成二维数组,配合一个“正反双向计数”的循环。这样胜负判定和 AI 估值可以共用同一套方向常量,不用写四遍几乎一样的代码。这里的关键认知是:五子棋的“五连”本质是某条轴线上连续同色棋子数达到 5,所以任何判定都必须沿轴线双向展开。

// 四个方向:横、竖、正斜、反斜 public static final int[][] DIRECTIONS = { {0, 1}, // 水平 {1, 0}, // 垂直 {1, 1}, // 正斜 {1, -1} // 反斜 };

DIRECTIONS里每个元素是{行增量, 列增量}。注意反斜方向列增量是-1,从左上往右下看是(1,1),从右上往左下看就是(1,-1),两者合起来才覆盖两条对角线。参数上不要随意改方向顺序,因为后面估值函数里我会用方向索引来区分权重,顺序乱了权重就对不上。

2.3 胜负判定的双向扫描实现

有了方向常量,判定五连就变成:对每个方向,从落子点分别向正、负方向数同色棋子,两边数量相加再加自己,达到 5 即胜。这里有个容易翻车的点——计数时先走一步再判断,还是先判断再走,边界处理不一样。我习惯先向一个方向走,越界或颜色不同就停,这样不会把落子点自己重复计入。

public boolean checkWin(int row, int col, int player) { for (int[] dir : DIRECTIONS) { int count = 1; // 包含当前落子 // 正方向 count += countDirection(row, col, dir[0], dir[1], player); // 负方向 count += countDirection(row, col, -dir[0], -dir[1], player); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, int dRow, int dCol, int player) { int count = 0; int r = row + dRow; int c = col + dCol; while (r >= 0 && r < SIZE && c >= 0 && c < SIZE && grid[r][c] == player) { count++; r += dRow; c += dCol; } return count; }

checkWin对四个方向各做一次双向计数,count初始为 1 代表当前棋子。countDirection负责沿给定增量一直走,直到越界或遇到异色。参数dRow、dCol可正可负,所以正负方向调用同一个方法,只是符号相反。这段逻辑跑通后,横竖斜四种赢法都能判出来,不会再出现斜线漏判。

3. AI 估值函数:把四个方向变成可比较的分数

3.1 棋型识别:活四、冲四、活三怎么区分

胜负判定只回答“赢没赢”,AI 要回答“哪步更好”,所以需要给每个候选点打分。打分的依据是棋型:连五最高,活四次之,冲四和活三再次,活二、眠二递减。所谓活四,是两边都空的四连,下一手必成五;冲四是一边被堵的四连,只有一种成五方式。区分它们靠的还是四个方向的连续计数和两端空位判断。

我一般把棋型抽象成“连续同色数 + 两端是否开放”的组合。比如连续 4 个、两端都空就是活四,连续 4 个、一端被堵就是冲四。连续 3 个两端都空是活三,一端被堵是眠三。这个判断必须四个方向都做,因为一个点可能在横向上形成活三,同时在斜向上形成冲四,综合分要叠加。

3.2 估值函数的代码结构与权重表

估值函数的核心是:对某个空位,假设某方落子后,沿四个方向统计形成的棋型,查权重表累加得分。权重表是经验值,不同实现会有差异,但量级关系要拉开,否则 AI 会分不清活四和活三哪个更急。

// 棋型分值,数值是经验值,可按手感微调 public static final int FIVE = 1000000; public static final int LIVE_FOUR = 100000; public static final int RUSH_FOUR = 10000; public static final int LIVE_THREE = 8000; public static final int SLEEP_THREE = 500; public static final int LIVE_TWO = 300; public static final int SLEEP_TWO = 50; public int evaluatePoint(int row, int col, int player) { int total = 0; for (int[] dir : DIRECTIONS) { int count = 1; int openEnds = 0; // 统计正负方向连续数和开放端 // ... 具体统计逻辑见下节 total += scoreOf(count, openEnds); } return total; }

FIVE到SLEEP_TWO是棋型权重,量级从百万到几十,保证 AI 优先堵活四、再考虑活三。evaluatePoint对四个方向分别统计后累加,这样横向活三加斜向活三会得到更高分,符合“双三很危险”的直觉。参数上这些数值不是标准答案,你可以按自己 AI 的表现微调,但别把活三设得比冲四还高,否则 AI 会放着必输的冲四不管去凑活三。

3.3 攻防一体的打分:为什么既要算自己也要算对手

只算自己进攻分的 AI 是“莽夫”,对手活四了它还在凑自己的活三。正确做法是:对每个空位,分别计算“我方落子得分”和“对方落子得分”,然后综合。常见策略是myScore * 1.0 + oppScore * 0.9,让 AI 稍微偏向进攻,但绝不忽视防守。

public int evaluateMove(int row, int col, int me, int opponent) { int myScore = evaluatePoint(row, col, me); int oppScore = evaluatePoint(row, col, opponent); // 防守权重略低于进攻,避免 AI 过于保守 return (int) (myScore * 1.0 + oppScore * 0.9); }

evaluateMove里me是当前 AI 执子方,opponent是对手。myScore衡量进攻价值,oppScore衡量如果对手占这个点有多危险,两者加权相加。参数0.9是防守系数,调高 AI 更爱堵,调低更爱攻。我试过 0.8 到 1.0 之间,0.9 在多数局面下比较均衡。注意这里假设了evaluatePoint内部会临时落子再撤销,否则统计不到落子后的棋型。

4. 搜索与落子决策:从单层贪心到极小极大

4.1 候选点生成:别对 225 个格子全算一遍

15×15 有 225 个空位,每步都全盘估值虽然能跑,但没必要。五子棋的有效落子通常集中在已有棋子附近,我一般只取“距离任意已有棋子不超过 2 格”的空位作为候选。这样候选数从两百多降到几十,搜索速度明显提升,棋力几乎不受影响。

public List<int[]> generateCandidates(Board board) { List<int[]> candidates = new ArrayList<>(); for (int r = 0; r < Board.SIZE; r++) { for (int c = 0; c < Board.SIZE; c++) { if (!board.isEmpty(r, c)) continue; if (hasNeighbor(board, r, c, 2)) { candidates.add(new int[]{r, c}); } } } return candidates; } private boolean hasNeighbor(Board board, int row, int col, int range) { for (int r = row - range; r <= row + range; r++) { for (int c = col - range; c <= col + range; c++) { if (r < 0 || r >= Board.SIZE || c < 0 || c >= Board.SIZE) continue; if (!board.isEmpty(r, c)) return true; } } return false; }

generateCandidates遍历全盘,只保留hasNeighbor为真的空位。range = 2表示周围两格内有棋子就算有效,这个参数调大候选变多、棋力略升但变慢,调小则相反。开局第一手棋盘全空,hasNeighbor全为假,需要特判直接下天元,否则候选列表为空 AI 会卡住。

4.2 极小极大搜索与深度控制

单层贪心容易被对手的“双三”套路骗,加一层搜索就稳很多。极小极大(Minimax)的思路是:AI 假设自己选最优,对手也选最优,往前推若干层。五子棋分支多,深度一般控制在 2 到 4 层,再深就要上 Alpha-Beta 剪枝,否则课程设计的机器上跑一步要好几秒。

public int minimax(Board board, int depth, boolean isMax, int me, int opponent) { if (depth == 0) { return evaluateBoard(board, me, opponent); } List<int[]> candidates = generateCandidates(board); if (isMax) { int best = Integer.MIN_VALUE; for (int[] pos : candidates) { board.place(pos[0], pos[1], me); best = Math.max(best, minimax(board, depth - 1, false, me, opponent)); board.place(pos[0], pos[1], Board.EMPTY); // 撤销 } return best; } else { int best = Integer.MAX_VALUE; for (int[] pos : candidates) { board.place(pos[0], pos[1], opponent); best = Math.min(best, minimax(board, depth - 1, true, me, opponent)); board.place(pos[0], pos[1], Board.EMPTY); } return best; } }

minimax里isMax为真表示轮到 AI,取最大值;为假表示对手,取最小值。每次落子后递归,返回前必须撤销,否则棋盘状态被污染,后续分支全错。depth是剩余搜索深度,到 0 就调evaluateBoard做全局估值。参数上深度每加 1,耗时大约翻几倍,课程设计里 3 层加剪枝通常够用。

4.3 Alpha-Beta 剪枝的接入位置

Alpha-Beta 剪枝是在 Minimax 基础上,把已经能确定“不会选”的分支提前砍掉。接入位置就在取最大/最小的循环里,维护alpha和beta两个边界,一旦alpha >= beta就 break。它不改变搜索结果,只减少计算量,是性价比最高的优化。

public int alphaBeta(Board board, int depth, int alpha, int beta, boolean isMax, int me, int opponent) { if (depth == 0) return evaluateBoard(board, me, opponent); List<int[]> candidates = generateCandidates(board); if (isMax) { for (int[] pos : candidates) { board.place(pos[0], pos[1], me); alpha = Math.max(alpha, alphaBeta(board, depth - 1, alpha, beta, false, me, opponent)); board.place(pos[0], pos[1], Board.EMPTY); if (alpha >= beta) break; // 剪枝 } return alpha; } else { for (int[] pos : candidates) { board.place(pos[0], pos[1], opponent); beta = Math.min(beta, alphaBeta(board, depth - 1, alpha, beta, true, me, opponent)); board.place(pos[0], pos[1], Board.EMPTY); if (alpha >= beta) break; } return beta; } }

alpha是 AI 已找到的最好分下界,beta是对手能接受的最差分上界。alpha >= beta说明这个分支对手不会让它发生,直接剪掉。参数初始调用时alpha = Integer.MIN_VALUE、beta = Integer.MAX_VALUE。注意候选点排序会影响剪枝效率,先试估值高的点能剪更多,这是进阶优化,课程设计里不排也能跑。

5. 避坑与排查:四个方向实现里最容易翻车的地方

5.1 斜线方向漏判,横竖正常

现象:横着竖着五连都能判赢,斜着连成五个程序没反应。原因:方向数组只写了{0,1}和{1,0},漏了{1,1}和{1,-1},或者只写了正斜没写反斜。解决:检查DIRECTIONS是否恰好四个元素,并且反斜的列增量是-1。我建议写个单元测试,手动在棋盘上摆四条五连线,逐条验证checkWin返回 true。

5.2 边界越界导致 AI 崩溃

现象:AI 思考时抛ArrayIndexOutOfBoundsException,尤其在棋盘边缘落子后。原因:方向扫描或候选生成时没做边界校验,row + dRow直接当索引用。解决:所有沿方向走的地方,循环条件必须先判r >= 0 && r < SIZE && c >= 0 && c < SIZE,再访问数组。countDirection和hasNeighbor里都要有这层保护,顺序不能反。

5.3 估值权重倒挂,AI 行为反常

现象:AI 放着对手的活四不堵,跑去连自己的活三,一步就被将死。原因:权重表里LIVE_THREE设得比RUSH_FOUR还高,或者防守系数0.9调得太低。解决:确保FIVE > LIVE_FOUR > RUSH_FOUR > LIVE_THREE,量级至少差几倍。防守系数先固定 0.9,观察 AI 是否堵关键点,再微调。血泪经验是别一次改好几个参数,否则出了问题不知道是哪个引起的。

5.4 递归落子没撤销,棋盘状态污染

现象:AI 第一步正常,第二步开始乱下,或者同一局面每次算出的分不一样。原因:Minimax 或 Alpha-Beta 里落子后忘记board.place(..., EMPTY)撤销,递归返回后棋盘残留了假设棋子。解决:落子和撤销必须成对出现,建议用try-finally包住,或者写个makeMove/undoMove辅助方法。这个坑很隐蔽,因为单步测试看不出来,多步搜索才暴露。

5.5 候选点为空导致 AI 卡死

现象:开局第一手 AI 不落子,程序无响应或抛异常。原因:generateCandidates依赖周围有棋子,空棋盘时返回空列表,搜索循环一次没进。解决:在落子决策入口特判,如果棋盘为空直接下天元(7,7);如果候选为空但棋盘非空,退化为全盘扫描空位。这个边界情况课程设计演示时经常被忽略,答辩现场卡住就很尴尬。

6. 让 AI 更聪明的两个实战技巧

第一个技巧是候选点排序。Alpha-Beta 剪枝的效率极度依赖搜索顺序,先试高分点能大幅减少节点数。做法很简单:在generateCandidates之后,对每个候选调一次evaluateMove,按分数从高到低排序再进搜索。我实测在 3 层深度下,排序后节点数能降一半左右,AI 落子从明显卡顿变成基本即时。代价是每个候选多算一次估值,但相比省下的递归开销完全值得。

candidates.sort((a, b) -> { int scoreA = evaluateMove(a[0], a[1], me, opponent); int scoreB = evaluateMove(b[0], b[1], me, opponent); return Integer.compare(scoreB, scoreA); // 降序 });

这段排序放在alphaBeta取候选之后、循环之前。evaluateMove返回综合攻防分,降序排列让最可能的好点先被搜索。注意排序本身有开销,候选数几十个时没问题,如果候选上百要权衡。

第二个技巧是必胜/必堵的快速通道。在搜索之前先扫一遍候选:如果某点让我方成五,直接下;如果某点让对手成五,直接堵。这两类情况不需要搜索,能省时间也能避免搜索深度不够导致的低级失误。判断方法就是临时落子调checkWin,成五立即返回。

场景处理方式判断依据
我方有落子即成五直接落子临时落子后checkWin为真
对手有落子即成五直接堵对手临时落子后checkWin为真
我方有活四机会优先搜索估值分接近LIVE_FOUR
普通局面进搜索走 Alpha-Beta

这张表是我落子决策的顺序,快速通道在前,搜索在后。参数上“成五”判断用checkWin最稳,别自己另写一套计数逻辑,容易和胜负判定不一致。

最后说个验证方法:写一个简单的对弈测试,让 AI 分别执黑执白自己跟自己下若干局,观察有没有出现“该赢没赢”“该堵没堵”的局面。我一般会打印每步的候选数和搜索节点数,节点数突然暴涨往往意味着剪枝失效或候选生成异常。这套东西调通后,课程设计答辩被问算法细节基本都能接住。我自己的习惯是每改一个权重就存一版,翻车了能回退,别嫌麻烦。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表