
1. 从“硬编码”到“函数模拟”一次五子棋AI算法的重构之旅最近在社区里看到不少朋友在讨论用Java Swing实现五子棋或者尝试挑战“地狱难度”的AI。这让我想起了几年前自己折腾五子棋算法时的一段经历。当时我写了一个能下棋的程序但核心的胜负判断和搜索逻辑写得一团糟——满屏的if-else和嵌套循环代码又长又难维护想加个“禁手”规则或者优化搜索深度都无从下手。后来我痛定思痛决定推倒重来这次的目标很明确用函数化的思想模拟一个可迭代、可配置的“解法引擎”。这就是“五子棋Version.2 函数模拟迭代解法”这个项目的由来。它不是一个简单的游戏实现而是一次关于如何将复杂的棋盘逻辑拆解成一个个职责单一、可组合的“函数单元”并通过迭代调用这些单元来模拟完整对弈过程的实践。如果你也受够了面条式的棋类游戏代码或者对如何设计一个清晰、可扩展的AI核心感兴趣那么这次的重构思路或许能给你带来一些启发。2. 核心困境为什么传统的五子棋代码难以维护在动手重构之前我们得先搞清楚老代码到底“烂”在哪里。以最常见的胜负判断为例很多初学者的写法是遍历整个15x15的棋盘对每个点再向四个方向横、竖、左斜、右斜分别检查是否有连续五个同色棋子。2.1 “面条代码”的典型症状这种写法的代码通常会膨胀成一个巨大的函数里面充斥着坐标计算和条件判断。比如检查横向时你需要判断board[x][y]、board[x1][y]……board[x4][y]是否都等于当前玩家颜色并且还要确保x4没有超出棋盘边界。四个方向就是四套几乎重复但又略有不同的逻辑。当你想加入“长连禁手”超过五子不算赢或者“四四禁手”等专业规则时就不得不在这团乱麻中插入更多的if语句代码的复杂度呈指数级上升。2.2 函数化思维带来的转机函数化编程的核心思想之一是将程序分解为一系列接受输入、产生输出且没有副作用的纯函数。应用到五子棋上我们可以这样思考胜负判断不应该是一个庞然大物而应该是一个纯函数checkWin(board, x, y, player)。它只关心在给定棋盘、给定落子点、给定玩家的情况下返回true或false。方向检查可以被进一步抽象checkDirection(board, x, y, dx, dy, player)。(dx, dy)表示方向向量比如(1,0)是横向(0,1)是竖向。这个函数只负责沿一个方向计数连续的同色棋子。棋盘评估为AI服务可以是另一个函数evaluatePosition(board, player)它扫描棋盘为当前玩家计算一个分数。这样一来复杂的全局逻辑被拆解成了一个个乐高积木似的函数块。checkWin函数内部只需要调用四次checkDirection分别传入四个方向向量即可。代码立刻变得清晰、可测试并且修改一个规则比如禁手只需要修改或替换对应的那个“积木”而不会牵一发而动全身。3. 构建五子棋的“函数模拟”核心引擎基于上面的思路我们来搭建Version.2的核心。我们不再关注Swing的界面如何画那是另一个模块的事情而是聚焦于棋盘数据模型和核心算法函数。3.1 数据模型设计简单的就是最好的棋盘本质上是一个二维数组。我们用0表示空位1表示黑棋2表示白棋。public class GomokuBoard { private int[][] board; // 15x15 的棋盘 private int currentPlayer; // 当前行棋方1 或 2 // ... 构造函数获取器设置器 }这个类只负责存储状态和提供基本的查询、落子方法如makeMove(x, y)会校验位置是否为空。它不应该包含任何复杂的游戏逻辑。3.2 核心算法函数库职责分离我们将所有算法剥离到独立的工具类或一组静态函数中。这是本次重构的精华所在。3.2.1 基础胜负判断函数public class GomokuLogic { // 方向向量横、竖、左斜左上-右下、右斜左下-右上 private static final int[][] DIRECTIONS {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; /** * 检查在(x,y)处落子后玩家player是否获胜 * param board 棋盘 * param x 落子横坐标 * param y 落子纵坐标 * param player 玩家编号 * return 是否构成五连 */ public static boolean checkWin(int[][] board, int x, int y, int player) { for (int[] dir : DIRECTIONS) { int count 1; // 刚落下的这颗子 // 向正方向检查 count countDirection(board, x, y, dir[0], dir[1], player); // 向反方向检查 count countDirection(board, x, y, -dir[0], -dir[1], player); if (count 5) { return true; } } return false; } /** * 沿某个方向(dx, dy)统计连续的同色棋子数 */ private static int countDirection(int[][] board, int startX, int startY, int dx, int dy, int player) { int count 0; int x startX dx; int y startY dy; while (x 0 x board.length y 0 y board[0].length board[x][y] player) { count; x dx; y dy; } return count; } }注意这里将方向向量定义为常量并使用循环遍历彻底消除了重复代码。countDirection是一个纯净的、可复用的函数单元。3.2.2 引入“迭代”概念棋盘状态生成器“迭代”在此处有两层含义。一是指AI搜索算法中的迭代加深Iterative Deepening。二是指我们可以设计一个函数来“迭代”地生成所有可能的下一步棋盘状态供AI评估。public class BoardUtils { /** * 生成当前棋盘所有合法落子点的列表 * 为了性能可以只生成棋盘上已有棋子周围一圈的空位启发式 */ public static ListMove generateLegalMoves(int[][] board, int currentPlayer) { ListMove moves new ArrayList(); SetString considered new HashSet(); // 用于去重 int size board.length; // 首先遍历整个棋盘找到所有已有棋子的位置 for (int i 0; i size; i) { for (int j 0; j size; j) { if (board[i][j] ! 0) { // 在该棋子周围3x3范围内寻找空位 for (int dx -2; dx 2; dx) { // 范围可以调整 for (int dy -2; dy 2; dy) { int nx i dx; int ny j dy; if (nx 0 nx size ny 0 ny size board[nx][ny] 0) { String key nx , ny; if (!considered.contains(key)) { moves.add(new Move(nx, ny, currentPlayer)); considered.add(key); } } } } } } } // 如果棋盘为空返回中心点 if (moves.isEmpty() board[size/2][size/2] 0) { moves.add(new Move(size/2, size/2, currentPlayer)); } return moves; } }这个generateLegalMoves函数就是一个典型的“迭代器”它基于当前状态产生一系列新的可能状态。这是后续AI搜索的基石。4. 实现“可迭代的”AI决策引擎有了核心函数库我们就可以构建一个更像“引擎”的AI。这个AI不再是一堆写死的逻辑而是通过组合和调用这些函数进行可配置深度的搜索。4.1 评估函数给棋盘局面打分AI需要知道哪个局面更好。我们实现一个简单的评估函数。这个函数本身也是由更小的“模式识别”函数组合而成。public class Evaluator { // 定义棋型分数非常简化的版本 private static final int SCORE_FIVE 100000; private static final int SCORE_FOUR 10000; private static final int SCORE_THREE 1000; // ... 其他棋型 /** * 评估当前棋盘对指定玩家的有利程度 */ public static int evaluate(int[][] board, int player) { int score 0; int size board.length; // 扫描整个棋盘识别棋型 for (int i 0; i size; i) { for (int j 0; j size; j) { if (board[i][j] player) { score evaluatePoint(board, i, j, player); } else if (board[i][j] ! 0) { score - evaluatePoint(board, i, j, 3 - player); // 对手的棋子减分 } } } return score; } /** * 评估单个棋子在其四个方向上形成的潜在棋型 */ private static int evaluatePoint(int[][] board, int x, int y, int player) { int pointScore 0; for (int[] dir : GomokuLogic.DIRECTIONS) { // 这里可以调用更精细的模式检查函数例如 // Pattern pattern detectPattern(board, x, y, dir[0], dir[1], player); // pointScore getPatternScore(pattern); // 简化版只计算连续棋子数 int count GomokuLogic.countDirection(board, x, y, dir[0], dir[1], player) 1; // 1是自身 pointScore getScoreByCount(count); } return pointScore; } private static int getScoreByCount(int count) { switch (count) { case 5: return SCORE_FIVE; case 4: return SCORE_FOUR; case 3: return SCORE_THREE; default: return count; // 连续子数越多基础分越高 } } }评估函数是AI的“眼睛”它的好坏直接决定AI的强弱。这里只是一个示例真正的强AI比如“地狱难度”会使用更复杂的模式库和更精细的分数计算。4.2 极小化极大算法与Alpha-Beta剪枝的函数化实现这是AI的“大脑”。我们将搜索算法也实现为一系列函数。public class AISearchEngine { private int maxDepth; // 搜索深度 public AISearchEngine(int maxDepth) { this.maxDepth maxDepth; } /** * 主入口寻找当前最佳落子点 */ public Move findBestMove(int[][] board, int currentPlayer) { ListMove moves BoardUtils.generateLegalMoves(board, currentPlayer); Move bestMove null; int bestValue Integer.MIN_VALUE; for (Move move : moves) { // 模拟落子 board[move.x][move.y] move.player; // 调用递归搜索函数评估这个走法后的局面 int moveValue alphaBeta(board, maxDepth, Integer.MIN_VALUE, Integer.MAX_VALUE, false, 3 - currentPlayer); // 撤销落子 board[move.x][move.y] 0; if (moveValue bestValue) { bestValue moveValue; bestMove move; } } return bestMove ! null ? bestMove : moves.get(0); // 保底返回第一个合法走法 } /** * Alpha-Beta 剪枝搜索核心函数 * param depth 剩余搜索深度 * param alpha 当前层已知的最好值对MAX方 * param beta 当前层已知的最差值对MIN方 * param isMaximizing 当前是否是MAX方AI在决策 * param player 当前要下棋的玩家 * return 当前节点的评估值 */ private int alphaBeta(int[][] board, int depth, int alpha, int beta, boolean isMaximizing, int player) { // 终止条件达到深度限制或游戏结束 if (depth 0 || isTerminal(board)) { return Evaluator.evaluate(board, this.maximizingPlayer); // 假设AI是 maximizingPlayer } ListMove moves BoardUtils.generateLegalMoves(board, player); if (isMaximizing) { int value Integer.MIN_VALUE; for (Move move : moves) { board[move.x][move.y] player; value Math.max(value, alphaBeta(board, depth - 1, alpha, beta, false, 3 - player)); board[move.x][move.y] 0; alpha Math.max(alpha, value); if (value beta) { break; // Beta 剪枝 } } return value; } else { int value Integer.MAX_VALUE; for (Move move : moves) { board[move.x][move.y] player; value Math.min(value, alphaBeta(board, depth - 1, alpha, beta, true, 3 - player)); board[move.x][move.y] 0; beta Math.min(beta, value); if (value alpha) { break; // Alpha 剪枝 } } return value; } } private boolean isTerminal(int[][] board) { // 这里可以调用 GomokuLogic.checkWin 遍历检查但更高效的做法是在递归过程中判断。 // 简化处理假设深度够了就评估。 return false; } }这个AISearchEngine类就是一个标准的“函数模拟迭代解法”的集大成者。它通过迭代调用generateLegalMoves生成状态、递归调用自身模拟未来、调用Evaluator.evaluate评估叶节点来完成整个决策过程。alphaBeta函数是核心它清晰地展示了“迭代”遍历走法和“递归”模拟未来步骤的过程。5. 版本迭代与性能优化实战第一版的函数化引擎跑起来后你会发现随着搜索深度增加速度会急剧下降。这就是“迭代”需要优化的地方。5.1 迭代加深搜索Iterative Deepening Search, IDS与其一开始就进行深度为5的搜索不如先搜深度1再搜深度2依次加深。这样做好处很多可以在固定时间内返回一个尽可能好的结果时间控制并且浅层搜索产生的“最佳走法”排序信息可以为深一层搜索的Alpha-Beta剪枝提供更好的节点顺序极大提升剪枝效率。public Move findBestMoveWithTimeLimit(int[][] board, int currentPlayer, long timeLimitMillis) { Move bestMove null; long startTime System.currentTimeMillis(); for (int depth 1; depth MAX_POSSIBLE_DEPTH; depth) { this.maxDepth depth; Move currentBest findBestMove(board, currentPlayer); // 调用之前的函数 if (currentBest ! null) { bestMove currentBest; } // 检查是否超时 if (System.currentTimeMillis() - startTime timeLimitMillis * 0.9) { // 留10%余量 break; } } return bestMove; }5.2 启发式排序与置换表在generateLegalMoves中我们返回的走法列表是乱序的。Alpha-Beta剪枝的效率极度依赖节点顺序。我们应该把“看起来更好”的走法比如成四、活三的点排在前面。// 在生成走法后进行排序 moves.sort((m1, m2) - { // 简单启发靠近棋盘中心、靠近已有棋子的位置优先 int score1 heuristicScore(m1.x, m1.y, board); int score2 heuristicScore(m2.x, m2.y, board); return Integer.compare(score2, score1); // 降序 });更进一步可以使用“置换表”Transposition Table来缓存已经搜索过的棋盘局面的结果。棋盘局面可以通过Zobrist哈希转换成一个唯一的long型键值。当再次遇到相同的局面时直接查表返回结果避免重复搜索。这是提升博弈树搜索性能的经典手段。5.3 多线程并行搜索现代CPU都是多核的。我们可以将第一层生成的多个走法即不同的“根节点”分配给不同的线程并行进行Alpha-Beta搜索最后汇总结果。这是从“函数迭代”到“并行迭代”的升级。ExecutorService executor Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); ListFutureMoveResult futures new ArrayList(); for (Move move : firstLevelMoves) { futures.add(executor.submit(() - { // 复制棋盘模拟落子 int[][] newBoard copyBoard(board); newBoard[move.x][move.y] currentPlayer; // 在新的棋盘上执行搜索 int score alphaBeta(newBoard, maxDepth-1, ...); return new MoveResult(move, score); })); } // ... 收集所有结果选择分数最高的6. 从理论到实践集成与调试心得将这套函数化的引擎集成到Swing GUI中就完成了整个五子棋游戏。这里分享几个关键的集成点和调试技巧。6.1 引擎与界面的松耦合GUISwing部分只负责三件事1. 绘制棋盘和棋子2. 接收玩家鼠标点击转换为坐标3. 在玩家落子后调用GomokuBoard.makeMove()和GomokuLogic.checkWin()然后调用AISearchEngine.findBestMove()获取AI落子再重复这个过程。 它们之间通过定义清晰的接口比如一个GameController来通信引擎完全不知道界面的存在。这使得你可以轻松替换GUI比如换成JavaFX或控制台或者替换AI引擎比如换成一个神经网络模型。6.2 调试复杂递归函数的技巧Alpha-Beta搜索递归深状态多出错了很难调试。日志输出在递归函数入口打印深度和当前主要参数如alpha, beta但要注意日志量巨大可以只对特定搜索路径或深度小于3时开启。单元测试为每一个基础函数checkWin,countDirection,evaluatePoint编写详尽的单元测试。确保这些“积木”本身是牢固的。可视化调试写一个简单的函数将搜索过程中AI考虑过的前几个最佳走法在控制台用字符画出来直观感受AI的“思考”过程。使用断言在关键位置使用assert语句例如在递归函数中确保depth 0在评估函数中确保棋子坐标有效。6.3 性能分析与瓶颈定位使用JProfiler或VisualVM等工具监控游戏运行时你会发现90%的时间可能都花在了evaluatePoint和generateLegalMoves上。评估函数优化将棋型模式预计算成查表。例如事先计算好所有可能的五元组一行5个点对应的棋型分数评估时直接拼接查表避免实时分析。走法生成优化维护一个“空位热点列表”只记录棋盘上所有空位并在每次落子后只更新这个列表移除被占用的点加入新棋子周围新增的空位而不是每次都全盘扫描。剪枝优化除了Alpha-Beta实现更激进的剪枝如“空步裁剪”Null-move pruning在特定情况下假设自己停一手如果局面仍然大优则直接剪枝。通过这样一轮从“面条代码”到“函数模拟迭代引擎”的重构你得到的不仅仅是一个能运行的五子棋程序而是一个清晰、模块化、可测试、可扩展的算法框架。你可以很方便地在这个框架上实验新的评估函数、尝试蒙特卡洛树搜索MCTS、或者加入更复杂的禁手规则。这种用函数组合来模拟复杂过程、用迭代和递归来探索解空间的思想其价值远远超出了五子棋这个具体的项目它是解决许多计算问题的通用利器。