☰
Java Swing象棋人机对弈:Alpha-Beta剪枝与置换表实战
2026/10/28 22:48:38 网站建设 项目流程

Java Swing 象棋人机对弈这套东西,我断断续续折腾了大半年,从最初只想验证一个想法,到后来把极大极小值搜索、Alpha-Beta 剪枝、置换表、静止搜索一层层加进去,中间踩过的坑比写出来的代码有意思得多。最早那版用的是最朴素的纯极大极小搜索,搜索四层就要等七八秒,界面直接卡成白板;把 Alpha-Beta 加进去之后,同样的深度节点数砍掉了七成还多,同样的时间能多搜两层,棋力肉眼可见地往上蹿。这篇文章我打算把这套东西从头到尾拆一遍:棋盘怎么表示、走法怎么生成、评估函数怎么定、搜索怎么剪、界面线程怎么处理,全是能直接照着敲的实操细节。适合两类人看,一类是想学博弈树搜索但被论文里的公式劝退的,另一类是程序已经跑起来了但发现 AI 下得莫名其妙的,前者能拿走一套完整骨架,后者大概率能在排查清单里找到自己那个问题。

1. 整体设计与技术选型:这套组合是怎么定下来的

1.1 Swing 在这个场景里的真实定位

先说界面的选择。很多人一听 Swing 就摇头,觉得是老古董,做棋盘界面完全可以用更现代的方案。我试过别的路子,最后还是回到 Swing,理由很实在:整个项目就是一台本地机器上跑的单机工具,不需要联网、不需要安装运行时环境外的东西、不需要考虑跨端适配,双击一个 jar 就能玩,这种“零依赖”的体验对个人项目来说太重要了。

从绘制量上看,中国象棋的要求低得可怜。一个 9 乘 10 的网格,32 个圆形棋子,加几条河界和九宫斜线,paintComponent里画几十个fillOval加drawLine就完事,开个Graphics2D的抗锯齿,观感不比很多游戏引擎差。真正吃性能的从来不是画棋盘,而是搜索线程,这两件事必须彻底分开,后面第 5 章会专门讲 EDT 的问题。

Swing 的短板也很明确:控件样式老旧、布局管理器的写法反直觉、高 DPI 屏幕下字体缩放需要手动处理。但这些东西对棋盘界面影响极小,因为棋盘是完全自绘的,我连JButton都只用两三个(新局、悔棋、切换先后手),其余全是自绘区域,样式问题自然就不存在了。

1.2 从纯极小极大到 Alpha-Beta 的必然性

中国象棋开局的分支因子大概在 40 上下,中局略高,残局会降到 20 以内。这个数字意味着纯极大极小搜索在深度上根本走不远:深度 4 是 40 的四次方,约 256 万个叶子节点;深度 5 直接翻到一亿量级。每一层都要做一次走法生成和一次评估,Java 在普通笔记本上跑到百万级节点每秒已经算不错了,深度 4 大概就要好几秒。

Alpha-Beta 剪枝改变的是常数,不是复杂度——它最坏情况仍然是指数级,但理想情况下可以把有效分支因子从 b 降到根号 b,也就是 40 变成 6 左右,等效深度直接翻倍。实际实现里因为走法顺序不可能永远最优,能砍掉 60% 到 85% 的节点就已经很不错了。我实测过同一个局面、同一套评估函数,纯搜索跑到深度 4 用了 3.1 秒约 240 万节点,加上 Alpha-Beta 之后深度 4 只要 0.4 秒约 31 万节点,同样的时间够跑到深度 6。

这里有个特别容易被忽略的点:Alpha-Beta 的效果高度依赖走法排序的质量。同一个搜索函数,走法随机排序和按“吃子价值优先”排序,节点数能差三到五倍。所以第 4 章我会花不少篇幅讲排序,那才是真正决定棋力的地方,而不是剪枝本身。

1.3 模块拆分与数据流

我把整个程序切成五块,边界划得很清楚,这样调试的时候能单独把某一块拎出来测:

模块职责关键类
棋盘状态保存 90 格棋子、轮走方、历史走法Board
走法生成给定局面产出全部走法,含合法性与伪合法两套MoveGen
评估把局面折算成一个整数分值,红方视角Evaluator
搜索迭代加深、Alpha-Beta、置换表、静止搜索Searcher
界面绘制、鼠标交互、把搜索丢到后台线程BoardPanel、MainFrame

数据流是单向的:玩家点击棋盘 → 界面把像素坐标换算成格点坐标 → 询问Board这一格是不是自己的子 → 是的话高亮并缓存选中位置 → 第二次点击时向MoveGen要合法走法列表,看目标点是否在其中 → 合法就落子、切换轮走方、重绘 → 触发 AI 思考 → 后台线程跑搜索返回一个走法 → 主线程落子重绘。

单向数据流的好处是排查问题特别快。AI 走出奇怪的一步时,我只要在“搜索返回的走法”这个点上打个日志,就知道是搜索选错了(引擎问题)还是落子落错了(界面问题),不用满屏找 bug。

注意:Board里的棋盘数组不要在界面和搜索之间共享可变引用。搜索过程会大量 make/unmake,如果界面线程同时在读这个数组,轻则画面闪烁,重则读到半途状态。我的做法是搜索开始时深拷贝一份数组,搜索完丢弃。

2. 棋盘表示与走法生成:所有上层优化的地基

2.1 坐标约定与一维棋盘

棋盘我用一维int[90]表示,索引公式idx = y * 9 + x,其中x取 0 到 8 从左到右,y取 0 到 9 从上到下。约定y=0 是黑方底线(屏幕顶部),y=9 是红方底线(屏幕底部),红方在下方,红兵朝 y 减小的方向前进。

为什么不用二维数组board[x][y]?因为一维下标在做 Zobrist 哈希、置换表索引、走法编码的时候特别方便,一个int就能同时装下起点和终点(起点左移 8 位或上终点),存进置换表、杀手表、历史表都不用额外分配对象。二维数组反而要频繁做board[fromX][fromY]这样的拆解,代码更啰嗦,缓存局部性也更差。

这个约定必须在项目第一天就写死,并且写在注释最显眼的位置。我最初一版把 y 轴方向搞反了,红兵朝着自己家走,程序跑起来没报任何错,就是 AI 老是把兵往回撤,调了两个晚上才发现是坐标方向的问题。后来我在Board类顶部加了一行大字注释,再没犯过。

2.2 棋子编码与 make/unmake 的快照思路

棋子用一个int表示,符号区分阵营:红方为正,黑方为负。类型值定义成常量:

public static final int KING = 1, ADVISOR = 2, BISHOP = 3, HORSE = 4, ROOK = 5, CANNON = 6, PAWN = 7;

红车是5,黑车是-5,空位是0。判断阵营用p > 0,判断类型用Math.abs(p),非常简单。

走法我编码成一个int:高 8 位存起点,低 8 位存终点。make和unmake只需要保存被吃掉的棋子,因为棋盘上其他格子都没动:

public int make(int move) { int from = move >>> 8; int to = move & 0xFF; int captured = board[to]; board[to] = board[from]; board[from] = 0; side = -side; hash ^= Zobrist.of(board[to], from) ^ Zobrist.of(board[to], to) ^ (captured != 0 ? Zobrist.of(captured, to) : 0); return captured; } public void unmake(int move, int captured) { int from = move >>> 8; int to = move & 0xFF; board[from] = board[to]; board[to] = captured; side = -side; hash ^= Zobrist.of(board[from], from) ^ Zobrist.of(board[from], to) ^ (captured != 0 ? Zobrist.of(captured, to) : 0); }

这段代码是整个引擎里调用频率最高的部分,一次搜索动辄几百万次调用,任何多余的对象分配都会拖垮性能。所以千万别在这里new什么东西,也不要用List<Integer>装走法,用int[]加一个计数器就够。

2.3 六类棋子的走法细节

走法生成是 bug 重灾区,尤其是几个特殊规则,我一条一条说。

马和蹩马腿。马的八个方向,每个方向对应的“腿”位置是固定的。我用一个四元组表来存,前两位是位移,后两位是腿的位移:

private static final int[][] HORSE = { {-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} };

生成时先算腿的坐标(x + HORSE[i][2], y + HORSE[i][3]),如果这一格有子,这个方向直接跳过。这是最容易写错的地方——很多人把腿算成了目标格旁边的位置,导致马能穿过棋子。

象和象眼。象走田字,四个斜二格方向,中间的“眼”是斜一格的位置。同时象不能过河:红象的 y 必须始终大于等于 5,黑象的 y 必须小于等于 4,生成时直接按 y 范围过滤。

炮的隔子吃。炮的规则是走法同车,但吃子必须隔一个子。实现时分两段:第一段沿方向一直走,遇到空格就记一步“移动走法”,遇到第一个棋子就停下来;第二段从那个棋子后面继续走,直到遇到下一个棋子,如果它是敌方,记一步“吃子走法”。这两段很容易写成一段,结果是炮能吃相邻的子,实战中会立刻暴露。

将和飞将。将只能在九宫内直走一格:红将 x 在 3 到 5 之间、y 在 7 到 9 之间,黑将 y 在 0 到 2 之间。飞将规则是另一个必须单独处理的东西——两个将如果在同一列且中间没有任何棋子,局面就是非法的。我的处理方式是在走法生成之后、返回给搜索之前,统一做一次“自杀检测”:模拟走这步,然后检查己方将是否被吃、是否形成照面,不合法的直接剔除。

private boolean kingsFace() { int rk = -1, bk = -1; for (int i = 0; i < 90; i++) { if (board[i] == KING) rk = i; else if (board[i] == -KING) bk = i; } if (rk < 0 || bk < 0) return false; if (rk % 9 != bk % 9) return false; int x = rk % 9; int lo = Math.min(rk / 9, bk / 9) + 1; int hi = Math.max(rk / 9, bk / 9); for (int y = lo; y < hi; y++) { if (board[y * 9 + x] != 0) return false; } return true; }

兵过河。红兵在 y 大于等于 5 时(还没过河)只能往 y 减小的方向走一步;过了河之后,除了前进,还可以左右平移一步,但永远不能后退。黑兵完全镜像。这条如果写成“兵可以前进和左右”而不判过河,开局兵就能横着走,一眼就能看出不对。

2.4 伪合法与完全合法的取舍

走法生成有两种做法。一种是完全合法:每生成一步就模拟走一遍,检查己方将是否安全,不安全的丢掉,返回的列表保证全部合法。另一种是伪合法:只按棋子规则生成,不管会不会送将,把安全检查交给搜索层去做。

完全合法的写法直观,界面直接用,但代价很大——每一步都要额外做一次 make/unmake 加一次照面检测,在搜索里是双倍开销。伪合法的性能好得多,代价是搜索时必须处理“一方把将送掉”的情形。

我的最终方案是混合的:界面用完全合法(因为要给人看,列表必须干净),搜索用伪合法(性能优先),搜索里一旦发现某步棋吃掉了对方的将,立刻返回一个必胜分值。这种写法在大多数棋类引擎里是标准做法,代价是需要保证伪合法走法生成本身没有遗漏,否则会出现“将明明能被吃却搜不到”的诡异现象。

3. 局面评估:引擎的“价值观”

3.1 子力价值与位置价值表

评估函数决定了引擎的眼光。最朴素的做法是只算子力总和,但这会导致 AI 把所有棋子堆在原始位置不动,因为动一个子可能“亏”。位置价值表(Piece Square Table,简称 PST)就是为了解决这个问题:给每一类棋子在棋盘每个位置一个附加分,鼓励棋子往好位置走。

子力基础分值我用的是一套比较常规的配置:车 900、炮 450、马 400、士 200、象 200、兵 100、将 10000。这个比例关系不是随便定的——一车约等于两马或者一马一炮多一点,一炮略强于马(开局阶段),过河兵的价值能顶半个马。这些数值会直接影响 AI 愿不愿意做交换,所以不能凭感觉乱填。

private int evaluate() { int score = 0; for (int i = 0; i < 90; i++) { int p = board[i]; if (p == 0) continue; int type = Math.abs(p); int pos = p > 0 ? i : mirror(i); // 黑方复用红方的表 int v = VALUE[type] + PST[type][pos]; score += p > 0 ? v : -v; } return side == RED ? score : -score; } private static int mirror(int i) { return (9 - i / 9) * 9 + i % 9; }

mirror这个技巧省掉了一半的表:黑方的局面上下翻转之后就能直接用红方的 PST,维护成本减半。

PST 没必要一次写全,可以先只给兵和马做两张表,跑起来看看效果,再慢慢加。我最初给兵做的那张表只表达了一个意思:越靠近对方九宫,分值越高,过河之后加 30 到 60 分。就这一点改动,AI 的进攻欲望立刻就不一样了,开局不再磨磨蹭蹭。

3.2 机动性、兵型与将帅安全

子力加 PST 已经能撑起一个不错的引擎,但再往上加几个特征项,棋力还会有明显提升。

机动性:统计己方所有走法的数量,乘以一个小系数(比如 2 分)加进去。代价是要额外做一次走法生成,比较贵。我的做法是只在叶子节点用一个轻量版本:只数车、马、炮的活动格数,不算全部棋子,成本能降下来。

兵型:同一列上叠了两个以上的兵要扣分,因为互相挡路;孤兵(相邻列没有己方兵)也扣一点。这部分权重别给太大,否则 AI 会为了保兵型放弃实质性的子力。

将帅安全:看九宫周围一圈有没有己方的士象,缺一个扣一点分;对方的重子(车、炮)如果控制了将所在的列,再扣一点。这一项在残局阶段要调大权重,因为残局里将帅暴露是致命的。

这些特征项的系数我用的是经验值加小范围网格搜索:固定搜索深度和局面集合,把系数在某个区间内按 0.5 的步长扫一遍,看哪个组合的胜率最高。这个过程耗时但有效,比拍脑袋定值靠谱得多。

3.3 分值设计与胜负常量的坑

评估函数返回的是一个整数,单位可以理解为“厘兵”——一个兵值 100 分。

胜负常量必须和评估值拉开足够大的差距。我用MATE = 30000,即“将死对手”的分值是 30000 减去当前层数,这样引擎会优先选择更快的杀法;INF = 1_000_000用作 Alpha-Beta 初值。

这里有个真实的坑:别用Integer.MAX_VALUE当 alpha 初值。因为搜索里会做-alpha取负,Integer.MAX_VALUE取负不会溢出,但如果你在某个地方写了-alpha - 1或者加了评估值,很快就会溢出成负数,然后剪枝逻辑整个乱掉,表现为 AI 突然走出完全不合逻辑的棋。用 100 万这种量级的数,怎么加都不会溢出,安全得多。

4. 搜索内核:negamax、Alpha-Beta 与配套优化

4.1 为什么用负极大值写法

极大极小值的教科书版本是分开写maxValue和minValue两个函数,逻辑对称但代码重复。搜索加剪枝之后,两份代码很容易改漏一处。

负极大值(negamax)把它们合成一个函数,核心思想是:当前走子方的分值,等于对手视角分值的相反数。整个搜索里只有“最大化”一种逻辑:

private int negamax(int depth, int alpha, int beta, int ply) { nodes++; if (depth <= 0) return quiescence(alpha, beta, ply); if (timeUp) return alpha; int best = -INF; int[] moves = new int[128]; int n = moveGen.generate(side, moves); orderMoves(moves, n, ply); for (int i = 0; i < n; i++) { int mv = moves[i]; int to = mv & 0xFF; int captured = board[to]; make(mv); int score; if (Math.abs(captured) == KING) { score = MATE - ply; // 吃到将,直接判胜 } else { score = -negamax(depth - 1, -beta, -alpha, ply + 1); } unmake(mv, captured); if (score > best) best = score; if (score > alpha) alpha = score; if (alpha >= beta) { recordKiller(ply, mv); return best; } } return best; }

注意-negamax(depth - 1, -beta, -alpha, ...)这一行,alpha 和 beta 在新一层里要交换位置并取反,这是 negamax 和 Alpha-Beta 结合的关键,写反了剪枝就失效,程序不会报错,只是慢得离谱。

4.2 剪枝条件到底在剪什么

Alpha-Beta 的剪枝条件只有一句:alpha >= beta就停。它的含义用大白话讲是——如果当前这步棋已经好到让对手在上一层绝对不会选择走到这个分支,那这个分支剩下的兄弟节点就没必要再看了。

举个具体的例子。假设搜索到某个节点,你已经找到一步能拿 500 分的棋,此时轮到你对手在上一层做决策。对手在上一层至少已经有一个能让他拿到 -400 分的选项(对你来说就是 +400),那么你这一支里出现的 500 分对他来说比 -400 更糟,他根本不会走到这一支来,剩下没搜完的兄弟走法就全部作废。

剪枝分两种:fail-high时返回的是下界(真实值只会更大),fail-low时返回的是上界。这两个语义在置换表里必须准确标记,否则命中表的时候会拿到错误的分值,表现为 AI 在等价局面下前后判断不一致。

4.3 走法排序:真正决定性能的地方

前面说过排序的重要性,这里展开讲。

MVV-LVA(最有价值受害者-最不价值攻击者):吃子走法优先,且优先用小子吃大子。用一个二维表直接查分:被吃子是车、攻击者是兵,这一手排序分最高。这个排序几乎零成本,却能显著提升剪枝率。

杀手走法(Killer Moves):在同一层里,如果某一步不走子却引发了剪枝,那它在兄弟节点里很可能同样有效。每层存两个杀手走法,排序时给一个较高但低于吃子的权重。

历史启发(History Heuristic):维护一张[90][90]的表,记录每个“起点-终点”组合在搜索中造成剪枝的次数。次数越多,排序权重越高。这张表在搜索开始时清零,迭代加深时不重置,让上一轮的信息帮助下一轮。

我的排序实现是一个简单的三档:吃子走法用 MVV-LVA 分数,杀手走法给 10000 分,历史启发取对数后加分,其余走法按历史分排。用插入排序就够了——每层走法数量最多四五十个,插入排序的常数比快排还小。

4.4 置换表与 Zobrist 哈希

同一局面会通过不同的走法顺序到达,比如“炮二平五、马8进7”和“马8进7、炮二平五”最后是同一个局面。置换表就是把这些重复局面缓存起来,直接返回上次搜出来的结果,省掉整棵子树。

哈希用 Zobrist 方案:给每种棋子在每个位置预先准备一个随机数,局面的哈希值就是所有棋子的随机数异或和。走一步棋只需要异或掉起点和终点的旧值、异或上新值,O(1) 就能更新。

private static final long[][] ZOBRIST = new long[15][90]; static { Random r = new Random(20240501L); for (int t = 0; t < 15; t++) for (int i = 0; i < 90; i++) ZOBRIST[t][i] = r.nextLong(); }

固定种子是为了让搜索可复现,调试的时候能重现同一个 bug。

表项要记录:哈希值(用于校验冲突)、搜索深度、分值、标志位(精确值/下界/上界)、最佳走法。命中规则是深度必须大于等于当前剩余深度才能直接用,浅层结果不能替代深层结果,这是新手最容易写错的地方——不判深度直接用,AI 会突然变笨,因为它拿到了一堆深度 1 的估值当深度 6 用。

表的大小我用的是一百万项,用哈希值的低位做索引,冲突直接覆盖。这在普通机器上大约占几十兆内存,跑起来完全无压力。

4.5 迭代加深、静止搜索与时间控制

迭代加深是让引擎先搜深度 1、再搜深度 2、一直搜到时间用完。表面上做了很多重复工作,但因为置换表和排序信息的复用,实际开销只比直接搜最深深度多一点点。它的好处是任何时候被打断,都有上一层的完整结果可用,时间控制非常自然。

静止搜索解决的是“水平线效应”。如果一个叶子节点上还能吃子,直接评估会严重失真——比如搜索深度刚好用完时,你的车正在被对方兵白吃,评估函数却看不见。做法是在深度为 0 时,只继续展开吃子走法,直到局面“安静”下来。

private int quiescence(int alpha, int beta, int ply) { int stand = evaluate(); if (stand >= beta) return beta; if (stand > alpha) alpha = stand; int[] caps = new int[64]; int n = moveGen.generateCaptures(side, caps); orderByMVV(caps, n); for (int i = 0; i < n; i++) { int mv = caps[i]; int captured = board[mv & 0xFF]; make(mv); int score = Math.abs(captured) == KING ? MATE - ply : -quiescence(-beta, -alpha, ply + 1); unmake(mv, captured); if (score >= beta) return beta; if (score > alpha) alpha = score; } return alpha; }

时间控制用一个截止时间戳,每搜若干个节点检查一次,超时就置标志位逐层返回。我用的是每 2048 个节点检查一次,检查太频繁本身就有开销,间隔太大则可能超时几十毫秒。

5. Swing 界面与线程模型

5.1 绘制与坐标映射

棋盘格子的边长我设成 60 像素,整个绘制区 9 列 10 行,加两边留白,大约 600 乘 680。像素坐标和格点坐标的换算写成两个纯函数:

private int px(int x) { return MARGIN + x * CELL; } private int py(int y) { return MARGIN + y * CELL; } private int gridX(int mouseX) { return Math.round((mouseX - MARGIN) / (float) CELL); } private int gridY(int mouseY) { return Math.round((mouseY - MARGIN) / (float) CELL); }

用Math.round而不是整除,是为了让点击落在格子附近的区域也能命中,体验会好很多。换算完之后还要校验范围在 0 到 8 和 0 到 9 之间,否则点边距区域会算出负索引,直接数组越界。

绘制顺序:先画木色背景和网格线,再画河界的“楚河汉界”文字(用Graphics2D旋转 90 度画在中间),然后画九宫斜线,最后遍历棋盘画棋子。棋子就是圆加文字,红方用深红色描边,黑方用黑色,选中的棋子加一个高亮外圈,上一步的起终点画两个小方块做标记。

5.2 鼠标交互与走子流程

点击逻辑用状态机最清晰。维护一个selected变量,存当前选中的格点索引,-1 表示没选中。

第一次点击:如果这格是自己方的子,把selected设成它并重绘;如果这格是空的或者对方的子,忽略。 第二次点击:如果点的是自己方另一个子,切换选中;否则去合法走法列表里查有没有“从 selected 到目标点”的走法,有就落子,没有就忽略(也可以给一个提示音,但别弹对话框,很烦)。

查合法走法列表这个操作在界面上做完全没问题,因为走法数量最多几十个,一次遍历开销可以忽略。但注意不要每帧都生成一遍,只在点击事件里生成。

5.3 EDT 阻塞与 SwingWorker

这是 Swing 做这类程序的头号大坑。所有界面更新必须跑在事件分发线程(EDT)上,而搜索动辄几百毫秒到几秒。如果你在按钮的回调里直接调用search(),EDT 被占住,窗口不响应任何事件,鼠标点击没反应,窗口拖动白屏,操作系统甚至会提示“程序未响应”。

正确做法是SwingWorker:

private void startAiThink() { setInputEnabled(false); new SwingWorker<Integer, Void>() { @Override protected Integer doInBackground() { return searcher.think(boardCopy, timeLimitMs); } @Override protected void done() { try { int mv = get(); if (mv != 0) applyMove(mv); } catch (Exception e) { e.printStackTrace(); } finally { setInputEnabled(true); } } }.execute(); }

几个要点:doInBackground里绝对不能碰任何 Swing 组件,包括repaint();done()是在 EDT 上执行的,在这里落子和重绘才安全;搜索期间要把棋盘输入禁掉,否则玩家能在 AI 思考时抢先走一步,局面就乱了。

如果还想在思考过程中显示“已搜索深度”和“当前最佳走法”,用SwingWorker的publish加process回调,别自己在后台线程里直接setText。

5.4 思考信息与复盘展示

我在界面右侧加了一个小信息栏,显示:搜索深度、耗时、评估分值、节点数、每秒节点数、最佳走法。这些数据在调参的时候价值巨大——深度上不去说明剪枝有问题,节点数异常高说明排序失效,评估分值和实际棋力不符说明评估函数有 bug。

悔棋用双步回退:维护一个走法历史栈,悔棋就连续 unmake 两步(玩家一步加 AI 一步),回到玩家走之前的状态。注意如果玩家执黑先行,回退步数要相应调整,别把一步当两步撤。

6. 常见问题与排查实录

6.1 棋力异常速查表

AI 下得莫名其妙,原因通常集中在下面这几类。这张表是我自己踩坑之后整理的,按出现频率排序:

现象最可能的原因排查手段
AI 反复走同一步、来回晃置换表未判深度,浅层结果被当成深层用加深度校验,打印命中日志
明明能赢却选择和棋评估常量设计不合理,胜负分和子力分没拉开检查 MATE 与最大可能评估值的差距
白送大子静止搜索写错,或者吃子走法生成有遗漏打印该局面的全部吃子走法人工核对
开局不出子、原地打转PST 权重过大,或者机动性项缺失临时关掉 PST 看行为是否恢复正常
从不主动吃子MVV-LVA 排序表写反,导致吃子走法排到最后打印排序后的走法列表前三项
将死局面走不出杀招深度不够,或静止搜索把吃将判成了普通吃子检查吃将分支是否直接返回 MATE
一思考就卡住不动EDT 被搜索占用,没走 SwingWorker看窗口是否还能拖动

6.2 卡顿崩溃与线程问题

ConcurrentModificationException或棋盘数据错乱:99% 是搜索线程和界面线程共享了同一个数组。解决方式是搜索开始前深拷贝棋盘,搜索过程中只操作副本,搜索结束后把结果走法交给 EDT 去落子。

长时间运行后内存持续上涨:检查搜索里有没有在叶子节点创建对象。List<Integer>装走法、每次评估都new一个数组,这些在百万级节点下会直接触发频繁 GC,表现为搜索时快时慢。全部换成复用的int[]。

跨线程更新 UI 报错但不崩溃:Swing 的线程检查不一定每次都抛异常,有时候只是偶尔花屏或者组件状态不对。养成习惯,任何setText、repaint、setEnabled都只在 EDT 上调。

搜索深度突然掉到 1:通常是时限设置得太短,或者迭代加深的循环条件写成了while (true)没有正确累加深度。加一行深度日志就看得出来。

6.3 调参记录与效果对比

下面是我在同一个测试局面集合(大约 120 个中局局面)上做的几组对比,测试条件是每步限时 1 秒、同一台机器:

配置平均搜索深度每秒节点数与基础版对战胜率
纯 negamax,无剪枝4.1约 78 万—
加 Alpha-Beta5.6约 92 万基础版 8 成以上
加 MVV-LVA + 杀手6.3约 95 万上一版约 7 成
加置换表6.8约 90 万上一版约 6 成
加静止搜索6.6约 85 万上一版约 6.5 成

注意最后一行:加了静止搜索之后搜索深度反而降了,节点数也变少了,但胜率是上升的。原因是静止搜索在叶子处展开吃子链,虽然少搜了一层,但评估准确了很多,避免了大量因水平线效应导致的错误判断。这个现象说明深度不是唯一指标,别一味追求搜得深。

我个人的调参顺序建议是:先保证走法生成绝对正确(写一套单元测试,用固定的残局局面验证走法数量),再上 Alpha-Beta,然后做排序,最后才加置换表和静止搜索。顺序反了会很痛苦,因为前面错一步,后面的调试信息全部失真,根本分不清是哪个模块的问题。

再分享一个小技巧:调试期间可以做一个“双引擎对战”模式,让同一套代码的两个不同参数版本互相对弈 50 局,统计胜负和平均步数。这比人工看棋谱高效得多,很多隐蔽的问题(比如某个特定兵型下必然失误)只有在对战中才会暴露出来。我自己就是靠这个办法发现了一个炮的走法生成 bug——只有在炮和目标之间恰好隔了两个棋子时才会漏掉一个吃子走法,人工测试几乎不可能覆盖到。

做到这个程度,桌面端的单机象棋引擎已经足够好玩了,深度 7 到 8 的搜索在普通笔记本上每步一两秒能出来,业余中等水平完全能打得有来有回。后面想再往上走,方向是更精细的评估(比如引入简单的棋子配合项)、更聪明的剪枝(空着裁剪、后期走法缩减),以及把搜索做成可以随时暂停恢复的增量式。这些都是一步一个脚印的事,先把前六层打牢,剩下的都是锦上添花。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询