VC++中国象棋人机对弈:Alpha-Beta剪枝与评估函数实战
2026/9/15 16:42:06 网站建设 项目流程

简介:这是一份以 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::vector<Move>& 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::vector<Move>& 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::vector<Move>& 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; // 稳定排序,便于调试复现 }); }

这里的capturedchar类型,直接取绝对值用于数组下标。注意吃的动作比移动位置更重要,因为吃子通常能立即改变子力平衡,也更容易触发将军。

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; }

pieceValueknightPos的索引都用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_cast<CChessDlg*>(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的每层循环里检查一次。

注意:makeMoveunmakeMove操作的是同一份棋盘内存,工作线程在搜索期间,UI 线程不要对棋盘做任何写操作,否则会出现“思考的棋和显示的棋不一致”的灵异问题。

5.2 主变化输出:把 AI 的思考过程打到输出窗口

搜索引擎最常见的调试手法是输出主变化,也就是当前最优思路上的完整走法序列。在搜索根节点每层完成时,把 PV 打出来。

void CChessDlg::DebugPrintPV(const std::vector<int>& 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 截断。测试时分别跑相同局面,对比两种配置下的搜索深度和落子质量,再决定是否默认开启。

本文还有配套的精品资源,点击获取

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

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

立即咨询