2026/10/14 22:54:47

五子棋人机对战实战:C#数据模型、评估函数与Alpha-Beta剪枝

五子棋人机对战实战:C#数据模型、评估函数与Alpha-Beta剪枝 简介一套基于Visual Studio与EasyX图形库实现的五子棋完整工程面向学习C#/C窗体应用、游戏逻辑与基础AI的开发者可作为课程设计或入门项目参考。工程含人机对战与人人对战双模式棋盘绘制、落子判断、五子连珠检测均模块化实现并配有背景音乐和棋子图片。包内共49个文件以cpp源文件、h头文件、sln工程文件为主辅以jpg素材、wav音效及VS配置压缩包约107MB解压后可直接打开编译。源码划分主程序、AI逻辑、游戏逻辑、关于界面等模块AI实现含搜索与局面评估思路并支持悔棋、重开、存档能帮助理解事件驱动编程、状态管理和简单博弈算法。已有2655人学习下载适合对照完整工程快速掌握五子棋开发流程。1. 五子棋人机对战在 Visual Studio 里的真实落地价值不是画棋盘而是拆解博弈刚开始在 Visual Studio 里做五子棋含人机对战的人往往会低估一件事真正耗时间的不是画棋盘不是放棋子而是让“几时轮到谁、谁赢了、AI 下一步走哪里”这些概念变成可测试、可复现的代码。这个题目我推荐过不少开发同学去做刚学完 C# 语法的人可以用它把数组、枚举、递归一次性串起来写过几个界面的人则又要面对评估函数、极大极小搜索和 Alpha-Beta 剪枝这三座小山。它最实在的地方是界面可见输赢立刻能验证AI 的每一步都能回放。不像很多底层库的问题是个黑匣子五子棋项目的每个“异常决策”几乎都能定位到具体一行代码。下面直接从数据模型开始按“能落子 → 能算棋 → 能交互 → 能调优”的顺序把这条路走通最后附一份踩坑清单。2. Visual Studio 下的棋盘数据模型先把落子与胜负判定做成一个能单测的类做任何棋类程序我的习惯都是先写数据层再碰界面。五子棋的数据层只有三件事棋盘怎么存、棋子能不能落下去、落子之后有没有人赢。这三件事如果写在界面按钮的点击事件里后面接 AI 时会非常痛苦因为 AI 要在内存里反复模拟落子不可能每次都弹出消息框。2.1 为什么我把棋盘存成 15×15 的二维数组而不是“棋子列表”棋盘是固定 15×15用二维数组存最直接board[row, col]一次就能拿到某个位置的状态时间复杂度是 O(1)。如果用List棋子每次要判断某位置是否为空都得遍历列表查坐标画棋盘时要遍历列表AI 生成候选点时还要反复查重。列表在棋子数量少的时候不慢但代码里到处都是“先 Find 再判断”可读性和出错的概率都会明显上升。我这里用了一个枚举PieceColor分别是None、Black、White而不是用 0、1、2 裸数字。原因很朴素AI 模块里要频繁比较“这步棋是不是我下的”如果到处写if (value 1)过两周自己都分不清 1 是黑还是白。枚举在 Visual Studio 的智能提示里直接显示颜色名调试和重构都省事。2.2 落子、查胜、克隆与候选点四个基础方法一次写对下面是GameBoard的完整代码。它不依赖任何界面组件可以直接放到一个类库项目里也可以放在 WinForms 项目的Core文件夹下。namespace GomokuDemo.Core { public enum PieceColor { None 0, Black 1, White 2 } public class GameBoard { public const int Size 15; private readonly PieceColor[,] _grid new PieceColor[Size, Size]; public PieceColor this[int row, int col] { get { return _grid[row, col]; } } // 落子越界和占用都返回 false不抛异常 public bool Place(int row, int col, PieceColor color) { if (row 0 || row Size || col 0 || col Size) return false; if (_grid[row, col] ! PieceColor.None) return false; _grid[row, col] color; return true; } // 整盘扫描简单直观适合调试 public PieceColor CheckWinner() { for (int color 1; color 2; color) { PieceColor c (PieceColor)color; for (int row 0; row Size; row) { for (int col 0; col Size; col) { if (_grid[row, col] ! c) continue; if (CountDirection(row, col, 1, 0, c) 5 || CountDirection(row, col, 0, 1, c) 5 || CountDirection(row, col, 1, 1, c) 5 || CountDirection(row, col, 1, -1, c) 5) { return c; } } } } return PieceColor.None; } public bool IsFull() { for (int row 0; row Size; row) for (int col 0; col Size; col) if (_grid[row, col] PieceColor.None) return false; return true; } // 候选点从已有棋子周围取空位半径传 2 比较平衡 public List(int row, int col) GetCandidateMoves(int radius) { var result new List(int row, int col)(); var added new HashSet(int row, int col)(); for (int row 0; row Size; row) { for (int col 0; col Size; col) { if (_grid[row, col] PieceColor.None) continue; for (int dr -radius; dr radius; dr) { for (int dc -radius; dc radius; dc) { int nr row dr; int nc col dc; if (nr 0 || nr Size || nc 0 || nc Size) continue; if (_grid[nr, nc] ! PieceColor.None) continue; if (added.Add((nr, nc))) result.Add((nr, nc)); } } } } return result; } private int CountDirection(int row, int col, int dr, int dc, PieceColor color) { int count 1; int r row dr, c col dc; while (r 0 r Size c 0 c Size _grid[r, c] color) { count; r dr; c dc; } return count; } // AI 搜索时需要模拟落子不能改原棋盘 public GameBoard Clone() { var copy new GameBoard(); for (int row 0; row Size; row) for (int col 0; col Size; col) copy._grid[row, col] _grid[row, col]; return copy; } } }逻辑说明Place返回bool而不是直接抛异常这一点是为 AI 搜索设计的。AI 会尝试大量候选点如果用异常表示“不能下”递归里全是 try-catch性能会很差。返回false后调用方跳过这个点即可。CheckWinner用了整盘扫描每次落子后调用一次15×15 的规模完全承受得住调试时看到的结果也符合直觉。实际还可以优化成“只查最后一步落子位置的四条线”后面 AI 深度加大时再换也不迟。参数说明GetCandidateMoves(int radius)的半径很关键。半径传 1 时候选点只紧挨着已有棋子速度快但会漏掉“隔一个空位的跳活三”这类棋型我一般默认传 2也就是以每个棋子为中心向外扩展两格。这样能覆盖常见的跳棋型和双活三候选点数量也不会爆炸。真正需要满盘扫描的情况几乎没有因为远离棋子的空位在开局阶段没什么价值。3. 人机对战的核心算法评估函数、候选点排序与 Alpha-Beta 剪枝五子棋 AI 的“脑子”由三部分组成评估函数负责告诉程序一个局面好不好候选点生成负责告诉程序该看哪些位置极大极小搜索负责往前推演几步。三者缺一不可。很多人一上来就写“五子棋算法”结果只写了一个“看自己能不能凑五个”的函数那其实只是查胜不是博弈。3.1 评估函数把活三、眠三、冲四变成可比较的分数评估函数要从“我方”视角看棋盘给每个空位打分。常见做法是想象某个空位放上我方棋子然后沿水平、垂直、两条斜线四个方向统计连子数和两端开放状态。两端都通叫“活”只通一端叫“眠”或“冲”。权重设计直接决定 AI 的性格。棋型典型局面我常用的权重五连已有 4 子再下一子成五100000活四已有 4 子两端都空50000冲四已有 4 子一端被堵5000活三已有 3 子两端都空3000眠三已有 3 子一端被堵500活二已有 2 子两端都空300眠二已有 2 子一端被堵50下面是评估函数的实现。EvaluatePoint是给某个空位打分EvaluateBoard则是把所有空位分数加起来得到一个局面分。namespace GomokuDemo.Core { public class Evaluator { public int EvaluateBoard(GameBoard board, PieceColor myColor) { int score 0; for (int row 0; row GameBoard.Size; row) { for (int col 0; col GameBoard.Size; col) { if (board[row, col] ! PieceColor.None) continue; score EvaluatePoint(board, row, col, myColor); } } return score; } public int EvaluatePoint(GameBoard board, int row, int col, PieceColor myColor) { int score 0; int[][] dirs { new[] { 1, 0 }, new[] { 0, 1 }, new[] { 1, 1 }, new[] { 1, -1 } }; foreach (var d in dirs) { int dr d[0], dc d[1]; int forward CountSameDirection(board, row, col, dr, dc, myColor); int backward CountSameDirection(board, row, col, -dr, -dc, myColor); int count 1 forward backward; int fr row dr * (forward 1); int fc col dc * (forward 1); int br row - dr * (backward 1); int bc col - dc * (backward 1); bool openForward InBoard(fr, fc) board[fr, fc] PieceColor.None; bool openBackward InBoard(br, bc) board[br, bc] PieceColor.None; if (count 5) score 100000; else if (count 4 openForward openBackward) score 50000; else if (count 4 (openForward || openBackward)) score 5000; else if (count 3 openForward openBackward) score 3000; else if (count 3 (openForward || openBackward)) score 500; else if (count 2 openForward openBackward) score 300; else if (count 2 (openForward || openBackward)) score 50; } return score; } private int CountSameDirection(GameBoard board, int row, int col, int dr, int dc, PieceColor color) { int count 0; int r row dr; int c col dc; while (r 0 r GameBoard.Size c 0 c GameBoard.Size board[r, c] color) { count; r dr; c dc; } return count; } private bool InBoard(int row, int col) { return row 0 row GameBoard.Size col 0 col GameBoard.Size; } } }逻辑说明EvaluatePoint把某个空位看成“我方已经落子”接着向两个方向数同色棋子。比如横向空位两边各有一颗我方棋子count就是 3再看两端是否为空。如果两端都空就是活三记 3000 分只有一端空就是眠三记 500 分。EvaluateBoard是把所有空位都按这个思路算一遍并求和。参数说明权重表里的相对比例比绝对数字重要。活三 3000、眠三 500意味着 AI 会优先发展活三而不是去堵一个低价值的眠三如果活三和眠三差距拉得太小AI 就会表现出“该进攻时不进攻”的迟钝。更要注意这个简化版只识别“连续棋型”不识别“跳活三”这类中间有空位的棋型想支持需要额外写间隔扫描属于后续优化方向。3.2 候选点收集与快速排序让搜索宽度从“满盘”压到二十以内如果每一步都从 225 个空位里选深度一深节点数就是天文数字。我在第 2 章里写的GetCandidateMoves(2)已经把范围缩小到已有棋子周围两格但早期棋盘棋子少候选点仍然可能上百个。所以在搜索前我会再做一个快速排序只保留分数最高的前 20 个候选点。排序依据就用刚才的EvaluatePoint它只评估一个位置速度很快。private List(int row, int col) OrderMoves(GameBoard board, List(int row, int col) moves, PieceColor side, int take) { var evaluator new Evaluator(); return moves .Select(move new { Move move, Score evaluator.EvaluatePoint(board, move.row, move.col, side) }) .OrderByDescending(x x.Score) .Take(take) .Select(x x.Move) .ToList(); }逻辑说明这把候选点按“当前这一手看上去好不好”从高到低排序然后截断。好处有两个第一搜索宽度被限制在 20 以内计算量可控第二Alpha-Beta 剪枝对搜索顺序很敏感如果先搜高分点剪枝会更早触发整体速度快很多。参数说明take值我一般设 20。深度 4 时最坏情况是 20 的 4 次方约 16 万个节点配合剪枝实际会少很多take设成 30 后节点数会明显上涨但棋力提升有限因为低分点绝大多数情况下不会成为最佳着法。想提升棋力优先加深度而不是加宽度。3.3 极大极小搜索完整写法深度 4 是多数局面的性价比拐点极大极小搜索的核心逻辑是我方落子时希望局面分数最大对方落子时希望局面分数最小。搜索树里交替轮换最后把叶子节点的分往回传。加上 Alpha-Beta 剪枝后很多明显不优的分支可以直接砍掉。namespace GomokuDemo.Core { public class AiPlayer { private readonly PieceColor _aiColor; private readonly int _searchDepth; private readonly Evaluator _evaluator new Evaluator(); public AiPlayer(PieceColor aiColor, int searchDepth 4) { _aiColor aiColor; _searchDepth searchDepth; } public (int row, int col) GetBestMove(GameBoard board) { var allMoves board.GetCandidateMoves(2); if (allMoves.Count 0) return (GameBoard.Size / 2, GameBoard.Size / 2); var candidates OrderMoves(board, allMoves, _aiColor, 20); int bestScore int.MinValue; var bestMove candidates[0]; foreach (var move in candidates) { var next board.Clone(); next.Place(move.row, move.col, _aiColor); int score Minimax(next, _searchDepth - 1, int.MinValue 1, int.MaxValue - 1, false, OtherSide(_aiColor)); if (score bestScore) { bestScore score; bestMove move; } } return bestMove; } private int Minimax(GameBoard board, int depth, int alpha, int beta, bool isMaximizing, PieceColor turn) { var winner board.CheckWinner(); if (winner _aiColor) return 100000 depth * 100; if (winner ! PieceColor.None) return -100000 - depth * 100; if (depth 0 || board.IsFull()) { int myScore _evaluator.EvaluateBoard(board, _aiColor); int otherScore _evaluator.EvaluateBoard(board, OtherSide(_aiColor)); return myScore - otherScore; } var moves OrderMoves(board, board.GetCandidateMoves(2), turn, take: 15); if (isMaximizing) { int value int.MinValue; foreach (var move in moves) { var next board.Clone(); next.Place(move.row, move.col, turn); value Math.Max(value, Minimax(next, depth - 1, alpha, beta, false, OtherSide(turn))); alpha Math.Max(alpha, value); if (beta alpha) break; } return value; } else { int value int.MaxValue; foreach (var move in moves) { var next board.Clone(); next.Place(move.row, move.col, turn); value Math.Min(value, Minimax(next, depth - 1, alpha, beta, true, OtherSide(turn))); beta Math.Min(beta, value); if (beta alpha) break; } return value; } } private static PieceColor OtherSide(PieceColor color) { return color PieceColor.Black ? PieceColor.White : PieceColor.Black; } } }逻辑说明GetBestMove先收集 AI 自己这一手的所有候选点排序取前 20然后逐个模拟落子调用Minimax评估后续局面。Minimax里isMaximizing表示当前轮到 AI 进攻还是对方进攻。叶子节点直接用myScore - otherScore返回局面分。胜负判定优先于深度AI 赢了返回大正分AI 输了返回大负分depth 加一点分值是为了让 AI 在同样能赢时优先选更快赢的路线。参数说明searchDepth 4是大多数场景下的甜点位。深度 3 太浅经常看不到两步之后的冲四深度 5 开始最坏情况节点数会变成深度 4 的 20 倍左右即使有剪枝等待时间也可能从几百毫秒涨到十几秒。先保持深度 4等确认剪枝和评估都正确再尝试动态深度。take: 15是递归内部的宽度限制比根节点的 20 略小用来防止中盘节点爆炸。4. 界面交互与回合状态机鼠标落子、异步 AI 思考与棋盘重绘数据层和 AI 都就绪后剩下的就是 WinForms 界面。这里最常见的翻车点是坐标转换和线程阻塞。我建议把界面代码控制在两个职责内把鼠标点转换成棋盘格子坐标然后把落子结果交给数据层处理。界面自己不保存“当前棋盘副本”所有状态都从_board读。4.1 像素坐标与格子坐标用两个转换方法消灭落子偏移棋盘左上角留出边距每个格子边长设为 40 像素棋子画在交叉点上。鼠标点击时要先用边距和格子大小算出落点坐标再判断这个坐标是否在棋盘范围内。public partial class GomokuForm : Form { private const int CellSize 40; private const int Margin 30; private readonly GameBoard _board new GameBoard(); private readonly AiPlayer _ai new AiPlayer(PieceColor.White, searchDepth: 4); private PieceColor _currentPlayer PieceColor.Black; private bool _gameOver; private bool _busy; public GomokuForm() { InitializeComponent(); DoubleBuffered true; Text 五子棋人机对战; } private Point GridToPixel(int row, int col) { return new Point(Margin col * CellSize, Margin row * CellSize); } private bool PixelToGrid(int x, int y, out int row, out int col) { row (y - Margin CellSize / 2) / CellSize; col (x - Margin CellSize / 2) / CellSize; return row 0 row GameBoard.Size col 0 col GameBoard.Size; } }逻辑说明PixelToGrid里的 CellSize / 2是四舍五入的关键。如果不加它点击靠近交叉点左上区域时会算到前一个格子去棋子就会画到格线上附近的位置视觉上像“没点到交叉点”。调整后鼠标落在哪个格子的中心区域就落回那个交叉点。参数说明CellSize 40是视觉舒适度比较高的值棋盘整体宽 15×40600 像素加上左右边距共 660 像素窗口默认尺寸刚好放下。如果屏幕分辨率高可以调到 44 或 48调大的同时注意Margin也要等比放大否则棋盘会贴边。Margin 30保证边上的棋子不会被窗口边缘裁掉。4.2 用 _busy 标记挡住“人机同时走棋”的线程问题AI 搜索比较耗时如果直接在 UI 线程里调用GetBestMove窗口会进入“未响应”状态。正确做法是用Task.Run把搜索放到后台线程搜完再用BeginInvoke回到 UI 线程更新棋盘。protected override void OnMouseClick(MouseEventArgs e) { base.OnMouseClick(e); if (_gameOver || _busy) return; if (!PixelToGrid(e.X, e.Y, out int row, out int col)) return; if (!_board.Place(row, col, _currentPlayer)) return; Invalidate(); PieceColor winner _board.CheckWinner(); if (winner ! PieceColor.None) { _gameOver true; MessageBox.Show(winner PieceColor.Black ? 黑方胜 : 白方胜); return; } _busy true; _currentPlayer PieceColor.White; Text AI 思考中...; Task.Run(() _ai.GetBestMove(_board)) .ContinueWith(t { if (t.IsFaulted) { BeginInvoke(new Action(() { _busy false; Text AI 计算异常; })); return; } var move t.Result; BeginInvoke(new Action(() { _board.Place(move.row, move.col, PieceColor.White); _busy false; _currentPlayer PieceColor.Black; Text 黑方回合; Invalidate(); PieceColor w _board.CheckWinner(); if (w ! PieceColor.None) { _gameOver true; MessageBox.Show(w PieceColor.Black ? 黑方胜 : 白方胜); } })); }); }逻辑说明_busy是这里最重要的状态标记。玩家点击落子后立刻把它设为trueAI 思考期间所有鼠标点击直接忽略避免出现玩家连点两下、或者 AI 还没走完玩家又落子的竞态。Task.Run里读取的是_boardAI 只会读不会写它所有模拟落子都发生在克隆棋盘上所以后台线程是安全的。搜索结果回到 UI 线程后再真正把 AI 的白棋写入棋盘。参数说明AI 的颜色设成PieceColor.White玩家执黑先手。如果想让 AI 先手把颜色对调即可。_busy的恢复时机必须放在BeginInvoke内部不能放在Task.Run刚结束时否则 UI 线程还没更新完棋盘玩家就能点击下一手。4.3 重新开局时棋盘、AI、回合标记必须一起重建很多人新开一局只调用Invalidate()结果棋盘画面重新画了但_board里还留着上一局的棋子。原因很简单_board是窗口的字段除非显式清空或重新new否则旧数据还在。处理办法有两个一是给GameBoard加Clear()方法把所有格子重置为None二是把_board从readonly改成普通字段新开局直接_board new GameBoard()。我更推荐后者因为重新创建对象最不容易漏状态。同时必须把_gameOver false、_busy false、_currentPlayer PieceColor.Black全部重置否则会出现“上一局已经结束了这一局点棋子没反应”的诡异现象。5. 五子棋人机对战避坑清单五个高频翻车点与对应修法界面、搜索和状态机都跑通后剩下的问题绝大多数不是“算法完全没写”而是某个边界条件或状态复位被漏掉了。下面这五条是我见过最频繁的翻车点每一条都是“现象 → 原因 → 解决”的顺序可以直接对照排查。5.1 现象棋子不同步看着落在交叉点附近实际却判定到了格子里如果画面上的棋子总是偏向某个方向先检查PixelToGrid。最常见的原因是row (y - Margin) / CellSize少了 CellSize / 2这步四舍五入。鼠标点在线下方 5 像素时除法会直接落到上一行。解决方法是按第 4 章的写法先加半个格子再整除。另一个隐蔽原因是Margin和画线用的边距不一致。画线时如果从Margin画到Margin 14 * CellSize转换时却用另一个边距结果棋盘越靠近边缘偏差越大。统一用同一个const int Margin这类问题基本能绝迹。5.2 现象AI 突然“犯浑”放着必胜不打去走边角这种问题通常不在搜索而在评估函数。很多人把“活四”和“冲四”都写成同一个分数AI 无法区分“下这里五连”和“下这里只是漫长进攻线中的一步”于是随机挑了一个。另一个常见原因是评估时只算了己方分数没有减掉对方分数导致 AI 对敌人的冲四毫无反应。解决方法是回到权重表至少保证“五连 活四 冲四 活三 眠三”。在叶子节点用myScore - otherScore做最终值让 AI 同时考虑进攻和防守。改完权重后用第 3 章的评估函数输出几个典型局面例如“已有活三的局面分数必须明显大于已有眠三的局面”确认数值后再对弈。5.3 现象轮到 AI 思考界面直接假死或者转圈卡住在 UI 线程里直接调用GetBestMove是最典型的死因。搜索深度只要到 5一次计算就可能消耗十几秒窗口在 Windows 看来就是“未响应”。解决方法是把GetBestMove放进Task.Run并用BeginInvoke回 UI 线程更新棋盘参见第 4 章代码。如果已经用了Task.Run还是很慢要检查剪枝是否真正生效。常见误用是把alpha和beta放在递归外层更新但递归返回值没有参与比较剪枝等于没剪。正确写法是像第 3 章那样在极大层更新alpha极小层更新beta并且每个分支都用更新后的值继续递归。5.4 现象AI 先手强后手弱同一套权重换个先后就变味五子棋棋盘是对称的代码本身不分先后但在游戏平衡性上先手确实有优势。如果 AI 执黑时胜率高执白时胜率低到没法看说明评估函数只考虑了“连成五个”的静态价值没有考虑“开局主动权”。这不算 bug而是可以接受的工程取舍。想改善一个低成本方案是在 AI 执白且局面还没形成明显优势时让白棋优先在靠近己方棋子的区域落子更早与黑棋纠缠更进阶的做法是引入“先手附加上分”在评估函数里根据当前轮到谁做小幅调整。对入门项目来说先手 AI 强、后手 AI 弱并不是实现错误不必在初期花太多力气。5.5 现象新的一局画面重画了判断胜负的层还是旧棋盘这个现象最迷惑人因为棋盘看起来是空的但 AI 第一步走得特别奇怪或者玩家下了几颗子后 AI 的表现像“记得”上一局。原因几乎都是_board没被重置。WinForms 的Invalidate()只触发重绘不会清空数据层。解决方法是把新局状态集中放在一个NewGame()方法里_board new GameBoard()、_gameOver false、_busy false、_currentPlayer PieceColor.Black。如果_board声明成了readonly就把它改成普通字段。这个习惯能省掉大量“看起来像玄学”的问题。6. 从能赢到稳赢两个立刻能用的调优手段与一条调参闭环深度 4 加基础评估函数已经能让 AI “会下棋”但面对玩家常见套路时还是容易吃亏比如开局前三步就丢了主动权。这里给两个低成本改进以及一个我常用的调参习惯。6.1 内嵌迷你开局库保住前几步的主动权开局阶段的搜索价值很低因为棋盘太空评估函数看不出长远趋势。常见做法是给 AI 加一个开局库前一两手直接用固定落点后面再交给搜索。public (int row, int col)? TryOpening(GameBoard board) { int moveCount 0; (int row, int col)? firstMove null; for (int r 0; r GameBoard.Size; r) { for (int c 0; c GameBoard.Size; c) { if (board[r, c] PieceColor.None) continue; moveCount; firstMove (r, c); } } if (_aiColor PieceColor.Black moveCount 0) return (7, 7); // AI 执黑先手直接占天元 if (_aiColor PieceColor.White moveCount 1 firstMove.HasValue) return (7, 6); // AI 执白后手贴近对方落子 return null; // 后续交给搜索 }逻辑说明这个方法放在GetBestMove最前面如果有返回坐标就直接用否则继续走极大极小搜索。AI 执黑时第一步固定走天元AI 执白时贴着对方第一步附近落子避免白棋开局就陷入被动。更完整的开局库可以做成坐标字典覆盖十几套常见变化但对入门项目来说先把“前三步不犯傻”做到就够了。6.2 权重调整后如何验证别用一盘肉眼棋盘下结论评估函数里最诱人的就是把活三、冲四的权重改来改去。血泪经验是只盯着自己下的一盘棋看不出权重优劣。今天改完觉得自己赢了一盘明天换执黑又输了很容易怀疑是自己运气问题。我的做法是固定两个 AI 版本在无界面环境里跑多局对战。把GameBoard、Evaluator、AiPlayer放在独立的类库工程里不依赖Form这样可以在控制台里循环模拟。每次记录胜负、平均步数和单步耗时。改权重的流程是先跑旧权重 20 局再跑新权重 20 局对比胜率。如果新旧棋力接近而新权重的平均思考时间涨了 50%这个改动就不值得保留。最后一个习惯是每次只改一个参数。同时改活三权重和眠三权重赢了也不知道是谁的功劳。我调整参数时会在代码里留一行注释写着旧值和新值方便随时回退。后来我养成的习惯是先写“验证局面”再改权重再跑多局这比在界面上凭感觉试棋要可靠得多。希望帮到你。本文还有配套的精品资源点击获取