十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

VC++中国象棋人机对弈:Alpha-Beta剪枝与评估函数实战

VC++中国象棋人机对弈:Alpha-Beta剪枝与评估函数实战 简介这是一份以 VC 与 MFC 实现的中国象棋人机对弈完整源码工程适合有一定 C 基础、想深入游戏 AI 的读者参考。压缩包共 88 个文件包含 26 个头文件与 24 个 C 源文件另附图标、位图、资源脚本、工程配置和说明文档整体仅 214KB目录结构清晰、易于按模块查找。已有 1717 人学习。源码覆盖面较完整从棋盘绘制、走法生成、规则校验到多种主流搜索引擎及置换表、历史启发、迭代加深、评估函数等优化模块均有体现界面部分也包含 MFC 自绘控件、正反棋盘位图及消息响应处理便于对照理解交互逻辑。通过编译调试这套工程能系统掌握象棋 AI 从底层搜索决策到界面反馈的完整闭环配合源码说明和调试记录特别适合课程设计、毕业设计或个人 AI 编程练手参考。1. 为什么说中国象棋人机对弈是 VC 进阶绕不过去的练手项目写 C 小游戏的人很多但绝大多数止步于贪吃蛇和俄罗斯方块棋盘是死的规则是顺的程序只是在等输入。人机对弈完全不同你要把“下棋”这个决策过程拆成规则、搜索和评估三件事让程序在 1 秒内替你想清楚后面好几步棋。它是 VC 进阶绕不开的练手项目。棋盘的内存布局、递归搜索、剪枝判断、MFC 窗口消息这些在学校课程作业里几乎不会同时出现却是一个可执行程序从“能跑”走向“能赢”的必经之路。尤其在中国象棋里平均每一步有约 40 种合法走法比国际象棋小一个量级反而更适合用来观察搜索深度和评估质量如何影响棋力。下面按一条能实际编译运行的路径走先定义棋盘和走法生成器再实现带走法排序的 Alpha-Beta 搜索把评估函数调到能赢新手最后落到 VC 工程里的线程与调试。代码以标准 C 为主MFC 只在界面层出现。2. 棋盘表示与走法生成把规则翻译成机器能跑的数据结构2.1 一维数组比二维数组更适合搜索这是老代码的共识中国象棋棋盘是 10 行乘 9 列共 90 个交叉点。常见做法是用char board[90]的一维数组行优先排列坐标换算为pos row * 9 col。为什么不用int board[10][9]两层原因一维下标少一次间接寻址走法生成中对目标格只要board[to]一次访问整个棋盘 90 字节几乎能全部命中 L1 缓存这对后面千万次递归调用很关键。棋子用正负号区分红黑这样判断敌我只需要一次乘法红方为正值黑方为负值双方各自走棋时用color变量表示红方color 1黑方color -1。下面是一份可以直接放进源文件的定义和初始化代码。// board.h enum Piece : char { EMPTY 0, KING 1, // 帅/将 ADVISOR 2, // 仕/士 BISHOP 3, // 相/象 KNIGHT 4, // 马 ROOK 5, // 车 CANNON 6, // 炮 PAWN 7 // 兵/卒 }; static char board[90]; void initBoard() { std::memset(board, 0, sizeof(board)); static const char backRank[9] { ROOK, KNIGHT, BISHOP, ADVISOR, KING, ADVISOR, BISHOP, KNIGHT, ROOK }; for (int col 0; col 9; col) { board[0 * 9 col] -backRank[col]; // 黑方底线行0 board[9 * 9 col] backRank[col]; // 红方底线行9 board[1 * 9 col] -CANNON; // 黑方炮位行1 board[7 * 9 col] CANNON; // 红方炮位行7 board[3 * 9 col] -PAWN; // 黑方卒林行3 board[6 * 9 col] PAWN; // 红方兵林行6 } }这里的存取规则很容易对错行 0 是黑方底线行 9 是红方底线board[9 * 9 col]取出的是红方最底下一排。炮在第 1 行和第 7 行兵卒在第 3 行和第 6 行。之所以用行 3 和行 6 而不是行 4 和行 5是因为中国象棋的兵卒在棋盘的三、六路布阵这个初始化布局是通用开局。提示仅在初始化阶段写board后续所有搜索过程都改用makeMove/unmakeMove两个接口修改这是后面调试吃子、将军和回退的基础。2.2 走法生成分两步先生成候选走法再做送将过滤走法结构体只需要三个字段起点、终点、吃掉的棋子。被吃棋子放入captured是为了unmakeMove时原样恢复否则回退会丢子。struct Move { int from; // 起点下标 0..89 int to; // 终点下标 0..89 char captured; // 吃掉的棋子类型EMPTY 表示没吃子 };以车为例车的走法是最直观的四个方向延伸。void generateRookMoves(int pos, int color, std::vectorMove moves) { static const int dirs[4][2] { {1,0}, {-1,0}, {0,1}, {0,-1} }; int row pos / 9, col pos % 9; for (auto d : dirs) { int nr row d[0], nc col d[1]; while (nr 0 nr 10 nc 0 nc 9) { char target board[nr * 9 nc]; if (target EMPTY) { moves.push_back({pos, nr * 9 nc, EMPTY}); } else { if (target * color 0) // 异色可以吃 moves.push_back({pos, nr * 9 nc, target}); break; // 不管吃没吃到都要停 } nr d[0]; nc d[1]; } } }target * color 0是判断敌我的紧凑写法红方color 1黑方color -1同色相乘为正异色相乘为负。看到target EMPTY时继续走碰到格子就退出循环这就是车的行进规则。炮的生成稍微特殊需要在四个方向上先跳过第一个子再找炮架后面的目标但整体代码结构不变把上面的while拆成两段即可。马的核心逻辑是蹩马腿检测我一般把马腿方向和跳跃方向分开写避免下标算错。void generateKnightMoves(int pos, int color, std::vectorMove moves) { static const int jumpDirs[8][2] { {-2,-1}, {-2,1}, {-1,-2}, {-1,2}, {1,-2}, {1,2}, {2,-1}, {2,1} }; static const int legDirs[8][2] { {-1,0}, {-1,0}, {0,-1}, {0,1}, {0,-1}, {0,1}, {1,0}, {1,0} }; int row pos / 9, col pos % 9; for (int d 0; d 8; d) { int legRow row legDirs[d][0], legCol col legDirs[d][1]; // 蹩马腿马腿位置有任意棋子都不能跳 if (board[legRow * 9 legCol] ! EMPTY) continue; int nr row jumpDirs[d][0], nc col jumpDirs[d][1]; if (nr 0 || nr 10 || nc 0 || nc 9) continue; char target board[nr * 9 nc]; if (target * color 0) continue; // 目标位置是同色棋子 moves.push_back({pos, nr * 9 nc, target}); } }legDirs里有重复值这不是笔误而是为了让马腿的方向数组和跳跃方向数组一一对应。比如竖直方向跳两格时马腿只有一个方向{-1,0}或{1,0}但两种情况都占用同一组。把两个表并列维护代码比在判断里临时推导马腿位置更不容易出错。2.3 帅将照面与送将过滤是初学者最容易漏掉的一条规则中国象棋有一条隐性规则帅和将不能在同一列直接照面谁先照面谁算负。这个规则在makeMove之后必须检查。一套完整的合法性过滤如下bool isInCheck(int color) { int kingPos -1; for (int i 0; i 90; i) { if (board[i] (color 0 ? KING : -KING)) { kingPos i; break; } } // 检测对方所有棋子是否能攻击到 kingPos此处省略常规攻击检测 return checkAttackOn(kingPos, -color); }检查走法是否合法的方法是先临时走这一步然后看己方帅是否处于被将军状态如果是就丢弃。bool isLegalMove(const Move mv, int color) { makeMove(mv); bool legal !isInCheck(color); unmakeMove(mv); return legal; }在所有走法生成完之后用isLegalMove过滤。这个方案会多走两步棋但在搜索深度不超过 6 层时性能完全够用。追求极致性能的引擎会在生成时直接排除非法走法不过那属于后续优化不是第一版该做的事。3. Alpha-Beta 剪枝让 AI 在 1 秒内往后算 4 步棋3.1 负极大值写法让递归逻辑短一半从 MinMax 到 Alpha-Beta 有一个常见写法转换因为棋类是一个交替决策的零和博弈始终用当前走棋方的视角来表示分数递归时取负号就行了。这种写法叫 Negamax代码比分别写 max 和 min 两层逻辑简洁得多。int negamax(int depth, int color) { if (depth 0) return evaluate(color); auto moves generateMoves(color); if (moves.empty()) return -100000; // 无棋可走判负 int best -100000; for (const Move mv : moves) { makeMove(mv); int score -negamax(depth - 1, -color); unmakeMove(mv); if (score best) best score; } return best; }这里的核心是-negamax(depth - 1, -color)这一步对我方是好棋那么对对方就是坏棋所以对方的分数取反就是我的分数。评估函数evaluate也必须返回“当前走棋方视角”的分数否则整个递归的符号会乱掉。这个视角一致性问题是后来查 bug 时最常见的争议点。3.2 Alpha-Beta 剪枝的三个关键参数Alpha-Beta 在 Negamax 上加两个边界参数alpha 是当前方能保底拿到的最低分数beta 是对方能忍受的最高分数。当某个走法返回的分数大于等于 beta意思是这一步对对方太好对方在上层一定会选择其他分支避开所以当前分支不会被执行。int alphaBeta(int depth, int alpha, int beta, int color) { if (depth 0) return evaluate(color); auto moves generateMoves(color); if (moves.empty()) return -100000; orderMoves(moves); // 走法排序直接决定剪枝效率 for (const Move mv : moves) { makeMove(mv); int score -alphaBeta(depth - 1, -beta, -alpha, -color); unmakeMove(mv); if (score beta) return beta; // beta 截断这层不需要再搜 if (score alpha) alpha score; } return alpha; }参数的含义要在脑子里形成一个闭环alpha只增不减表示当前节点已经找到的最佳选项beta是上层传下来的容忍上限任何超过beta的结果都直接返回。搜索根调用时alpha -1000000, beta 1000000相当于敞开口子让第一层任意选。很多资料讲 Alpha-Beta 时只给代码不提一个现状如果不做走法排序剪枝率可能只有 10% 到 20%搜索花的时间几乎和原生 MinMax 一样。走法排序才是 Alpha-Beta 真正值钱的地方。3.3 走法排序按“吃子价值”排收益立竿见影搜索时最理想的情况是每次都在第一步就发现最优走法从而让其他分支全部被剪掉。实用的排序策略分三级对应不同实现成本。优先级策略实现成本剪枝效果1MVV-LVA先走吃子多的小棋子吃大棋子优先低明显2杀手走法上一层同一节点的最佳走法优先中额外提速3历史表按历史命中的次数排序高接近完美排序第一版先做 MVV-LVA 就够了。给被吃棋子一个价值表被吃的价值越高这个走法越优先探索。int pieceValue[8] { 0, 100000, 500, 300, 400, 1000, 600, 100 }; void orderMoves(std::vectorMove moves) { std::sort(moves.begin(), moves.end(), [](const Move a, const Move b) { if (a.captured ! b.captured) return pieceValue[std::abs(a.captured)] pieceValue[std::abs(b.captured)]; return a.to b.to; // 稳定排序便于调试复现 }); }这里的captured是char类型直接取绝对值用于数组下标。注意吃的动作比移动位置更重要因为吃子通常能立即改变子力平衡也更容易触发将军。3.4 迭代加深搜索时间可控且每层都能落子搜索引擎不能长时间卡在某一层。常见做法是迭代加深从深度 1 开始逐层加深每层完整搜完才把本层的最佳走法作为最终走法如果时间到了就中断本轮沿用上一层结果。这样即使突然超时程序也总能给出一个可下的棋。int searchRoot(int color, int maxTimeMs) { auto start std::chrono::steady_clock::now(); int iterBestMove 0; for (int depth 1; depth 6; depth) { bool layerComplete true; int layerBestMove 0; int layerBestScore -1000000; auto moves generateMoves(color); orderMoves(moves); for (const Move mv : moves) { makeMove(mv); int score -alphaBeta(depth - 1, -1000000, 1000000, -color); unmakeMove(mv); if (score layerBestScore) { layerBestScore score; layerBestMove mv.from * 100 mv.to; } if (elapsedMs(start) maxTimeMs) { layerComplete false; break; } } if (layerComplete) { iterBestMove layerBestMove; } } return iterBestMove; }elapsedMs可以用std::chrono::duration_cast实现代码略。重点是layerComplete的用法只有整层搜完这一层的“最佳走法”才可靠因为未完成的搜索会漏掉部分分支不能直接使用。4. 评估函数把棋手的感觉量化成 AI 能比较的数4.1 子力价值车 1000炮 600马 400评估函数是 AI 的“大局观”。第一版只需要做两条子力价值和位置价值。子力价值描述“这棋子值多少分”位置价值描述“这个棋子站在这里值多少分”。棋子子力价值红方视角说明车1000横竖控制力最强炮600开局和中局价值高于马马400残局价值高于炮兵过河200过河后的威胁显著提升兵未过河100前期只算先手优势仕/相150防御为主不算进攻分炮和马的价值在实战中会互换。开局到中局炮的机动性强于马残局时棋盘变敞马的控制点更稳定所以很多引擎会在残局动态调整两者的差值。第一版先固定一个值后面再考虑残局表。4.2 位置价值表让马跳向中心让兵过河给每个棋子配一张 10 乘 9 的位置价值表。以马为例理想位置是棋盘中心附近边角价值低。下面是一张红方视角的马位置表行 0 为黑方底线行 9 为红方底线。// 红方视角的马位置价值表 static const int knightPos[10][9] { { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 10, 0, 10, 0, 0, 0 }, { 0, 5, 10, 20, 20, 20, 10, 5, 0 }, { 0, 10, 20, 30, 30, 30, 20, 10, 0 }, { 5, 10, 20, 30, 30, 30, 20, 10, 5 }, { 5, 10, 20, 30, 30, 30, 20, 10, 5 }, { 0, 10, 20, 25, 25, 25, 20, 10, 0 }, { 0, 5, 10, 20, 20, 20, 10, 5, 0 }, { 0, 0, 0, 10, 0, 10, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 } };这些数值是经验值不是某个标准答案。它们的意义是给搜索一个倾向马往中心走的分比往边角走高AI 就更愿意调马。搜索在计算时对红方直接取knightPos[row][col]对黑方要把行镜像一下因为黑方的视角是从上往下看。int evaluate(int color) { int score 0; for (int pos 0; pos 90; pos) { char piece board[pos]; if (piece EMPTY) continue; int type std::abs(piece); int row pos / 9, col pos % 9; int vrow (piece 0) ? row : 9 - row; // 黑方镜像 int val pieceValue[type] knightPos[vrow][col]; score (piece 0) ? val : -val; } return color 0 ? score : -score; }pieceValue和knightPos的索引都用std::abs(piece)这样红黑共用同一套表只在取行号时做镜像。这个思路可以扩展到车、炮、兵的位置表。4.3 评估函数的三个常见 bug评估函数出 bug 比搜索算法难查因为它不报错只是棋力忽高忽低。最常见的三个坑如下。第一是正负号不一致。如果evaluate返回的分数总以红方为正而 Negamax 要求以当前走棋方为正那么黑方走棋时所有分数都反了AI 会系统地回避好棋。这个 bug 的表现是 AI 下子看起来很“怂”专门走保守路线。定位时只用在搜索入口打日志对比红黑双方的评估值是否在互换视角时变号。第二是位置表没镜像。黑方的马使用了红方的行号导致黑方的马永远被认为待在“低位”AI 会刻意把黑马往红方底线赶看起来像乱走。第三是将军奖励加错了地方。有的初学者把所有走法的将军动作都加分就是在evaluate里加一个isInCheck这本身没问题但如果在searchRoot里也重复加了一次分数就通胀了AI 会出现“宁可被吃马也要将军”的怪棋。评估函数的调试有一个实用技巧写一个小工具手动摆一个局面调用evaluate输出分值然后一行一行对照位置表验证。这个验证比在完整对局里调试快得多。5. 把搜索塞进 VC 工程MFC 线程与断点调试5.1 不要让搜索跑在 UI 线程MFC 里如果直接在OnLButtonDown里调用searchRoot界面会在搜索的几百毫秒到一两秒内完全卡死。原因很简单OnLButtonDown是 UI 线程的回调UI 线程被搜索循环占住窗口消息队列就没法处理重绘。正确做法是用工作线程搜索完成后通过PostMessage把结果送回主窗口。UINT CChessDlg::SearchThreadProc(LPVOID pParam) { CChessDlg* dlg static_castCChessDlg*(pParam); int move dlg-m_engine.searchRoot(dlg-m_aiColor, 1500); ::PostMessage(dlg-m_hWnd, WM_MY_SEARCH_DONE, move, 0); return 0; }线程入口里调用搜索搜索是纯 C 代码不碰任何 MFC 控件因此不存在跨线程访问控件的问题。WM_MY_SEARCH_DONE是自定义消息在消息响应函数里解析move更新棋盘。需要停止搜索时设置一个volatile BOOL m_bStopSearch在searchRoot的每层循环里检查一次。注意makeMove和unmakeMove操作的是同一份棋盘内存工作线程在搜索期间UI 线程不要对棋盘做任何写操作否则会出现“思考的棋和显示的棋不一致”的灵异问题。5.2 主变化输出把 AI 的思考过程打到输出窗口搜索引擎最常见的调试手法是输出主变化也就是当前最优思路上的完整走法序列。在搜索根节点每层完成时把 PV 打出来。void CChessDlg::DebugPrintPV(const std::vectorint pv) { CString line; for (size_t i 0; i 1 pv.size(); i) { CString moveStr; moveStr.Format(L%d,%d - %d,%d , pv[i] / 9, pv[i] % 9, pv[i 1] / 9, pv[i 1] % 9); line moveStr; } OutputDebugString(line); }用OutputDebugString而不是printf是因为调试输出不会影响 UI 交互。看主变化时重点关注两点每层加深后主变化是否和前一层一致如果变了说明某层的剪枝把之前的浅层答案推翻了以及评估值是否单调改善如果评估值在加深时突然暴跌多半是搜索窗口或将军判断出了问题。5.3 断点失效和 DLL 调试的两个坑VC 调试搜索引擎时“当前不会命中断点”是高频问题。常见原因是代码被优化掉了Release 配置下局部变量和短函数会被编译器直接合并或内联断点落在一个不存在的指令地址上。排查时先在searchRoot的入口设断点如果这里能停再往alphaBeta内部挪同时把工程切到 Debug 配置。另一个坑是搜索引擎编译成 DLL 供界面调用。如果 DLL 的.pdb文件和.dll不在同一目录或者界面工程加载的是旧 DLL就会出现命中断点后看不到任何局部变量甚至直接显示“源代码与原始版本不同”。解决方法是把 DLL 和它的 PDB 一起放到运行目录并且在工程属性里关闭“在调试会话中忽略未加载的 PDB”这个选项。空步剪枝是我最后建议加的一个优化开关它能在残局阶段让搜索深度凭空多一层但这个优化对杀棋计算有副作用。把它做成一个命令行参数或 ini 配置项对局测试时不用重新编译就能开关对比。extern bool g_enableNullMove; // true 时启用空步剪枝在alphaBeta入口处如果g_enableNullMove为真且当前不是将军状态就尝试跳过本方走棋直接用对方视角减一层深度看能不能产生 beta 截断。测试时分别跑相同局面对比两种配置下的搜索深度和落子质量再决定是否默认开启。本文还有配套的精品资源点击获取
返回列表