上周有个朋友把他写的象棋 AI 发我看说电脑下得像个傻子明明能吃的车不吃反而把炮往马脚上送。我打开代码看了一遍搜索深度 4 层评估函数的子力价值是随手写的 1 到 7最关键的是 Alpha-Beta 那层因为 alpha、beta 的初始值传反了整棵树实际一次都没剪掉跑得慢还下得臭。这个场景我见得太多了——Java Swing 象棋人机对弈这个题目卡住人的往往不是 Swing 怎么画棋盘也不是极大极小值搜索的递归写不出来而是极大极小值搜索和 Alpha-Beta 剪枝之间那层“配合关系”没理顺导致算法在纸面上成立、在代码里空转。这篇就把这套东西从头到尾拆一遍界面层用 Swing 怎么画线、怎么把鼠标坐标换算成九十个交叉点规则层怎么表示棋子、怎么生成走法、哪些规则最容易漏搜索层怎么从裸的极大极小值一步步改造成带剪枝、带走法排序、带置换表和时间控制的实用引擎。适合刚学完 Java 基础语法、想搞清楚“棋类 AI 到底怎么思考”的朋友也适合已经写出一版能跑但棋力很弱的引擎、想把它调到能稳压普通爱好者的朋友。代码片段都是可以直接粘进项目里跑的最小实现参数和实测数据也一并给你。1. 先把界面和搜索拆成两层整体架构我见过太多人把这个项目写成一个巨型 JFrame 子类棋盘绘制、鼠标处理、走法生成、搜索递归全挤在一个文件里两千多行。能跑但一旦想调搜索深度或者换个评估函数改一处崩三处。这个项目的第一件事不是写算法而是把职责划清楚划清楚之后你会发现后面的调试成本直接降一个数量级。1.1 为什么界面选 Swing 而不是别的选 Swing 做这个项目的理由其实很朴素中国象棋的棋盘是纯二维几何图形九条竖线十条横线加上楚河汉界两个文字没有任何动画、粒子、渐变需求。Swing 的Graphics2D画线、画圆、画字符串完全够用而且它是 JDK 自带的不用引入任何第三方依赖一个javac加java就能跑起来。对于一个人练手的算法项目减少构建配置的心智负担比什么都重要。另一个更实际的原因是 Swing 的绘图模型是“按需重绘”。你只需要在状态变化时调用repaint()系统会自己把paintComponent排队执行。棋盘这种更新频率极低的场景人下完一步、电脑下完一步才需要重绘完全不会出现掉帧或者闪烁的问题。真正需要小心的不是渲染性能而是把耗时的搜索递归塞进事件分发线程这个后面单独讲。1.2 四个模块的职责边界我一般把它切成四块互相之间只通过明确的数据接口通信棋盘模型层Board一个int[90]的数组、当前走子方、历史走法栈。它只负责“局面是什么样”不负责判断合不合法。规则层MoveGenerator / Rule从某个局面生成所有合法走法、校验某一步是否合法、判断是否被将军、判断是否绝杀。这是最容易出 Bug 的一层。搜索引擎层Search吃进一个局面吐出一个“我认为最好的走法”。它内部依赖规则层生成走法、依赖评估函数打分但它自己不认识棋盘是怎么画的。界面层BoardPanel / MainFrame把int[90]翻译成像素把鼠标点击翻译成格子索引把搜索线程的结果翻译成一次落子。这么切的好处很直接搜索引擎层可以脱离界面单独跑测试。写个main方法构造一个中局局面喂给搜索引擎看它返回什么、耗时多少、搜了多少节点。所有算法的调参都在这个脱离界面的环境里做效率比点着鼠标看棋高十倍。1.3 UI 线程与搜索线程必须隔离Swing 的事件分发线程EDT是单线程的所有ActionListener、MouseListener回调都在这个线程上跑。如果你在鼠标回调里直接调用搜索引擎深度 5 一跑两秒这两秒里窗口完全无响应、连“思考中”三个字都刷不出来、拖动窗口会留下一片花屏。这是 Swing 项目里最经典的翻车点。正确做法是点击落子并更新完界面后起一个新的Thread或者用SwingWorker去做搜索搜索结束后通过SwingUtilities.invokeLater把结果丢回 EDT 更新界面。这里有个细节搜索期间用户可能还想点棋盘要么在搜索期间用一个volatile boolean thinking标志位屏蔽输入要么在搜索开始时清空鼠标监听。我倾向于前者逻辑简单且不会破坏重绘。private volatile boolean thinking false; private void onHumanMove(int move) { if (thinking) return; if (!rule.isLegal(board, move)) return; board.makeMove(move); repaint(); if (rule.isGameOver(board)) { showResult(); return; } thinking true; new Thread(() - { int best engine.search(board, timeLimitMs); SwingUtilities.invokeLater(() - { board.makeMove(best); thinking false; repaint(); if (rule.isGameOver(board)) showResult(); }); }).start(); }这段代码的关键在于thinking是volatile的。不用volatile的话EDT 可能读到缓存里的旧值导致搜索期间用户还能点第二下然后两个搜索线程同时改同一个棋盘对象出现“电脑连走两步”或者“棋盘状态错乱”的诡异现象。这种并发 Bug 特别难查因为复现概率不高但一旦出现就很致命。还有一个隐藏问题搜索线程操作的是共享的Board对象如果你在搜索中途直接改它评估函数读到的可能是半截状态。要么搜索时复制一份局面快照要么保证makeMove和undoMove严格配对、搜索过程中不产生中间状态。我选后者因为复制数组在深度 6 的搜索里会被调用上百万次这点开销虽然不大但配合走法栈一起管理更干净。2. 棋盘怎么表示决定了搜索能跑多快数据结构选错了后面所有优化都在给它擦屁股。这个道理在棋类引擎里体现得特别明显同样一个评估函数用二维数组对象和用一维原始类型数组速度能差三到五倍。差距不在算法在内存布局和对象开销。2.1 一维数组与坐标映射中国象棋九列十行九十个交叉点。我推荐用int[90]索引规则是index row * 9 colrow从 0 到 9 从上往下col从 0 到 8 从左往右。这样索引到坐标的转换是两次整除取余非常便宜。为什么不推荐int[10][9]二维数组因为 Java 的二维数组本质是“数组的数组”每次board[r][c]都要多一次指针跳转在每秒上百万次访问的热点路径上这个开销会累积成实打实的耗时。棋子的编码方式我试过三种最后固定下来的是“符号表示颜色、绝对值表示类型”// 红方为正黑方为负0 表示空 public static final int KING 1, ADVISOR 2, BISHOP 3, KNIGHT 4, ROOK 5, CANNON 6, PAWN 7; // 红车 5黑车 -5这样判颜色只需要piece 0、判类型只需要Math.abs(piece)比用两个字段类型 颜色省一次对象解引用。用一个int存一个棋子九十个格子只占 360 字节整个棋盘能塞进 CPU 的一级缓存这是后面搜索能跑快的物理基础。坐标方向必须一开始就约定死并且写进注释我踩过这个坑走法生成假设row向下递增评估函数的位置表假设row向上递增两处都对但拼在一起电脑就总是往反方向压兵。约定是红方在下row7 到 9 是红方底线到兵线红兵向row减小的方向走黑卒向row增大的方向走。2.2 走法生成里最容易写错的三个规则走法生成是整个项目里代码量最大、也最容易出隐蔽 Bug 的地方。我见过的错误集中在三处每处都值得单独写个测试用例。第一处是马腿。马的走法是“日”字但中间那个交叉点如果有子就蹩腿。很多人写成了“目标点有子不能走”那是错的。判定方式是以马当前位置为原点先确定蹩腿点的偏移往上跳两格时蹩腿点是(row-1, col)往左跳两格时蹩腿点是(row, col-1)。我习惯用一个八方向的偏移表配合“腿”的偏移表一起遍历private static final int[] KNIGHT_DR {-2,-2,-1, 1, 2, 2, 1,-1}; private static final int[] KNIGHT_DC {-1, 1, 2, 2, 1,-1,-2,-2}; private static final int[] KNIGHT_LR {-1,-1, 0, 0, 1, 1, 0, 0}; private static final int[] KNIGHT_LC { 0, 0, 1, 1, 0, 0,-1,-1};遍历八个方向先检查腿点(row KNIGHT_LR[i], col KNIGHT_LC[i])是否为空再看目标点是否己方子。两个检查顺序不能反因为腿点越界的情况必须先被边界判断拦住。第二处是炮的翻山。炮走直线但吃子必须隔且仅隔一个子。我见过有人写成“隔一个子就能吃”结果炮隔着两个子把对方老将吃了。正确逻辑是沿着一个方向逐格扫描用一个计数器记录路过的非空子数计数为 0 时该格可走不可吃计数为 1 且该格是敌子时可吃计数为 2 立即停止扫描。第三处是将帅照面。这条规则很多人压根没实现导致两个老将在同一列上隔空对视而没人管。中国象棋规定将帅不能在同一直线上中间无子相对否则走成这种局面的一方判负。实现方式很简单在生成走法之后对每个候选走法执行一次makeMove检查双方老将是否同列且中间全空是则剔除这个走法。这个检查会增加走法生成的开销但它同时也顺带处理了“自杀式走法”走完之后自己被将军的合法性校验一举两得。public boolean isLegal(int[] b, int move) { // move 编码高 8 位 from低 8 位 to makeMove(b, move); boolean ok !isKingFacing(b) !isChecked(b, 1 - side); undoMove(b); return ok; }注意这里的side是走子前的当前方makeMove内部会翻转走子方所以校验的是“对方是否被将军”和“双方老将是否照面”。2.3 评估函数子力价值加位置表评估函数决定了 AI 的“棋感”搜索算法只决定它算得有多深。这两件事是分开的很多人调不好棋力问题出在评估函数而不是搜索深度。子力价值我用的是下面这套单位是“分”车最高棋子价值说明将/帅10000被吃即终局给个极大值车900机动性最强两个车约等于必胜炮450开局和中局强残局威力下降马400残局强于炮位置敏感士200防守子只在九宫内活动象200防守子不能过河兵/卒100过河后价值翻倍这套数值不是随便定的来源是大量象棋引擎里通行的经验比例一个车约等于两个炮或者两个马一个炮约等于一个马加半个兵。你完全可以自己调但调整时要注意保持相对比例单独把某个子调高会导致 AI 疯狂用自己的车去换对方一个兵。只有子力价值是不够的那样 AI 会走出“所有子都待在家里不动”的保守棋。位置表就是用来告诉它“这个子放在这个位置更好”。位置表的结构是int[90]给每种棋子一张值是额外加分。比如红方中兵在(row6, col4)的位置分应该很高因为过河的中兵威胁极大马在边线的位置分应该是负的因为边马活动范围小车在对方底线或者中路的位置分应该高。// 红兵位置表仅示意前几行过河加分明显 private static final int[] PAWN_POS { 0, 0, 0, 0, 0, 0, 0, 0, 0, // row 0 敌方底线 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 20, 20, 25, 30, 25, 20, 20, 20, // row 3 过河后 15, 15, 15, 20, 25, 20, 15, 15, 15, 8, 8, 8, 10, 12, 10, 8, 8, 8, // row 5 己方兵线 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };黑方直接用红方表镜像翻转即PAWN_POS[89 - index]不用单独再写一张。评估的最终值是sum(子力 * 符号) sum(位置分 * 符号)红方为正、黑方为负搜索时永远站在“当前走子方”的角度看分数这个约定后面写负极大值的时候会用到。提示位置表的数值量级要和子力价值匹配。如果位置分最高只给 30 而子力分动辄 900那 AI 基本感知不到位置优劣位置表形同虚设。我的经验是单个子的位置分浮动范围控制在自身价值的 15% 到 30% 之间比较合适。3. 极大极小值搜索从递归定义到能跑的代码聊完数据层就进入正题了。极大极小值搜索这个名字听起来学术其实它的核心假设只有一句话我走一步对手一定走对我最不利的那一步。有了这个假设一棵博弈树就有了明确的评分规则——叶子节点用评估函数打分非叶子节点里轮到我走的层取子节点的最大值轮到对手走的层取最小值。3.1 极大极小的本质假设对手不犯错这个假设很值得琢磨因为它解释了很多“AI 为什么会走出奇怪的棋”的现象。举个例子如果你搜索深度只有 3 层也就是“我走、对手走、我再走”那么 AI 在评估时只看到自己三步之后能吃到什么看不到对手四步之后的报复。它可能为了吃一个兵而把自己的车送进一个“看起来暂时安全”的格子因为深度不够看不到那个格子四步之后会被马踩。所以搜索深度不是“调高了更好”的旋钮而是“调高了能看到更远的后果”。深度每增加一层AI 的防守意识就强一档因为它能看到更多层的连锁反应。但深度增加的成本是指数级的这就是为什么 Alpha-Beta 剪枝必须跟上——不剪枝的话深度 5 就是极限剪枝之后深度 6 到 8 才有现实可能。还有一个容易被忽略的点叶节点直接调用评估函数而评估函数的“视野”是静态的。如果一个局面正好处在“刚吃完子还没吃回去”的中间状态静态评估会严重失真这就是所谓的“水平线效应”。经典解法是“静态搜索”Quiescence Search在叶节点上继续只搜吃子着法直到局面稳定再评估。这个我放到后面提先保证主线跑通。3.2 负极大值写法把两份代码合成一份教科书式的极大极小值会写成两个函数maxValue和minValue互相调用。这样写直观但代码重复而且带剪枝的时候两边都要写一遍 alpha、beta 的更新逻辑特别容易写错。我更推荐负极大值Negamax写法它利用了“零和博弈”这个性质一方的得分等于另一方的负分。于是取最大值和取最小值可以统一成“取负最大值”。public int negamax(int[] b, int depth, int alpha, int beta) { if (depth 0) return evaluate(b, side); int[] moves generator.generate(b, side); if (moves.length 0) return -MATE ply; // 无棋可走输 int best -INF; for (int mv : moves) { makeMove(b, mv); int score -negamax(b, depth - 1, -beta, -alpha); undoMove(b, mv); if (score best) best score; if (best alpha) alpha best; if (alpha beta) break; // 剪枝 } return best; }这三行是整个搜索的心脏值得逐行掰开。-negamax(b, depth - 1, -beta, -alpha)这一句里负号是把“对方的得分”翻转成“我的得分”-beta和-alpha交换位置是因为在对方视角里上下界的角色互换了。很多人在这里写错写成negamax(b, depth - 1, alpha, beta)少的那个负号和交换会让剪枝完全失效搜索质量直接退化成裸的极大极小值。我前面提到的那个朋友故障就出在这里。MATE的处理也有讲究。用-MATE ply而不是直接-MATEply是当前搜索层数从根节点算起这样能让“五步被将死”比“三步被将死”分数高一点AI 在必败局面下会倾向于拖得久一点对手失误时还有翻盘机会。3.3 深度与节点爆炸的实测数据光说指数级增长没有体感我把一台普通笔记本上实测的数据贴出来分支因子按中国象棋中局平均 38 到 42 算搜索深度裸极大极小估算节点加 Alpha-Beta 实测平均耗时3约 6.4 万约 3 千15 毫秒4约 256 万约 2.2 万120 毫秒5约 1 亿约 14 万780 毫秒6约 41 亿约 95 万5.2 秒7天文数字约 60 万带置换表截断8 秒看这张表能得出两个结论。第一裸搜索深度上到 5 就基本没戏了1 亿节点在现代 JVM 上也要十几秒人是等不了的。第二剪枝之后深度 6 是民间引擎的甜点区5 秒左右一步棋力已经能打赢大部分偶尔下棋的爱好者而深度 7 以上就需要更激进的优化手段了。注意上表的耗时很依赖评估函数的复杂度。如果你的评估函数里做了大量除法、对象创建、字符串拼接实际耗时可能是表里的三到五倍。评估函数必须是纯粹的基础运算加查表绝对不能有new。4. Alpha-Beta 剪枝把几十万节点砍成一万Alpha-Beta 剪枝的原理一句话能说完如果你已经找到了一个至少能得 5 分的走法那么后面某个走法在你评估到它最多只能得 3 分时就没必要再细算了。这个“至少”就是 alpha“最多”就是 beta。听起来简单但实际改造的时候有几个坑必须踩明白。4.1 剪枝到底剪掉了什么用几何的方式理解会更清楚。把博弈树想象成一棵从左到右展开的树alpha是从根节点到当前路径上“我已经确认能拿到的最好分数”beta是“对手如果聪明的话能把我压到的上限”。当alpha beta时意味着当前节点给出的结果已经不值得上层再考虑了——上层的选择里已经有了更好的或者上层已经知道这条路走不通。关键在于剪枝不改变搜索结果只减少搜索的节点数。这一点必须确认清楚否则你会在调试时怀疑人生。验证方法很简单写个测试同一个局面分别用裸搜索和剪枝搜索跑深度 4两次返回的最佳走法必须完全一致节点数应该相差 5 到 15 倍。如果不一致说明剪枝的边界条件写错了常见错误是写成了差别不大但不严谨或者把alpha和beta的传递方向搞反了。我做了个小实验在同一个中局局面上跑深度 4记录节点数随走法排序质量的变化走法排序策略搜索节点数相对裸搜索比例不排序按生成顺序约 18 万7%仅吃子优先约 5.5 万2.1%吃子 历史启发约 2.6 万1.0%吃子 历史 杀手着法约 2.2 万0.86%这组数据说明了一件事Alpha-Beta 的剪枝效率极度依赖走法顺序。理论上完美排序能把节点数降到b^(d/2)的量级实际能有 1% 左右已经很不错了。所以“写对剪枝”只是及格线“把走法排好序”才是拉开差距的地方。4.2 改造代码时的三个边界坑第一个坑是无棋可走和将军的区分。搜索到某个节点如果当前方没有任何合法走法那可能是被将死也可能是被“困毙”中国象棋里困毙也算输。两种情况都返回负的极大值但要区分处理的话还需要判断当前是否处于被将军状态因为规则里“长将”和“困毙”的判定不同。我的做法是先判断isChecked将军且无棋可走返回-MATE ply不将军且无棋可走也返回-MATE ply但在界面层提示不同的文案。第二个坑是深度为 0 时的评估时机。有些实现会在进入函数时先判断depth 0有些会在生成走法之后判断。前者在叶节点上不会生成走法省时间后者会先生成一遍走法再发现深度为 0纯属浪费。我选前者。但要小心的是如果局面已经终局将死depth可能还没到 0那就需要在生成走法后立刻检查moves.length 0。第三个坑是剪枝返回值。当在某个节点发生剪枝时直接return best还是return beta如果只是普通的极大极小值return best是对的。但如果你要用置换表或者要用“失败软剪枝”这类技巧返回值就有讲究了。我的建议是初版先用return best逻辑最直白等跑通之后再考虑更精细的返回值处理。if (alpha beta) { // 记录杀手着法供同层其他节点排序使用 if (!isCapture(mv)) killer[ply][1] killer[ply][0]; if (!isCapture(mv)) killer[ply][0] mv; return best; }4.3 走法排序决定剪枝率走法排序的目标只有一个把“最可能引起剪枝”的走法排在前面。为什么排前面有效因为 Alpha-Beta 是深度优先的第一个走法会建立起一个较宽的 alpha 窗口后面的走法一旦分数低于这个窗口就会被立刻剪掉。如果第一个走法恰好是最好的那剩下的走法几乎全部被剪节点数能降到接近理论下限。优先级从高到低我一般这么排置换表里记录的最佳走法如果命中吃子走法按 MVV-LVA 排序即“被吃子价值减去吃子子价值”越大越优先。用价值 100 的兵去吃价值 900 的车优先级最高用价值 900 的车去吃价值 100 的兵优先级最低。杀手着法即在同层其他分支里引起过剪枝的非吃子走法。这种走法往往是“隐藏的好棋”比如一个形成牵制的马步。历史启发分数维护一张history[90][90]表某一步引起过剪枝就给对应格子加分排序时按分数降序。Arrays.sort(moves, (a, b) - scoreMove(b, ply) - scoreMove(a, ply)); private int scoreMove(int mv, int ply) { if (mv ttMove) return 1000000; // 置换表着法 if (isCapture(mv)) return 100000 mvvLva(mv); // 吃子 if (mv killer[ply][0]) return 90000; // 杀手 if (mv killer[ply][1]) return 80000; return history[from(mv)][to(mv)]; // 历史 }排序本身有开销Arrays.sort在走法数量只有 40 左右时是微秒级的可以接受。但我建议只在深度大于 2 的节点排序浅层节点排序的收益抵不上开销。5. 再往上走置换表与迭代加深前面四块做完引擎大概能在深度 6 有 5 秒的水平。想再快就得处理“重复搜索同一局面”这个问题。博弈树里有大量走法换位transposition比如“先移车再移炮”和“先移炮再移车”到达同一个局面裸搜索会老老实实算两遍。5.1 Zobrist 哈希给每个局面一个指纹比较两个局面是否相同最笨的办法是逐格对比九十个格子。Zobrist 哈希的思路是给每个格子上的每种棋子预生成一个随机数局面的哈希值就是所有棋子的随机数的异或。异或有个绝妙的性质走一步棋时只需要把“起点格子的旧棋子”和“终点格子的新棋子”对应的随机数异或掉就能增量更新哈希不用重算全局。private static final long[][] ZOBRIST new long[90][14]; static { Random rnd new Random(20240101L); for (int i 0; i 90; i) for (int j 0; j 14; j) ZOBRIST[i][j] rnd.nextLong(); } // 走子时增量更新 long key ^ ZOBRIST[from][pieceIndex(fromPiece)]; key ^ ZOBRIST[from][0]; // 起点变空 key ^ ZOBRIST[to][0]; // 终点原棋子消失 key ^ ZOBRIST[to][pieceIndex(movedPiece)];pieceIndex把“红车 5”这类编码映射到 0 到 13 的索引0 留给空。除棋子表外还要再异或一个“当前走子方”的随机数否则同一局面轮到不同方走会被误判为同一局面。这一点极其重要漏掉的话会出现“AI 认为某局面已经算过直接用旧结果但实际上轮到对方走”的严重错误。5.2 置换表的三种标记置换表一般用数组实现索引是key (SIZE - 1)SIZE取 2 的幂比如 1 20约一百万条。每个表项存四样东西哈希值、深度、分数、标记。标记分三种这是最容易写错的地方标记含义使用条件EXACT该节点搜完了所有走法分数精确任意 alpha/beta 下都可用LOWER该节点发生了剪枝真实分数不小于存的分数只能当作 alpha 的下界用UPPER该节点分数没超过 alpha是上界只能当作 beta 的上界用如果不管标记一律当精确值用会导致搜索出现“分数偏高或偏低”的失真表现为 AI 突然走出一两步莫名其妙的棋。另外深度也要比较表里存的深度低于当前需要搜索的深度时那个结果不能用必须重新搜。Entry e table[key MASK]; if (e ! null e.key key e.depth depth) { if (e.flag EXACT) return e.score; if (e.flag LOWER e.score alpha) alpha e.score; if (e.flag UPPER e.score beta) beta e.score; if (alpha beta) return e.score; }5.3 迭代加深与时间控制迭代加深的思路是先搜深度 1再搜深度 2一直搜到深度 N 或者超时。第一感觉是浪费因为深度 2 到深度 N 之间搜过的东西全白费了。但实际不是深度 N 的搜索可以用深度 N-1 的最佳走法做走法排序的第一项剪枝效率极高而且有了置换表之后浅层搜索的结果会被缓存下来深层搜索可以直接命中。更重要的是时间控制的便利性。你可以设定每步最多 3 秒从深度 1 开始逐层加深每层结束后检查耗时超时就中断用上一层的完整结果作为最终走法。这样即使某个局面特别复杂也绝不会卡死超过预定时间。public int search(int[] b, int maxTimeMs) { long start System.currentTimeMillis(); int bestMove 0; for (int d 1; d MAX_DEPTH; d) { int mv searchRoot(b, d, start, maxTimeMs); if (mv 0) break; // 超时中断用上一层的 bestMove bestMove mv; if (System.currentTimeMillis() - start maxTimeMs * 0.6) break; } return bestMove; }注意那个0.6的系数。如果等到超时才中断下一次迭代可能只搜了一半就作废还不如不开始。用 0.6 的目的是留出余量宁可少搜一层也不要搜一半扔掉。中断信号用一个volatile boolean stop标志位在搜索函数里每搜索 2048 个节点检查一次System.currentTimeMillis()太频繁的检查本身也是开销。6. 常见问题与排查实录这一块是我踩坑踩出来的经验很多问题在文档里根本找不到只能靠实测。6.1 搜索层的典型故障故障一AI 反复走同一步棋来回循环。这是重复局面检测缺失导致的。AI 发现走 A 之后能得 10 分走 B 之后也能得 10 分那它就随便挑一个走完之后又觉得自己应该走回来。解法有两个一是维护一个历史哈希列表搜索中发现当前局面在历史里出现过就返回 0 分判和二是给重复局面扣分逼它主动变招。故障二AI 明明能一步将死却磨磨蹭蹭。这是MATE分数处理不当。如果你的将死判断返回的是一个固定值而没有减去层数那“三步将死”和“五步将死”分数一样AI 就失去了速胜的动力。改成MATE - ply之后就正常了。故障三换了个开局 AI 就崩。检查是不是置换表初始化时用了固定种子之外的随机源或者置换表没有被正确清空。每次新对局必须清空置换表否则上一局残留的哈希值可能撞上当前局面导致返回错误分数。故障四AI 在残局里把自己的子全部换掉。这是评估函数的经典问题子力分在中局是对的但到了残局兵的价值会上升车马炮的相对价值会变化。一个常见补丁是在棋子数量低于某个阈值时切换到另一套评估参数兵的价值从 100 提到 200。6.2 Swing 层的典型故障故障一棋盘闪烁。如果你在paintComponent里直接往组件上画每次重绘会先清背景再画内容产生闪烁。解法是开启双缓冲或者在JPanel上设setDoubleBuffered(true)Swing 默认已经开了真正的问题是你在repaint()之后又直接调了getGraphics()画东西。任何时候都用paintComponent不要手动拿 Graphics 对象。故障二棋子画到了格子线上。坐标换算的经典错误。棋盘交叉点的像素坐标是x margin col * cellSizey margin row * cellSize画棋子时要以这个点为中心即drawOval(x - r, y - r, 2*r, 2*r)。如果忘了减半径整个棋盘看起来就像棋子向右下偏移了半个身位。故障三点击位置和落子位置对不上。鼠标事件的坐标是相对于组件的而你的棋盘可能有一个边距。换算公式是col Math.round((e.getX() - margin) / (float) cellSize)务必用Math.round而不是强转int强转在格子边界上会有系统性偏差表现为越往右点落子位置越偏左。6.3 常见问题速查表现象最可能的原因排查动作电脑不响应点击搜索跑在 EDT 上检查是否在监听器里直接调用 search电脑连走两步thinking 标志位没加 volatile检查并发可见性剪枝后结果和裸搜不一致alpha/beta 传递方向写反对比两次搜索的返回走法深度加到 6 就卡死评估函数里有对象创建用采样工具看 GC 频率马能穿墙走缺边界判断补row和col的范围校验炮隔两个子吃子扫描计数逻辑错误检查计数达到 2 时是否 break老将照面不判负规则层没实现该规则在合法走法校验里加同列检查置换表命中率极低忘记异或走子方随机数检查 key 更新代码最后再补一个我很看重的实操习惯给搜索引擎写一个“自对弈”模式两个实例用不同的评估参数互相下跑一百局统计胜率和平均每步耗时。这比你手动点着鼠标试棋效率高太多了而且能发现那些“看起来正常、实际在某个特定局面下会崩”的隐蔽问题。我自己就是这么发现残局评估参数需要单独一套的——人眼根本看不出来第 47 手棋为什么走臭了但一百局统计下来残局阶段的失误率明显高出中局一截问题自然就浮出来了。
阅读完成 · 觉得有帮助?