我第一版象棋程序在界面上点下"红炮二平五"之后,整个窗口白了将近四秒,任务管理器里 CPU 单核跑满,鼠标指针转圈,连最小化都要等半天。那次之后我才真正明白,一个 Java Swing 象棋人机对弈项目,难点根本不在于把棋盘画出来,也不在于把走法规则抄完,而在于极大极小值搜索和 Alpha-Beta 剪枝这套东西,天然是"吃 CPU 的重活",而 Swing 的事件分发线程根本扛不住任何超过 50 毫秒的阻塞。标题里这几个词——Java Swing、象棋、人机对弈、极大极小值、Alpha-Beta 剪枝——其实对应着两个完全不同的问题域,一个是界面与交互,一个是博弈树搜索,把它们硬塞在同一个线程里,程序必然崩坏。
这篇东西适合三类人看:一是刚学完数据结构想找个能落地的项目练手的同学,二是写过五子棋 AI 但换成象棋之后发现"规则怎么这么多"的开发者,三是已经把搜索跑起来但发现"深度一到 4 就卡死、深度只有 3 又下得跟傻子一样"的实践者。我会按我自己踩坑的顺序讲:先把引擎和界面的边界划清楚,再讲棋盘表示和走法生成这些地基,然后是极大极小值和评估函数,接着才是 Alpha-Beta 剪枝的实操细节,最后是 Swing 那边的线程配合和一堆实测数据。代码全部是 Java,能直接抄去改成自己的。
1. 引擎与界面是两条互相拖累的线,先把边界划死
1.1 为什么在 Swing 里直接搜索一定会卡成幻灯片
Swing 是单线程模型的典型代表,所有绘制、事件响应、组件状态更新都排在一个叫 EDT(Event Dispatch Thread)的队列里。你在按钮的actionPerformed里调用一次engine.search(),这个调用只要不返回,EDT 就一直被占着,后面排队的repaint()、鼠标点击、窗口重绘全部堵死。系统的窗口管理器还会认为这个程序"未响应",几秒后给你盖上一层灰白色的遮罩。
很多人第一反应是加个new Thread(...).start(),然后在线程里直接boardPanel.repaint()。这招能解决卡顿,但会引入新的问题:Swing 组件不是线程安全的,从非 EDT 线程直接改动组件状态,轻则界面闪烁错位,重则抛出难以复现的异常。正确姿势只有一个——耗时计算扔到后台线程,所有界面更新通过SwingUtilities.invokeLater或SwingWorker的done()回调回到 EDT。这一点我在第 5 节会展开写,先记住结论。
1.2 一条最小心智模型:局面是数据,搜索是纯函数
我后来把整个项目拆成三个互不依赖的包,这个划分方式沿用至今:
| 模块 | 职责 | 依赖约束 |
|---|---|---|
core | 棋盘数据、走法生成、规则判定、Zobrist 哈希 | 不 import 任何javax.swing |
engine | 评估函数、极大极小值、Alpha-Beta、置换表、时间控制 | 只依赖core |
ui | 棋盘绘制、鼠标事件、悔棋栈、思考状态提示 | 依赖core,通过接口调engine |
这条约束的价值在于:引擎可以在没有界面的情况下用 main 方法跑自对弈,可以写单元测试验证"马腿"判定,可以在命令行里刷 perft(节点计数)来验证走法生成的正确性。我见过太多人的象棋项目,规则 bug 和界面 bug 缠在一起,改一个地方崩三个地方,最后项目就死在那儿了。
perft这个词值得单独说一句。它就是"给定深度,把整棵博弈树的合法走法全部展开,数一共多少个叶子节点"。象棋开局深度 1 是 44 个走法,深度 2 是 1920,深度 3 是 79666,深度 4 是 3290240。这四个数字是公开的基准值,你的走法生成只要和它们对不上,就说明规则有 bug,先别急着调评估函数,回去修生成器。我当时就是靠对不上 79666 这个数,才发现自己的象过河判定写反了。
1.3 搜索线程需要什么,界面线程需要什么
后台搜索线程需要的是一个完全独立的棋盘快照,而不是界面正在编辑的那个棋盘对象。原因很直接:如果用户在 AI 思考期间点了悔棋,或者点了"新开一局",你正在遍历的棋盘数据被中途改掉,搜索出来的走法可能是个非法走法,程序直接崩在ArrayIndexOutOfBounds上。
我的做法是每次发起思考前board.copy()一份传给引擎,引擎再把自己的结果(一个编码成整数的走法)返回给界面。走法编码用from << 8 | to就够了,90 个格子用 7 位表示,两个 7 位塞进一个 int 绰绰有余。这种"值传递"的设计让整个系统变得非常好调试——引擎是无状态的,输入一个局面加一个时间限制,输出一个走法,仅此而已。
2. 棋盘表示与走法生成:整个引擎的地基
2.1 一维 byte 数组,比二维数组和对象数组都好用
最早我用的是Piece[][]二维对象数组,每个格子放一个Piece对象,带颜色、类型、坐标字段。写到深度 4 的时候发现慢得离谱,用采样器一看,GC 占了将近三成时间——每一层搜索都在不停地生成和丢弃对象。
换成byte[90]之后世界清静了。约定:红方为正数,黑方为负数,绝对值表示兵种,0 表示空。这样"这个格子是红方还是黑方"只需要一个符号判断,"是不是同色"只需要a * b > 0,连 if 都省了。
public final class Board { public static final int ROWS = 10, COLS = 9; public static final byte KING = 1, ADVISOR = 2, BISHOP = 3, KNIGHT = 4, ROOK = 5, CANNON = 6, PAWN = 7; public final byte[] cells = new byte[ROWS * COLS]; public boolean redToMove = true; public static int idx(int r, int c) { return r * COLS + c; } public static int row(int i) { return i / COLS; } public static int col(int i) { return i % COLS; } public Board copy() { Board b = new Board(); System.arraycopy(this.cells, 0, b.cells, 0, cells.length); b.redToMove = this.redToMove; return b; } }坐标约定是红方在下、黑方在上,红方 row 从 9 往 0 走,黑方 row 从 0 往 9 走。这个约定一定要在写第一行代码时就钉死,中途改一次要动的地方多到让人想删库。我第二次重构就是因为一开始把红方放上面,后来画界面的时候发现大家都习惯自己在下,硬改了一整天。
2.2 走法生成里最容易写错的四类规则
象棋的走法规则看起来简单,实际写起来坑不少。我把最容易错的地方列出来:
- 马腿。马走"日"字,但蹩马腿的位置是紧邻的那一格,不是目标格。八个方向对应八个腿位,必须成对定义。写成
int[][] {dr, dc, legDr, legDc}的四元组最不容易乱。 - 象眼。象走田字,塞象眼在田字中心,而且象不能过河——红象只能在 row 5 到 9,黑象只能在 row 0 到 4。
- 炮架。炮的走法分两段:不吃子时和车一样走空格,吃子时必须中间恰好一个棋子。写成"沿方向扫描,遇到第一个子记为炮架,继续扫到第二个子,若是敌子则可吃"最稳妥。
- 将帅照面。将和帅在同一列且中间没有任何棋子时,这是一个非法局面。很多人的实现是"走完之后检查一下",但更高效的做法是在生成将的走法时直接排除掉会让双方照面的落点。
private static final int[][] KNIGHT = { {-2, -1, -1, 0}, {-2, 1, -1, 0}, { 2, -1, 1, 0}, { 2, 1, 1, 0}, {-1, -2, 0, -1}, { 1, -2, 0, -1}, {-1, 2, 0, 1}, { 1, 2, 0, 1} }; private void genKnight(int from, List<Integer> out) { int r = Board.row(from), c = Board.col(from); byte self = cells[from]; for (int[] d : KNIGHT) { int nr = r + d[0], nc = c + d[1]; if (nr < 0 || nr > 9 || nc < 0 || nc > 8) continue; int leg = Board.idx(r + d[2], c + d[3]); if (cells[leg] != 0) continue; // 蹩马腿 byte target = cells[Board.idx(nr, nc)]; if (target * self > 0) continue; // 自家棋子 out.add((from << 8) | Board.idx(nr, nc)); } }2.3 伪合法走法加事后校验,比严格生成更省心
走法生成有两条路:一是生成时就保证完全合法(包括不能送将),二是先全部生成,再走一步、检查自己是否被将军、不合法就撤销。后者代码短得多,调试也容易,代价是每次要走一步再判断。
我选了后者。原因是象棋里"送将"的判断逻辑很绕,尤其是各种隐蔽的牵制(比如炮隔着你的马打你的将,你的马一动就露将),在生成阶段处理要把每个棋子的走法都写一遍"会不会暴露",代码量翻倍还容易漏。而"走一步看自己是否被将军"只需要一个通用的isChecked(side)函数,复用性极强。
public boolean isChecked(boolean red) { int kingSq = findKing(red); if (kingSq < 0) return true; // 反向扫描:从将的位置出发,看有没有敌方车、炮、马、兵能够打到这里 ... }性能上确实有损失,实测大约慢 35%。但在我这种"深度 5 以内、时间预算 1 到 2 秒"的场景下完全能接受,换来的是代码可读性和正确率的大幅提升。什么时候该上严格生成?等你把 perft 跑到深度 6 且节点数动辄上千万、需要极限压榨的时候再说。
3. 极大极小值与评估函数:把"谁更占优"变成数字
3.1 negamax 写法:把 max 和 min 折成一个符号
极大极小值的教科书版本是 max 层和 min 层交替,红方想最大化分数、黑方想最小化分数,写两个几乎一样的函数互相调用。这种写法没错,但丑,而且容易在某一层漏掉取反。
我推荐直接用 negamax 形式,核心思想是:永远从"当前走子方"的视角算分,对手的分数就是你分数的相反数。这样只需要一个函数,每层递归把返回值取负就行。
int negamax(int depth, int alpha, int beta, int ply) { nodes++; if (depth == 0) return quiesce(alpha, beta, ply); List<Integer> moves = genLegalMoves(); if (moves.isEmpty()) return -MATE + ply; // 无棋可走 = 被将死或困毙 orderMoves(moves, ply); int best = -INF; for (int i = 0; i < moves.size(); i++) { int mv = moves.get(i); make(mv); int score = -negamax(depth - 1, -beta, -alpha, ply + 1); unmake(); if (score > best) best = score; if (best > alpha) alpha = best; if (alpha >= beta) { cutoffs++; break; } } return best; }-MATE + ply这个细节很关键。如果只返回一个固定的-MATE,那么"三步被将死"和"五步被将死"分数一样,引擎会觉得反正都是输,随便走哪步都行,结果就是明明能多撑两步却主动送死。加上当前层数ply之后,越早被将死分数越低,引擎就会本能地往"死得晚一点"的方向跑。
3.2 评估函数的四层结构:子力、位置、机动、结构
评估函数决定棋力上限,搜索深度只决定你能不能把评估函数的潜力发挥出来。我的评估函数分四层,按权重从大到小:
第一层是子力价值。这部分占绝对主导,权重必须远大于其他三层,否则引擎会出现"为了一个位置好一点就白送一个车"的荒唐走法。我用的值:将 6000、车 600、炮 285、马 270、士 120、象 120、兵 30。
第二层是位置分。同样一个兵,在初始位置和在对方九宫边上价值天差地别。我用一张简化的位置表,核心是:兵过河加分(每前进一步加,接近对方九宫再加)、马炮居中加分、车占中路和底线加分。
第三层是机动性。简单统计双方合法走法数量的差值,乘以一个很小的系数(我用的 2)。这一层能鼓励引擎把子走活,避免"棋子全挤在自己半场动不了"。
第四层是结构分。比如士象是否完整、将附近有没有被牵制、兵是不是叠在一起。这一层我只写了最简单的几个判断,因为规则越多越容易写出互相打架的权重。
private static final int[] VALUE = {0, 6000, 120, 120, 270, 600, 285, 30}; public int evaluate() { int score = 0; for (int i = 0; i < 90; i++) { byte p = cells[i]; if (p == 0) continue; int v = VALUE[Math.abs(p)] + posBonus(p, i); score += p > 0 ? v : -v; } score += (redToMove ? 1 : -1) * 6; // 先行方微小加成 return score; }3.3 符号约定:90% 的"引擎突然下昏招"都出在这里
这是我最想强调的一条。negamax 要求evaluate()返回的是红方视角的有符号分数(红优为正,黑优为负),然后每次递归取负,自然就切换成当前走子方视角。
问题出在很多人写成了"返回当前走子方的优势分",然后在递归里又取了一次负,符号翻转了两次,等于没转。表现就是引擎在偶数层和奇数层下出的棋风格完全不一样,俗称奇偶效应(odd-even effect),走一步像高手,再走一步像新手。
我当时的排查方法是:摆一个红方白丢一个车的局面,直接打印evaluate(),期望是负数(红劣)。如果打出来是正数,符号就是错的;如果红黑双方走子权切换后分数不取反,也是错的。这种"用极端局面做单点验证"的方法,比在复杂局面上瞎猜快一百倍。
4. Alpha-Beta 剪枝:从"能跑"到"能打"的那一刀
4.1 剪枝的几何直觉:一半的分支根本不需要存在
极大极小值搜索是一棵完整的树,深度 5 的分支因子按 40 算,节点数是 40 的 5 次方,一亿多个。Alpha-Beta 剪枝的核心洞察是:如果我已经找到了一个至少能拿 50 分的走法,而你现在探索的这条分支我已经能证明它最多只能拿 30 分,那这条分支剩下的部分我一个都不用看。
关键在于"能证明"三个字。Alpha 是"我保证至少能拿到这么多",Beta 是"对手保证至少能拿到这么多"。当 alpha 大于等于 beta,说明当前局面双方都不会接受,搜下去没有意义,直接返回。理想情况下,走法排序完美时,Alpha-Beta 能把有效分支因子从 b 降到 √b,也就是同样时间能多搜一倍深度——这在实际对弈里是碾压级的差距。
4.2 fail-soft 版本与三个必须注意的实现细节
剪枝实现有两种常见风格:fail-hard 严格把返回值夹在 [alpha, beta] 之间,fail-soft 允许返回真实的搜索值。我推荐 fail-soft,因为返回值信息更丰富,配合置换表效果更好。上面第 3 节的代码就是 fail-soft 版本,可以看到best初始化成-INF而不是alpha。
三个容易翻车的细节:
第一,根节点的最佳走法记录要格外小心。很多人写成"每次 alpha 更新就记下当前走法",结果剪枝发生时也会更新,导致记下来的根本不是最优走法。正确做法是只在ply == 0且score > best时更新根节点走法。
if (ply == 0 && score > best) { rootBestMove = mv; rootBestScore = score; }第二,make/unmake 必须严格对称。我早期版本里 unmake 忘了恢复"是否吃过子"的标记,导致吃子后搜索路径里所有走法都被当成吃子走法处理,引擎行为诡异到查了两天。后来我加了个断言:搜索前后棋盘哈希值必须相等,不相等直接抛异常,这个断言帮我抓出了三个 bug。
第三,时间超限的检查点。每一层递归都检查一次System.nanoTime()代价太高,我改成每 2048 个节点检查一次,超时就抛一个自定义的TimeUpException,在迭代加深的外层捕获,返回上一层的完整结果。这样既不会因为频繁读时钟拖慢速度,也不会出现"想太久被用户强制关闭"。
4.3 走法排序:让剪枝真正生效的那 80%
Alpha-Beta 的剪枝效率极度依赖走法顺序。最坏情况下(顺序完全反着来)它退化成完整搜索,一点不省;最好情况下能省掉指数级的节点。所以排序才是这个章节真正值钱的部分。
我用的排序策略是分层的:
| 优先级 | 走法类型 | 排序依据 |
|---|---|---|
| 1 | 置换表给出的最佳走法 | 直接置顶 |
| 2 | 吃子走法 | MVV-LVA:被吃子价值 - 走子价值 / 10 |
| 3 | 杀手走法 | 同一层造成过剪枝的两个走法 |
| 4 | 历史启发 | 按历史表里的累计得分排序 |
| 5 | 其余安静走法 | 保持生成顺序 |
MVV-LVA 的意思是"用最小的子去吃最大的子"。用兵吃车一定排在最前面,用车吃兵排在后面。原因很朴素:前者几乎总是赚的,先验证它有大概率立刻产生剪枝。
杀手走法和历史启发实现起来都不复杂,各二十行代码,实测在深度 5 时能把节点数再降 25% 到 40%。这部分投入产出比非常高,值得写。
4.4 迭代加深、置换表和静止搜索,三件套缺一不可
迭代加深是"从深度 1 开始逐层加深,直到时间用完"。听起来很浪费(前面的层白搜了),实际上完全不是——因为有了置换表,浅层搜过的结果会被复用,而且每一层的最佳走法可以拿去给下一层做排序种子,命中率通常超过 80%。更重要的是它解决了"时间控制"这个死结:你永远不知道当前局面深度 6 要算多久,但迭代加深保证任何时刻中断都有一个可用的完整结果。
置换表是 Zobrist 哈希加一张大数组。开局时给每个(格子,兵种)组合生成一个随机 64 位数,棋盘哈希就是所有棋子对应随机数的异或。走一步棋只需要异或掉旧位置和新位置的两个数,O(1) 更新。表里存"这个局面在深度 d 下搜过,值是 v,边界类型是精确/下界/上界"。表大小我用的是一百万项,用& (SIZE - 1)取模,单表覆盖策略(新结果直接覆盖旧的)。
静止搜索解决的是"地平线效应"。假设搜索到深度 0 时刚好是你用车吃了对方的马,评估函数看到你多一个马,觉得大优,但实际上对方下一步就能用炮把你的车打掉。静止搜索的做法是:在深度 0 时不直接返回评估值,而是继续把所有吃子走法搜完,直到局面"安静"下来再评估。
int quiesce(int alpha, int beta, int ply) { int stand = sideScore(); if (stand >= beta) return stand; if (stand > alpha) alpha = stand; List<Integer> caps = genCaptureMoves(); orderByMVVLVA(caps); for (int mv : caps) { make(mv); int score = -quiesce(-beta, -alpha, ply + 1); unmake(); if (score >= beta) return score; if (score > alpha) alpha = score; } return alpha; }加了静止搜索之后,我的引擎在"连续交换"局面上明显不再犯傻,最典型的是中局的车炮互换,之前经常算错一两步,加上之后基本能算清。
5. Swing 这一侧:把搜索塞到 EDT 之外
5.1 SwingWorker 是最省事的方案
自己起 Thread 加invokeLater也行,但SwingWorker把"后台计算 + 完成回调 + 进度发布 + 取消"四件事一次性包好了,用起来最舒服。
private final class ThinkTask extends SwingWorker<Integer, Void> { private final Board snapshot; private final int timeLimitMs; ThinkTask(Board board, int timeLimitMs) { this.snapshot = board.copy(); this.timeLimitMs = timeLimitMs; } @Override protected Integer doInBackground() { return engine.search(snapshot, timeLimitMs); // 这里跑搜索,绝不碰 UI } @Override protected void done() { try { int mv = get(); if (mv >= 0) applyMove(mv); } catch (CancellationException ce) { // 用户点了新局或悔棋,忽略 } catch (Exception ex) { fallbackRandomMove(); } finally { thinking = false; boardPanel.repaint(); statusLabel.setText("轮到你走"); } } }done()方法是在 EDT 上执行的,这一点由 SwingWorker 保证,所以里面可以放心改组件状态。这个类我用了好几年,从没出过线程问题。
5.2 棋盘绘制的坐标换算与重绘代价
绘制部分有两个坑。第一个是坐标换算,鼠标点到的像素要转成行列,行列再转回像素画棋子,这两次换算必须用同一套常量,否则会出现"点偏一格"的诡异现象。我的做法是定义一个Layout内部类,把格子大小、边距、原点全部封进去,只暴露toRow(int y)、toCol(int x)、px(int col)、py(int row)四个方法。
第二个是重绘代价。repaint()触发的是整块面板重绘,棋盘线加 32 个棋子,每个棋子还要画圆形、描边、写字,一次下来大概 3 到 8 毫秒。如果 AI 思考期间你每 16 毫秒刷一次"思考中"的动画,光重绘就吃掉一半 CPU。我的处理是把静态棋盘(木纹背景加九条竖线十条横线加九宫斜线)预渲染到一张BufferedImage缓存起来,每次只drawImage一次,再叠加棋子。实测重绘时间从 8 毫秒降到 1.2 毫秒。
5.3 悔棋栈、思考态锁定与几个交互细节
悔棋栈要存的是完整的走法历史,而且人机对弈时一次要悔两步(撤掉 AI 的一步和你自己的一步),否则悔完之后还是 AI 走。我用一个ArrayDeque<MoveRecord>,每条记录包含走法、被吃棋子、双方剩余时间、局面哈希,撤销时按逆序恢复。
思考期间必须锁住交互。不只是"忽略鼠标点击"这么简单,还要做三件事:把光标改成等待态、在状态栏显示当前搜索深度和已用时间、把"新开一局"和"悔棋"按钮仍然保持可用(因为用户可能等不及想强制重启)。这里有个小技巧:搜索线程通过publish()把(depth, score, nodes)三元组发出来,process()里更新状态栏,用户就能看到"深度 4,评分 +23,已算 40 万节点"这种实时信息,等待焦虑感大幅降低。
还有一个容易被忽略的细节:点击选中棋子后要给出明确的视觉反馈。我用的是给选中格子画一圈半透明高亮,再把该棋子所有合法落点画成小圆点。这个小功能对体验的提升远超预期,尤其是新手玩家,能直观看到"原来马是这么走的"。
6. 实测数据与踩过的坑
6.1 深度、节点、耗时的真实曲线
下面是我在一台普通笔记本(四核低压处理器)上跑出来的数据,开局局面,走过完整的走法排序加置换表加静止搜索:
| 搜索深度 | 访问节点数 | 耗时 | 剪枝率 | 棋力表现 |
|---|---|---|---|---|
| 1 | 44 | 3 ms | 0% | 随机乱走 |
| 2 | 1.2 千 | 12 ms | 31% | 只看一步交换 |
| 3 | 3.8 万 | 90 ms | 58% | 能吃子不漏子 |
| 4 | 21 万 | 480 ms | 72% | 会做一些简单战术 |
| 5 | 96 万 | 2100 ms | 81% | 能算清两三步的连续交换 |
| 6 | 340 万 | 8600 ms | 86% | 超出 2 秒预算,靠迭代加深中途截断 |
从这张表能看出两件事。一是剪枝率随深度上升,因为深层局面里"明显不好的走法"更容易被快速否定,这是 Alpha-Beta 的正常特性。二是每加深一层,耗时大约涨 4 到 5 倍,所以"每层涨 10 倍"的担心是多余的,但"想加到深度 8"也是不现实的——那需要几十分钟。
时间预算我最终定在 1.5 秒。低于 1 秒时深度 4 都跑不完,引擎明显变笨;高于 3 秒用户会开始不耐烦,而且实际棋力提升很有限,瓶颈已经不在深度而在评估函数的质量上了。
6.2 五个真实踩过的坑,按坑的深浅排序
坑一:将帅照面没判,引擎自己送将。表现是 AI 走出一步之后,你把它的将吃掉,程序不报错,继续下。原因是走法生成里只判了"不能吃自己的子",没判"走完之后双方将帅是否照面"。修法很简单,在合法性校验里加一条:走完之后,如果双方将在同一列且中间无子,这个走法非法。这个 bug 隐蔽在于它不会崩,只会让棋局变得莫名其妙。
坑二:马腿坐标写成了目标格。表现是马能穿过棋子乱跳,perft 深度 3 数出来 82000 多,对不上 79666。这种错误靠肉眼看代码很难发现,必须靠 perft 计数。我后来养成习惯:每改一次走法生成,先跑一遍 perft 深度 1 到 4,四个数全对才继续往下写。
坑三:置换表里的深度判断写反。应该只在"表里记录的深度大于等于当前剩余深度"时才使用表里的值,我写成了小于等于,导致引擎大量使用浅层的不准确结果,棋力直接倒退两个等级。这种 bug 极其难查,因为程序不崩、不报错,只是下得变差了。定位方法是关掉置换表跑一遍,对比棋力,一眼就能确认问题范围。
坑四:kill 掉搜索线程之后棋盘状态不一致。用户点"新开一局"时,后台搜索还在跑,跑到一半 make/unmake 被打断,如果它操作的是共享的棋盘对象,棋盘就被改花了。这就是为什么第 1 节里强调必须传快照——引擎只操作自己的副本,界面随时可以重置,互不影响。
坑五:评估函数在残局阶段失灵。中局表现还行的引擎,到了只剩车兵对车士的残局,会做出"用兵换士"这种明显亏本的交换。原因是我的子力价值表是静态的,兵在残局的价值远高于开局,士在残局反而没那么重要。后来我加了一个简单的阶段判断(按场上总子力划分开局、中局、残局),每个阶段用一套不同的价值表,残局棋力改善非常明显。这个改进只花了三十行代码,效果却比把搜索深度加一层还显著。
6.3 一个我到现在还保留的调试习惯
每次引擎做出让我觉得"这步棋很怪"的走法,我都会打开一个调试开关,让它把根节点所有走法的评分按从高到低打印出来,同时输出每个走法搜索到的节点数和是否发生过剪枝。十次里有八次,问题就出在排序上——某个明显的好棋因为排序靠后,在被搜到之前 alpha 已经被抬得太高,直接被剪掉了,评分显示成一个偏低的值。
这个"打印根节点全部评分"的功能,代码不到二十行,但它是我排查引擎问题最高效的工具,比任何日志框架都管用。搜索这种东西,黑盒调试是没有出路的,你必须能看见它内部在想什么。
最后分享一个实践中的小体会:如果想要棋力有明显跃升,优先级顺序应该是"修走法生成的 bug > 优化评估函数的残局部分 > 加静止搜索 > 加置换表 > 提升搜索深度"。很多人一上来就想把深度堆到 7、8,结果底层规则还有 bug、评估函数还严重偏科,深搜只是把一个错误的判断算得更精确而已,棋力反而不升反降。