首页 / 资讯中心 / 文章详情

Java Swing象棋人机对弈:Alpha-Beta剪枝与置换表实战

Java Swing象棋人机对弈:Alpha-Beta剪枝与置换表实战 ★ FEATURED ARTICLE
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 从上到下。约定y0 是黑方底线屏幕顶部y9 是红方底线屏幕底部红方在下方红兵朝 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什么东西也不要用ListInteger装走法用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 被占住窗口不响应任何事件鼠标点击没反应窗口拖动白屏操作系统甚至会提示“程序未响应”。正确做法是SwingWorkerprivate void startAiThink() { setInputEnabled(false); new SwingWorkerInteger, 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 去落子。长时间运行后内存持续上涨检查搜索里有没有在叶子节点创建对象。ListInteger装走法、每次评估都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 的搜索在普通笔记本上每步一两秒能出来业余中等水平完全能打得有来有回。后面想再往上走方向是更精细的评估比如引入简单的棋子配合项、更聪明的剪枝空着裁剪、后期走法缩减以及把搜索做成可以随时暂停恢复的增量式。这些都是一步一个脚印的事先把前六层打牢剩下的都是锦上添花。
阅读完成 · 觉得有帮助?
咨询建站