中国象棋Visual C++程序开发:从棋规到Alpha-Beta搜索的完整实现
简介基于Visual C实现的中国象棋桌面程序涵盖人机、人人、机机三种对弈模式把象棋规则、MFC图形界面与搜索算法放进同一工程适合C学习者、游戏开发入门者及对AI算法感兴趣的人。压缩包为rar格式共112个文件、1.84MB其中核心是27个头文件和25个C源文件另有25个obj编译中间文件、10个ico图标、6个bmp棋盘与按钮图片、2个exe可执行程序以及dsp/dsw/rc等工程配置和辅助文件覆盖从源码、界面资源到可运行程序的完整链路。目前已有220人学习下载。阅读源码可理解棋局合法性判断、走法生成、Alpha-Beta剪枝与评估函数等关键实现借助界面资源与事件响应代码也能弄清MFC窗口程序的组织脉络内含两个cm棋谱文件方便复盘或验证对弈逻辑。整体规模紧凑解压后可直接打开exe体验也适合按模块逐步研读。1. 中国象棋Visual C程序会下棋和会写棋是两回事自己动手写一个“中国象棋Visual C程序”卡住大多数人的从来不是界面而是棋规的翻译和搜索算法的正确性。很多新手第一天就能把棋盘画出来第二周却还在跟“马不能蹩腿”这种细节较劲——棋盘只是皮棋子规则和走法生成才是这个程序真正的主干。下面按一条能走通的路径展开先拆出界面层、逻辑层、搜索层再把棋盘数据结构和着法生成写扎实接着上Alpha-Beta搜索和评估函数最后是你在Visual C环境里一定会遇到的几个坑。适合有C/C基础、想独立完成课程设计或想弄懂象棋AI原理的人。学完之后你会得到一个能跟人下、能自己跟自己下、还能评估棋力的完整程序骨架。2. 棋盘数据结构怎么定数组布局、边界哨兵与MFC绘图绑定2.1 三层架构表现层、逻辑层、决策层不要混在一起第一版程序很自然地会把所有逻辑塞进鼠标点击事件里点一下棋盘判断棋子是否能走更新数组重绘。这个写法在只有“车”和“兵”时还能凑合一旦把所有棋子规则和AI加进来代码很快就变成一团乱麻。你会在OnLButtonUp里看到几十个if嵌套每个分支都在改棋盘数组AI想复用这段逻辑又不敢动最后只能复制粘贴一份。常见做法是把这个程序拆成三层。表现层只管画棋盘、接收鼠标输入逻辑层维护棋局状态负责走法合法性检查和胜负判定决策层是AI引擎根据局面算最佳着法。三层之间的接口是“当前局面数组 一个Move结构体”。这样界面的改动不会波及AIAI的调试也不用每次都点鼠标走棋——直接命令行调search函数打印每一步的评估值比对着界面猜快得多。把“规则”和“画面”解耦还有一个隐藏好处你可以先写一个纯控制台版本验证规则和AI全部调通后再接MFC界面。早期卡住的问题比如AI走出非法着法、搜索回溯后局面残留在控制台环境里定位比在界面环境里快很多。等控制台版本稳定了界面就是一个“调用逻辑层并显示结果”的壳。2.2 中国象棋棋盘的两种数组9x10还是12x11棋盘数据结构的常见选择有两种直接开int board[10][9]或者外层多围一圈哨兵的int board[12][11]。我建议直接用后者。虽然多占几十个字节但换来的是边界处理统一越界检查变成一次“这条路上有没有子挡住”而不是每次先判断坐标是否在合法范围内。棋子编码用正负号区分红黑约定红方为正、黑方为负绝对值表示兵种。这样判断“是不是自己人”只需要一次乘法board[r][c] * side 0side为1表示红方-1表示黑方。用int存棋盘还有一个好处可以在调试器里直接看到每个格子的宏值不用像char数组那样还要解码ASCII。// 棋子编码红方为正数黑方为负数0表示空格 #define EMPTY 0 #define RKING 1 #define RGUARD 2 // 仕 #define RELEPHANT 3 // 相 #define RHORSE 4 // 马 #define RCAR 5 // 车 #define RCANNON 6 // 炮 #define RPAWN 7 // 兵 #define BKING -1 #define BGUARD -2 #define BELEPHANT -3 #define BHORSE -4 #define BCAR -5 #define BCANNON -6 #define BPAWN -7 const int ROWS 12; // 10行棋盘 上下各1行哨兵 const int COLS 11; // 9列棋盘 左右各1列哨兵 int board[ROWS][COLS];逻辑说明ROWS和COLS把实际棋盘的范围放到索引(1,1)到(10,9)行0、行11、列0、列10都是哨兵。走法生成时只要一格的值是-1就说明已经出界不需要再写if (r 0 || r 9)这类判断。参数说明棋盘用int而不是char虽然每个格子多占3字节但换来的是setTextColor时直接判断正负搜索时乘side判断阵营代码更直白。初始化棋盘时先把整个数组清零再把最外圈标记为-1最后按开局摆子void initBoard() { for (int r 0; r ROWS; r) for (int c 0; c COLS; c) board[r][c] (r 0 || r ROWS-1 || c 0 || c COLS-1) ? -1 : 0; // 红方底线在行10列1~9 board[10][1] RCAR; board[10][2] RHORSE; board[10][3] RELEPHANT; board[10][4] RGUARD; board[10][5] RKING; board[10][6] RGUARD; board[10][7] RELEPHANT; board[10][8] RHORSE; board[10][9] RCAR; board[8][2] RCANNON; board[8][8] RCANNON; // 红炮 board[7][1] RPAWN; board[7][3] RPAWN; board[7][5] RPAWN; board[7][7] RPAWN; board[7][9] RPAWN; // 红兵 // 黑方底线在行1列1~9 board[1][1] BCAR; board[1][2] BHORSE; board[1][3] BELEPHANT; board[1][4] BGUARD; board[1][5] BKING; board[1][6] BGUARD; board[1][7] BELEPHANT; board[1][8] BHORSE; board[1][9] BCAR; board[3][2] BCANNON; board[3][8] BCANNON; // 黑炮 board[4][1] BPAWN; board[4][3] BPAWN; board[4][5] BPAWN; board[4][7] BPAWN; board[4][9] BPAWN; // 黑卒 }逻辑说明这里把实际棋盘的行列映射到索引1..10和1..9。业务代码里所有走法都基于“行1是最上方黑方底线、行10是最下方红方底线”这一约定。参数说明哨兵值-1既是不可走的标记也是棋盘数组越界的天然防线代价是所有遍历都要从行1、列1开始。如果以后想加“上下颠倒”的视角切换只需要改这一层的行列映射AI和画图都不用动。2.3 用MFC把棋盘画出来OnDraw与坐标换算在MFC单文档框架里棋盘绘制通常在视图类的OnDraw里完成。关键在于坐标换算棋盘数据是行和列屏幕坐标是像素中间通过一个格子边长CELL来换算。交叉点坐标是OX(col-1)*CELL、OY(row-1)*CELL其中OX、OY是棋盘左上角的留白。void CChessView::DrawBoard(CDC* pDC) { const int OX 40, OY 40, CELL 50; CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* oldPen pDC-SelectObject(pen); // 10条横线从行1到行10 for (int r 1; r 10; r) { pDC-MoveTo(OX, OY (r - 1) * CELL); pDC-LineTo(OX 8 * CELL, OY (r - 1) * CELL); } // 9条竖线河界处断开 for (int c 1; c 9; c) { pDC-MoveTo(OX (c - 1) * CELL, OY); pDC-LineTo(OX (c - 1) * CELL, OY 4 * CELL); pDC-MoveTo(OX (c - 1) * CELL, OY 5 * CELL); pDC-LineTo(OX (c - 1) * CELL, OY 9 * CELL); } // 画棋子 for (int r 1; r 10; r) { for (int c 1; c 9; c) { int piece board[r][c]; if (piece EMPTY) continue; CString name PieceToName(piece); int x OX (c - 1) * CELL; int y OY (r - 1) * CELL; pDC-Ellipse(x - CELL / 2, y - CELL / 2, x CELL / 2, y CELL / 2); pDC-SetTextColor(piece 0 ? RGB(255, 0, 0) : RGB(0, 0, 0)); pDC-TextOut(x - 12, y - 8, name); } } pDC-SelectObject(oldPen); }逻辑说明横线循环10次是因为中国象棋棋盘有10条横线竖线分两段画是因为河界中间没有竖线连接上半段画到第5行下半段从第6行开始画。棋子画在交叉点上所以Ellipse的中心点就是交叉点坐标。参数说明CELL是相邻交叉点的像素距离这个值决定整个棋盘在窗口里的大小你可以把OX、OY、CELL提成成员变量后续鼠标点击时用同一套换算反算行列避免两处坐标公式不一致。鼠标点击的处理也是同一套换算的反向操作。col (point.x - OX CELL/2) / CELL 1先加半个格子宽度是为了让鼠标落在交叉点附近时都能命中。换算出的行列先检查范围再交给逻辑层的走法判定不要在视图类里直接改board数组。3. 着法生成的C落地把车马炮兵仕相帅的规则翻译成代码3.1 Move结构体一次走法需要记录哪些信息着法生成是整个程序的地基AI搜索、界面校验、悔棋功能都依赖它。一个Move至少要记录起点、终点、以及“吃到什么子”最后一项是为了在搜索回溯时能还原被吃的棋子。如果你把吃掉的子存进Move里makeMove和unmakeMove就能严格配对中间不需要额外的局面快照。struct Move { int fromRow, fromCol; // 起点 int toRow, toCol; // 终点 int captured; // 被吃掉的棋子0表示没吃 int score; // 走法排序用AI搜索时会用到 Move(int fr, int fc, int tr, int tc, int cap) : fromRow(fr), fromCol(fc), toRow(tr), toCol(tc), captured(cap), score(0) {} };逻辑说明captured字段在生成着法时填入目标格子的棋子值在执行走法时先保存回溯时把它写回原目标格。score字段在着法生成阶段先置0AI搜索开始前根据“吃子价值差”或“历史启发”填充用于走法排序。参数说明Move是一个纯数据类型不持有棋盘指针这样着法列表可以跨函数传递也方便在调试时打印。3.2 生成红车的着法一份可以直接抄的C函数车是最适合当范例的棋子因为它走法最直观四个方向直线扫空位可以走遇到第一个棋子时如果是对方就吃如果是己方就停。边界由哨兵值-1兜住代码里不需要额外判断坐标是否越界。#include vector using namespace std; // side 1 红方-1 黑方moves 是输出参数 void generateCarMoves(int row, int col, int side, vectorMove moves) { // 四个方向上、下、左、右 int dr[4] {-1, 1, 0, 0}; int dc[4] {0, 0, -1, 1}; for (int i 0; i 4; i) { int nr row dr[i]; int nc col dc[i]; // 没撞到边界哨兵-1 while (board[nr][nc] ! -1) { if (board[nr][nc] 0) { // 空位可以走 moves.push_back(Move(row, col, nr, nc, 0)); } else { // 有棋子对方可吃己方不能走 if (board[nr][nc] * side 0) { moves.push_back(Move(row, col, nr, nc, board[nr][nc])); } break; // 不论吃不吃车被挡住停止继续扫 } nr dr[i]; nc dc[i]; } } }逻辑说明while循环沿一个方向推进每到一个新格子先判断是不是哨兵。空位加入着法列表遇到棋子时判断能不能吃然后break因为车不能跳过棋子。参数说明side在这里只用于阵营判断如果你在别处统一用isSameSide(a, b)函数这里可以改成那种更易读的写法。注意moves是引用传递调用方自己负责清空或累积不要每次都新建vector。车能跑通炮就很接近了直线扫描的循环结构完全一样唯一区别是炮的“吃子”需要隔一个炮架。你可以在炮的生成函数里加一个bool hasCannonMount标志遇到第一个子时空走、标记count为1遇到第二个子时判断是否可吃。这个逻辑看着简单但很容易在“两个子之间连续扫描”时写错下标建议先用固定残局验证。3.3 特殊规则统一收口马腿、象眼、将帅照面与炮架中国象棋的每类棋子都有自己的脾气新手最容易漏的是马腿和象眼其次是炮架最后是将帅照面。先把规则表列出来写代码时就不用来回翻棋书棋子走法特殊限制帅/将九宫内横竖一步双方将帅不得在同一列直面相见仕/士九宫内斜走一步不能出九宫相/象田字对角不能过河象眼被堵不能走马日字对角马腿位置有子不能跳车横竖任意格路径被堵不能走炮横竖任意格吃子必须隔一个炮架兵/卒过河前只能前进一步过河后可横走或前进不能后退马的规则最典型值得单独写一个函数。判断马腿的方法是如果马的横向位移是2格马腿就在起点和终点中间的那列相同行如果纵向位移是2格马腿就在中间那行的相同列。bool isLegalHorseMove(int fromR, int fromC, int toR, int toC, int side) { int dr toR - fromR; int dc toC - fromC; // 马走日|dr|和|dc|的组合必须是(1,2)或(2,1) if (!((abs(dr) 1 abs(dc) 2) || (abs(dr) 2 abs(dc) 1))) return false; // 找马腿位置 int legR fromR, legC fromC; if (abs(dr) 2) legR fromR dr / 2; // 纵向走两步腿在中间行 else if (abs(dc) 2) legC fromC dc / 2; // 横向走两步腿在中间列 // 马腿被堵不能跳 if (board[legR][legC] ! 0) return false; // 目标格不能是自己人 if (board[toR][toC] * side 0) return false; return true; }逻辑说明马腿位置和终点是两回事很多翻车现场就是把腿位置算错成终点旁边那格。这里先判断是否满足“日”字形状再定位腿最后检查腿和终点。参数说明这个函数只做合法性校验不生成着法所以调用方需要先保证终点在棋盘范围内12x11的哨兵数组已经帮你挡了一道。将帅照面是另一类容易漏的规则红帅在(10,5)黑将在(1,5)中间没有任何棋子时红帅不能走到(10,4)或(10,6)吗其实任何一方都不能走出让两将同列直面且中间无子的局面因为象棋里“将帅不能照面”是一条独立的禁着AI尤其容易在搜索时走这种棋因为搜索树里根本没人拦它。这个检查建议放一个独立函数在makeMove之前调用而不是散落在各棋子函数里// 检查当前棋盘上双方将帅是否照面若是则该局面本身就非法 bool isKingFace() { int kingR -1, kingRow -1, kingCol -1; for (int r 1; r 10; r) { for (int c 1; c 9; c) { if (abs(board[r][c]) RKING) { if (kingR -1) { kingR r; kingRow r; kingCol c; } else { /* 找到两个将若同列且中间无子则非法 */ } } } } // 完整实现略核心是检查两将同列时中间有没有子 return false; }逻辑说明这个函数在搜索的“合法性检查”阶段调用只要发现将帅照面就直接剪掉这个走法。你还得注意一个细节照面检查对“送吃”和“吃掉将军”两种情况都生效所以AI不会傻到主动走到照面位置。第5章会专门讲这个坑的实际表现。4. AI核心评估函数、Alpha-Beta搜索与3个必调参数4.1 评估函数先给每个棋子定价AI不知道“车厉害”还是“马厉害”它只能靠一个分数来判断局面好坏。所以评估函数的第一步是给棋子定基础价值。这个表是几十年棋手经验的浓缩虽然不精确但足以让AI避开无谓的兑子棋子基础分说明帅/将10000不能吃价值必须是最大车900真正的进攻主力马450中局价值高残局易被憋腿炮450开局价值略高于马残局略低相/象200防守子不过河仕/士200防守子不出九宫兵/卒100过河后可以再加50评估函数最简单的实现是把所有己方棋子总分减对方棋子总分红方返回正分、黑方返回负分。再往前走一步可以加位置加成中兵过河、马跳至七路河口、车占肋道都是值得给个20~30分的位置加分。// 返回当前局面下红方视角的评估分正数红优负数黑优 int evaluate() { int score 0; for (int r 1; r 10; r) { for (int c 1; c 9; c) { int p board[r][c]; if (p 0 || p -1) continue; int base 0; switch (abs(p)) { case RKING: base 10000; break; case RCAR: base 900; break; case RHORSE: base 450; break; case RCANNON: base 450; break; case RELEPHANT: base 200; break; case RGUARD: base 200; break; case RPAWN: base 100; break; } // 兵过河加分红兵行7黑卒行4 if (abs(p) RPAWN) { if (p 0 r 7) base 50; if (p 0 r 4) base 50; } score (p 0) ? base : -base; } } return score; }逻辑说明评估函数写得越细致AI棋力提升越明显但第一次做不要贪。先让分数能反映“子力对比兵过河”这两个事实后面再慢慢加位置表。参数说明基准分是全局常量你想调棋风就把车的900改成880、马的450改成480AI会更愿意保留马而弃车这个在调试时很好用。4.2 让AI想几步Alpha-Beta搜索的递归实现搜索的目标是假设对方也会用同样的搜索逻辑找出当前局面下最终能让自己得分最高的那一步。最朴素的实现是Minimax红方取最大值、黑方取最小值。Alpha-Beta剪枝在Minimax基础上加了一个窗口[alpha, beta]一旦某个分支的分数超出窗口就可以整支剪掉不再深入。这个剪枝不是玄学它保证结果和完整Minimax一样只是少了大量无用的计算。const int INF 100000000; // side 1 红方-1 黑方depth 是剩余搜索深度 int alphaBeta(int depth, int alpha, int beta, int side) { if (depth 0) { return evaluate(); } vectorMove moves; generateAllMoves(side, moves); // 走法排序吃大子优先排序越好剪枝越狠 sort(moves.begin(), moves.end(), [](const Move a, const Move b) { return a.captured b.captured; }); int best -INF; for (const Move mv : moves) { // 如果这一步把自家将帅送走直接跳过 if (!isLegalAfterMove(mv, side)) continue; makeMove(mv); // 负极大值对方视角返回的分数取负号就是本方的收益 int score -alphaBeta(depth - 1, -beta, -alpha, -side); unmakeMove(mv); if (score best) best score; if (best alpha) alpha best; if (alpha beta) break; // 剪枝这个分支已经不会影响上层决策 } return best; }逻辑说明负极大值写法让红黑双方共用同一个函数每层递归都把视角翻转一次返回值永远代表“当前走棋方”的分数。makeMove和unmakeMove必须严格配对否则搜索树会越走越乱这是新手最容易出错的点。参数说明depth从外部传入比如depth4表示AI想四步红、黑、红、黑。alpha和beta的初始值是-INF和INF越窄的窗口剪枝越多但窗口开错会导致结果不准所以第一版先不要自己调窗口。4.3 走法排序与搜索深度剪枝效率的胜负手Alpha-Beta剪枝的效率严重依赖走法排序。如果每次都是最差走法先搜剪枝几乎不发生深度4层的搜索可能卡到十几秒如果最佳着法先被搜到同样的深度可能只需几十毫秒。所以排序是必调的起步就按“吃子价值降序”吃车的走法排前面吃兵、吃卒的排后面。参数作用推荐起步值depth搜索深度4层是低配机器的安全值4走法排序决定剪枝效率影响一个数量级吃子优先搜索时限防止UI线程卡死超时返回当前最佳1000ms深度从4开始比较稳妥。如果你是i5以上机器可以试depth5甚至depth6但每加一层耗时大约翻3~5倍所以建议先把depth固定为4等排序和剪枝都优化好了再往上加。搜索时限不是一个参数而是一个“要不要继续搜下去”的终止标志放在迭代加深的最外层控制而不是写在alphaBeta递归里。5. 中国象棋程序的6个常见坑从界面乱码到AI主动送将这个章节是我一路调程序攒下来的血泪经验每一条都真实卡过我先现象、再原因、最后给解决路径。按顺序排查能省下至少一周的调试时间。5.1 字符集不匹配界面汉字乱码或直接编译报错现象界面上的“帅、车、马”显示成乱码或者代码里写CString s 车;直接编译报错说是无法从char*转换到CString。原因Visual C的默认项目字符集是Unicode你用了窄字符中文字面量系统不知道该怎么把多字节的中文转成宽字符。解决最省事的是去“项目属性 → 配置属性 → 常规 → 项目默认值 → 字符集”改成“使用多字节字符集”。更规范的写法是统一用_T()宏包住所有中文字符串字面量比如_T(车)这样不管项目是Unicode还是多字节都能编译。我第一次写时没管字符集把整个界面重写了一遍才意识到问题根本不在代码逻辑。5.2 棋盘重绘闪烁OnDraw全量重绘的代价与双缓冲修复现象每走一步棋整个窗口都闪一下棋子多的时候闪烁尤其明显像老式CRT显示器刷新率不够。原因MFC的OnDraw在窗口失效时全量重绘而每一步棋都会触发Invalidate等于把棋盘重新画一遍。绘制过程中先擦背景再画线再画棋子屏幕会短暂出现空白。解决双缓冲。在内存里创建一个和窗口等大的画布把所有内容先画到内存DC最后一次性BitBlt到屏幕。void CChessView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* old memDC.SelectObject(bmp); // 先画到内存DC DrawBoard(memDC); // 一次性拷到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(old); }逻辑说明memDC拥有一张和窗口大小一致的位图所有画线、画圆的调用都作用在内存里用户看不到中间过程。参数说明rect来自GetClientRect是当前窗口客户区的尺寸如果你的棋盘固定在某个位置可以裁剪更小的区域进一步减少拷贝量。5.3 走法回溯遗漏搜索时棋盘状态越搜越乱现象AI走完一步后棋盘上多出几颗棋子或者少了几颗莫名其妙地“吃”掉了没有吃过的子。原因makeMove之后忘了unmakeMove。最常见的场景是在搜索函数里某个分支提前return了恢复棋盘的代码没执行到或者复盘代码时把unmakeMove注释掉忘记打开。解决makeMove和unmakeMove成对出现尽可能用RAII或者函数开头确保异常路径也能恢复。另外一个习惯是把“吃掉的子”保存在Move的captured字段里不要每次从棋盘上读因为搜索过程中棋盘已经变了多次原目标格的子早就不是当初那一个了。5.4 将帅照面漏判AI为什么主动送将现象AI在残局阶段走出完全送死的棋比如主动把帅走到和黑将同一条直线上然后程序报“非法走法”或者直接判负。原因合法性检查里没有“将帅不能照面”这条规则。AI搜索时它只检查目标格能不能去却不知道两将同列且中间无子是犯规的于是搜索树里出现大量非法分支偶尔还会把“吃掉对方将”当成合法走法。解决在makeMove之前调用一个isKingFace()检查函数只要当前局面两将同列且中间无子就禁止这个走法。排查的时候可以在控制台打印每次搜索root返回的着法一旦出现将帅同列的情况九成是这个函数漏了。5.5 数组越界边界哨兵救了马腿和炮架现象程序运行一段时间后在随机位置崩溃调试器指向一个看起来完全没问题的赋值语句。原因棋盘用了board[10][9]马跳马腿的时候算出负行号或负列号直接访问了数组前的内存。这个问题最坑的地方在于越界不一定立刻崩溃可能等到内存被其他变量覆盖后才炸。解决换成12x11数组把越界路径变成边界哨兵的正常判断。这是最彻底的方案比在每一处访问前加范围检查靠谱得多。你能看到的现象是“崩溃位置完全随机”其实根因就是马腿坐标没做边界判断。5.6 重复局面死循环同一步棋来回走现象AI在一个局面反复来回走比如边车进退、进退、进退就是不进攻直到触发步数限制。原因搜索深度有限AI评估发现当前局面得分都一样又没有强制它“向前推进”它就停留在循环里。如果搜索树里出现“红车进一黑车退一红车退一黑车进一”这样的循环AI会把走子权一直耗费在无进展的动作上。解决加重复局面检测。简单做法是把每个局面算一个哈希值搜索路径上同一哈希出现两次以上就判和或给严重负分更保守的做法是限制单局步数比如超过120步无吃子就判和。先做后者因为实现快哈希优化留到第6章。6. 用自对弈验证棋力三个环节与迭代加深的下一步6.1 自对弈回归测试让程序红黑方互相对抗验证AI最简单可靠的方法是让程序自己跟自己下。写一个autoPlay循环红方用当前搜索函数走一步然后切换到黑方视角再走一步直到分出胜负或达到步数上限。这样每次修改评估函数或搜索逻辑后都能快速跑一盘看看结果有没有恶化。void autoPlay(int depthRed, int depthBlack, int maxMoves) { int side 1; // 红方先走 for (int step 0; step maxMoves; step) { int depth (side 1) ? depthRed : depthBlack; Move best searchRoot(side, depth); makeMove(best); // 打印本步着法和当前评估分方便观察 printf(step%d side%d move(%d,%d)-(%d,%d) score%d\n, step, side, best.fromRow, best.fromCol, best.toRow, best.toCol, evaluate()); if (isCheckmate(side)) { printf(checkmate\n); break; } side -side; } }逻辑说明searchRoot是Alpha-Beta的一个封装负责从根节点搜索并返回最佳Move。自对弈的价值在于回归测试每改一个参数就固定跑20盘对比胜率和平均步数不要依赖“看起来变强了”的感觉。参数说明depthRed和depthBlack可以设成不同深度比如红方4层、黑方3层用来验证“更强的一方胜率是否明显更高”这是检验评估函数有没有写反的快捷方式。6.2 残局验证与深度对比用固定局面测搜索逻辑用完整对局验证太慢还容易把“规则错误”和“棋力不足”混在一起。更有效的方式是摆几个固定残局单车对单士、双炮对双仕、兵单将。每个局面设一个预期结果AI必须在限定步数内达到比如单车对单士30步内必须将死或吃子。跑不过就是搜索或评估出了问题。深度对比是判断搜索是否正确的另一个手段同一局面depth2和depth4的第一效果应该一致depth4的后续着法应该更好。如果更深的搜索走出了和浅层搜索完全不同的送死棋说明评估函数里的某条规则写错了而不是搜索不够深。我一般把depth2当成“基准正确性测试”depth6当成“棋力上限测试”中间值用来观察棋力增长曲线。6.3 迭代加深与开局库我下一步会往哪边优化自对弈稳定后下一步最划算的优化是迭代加深先搜depth1再搜2逐步加深超过设定时限就返回上一次搜完全程的结果。这样不会出现“depth6超时后什么着法都拿不出来”的尴尬。Move searchRoot(int side, int maxDepth, int timeLimitMs) { vectorMove allMoves; int bestScore -INF; for (int d 1; d maxDepth; d) { int score alphaBeta(d, -INF, INF, side); // 每次搜完拿到当前最佳着法并保存 bestScore score; if (isTimeUp()) break; } // 返回 bestScore 对应的着法 return bestMove; }逻辑说明每一层搜索都是独立的Alpha-Beta调用上一层的搜索结果可以排序下一层的走法列表让剪枝起点更好。迭代加深的核心价值是“随时可以被中止但永远有一个阶段性结果”。参数说明timeLimitMs在UI线程里传到后台搜索线程搜索线程每隔几十毫秒检查一次这个标志在MFC里可以用PostMessage或AfxBeginThread不要在OnLButtonUp里同步搜索深度6否则界面会数秒无响应用户第一反应是程序死了。更远的优化方向是Zobrist哈希加置换表把每个局面哈希后缓存进一个全局表搜索时先查表能直接跳过重复计算。开局库也是一条路把前几步固定到常见开局走法里省下开局阶段的搜索量。我现在每改完一版都会先把深度4和深度6的自对弈各跑10盘再和固定残局对一遍确认没有回归才敢说“这版棋力没有退步”。这个习惯帮我挡住了很多次“越改越弱”的翻车希望帮到你。本文还有配套的精品资源点击获取