资讯详情

C++象棋人机对弈核心:走法生成与Alpha-Beta剪枝实现

📅 2026/9/15 2:55:10 | 华诺云谱 👁 阅读
C++象棋人机对弈核心:走法生成与Alpha-Beta剪枝实现
简介VC 中国象棋人机对弈程序源代码是一份面向C初学者与博弈算法爱好者的完整工程。程序基于MFC开发图形界面实现棋盘绘制、棋子拖拽、行棋合法性校验以及将军、胜负判断同时提供AlphaBeta、NegaScout、MTD(f)等多种搜索引擎配合置换表、历史启发和评估函数演示了从暴力搜索到剪枝优化的典型路线。压缩包内共88个文件以h头文件和cpp源文件为主辅以bmp棋盘位图、ico图标、dsp/dsw工程文件和txt说明文档整体仅214KB适合快速下载研读。目前已有1717人学习浏览无论用于课程设计还是算法原理学习这份代码都能帮助你理解人机对弈系统的完整架构与实现细节。1. VC 象棋对弈程序真正的分水岭在搜索算法一个常见的 MFC 象棋工程棋盘画好了棋子也能点击移动但轮到 AI 走棋时要么不动要么下出“送车”的低级失误。多数情况不是界面代码有问题而是局面表示、走法生成和搜索剪枝这三层没有理顺。人机对弈程序的核心不是“画棋子”而是“在合理时间内找到不差的走法”。这篇内容适合正在做 VC 课程设计、C 小游戏练手或者想把手上的 MFC 象棋程序从“能走”改到“能下”的人。我会按自己实现这类程序的顺序来讲先搭数据结构和规则再写评估与搜索最后接上 MFC 界面并处理运行效率问题。2. 搭好 C 局面表示与走法生成才能谈人机对弈中国象棋的规则比国际象棋少一些但马腿、象眼、将帅照面这些细节很考验代码结构。我一般建议把棋盘、走法生成、局面评估做成独立的 C 类MFC 的 View 只负责绘图和鼠标消息。这样 AI 调试时可以不启动界面直接在控制台工程里跑搜索。2.1 用二维数组还是位棋盘表示棋局常见做法是定义一个 10 行 9 列的二维数组行 0 到 9 表示棋盘从上到下列 0 到 8 表示从左到右。红方在下黑方在上。每个棋子的类型用一个整数表示红方用正数黑方用负数这样判断敌我只需要乘一下当前走棋方。枚举值棋子说明0空无棋子1帅/将全局胜负的关键价值最高2仕/士只能走九宫斜线3相/象走田字不能过河4马走日字有蹩马腿5车走直线控制力最强6炮吃子必须隔一个炮架7兵/卒未过河只能向前过河后可横走enum ChessType { EMPTY 0, KING 1, GUARD 2, BISHOP 3, KNIGHT 4, ROOK 5, CANNON 6, PAWN 7 }; class ChessBoard { public: int board[10][9]; // 行 0~9列 0~8 int sideToMove; // 1 表示红方走-1 表示黑方走 void Reset() { memset(board, 0, sizeof(board)); sideToMove 1; } };这里用 int 数组而不是 char 或位棋盘主要原因是可读性好。搜索时偶尔会拷贝整个局面10 乘 9 乘 4 字节只有 360 字节性能损失可以忽略。位棋盘在 64 位象棋引擎里常见但中国象棋的 90 个交叉点并不适合硬套两个 64 位整数开发效率反而下降。如果你对访问速度敏感把 int 改成 char 也是可以的只是判断正负逻辑要一致。初始化时我习惯摆一个数组模板把红黑双方初始棋子写进去。也可以解析 FEN 字符串但课设阶段直接初始化更省事。需要注意 sideToMove 每次走子后要取反AI 搜索过程中也要维护这个状态否则评估函数会分不清当前是谁走。2.2 走法生成从规则到可计算的 C 函数走法生成是搜索里调用最频繁的部分深度 6 的搜索会生成几万到几十万个节点如果生成函数写得慢整个程序就跑不动。我先把 Move 定义成简单的结构体不含指针和虚函数方便放进预分配数组。struct Move { int fromRow, fromCol; int toRow, toCol; int score; // 走法排序用提高剪枝效率 };马的走法最需要细心。“马走日”只是目标位置真正的问题是蹩马腿。马腿的偏移方向与马移动方向有关不能写错。下面这段代码是八个方向统一处理void GenerateKnightMoves(ChessBoard cb, int r, int c, int side, Move* moves, int count) { const int dr[8] { -2, -2, -1, -1, 1, 1, 2, 2 }; const int dc[8] { -1, 1, -2, 2, -2, 2, -1, 1 }; const int legDr[8] { -1, -1, 0, 0, 1, 1, 0, 0 }; const int legDc[8] { 0, 0, -1, 1, -1, 1, 0, 0 }; for (int i 0; i 8; i) { int nr r dr[i]; int nc c dc[i]; if (nr 0 || nr 9 || nc 0 || nc 8) continue; int legR r legDr[i]; int legC c legDc[i]; if (cb.board[legR][legC] ! 0) continue; // 蹩马腿 int target cb.board[nr][nc]; if (target * side 0) continue; // 目标是己方棋子 moves[count] { r, c, nr, nc, 0 }; } }参数说明dr/dc 是马跳跃后的坐标增量legDr/legDc 是马腿坐标增量。target 与 side 相乘大于 0说明目标棋子和当前走棋方同符号也就是己方棋子不能吃。target 为 0 时乘积为 0可以走target 与 side 异号时乘积小于 0可以吃。其他棋子的生成逻辑大同小异。车走四个方向直线遇到对方棋子可以吃遇到对方棋子后的格子不能继续。炮的走法分无炮架移动和有炮架吃子。兵要判断是否过河过河前的方向只有向前。2.3 合法性校验里最容易漏的“将帅对脸”走法生成只能保证棋子按自身规则移动但中国象棋还有一个特殊规则将帅不能直接照面。如果红帅和黑将处于同一列中间没有任何棋子那么这个局面是非法的。AI 搜索过程中如果漏掉这条就会出现“隔空将死”这种明显错误。判断对脸可以在搜索每个走法后调用一个检查函数也可以在走法生成的时候单独处理。我更推荐在搜索循环的 MakeMove 之后检查因为这样所有走法统一过滤不容易漏写某个棋子的特殊情况。bool IsKingFace(const ChessBoard cb) { int kr -1, kc -1; int er -1, ec -1; for (int r 0; r 10; r) { for (int c 0; c 9; c) { if (cb.board[r][c] KING) { kr r; kc c; } if (cb.board[r][c] -KING) { er r; ec c; } } } if (kc ! ec) return false; int r1 (kr er) ? kr : er; int r2 (kr er) ? kr : er; for (int r r1 1; r r2; r) { if (cb.board[r][kc] ! 0) return false; } return true; }这段代码先定位红帅和黑将的位置如果列不相同一定不会照面。只有列相同时才检查中间是否有棋子。如果中间没有任何棋子说明红帅和黑将中间是空的就是非法局面。把这个函数放在搜索的合法走法过滤里就能避免 AI 走出或接受对脸局面。3. 评估函数与 Alpha-Beta 搜索象棋 AI 的核心实现规则层准备好之后人机对弈的“人”就是界面而“机”就是搜索和评估。象棋 AI 的基础思路很直接穷举当前所有走法假设我方走一步对手会走对我不利的一步如此反复深度到指定层数后用一个评估函数打分。理论上全穷举到终局做不到工程里用 Alpha-Beta 剪枝砍掉明显没用的分支。3.1 子力评估表与先手局面分评估函数决定 AI 的棋风。最简单的评估是棋子价值总和帅 10000车 900炮 450马 400士相 200兵 100。这个表来自常见象棋程序的估值虽然不是绝对标准但足够让 AI 理解“换子”和“弃子”的代价。棋子基础价值说明帅/将10000被吃即输车900长距离控制中局主力炮450开局比马好用残局弱马400残局价值高于炮士/相200防御型棋子兵/卒100过河后通常可加 50~100int Evaluate(const ChessBoard cb) { static const int value[8] { 0, 10000, 200, 200, 400, 900, 450, 100 }; int redTotal 0; int blackTotal 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { int p cb.board[r][c]; if (p 0) redTotal value[p]; else if (p 0) blackTotal value[-p]; } } return cb.sideToMove 1 ? redTotal - blackTotal : blackTotal - redTotal; }说明这里的返回结果以当前走棋方视角为正红方走棋时返回红减黑黑方走棋时返回黑减红。这样配合负极大值搜索时可以直接取负号不需要为红黑写两套逻辑。基础评估没有把兵过河和车的位置算进去实际使用时可以加一个位置分数表比如中兵比边兵价值高马在中路比在边路威胁更大。评估函数不要太复杂。深度 6 的搜索本身要评估几万次如果表查得多、循环嵌套多运行时间会明显变长。我一般先跑通基础子力评估再逐步加位置表并用一个开关控制是否启用方便对比棋力。3.2 Alpha-Beta 剪枝深度、窗口和剪枝率Alpha-Beta 是负极大值的剪枝版本。每一步搜索都维护一个窗口alpha 是我方能接受的最低分beta 是对方能接受的最低分。当某个走法的返回值超过 beta后面的走法就不必继续看了。int AlphaBeta(ChessBoard cb, int depth, int alpha, int beta, int side) { if (depth 0) { return side * Evaluate(cb); } Move moves[512]; int count 0; GenerateAllMoves(cb, side, moves, count); if (count 0) { return -MATE depth; // 无棋可走判当前方输depth 越小说明越快输 } OrderMoves(moves, count); int bestValue -MATE; for (int i 0; i count; i) { MakeMove(cb, moves[i]); bool invalid IsKingFace(cb); if (!invalid) { int score -AlphaBeta(cb, depth - 1, -beta, -alpha, -side); if (score bestValue) bestValue score; if (bestValue alpha) alpha bestValue; } UndoMove(cb, moves[i]); if (alpha beta) break; } return bestValue; }参数说明depth 是剩余搜索层数MATE 是远大于任何实际分值的常量比如 100000。无子可走时返回 -MATE depth剩余层数越小分数越负这样 AI 会优先选择尽快将死对手的变化。OrderMoves 对走法排序先用吃子价值高的走法试探alpha 会被抬升得更快剪枝率更高。顶层调用时要对每个走法循环拿到最佳着法而不是把整个 AlphaBeta 当成一步。核心代码大致是Move GetBestMove(ChessBoard cb, int side, int depth) { Move moves[512]; int count 0; GenerateAllMoves(cb, side, moves, count); OrderMoves(moves, count); int bestScore -MATE; Move bestMove moves[0]; for (int i 0; i count; i) { MakeMove(cb, moves[i]); int score -AlphaBeta(cb, depth - 1, -MATE, -bestScore, -side); UndoMove(cb, moves[i]); if (score bestScore) { bestScore score; bestMove moves[i]; } } return bestMove; }这里的小技巧是根节点搜索时把 beta 设为 -bestScore也就是当前已经找到的最佳分数而不是固定 -MATE。这样后续走法如果不能提高 bestScore就不会被完整搜索能省下不少时间。深度参数的调法一般是先用深度 3 验证逻辑再升到 4 或 5。深度 5 的单步时间在老旧机器上就可能达到几秒具体要看剪枝质量。3.3 在 VC 中让 AI 思考不卡界面MFC 的界面消息循环是单线程的。如果直接在按钮点击事件里调用 GetBestMove深度超过 4 就会看到窗口拖不动、鼠标转圈。常见做法是把 AI 搜索放到工作线程里搜索完成后用 PostMessage 通知主线程更新棋盘。struct AIWorkItem { ChessBoard board; int depth; int side; Move result; HWND hWnd; CChessView* view; }; UINT AIThreadProc(LPVOID param) { AIWorkItem* work (AIWorkItem*)param; work-result GetBestMove(work-board, work-side, work-depth); ::PostMessage(work-hWnd, WM_AI_DONE, 0, (LPARAM)work); return 0; }参数说明AIWorkItem 里的 board 是当前局面的副本搜索不会直接改动主界面的棋盘。side 是轮到哪一方走棋hWnd 是主窗口句柄view 用来在主线程里取回结果。PostMessage 是异步投递不会阻塞工作线程主线程在 WM_AI_DONE 消息处理函数里把 result 落盘并刷新棋盘。还要注意一点不要让用户在人机思考期间连续点击走子。我一般在 StartAI 里设置一个 m_bThinking 标志点击消息里先判断这个标志。void CChessView::StartAI() { if (m_bThinking) return; AIWorkItem* work new AIWorkItem; work-board m_board; work-depth m_searchDepth; work-side m_board.sideToMove; work-hWnd GetSafeHwnd(); work-view this; m_bThinking true; AfxBeginThread(AIThreadProc, work); }m_bThinking 在 WM_AI_DONE 里重置。这个标志同时防止用户点击导致 m_board 被修改避免生成非法走法。4. 用 MFC 画棋盘、处理落子并把走法接到 AI 上VC 里最常见的界面框架是 MFC 的 CView 加上 OnPaint 绘制。这里不推荐用 PictureBox 一张大图因为棋子位置需要动态更新直接用 GDI 画线和圆形棋子反而更容易控制。4.1 棋盘绘制与鼠标点击坐标换算棋盘绘制要分两层线是固定不动的棋子会随局面变化。OnDraw 里先画所有线再画所有棋子。坐标用格子单位每个交叉点间隔 m_cell左上角留 m_margin 边距。void CChessView::OnDraw(CDC* pDC) { for (int r 0; r 10; r) { pDC-MoveTo(m_margin, m_margin r * m_cell); pDC-LineTo(m_margin 8 * m_cell, m_margin r * m_cell); } for (int c 0; c 9; c) { pDC-MoveTo(m_margin c * m_cell, m_margin); pDC-LineTo(m_margin c * m_cell, m_margin 9 * m_cell); } for (int r 0; r 10; r) { for (int c 0; c 9; c) { DrawPiece(pDC, r, c); } } }这里只是最基础的网格。中国象棋还要画九宫两条斜线、楚河汉界文字、炮位和兵位符号。为了不让代码太长可以把这些画到 OnDraw 的一个单独函数里DrawPiece 根据 board[r][c] 的正负决定颜色再画一个空心圆和汉字。鼠标坐标换算成棋盘行列时要用到四舍五入否则点击交叉点附近容易选中错位置。void CChessView::OnLButtonDown(UINT nFlags, CPoint point) { int c (point.x - m_margin m_cell / 2) / m_cell; int r (point.y - m_margin m_cell / 2) / m_cell; if (r 0 || r 9 || c 0 || c 8) { CView::OnLButtonDown(nFlags, point); return; } HandleClick(r, c); Invalidate(FALSE); CView::OnLButtonDown(nFlags, point); }参数说明m_cell / 2 的偏移让鼠标点在两个交叉点中间时选到更近的那一个这样可以减少误触。HandleClick 里实现选棋和走棋两个状态第一次点击己方棋子时选中第二次点击目标位置时尝试走子。4.2 人机回合的调度先更新局面再让 AI 走棋我习惯把用户固定为红方AI 固定为黑方。这样 sideToMove 为 1 时才能处理用户点击为 -1 时直接启动 AI。HandleClick 的简化版本如下void CChessView::HandleClick(int r, int c) { if (m_board.sideToMove ! 1) return; if (m_bThinking) return; if (m_selected) { Move mv { m_selRow, m_selCol, r, c, 0 }; if (IsMoveLegal(m_board, mv)) { MakeMove(m_board, mv); m_selected false; StartAI(); } else { m_selected false; } } else if (m_board.board[r][c] * m_board.sideToMove 0) { m_selRow r; m_selCol c; m_selected true; } }m_board.sideToMove 是当前走棋方m_board.board[r][c] 与 sideToMove 相乘大于 0 说明点击的是己方棋子。如果用户点击了对方棋子或者空位就当作一次未完成的落子清空选中状态。IsMoveLegal 可以直接生成走法列表后查找也可以单独写一个按棋子类型判断的简化函数。搜索已经需要完整的走法生成器点击走子这里直接复用它最省事。走子完成后调用 StartAIAI 线程搜索完成后 PostMessage 回来。主线程在 WM_AI_DONE 消息处理函数里更新局面然后再次 Invalidate 重绘。LRESULT CChessView::OnAIDone(WPARAM wParam, LPARAM lParam) { AIWorkItem* work (AIWorkItem*)lParam; if (work-result.fromRow 0) { MakeMove(m_board, work-result); } m_bThinking false; delete work; Invalidate(FALSE); return 0; }需要把 WM_AI_DONE 定义成自定义消息 ID并在消息映射里加上 ON_MESSAGE。这一步很容易漏漏了之后 AI 走完棋界面不会刷新看起来就像没走棋。4.3 VC 6 和老工程运行效率与新工具链的差异很多老课设是在 VC 6 环境下写的。VC 6 的 Release 模式默认优化较弱Debug 模式跑深度 4 的搜索就可能慢到不可用。如果你拿到的是老工程第一步不要换编译器先把深度调到 3 验证功能再开 Release 提升速度。运行场景建议深度说明VC 6 Debug2~3只用来验证规则不适合下棋VC 6 Release3~4加上走法排序后可以勉强使用VS2017 Release4~6现代编译器内联和优化更好还有一个隐藏性能问题VC 6 的 STL vector 在 Debug 下不会内联频繁 push_back 会拖慢走法生成。我的处理是把 vector 换成固定数组这是从象棋引擎里学到的习惯代码可读性略差但搜索提速非常明显。struct MoveList { Move move[512]; int count; void Clear() { count 0; } void Add(int fromR, int fromC, int toR, int toC) { move[count].fromRow fromR; move[count].fromCol fromC; move[count].toRow toR; move[count].toCol toC; count; } };在 AlphaBeta 里生成走法时直接在栈上声明一个 MoveList传给生成函数。这样不会在递归中频繁 new 和 delete。配合 OrderMoves 的走法排序深度 5 的搜索在老编译器上基本能出棋。5. 让 AI 再快一步置换表与开局库以及验证走法搜索到后面同一局面会从不同走法顺序反复到达尤其是兵和炮的移动顺序。加入置换表可以减少重复计算这是象棋引擎里成本最低的提速方式之一。常见做法是用 Zobrist 哈希给每个局面生成一个 64 位 key以 key 查表。Zobrist 表结构简单但工程里要注意哈希冲突和搜索深度判断只有表中保存的深度大于等于当前搜索深度时才能使用。unsigned __int64 zobrisTable[2][8][90]; void InitZobrist() { srand(12345); for (int side 0; side 2; side) for (int type 0; type 8; type) for (int pos 0; pos 90; pos) zobrisTable[side][type][pos] ((unsigned __int64)rand() 32) | rand(); }用一个索引 pos r * 9 c 表示棋盘位置。MakeMove 时对移除棋子和添加棋子的位置各做一次异或就能增量维护 hashKey不用每次搜索从棋盘重新计算。开局阶段 AI 如果每次都从搜索开始会浪费不少时间在已知变化上。常见做法是把常见开局的前几步写成一个简单的开局库。例如开局首选炮二平五或炮八平五然后根据回应对应变化。bool GetOpeningMove(ChessBoard cb, Move result) { if (cb.sideToMove ! 1) return false; if (cb.board[9][0] ! 0 || cb.board[9][8] ! 0 || cb.board[8][4] ! 0) return false; Move mv { 7, 1, 7, 4 }; if (IsMoveLegal(cb, mv)) { result mv; return true; } return false; }只看前几步还不够但配合置换表已经能让 AI 在开局阶段秒出棋。最后要做的是在源代码工程里放一个走子验证函数。每次 AI 返回着法后不直接落子而是先生成全部合法走法判断结果是否在列表中bool IsMoveLegal(const ChessBoard cb, const Move mv) { MoveList list; GenerateAllMoves(cb, cb.sideToMove, list); for (int i 0; i list.count; i) { if (list.move[i].fromRow mv.fromRow list.move[i].fromCol mv.fromCol list.move[i].toRow mv.toRow list.move[i].toCol mv.toCol) { return true; } } return false; }如果校验不通过说明走法生成、搜索或界面落子某一环节有 bug。可以在菜单里加一项“连下 50 步”每次 AI 走完都调用这个函数能快速暴露大多数规则错误。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。