ARTICLE DETAIL

资讯详情

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

VC++中国象棋人机对弈程序:从棋盘表示到Alpha-Beta剪枝的完整实现

VC++中国象棋人机对弈程序:从棋盘表示到Alpha-Beta剪枝的完整实现 简介这是一份面向C初学者与游戏开发爱好者的VC中国象棋人机对弈程序源代码帮助读者理解棋类AI与图形界面开发的完整实现路径。压缩包共88个文件、约214KB以24个cpp源文件与26个h头文件为核心另有ico、bmp、cur等图标位图资源及dsp、dsw工程文件可直接用VC打开编译运行。程序涵盖棋盘状态表示、合法走法判断、胜负判定等基础逻辑并集成Alpha-Beta、NegaScout、PVS、MTD(f)等多种搜索引擎配合置换表、历史启发与静态评估函数是学习博弈树搜索与剪枝优化的实用范例。界面部分基于MFC与自绘按钮控件演示了事件驱动下的棋盘绘制与棋子拖动响应。目前已有1723人学习下载适合希望从零梳理象棋AI架构、对照源码调试与优化搜索性能的开发者参考。1. VC 中国象棋人机对弈程序一份源代码能跑通哪些真问题很多人第一次看到「VC 中国象棋人机对弈程序源代码」这个标题第一反应是去搜一份能直接双击运行的工程结果下载下来要么缺 MFC 库、要么报一堆LNK2019要么棋盘画出来了但电脑走子像随机数。我当年也是这么翻车的。这份标题真正指向的不是一个「成品软件」而是一套用 C 在 Windows 上把棋盘表示、走法生成、局面评估、极小值搜索这几件事串起来的完整链路。它适合两类人一类是想通过一个看得见摸得着的项目把 C 类和递归真正用起来的新手另一类是想给搜索算法加剪枝、置换表、历史启发做性能调优的熟手。下面我按「棋盘怎么存、走法怎么生、AI 怎么想、界面怎么接、坑在哪」这条线把能复现的细节讲清楚你照着改参数就能看到棋力变化。2. 棋盘表示与走法生成从 10×9 数组到合法着法列表2.1 为什么用一维数组而不是二维数组中国象棋棋盘是 9 列 10 行最直觉的写法是int board[10][9]。但真写搜索的时候二维数组每次访问都要算两次下标而且做「马走日」这种偏移时容易越界。我一般用一维int board[256]把 9×10 的有效区域嵌在 16×16 的格子里这样任何走法偏移都是一个固定常数越界判断只需要看目标格是不是在有效区域内。// 棋盘用 16x16 的一维数组有效区域是 3..12 列、3..12 行 const int BOARD_SIZE 256; int board[BOARD_SIZE]; // 把 (x, y) 映射到一维下标x 是列 0..8y 是行 0..9 inline int idx(int x, int y) { return (y 3) * 16 (x 3); } // 初始化0 表示空正数红方负数黑方 void initBoard() { memset(board, 0, sizeof(board)); // 这里按标准开局摆子车马相仕帅仕相马车 // 具体摆法省略重点是所有棋子都通过 idx() 写入 }这段代码的关键在idx()它把逻辑坐标平移了 3 格让棋盘四周留出至少 2 格的缓冲区。为什么是 2 格因为「马」的走法偏移最大是(±2, ±1)留 2 格就能保证任何合法棋子位置加上偏移后下标仍然落在 0..255 内不会读到数组外面。参数上BOARD_SIZE必须是 16 的倍数idx里的3可以改成2但改成2后「马」在边线附近就要额外判断反而更慢。2.2 走法生成把「马腿」「象眼」这些规则写死走法生成是整个程序里最容易被低估的部分。很多人以为 AI 强不强只看搜索深度其实如果走法生成漏了「马腿被蹩」或者「将帅不能照面」搜索再深也是错的。我一般把每种棋子的偏移和阻挡规则写成一个表生成时先查表再判断阻挡。// 马的 8 个走法偏移前两个是马腿位置 const int KNIGHT_MOVES[8][2] { {-2, -1}, {-2, 1}, {-1, -2}, {-1, 2}, {1, -2}, {1, 2}, {2, -1}, {2, 1} }; // 对应的马腿偏移 const int KNIGHT_LEG[8][2] { {-1, 0}, {-1, 0}, {0, -1}, {0, 1}, {0, -1}, {0, 1}, {1, 0}, {1, 0} }; // 生成马的所有合法走法 void genKnightMoves(int from, int side, Move* moves, int count) { int x (from % 16) - 3; int y (from / 16) - 3; for (int i 0; i 8; i) { int nx x KNIGHT_MOVES[i][0]; int ny y KNIGHT_MOVES[i][1]; // 先判断目标是否在棋盘内 if (nx 0 || nx 8 || ny 0 || ny 9) continue; // 再判断马腿是否被占 int legX x KNIGHT_LEG[i][0]; int legY y KNIGHT_LEG[i][1]; if (board[idx(legX, legY)] ! 0) continue; int to idx(nx, ny); // 目标格不能是己方棋子 if (side 0 board[to] 0) continue; if (side 0 board[to] 0) continue; moves[count] {from, to}; } }逻辑上先算目标格再算马腿顺序不能反。因为如果目标格已经出界马腿判断就是多余的。参数上KNIGHT_MOVES和KNIGHT_LEG必须一一对应我见过有人把{-2,-1}的马腿写成{-1,-1}结果马能穿墙。生成完所有走法后还要过滤掉「走后自己被将军」的着法这一步叫合法性过滤通常放在搜索函数里做而不是在生成时做因为生成时做会重复计算。2.3 用位棋盘加速先别急网上有些文章一上来就讲位棋盘bitboard说能快几十倍。我的血泪经验是如果你连一维数组的走法生成都没写对位棋盘只会让你调试到怀疑人生。位棋盘适合棋子种类少、棋盘规整的棋类中国象棋有 7 种棋子、还有河界和九宫限制位棋盘的初始化表能写几百行。我一般建议先用数组版把搜索跑通测出每秒能搜多少节点再决定要不要换位棋盘。常见做法是数组版能到 10 万节点/秒位棋盘能到 50 万节点/秒但代码量翻三倍。对于学习目的数组版足够了。3. 极小值搜索与 Alpha-Beta 剪枝让电脑从「乱走」到「会算」3.1 负极大值搜索把「最大最小」写成一个递归中国象棋是零和博弈红方要最大化分黑方要最小化分。最朴素的写法是两层递归一层取最大一层取最小。但这样写代码重复而且容易在奇数层偶数层搞混。我一般用负极大值Negamax写法每一层都取最大值但把子节点的分数取负。// 负极大值搜索depth 是剩余深度alpha/beta 是剪枝窗口 int negamax(int depth, int alpha, int beta, int side) { // 到达叶子节点返回局面评估 if (depth 0) return evaluate(side); Move moves[128]; int count 0; genAllMoves(side, moves, count); int best -INFINITY; for (int i 0; i count; i) { // 走一步 int captured makeMove(moves[i]); // 递归时换边分数取负 int score -negamax(depth - 1, -beta, -alpha, -side); // 撤销 unmakeMove(moves[i], captured); if (score best) best score; if (score alpha) alpha score; // Alpha-Beta 剪枝如果 alpha beta后面的不用看了 if (alpha beta) break; } return best; }这段代码里alpha是当前能保证的最低分beta是对手能保证的最高分。当alpha beta时说明这个分支对手不会让你走到直接break。参数上初始调用时alpha -INFINITYbeta INFINITYside是当前走子方。注意makeMove必须返回被吃掉的棋子否则unmakeMove没法恢复。我见过有人用全局变量存被吃子结果递归一深就覆盖了这是典型的翻车点。3.2 评估函数子力价值 位置价值搜索本身只是「算」评估函数才是「判断」。如果评估函数只算子力电脑会一直用兵换士因为兵和士的分值差不多。我一般用「子力价值 位置价值」两层。棋子基础分值过河兵加分中心马加分帅/将10000--车900--马400-50炮450-30士/仕200--相/象200--兵/卒100100-位置价值可以用一张简单的表比如马在中心格加 50 分在边线减 20 分。参数上车的分值不能太低否则电脑会用两个马换一个车兵过河后加分要明显否则兵永远缩在家里。我一般把evaluate写成side视角的分数红方正黑方负这样 Negamax 里直接取负就行。3.3 迭代加深与置换表让搜索「有记忆」固定深度搜索有个问题如果时间到了还没搜完你连一个着法都拿不到。迭代加深的做法是先从深度 1 开始搜搜完再搜深度 2直到时间用完。这样任何时刻都有一个可用的着法。置换表则是把搜过的局面存起来下次遇到同样局面直接查表。// 置换表条目 struct TTEntry { uint64_t key; // 局面哈希 int depth; // 搜索深度 int score; // 分数 int flag; // 精确/下界/上界 Move best; // 最佳着法 }; TTEntry tt[1 20]; // 100 万条目 // 查表 bool probeTT(uint64_t key, int depth, int score, Move best) { TTEntry e tt[key ((1 20) - 1)]; if (e.key ! key) return false; if (e.depth depth) { score e.score; best e.best; return true; } return false; }参数上置换表大小取 2 的幂用key mask做索引比取模快。flag用来区分「精确分数」「只保证下界」「只保证上界」因为 Alpha-Beta 剪枝后有些分数是不精确的。我一般用 Zobrist 哈希生成key每个棋子在每个位置对应一个随机数走子时异或一下就行。注意随机数要用 64 位32 位冲突率太高会出玄学 bug。4. 界面与工程配置VC 下把 MFC 和搜索线程接起来4.1 MFC 绘图用双缓冲避免闪烁VC 做界面最顺手的是 MFC。棋盘可以用CDC画线棋子可以用Ellipse加TextOut。但直接画会闪因为每次重绘都先擦背景。我一般用双缓冲先在内存 DC 里画好再一次性贴到屏幕。void CChessView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); // 内存 DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(bmp); // 先画背景 memDC.FillSolidRect(rect, RGB(240, 220, 180)); // 画棋盘线 drawBoard(memDC); // 画棋子 drawPieces(memDC); // 一次性贴到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); }逻辑上CreateCompatibleDC创建的内存 DC 和屏幕 DC 兼容CreateCompatibleBitmap创建同样大小的位图。参数上FillSolidRect的颜色可以改成木色RGB(205, 170, 125)。注意memDC和bmp要在函数结束时自动析构MFC 的CDC和CBitmap析构时会释放资源但如果你把bmp选进了memDC要先SelectObject恢复原来的位图否则会内存泄漏。4.2 搜索线程别让界面卡死如果你直接在OnLButtonDown里调用negamax界面会卡住因为搜索是计算密集型的。我一般开一个工作线程跑搜索主线程只负责画图。// 线程函数 UINT SearchThread(LPVOID pParam) { CChessView* pView (CChessView*)pParam; // 迭代加深每搜完一层就更新界面 for (int depth 1; depth MAX_DEPTH; depth) { int score negamax(depth, -INFINITY, INFINITY, pView-m_side); // 把最佳着法存到成员变量 pView-m_bestMove getBestMove(); // 通知界面重绘 pView-Invalidate(); // 检查是否超时 if (timeUp()) break; } return 0; } // 启动线程 AfxBeginThread(SearchThread, this);参数上MAX_DEPTH一般设 6 到 8再深就要等很久。timeUp()可以用GetTickCount()判断比如限制 3 秒。注意线程里不能直接调用Invalidate因为 MFC 的窗口对象不是线程安全的正确做法是PostMessage一个自定义消息让主线程去重绘。我见过有人直接在子线程里pDC-TextOut结果程序随机崩溃这就是典型的踩坑。4.3 工程配置VC 运行库和字符集用 VS2017 或更高版本打开旧工程最常见的报错是LNK2019: 无法解析的外部符号。原因通常是运行库不匹配工程属性里「C/C → 代码生成 → 运行库」要选多线程调试 (/MTd)或多线程 (/MT)而不是DLL (/MD)。另外字符集要选「使用多字节字符集」因为很多老代码用char*而不是wchar_t*。如果你用的是microsoft visual c redistributable已经装好的机器选/MD也能跑但换一台机器就可能缺 DLL。我一般统一用/MT把运行库静态链接进去省得用户装 redistributable。5. 避坑与排查那些让程序「看起来能跑但结果不对」的细节5.1 将帅照面没判电脑会走出「自杀」着法现象电脑走了一步棋下一步你直接把它的将吃了它却不认输。原因走法生成时没有过滤「走后将帅照面」的着法。解决在makeMove之后加一个isKingFacing()判断如果两个将帅在同一列且中间没有棋子这个着法非法直接跳过。5.2 搜索深度一到 5 就卡死CPU 占满现象深度 4 秒出深度 5 要等半分钟。原因没有剪枝或者剪枝写错了。解决检查alpha beta的break是不是写在了makeMove之前或者alpha和beta传参时符号搞反了。我一般会在剪枝处加一个计数器看剪枝率有没有到 70% 以上低于这个数说明剪枝没生效。5.3 置换表命中率低反而变慢现象加了置换表后搜索节点数没降时间还多了。原因哈希冲突太多或者depth判断太严格。解决把置换表大小从116加到120并且只在e.depth depth时才用表里的分数。如果还是慢检查 Zobrist 随机数是不是每次启动都重新生成应该用固定种子。5.4 界面重绘时棋子位置偏移现象窗口缩放后棋子画到了格子外面。原因棋盘坐标和屏幕坐标没有做比例映射。解决在OnSize里记录当前窗口宽高画棋子时用x * cellWidth / 9这样的比例算屏幕坐标而不是写死像素值。5.5 多线程下随机崩溃错误码 c0000005现象搜索线程跑一会儿就access violation c0000005。原因子线程访问了主线程的 MFC 对象比如直接调pDC。解决所有界面更新通过PostMessage发到主线程子线程只操作纯数据。如果你用c#调用c的 DLL也要注意回调函数里不能碰界面。6. 让棋力再上一档历史启发与空着裁剪的实操参数如果你已经把上面的搜索跑通想让电脑从「会算」变成「算得巧」可以加两个东西历史启发和空着裁剪。历史启发是给每个走法记一个分数每次剪枝时把导致剪枝的走法加分下次排序时优先搜。空着裁剪是如果当前方不走棋对手都赢不了那当前方肯定优势很大可以直接返回一个高分。// 历史启发表from * 256 to 作为索引 int history[256][256]; // 在剪枝处加分 if (alpha beta) { history[move.from][move.to] depth * depth; break; } // 走法排序时按历史分降序 sort(moves, moves count, [](const Move a, const Move b) { return history[a.from][a.to] history[b.from][b.to]; });参数上depth * depth是让浅层的剪枝加分少一点深层的加分多一点。空着裁剪的触发条件是当前方不是被将军状态且剩余深度大于 2且当前方子力大于对手。我一般把空着裁剪的分数设成beta - 1这样如果对手真的赢不了搜索会直接返回这个分数。注意空着裁剪在残局容易出错因为残局子力少空着可能真的被将死。我一般只在深度大于 4 且子力大于 10 分时启用。最后说一个我自己的习惯每次改完搜索参数不要只看它赢不赢而是看它「输的时候输在哪」。我会把搜索日志打出来看它是不是在某个深度突然选了坏棋。如果深度 3 选对、深度 4 选错那多半是评估函数在某个局面下给错了分。调棋力这件事没有后悔药只能一遍遍跑、一遍遍看日志。希望帮到你。本文还有配套的精品资源点击获取
返回列表