恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
VC++中国象棋人机对弈源码实战:从走法生成到Alpha-Beta剪枝AI引擎
首页
资讯中心
/
VC++中国象棋人机对弈源码实战:从走法生成到Alpha-Beta剪枝AI引擎
VC++中国象棋人机对弈源码实战:从走法生成到Alpha-Beta剪枝AI引擎
发布时间:2026/10/5 5:35:32
简介一款基于VC/MFC开发的中国象棋人机对弈源码工程适合C游戏开发学习者、MFC界面设计者和象棋AI进阶者参考。工程完整覆盖棋盘绘制、棋子摆放、合法走法生成、规则判定与胜负判断并实现鼠标点击拖拽等事件驱动交互帮助读者理解棋类程序的整体架构。压缩包共88个文件以26个头文件和24个C源文件为主体同时含界面图标、位图、VC6.0工程配置及说明/调试文档整体仅214KB结构清晰便于直接编译查看。目前已有1724人学习下载。AI对战部分集成Alpha-Beta、NegaScout、MTD(f)、PVS等多种搜索引擎并配套置换表、历史启发、渴望搜索、局面评估等优化模块通过阅读源码和附带的调试记录可直观对比各类算法的搜索效率与棋力表现也便于在此基础上扩展新策略完成二次开发。1. VC 中国象棋人机对弈这个源码方向到底能解决什么“VC 中国象棋人机对弈程序源代码”这个标题下流传的工程绝大多数是 MFC/Win32 桌面程序棋盘数据、走法生成、搜索评估和界面交互四层堆叠在一起。有人拿它学 C 的类与指针有人想改一套自己的棋力引擎也有人只是想要一个能下棋的练手项目。先说一个反直觉的结论这类项目里最卡人的往往不是“AI 有多聪明”而是走法合不合法、谁先走、是否送将这类规则细节。适合谁——已经学过 C 基础语法想通过一个完整项目把数组、递归、消息循环串起来的人以及想做象棋 AI 但不想从数学论文开始的落地型开发者。接下来按四层拆开讲每一步都给可抄的代码和参数。2. 棋盘表示与走法生成90 个交叉点如何装进 int 数组中国象棋棋盘是 10 行 9 列共 90 个交叉点。程序的一切逻辑都以这 90 个点为基础。最常见的方案是一维数组索引从 0 到 89行的跨度是 9。这么做的好处是后续做局面哈希、走法记录时都只需要一个整数索引而且数组遍历比二维数组少一层指针寻址——虽然现代编译器会优化掉这部分差异但代码写起来也更直接。2.1 棋盘布局一维数组还是二维数组各自边界在哪我看过的象棋源码里约有一半用int board[90]另一半用int board[10][9]。两者选一我一般选一维因为后续大量代码要处理“这一步走完把棋盘状态存下来回溯局面”一维数组直接memcpy很方便和走法生成时的坐标换算也不冲突。坐标换算核心是两个公式行 idx / 9列 idx % 9反过来 idx 行 * 9 列。棋子编码是第二个要定的基础约定。我惯用的编码是0 表示空点红方 1~7 分别表示帅、仕、相、车、马、炮、兵黑方用 11~17 表示将、士、象、车、马、炮、卒。这样判断红黑非常快piece 0 piece 10就是红方piece 10就是黑方判断具体兵种用piece % 10就行。这种编码把“阵营”和“兵种”拆在两位上比用正负号表示阵营更不容易踩负数取模的坑。// 棋盘一行 9 列共 10 行 // board[row * 9 col] 存棋子编码 int board[90]; // 把行、列转成一维索引 int pos(int row, int col) { return row * 9 col; } // 判断是否红方棋子编码 1~7 是红方 bool isRed(int piece) { return piece 0 piece 10; } // 判断两枚棋子是否同一阵营避免吃自己人 bool sameSide(int piece1, int piece2) { if (piece1 0 || piece2 0) return false; return (piece1 10) (piece2 10); // 同为红或同为黑 }这段代码里的pos函数会被所有走法生成、画棋盘、鼠标点击转换反复调用。编码方案不要中途改否则后面评估函数、哈希表全要跟着动。我最初在这上面吃过亏一开始用1~16连续编码判断红黑要靠查表代码里到处都是uside这种临时变量后来改成1~7 / 11~17才清爽很多。2.2 走法生成车、马、炮的移动规则怎么用代码描述走法生成的本质是把规则翻译成“从某个起点能到哪些终点”的函数。车和炮是滑动走子区别在于炮隔子吃、车不隔。马走日字但要检查八个方向的蹩马腿。先把车的滑动走法写成函数// 生成车在 (from) 这个点的所有合法终点存入 moves 数组 // 参数说明 // from - 起点索引取值 0~89 // moves - 输出数组存放所有终点索引 void generateRookMoves(int from, int moves[], int moveCount) { int r from / 9; int c from % 9; moveCount 0; // 四个方向上、下、左、右对应的行列增量 int dirR[4] {-1, 1, 0, 0}; int dirC[4] {0, 0, -1, 1}; for (int d 0; d 4; d) { int nr r dirR[d]; int nc c dirC[d]; // 沿着这个方向一直走直到越界或撞子 while (nr 0 nr 10 nc 0 nc 9) { int to nr * 9 nc; if (board[to] 0) { // 空格可以走 moves[moveCount] to; } else { // 有子己方不能吃敌方可以吃 if (!sameSide(board[from], board[to])) { moves[moveCount] to; } break; // 不管吃不吃都得停车不能跳过棋子 } nr dirR[d]; nc dirC[d]; } } }这里是车但同一个循环结构稍加修改就是炮炮在不越子的情况下只能走空格越过一个棋子后才能吃距离最近的对方棋子。炮的扫描需要记录是否已经翻过山用int jumped 0遇到第一个非空子时jumped并继续遇到第二个非空子时只能停在那个点上吃子。马的代码则是一张方向表加一张蹩腿表// 马的八个落点偏移 int horseR[8] {-2, -2, -1, -1, 1, 1, 2, 2}; int horseC[8] {-1, 1, -2, 2, -2, 2, -1, 1}; // 每个落点对应的蹩马腿偏移 int legR[8] {-1, -1, 0, 0, 0, 0, 1, 1}; int legC[8] { 0, 0, -1, 1, -1, 1, 0, 0}; void generateHorseMoves(int from, int moves[], int moveCount) { int r from / 9; int c from % 9; moveCount 0; for (int i 0; i 8; i) { int nr r horseR[i]; int nc c horseC[i]; // 落点必须在棋盘内 if (nr 0 || nr 10 || nc 0 || nc 9) continue; // 蹩腿点不能越界且必然为空才能跳 int lr r legR[i]; int lc c legC[i]; if (board[lr * 9 lc] ! 0) continue; int to nr * 9 nc; if (board[to] 0 || !sameSide(board[from], board[to])) { moves[moveCount] to; } } }这里legR/legC的正确性直接决定“马能不能被蹩住”。方向表和蹩腿表必须一一对应我调试马腿时吃过不少闷亏表里某一项填错结果是马偶尔能斜穿棋子的“缝隙”棋盘上表现成马越过兵线——排查了半小时才定位到是静态数组写错一位。另一个容易漏的边界是蹩腿点的棋盘范围如果马在边线某些蹩腿点会落在棋盘外必须先判断越界再读取棋盘否则就是数组负索引。2.3 合法性校验先走子再回滚比先生成合法走法更省事虽然名字叫“走法生成”但实际工程里我更倾向于先生成“伪走法”再通过试走来判断是否合法。道理很简单中国象棋的很多禁止规则是全局性的——当前方被将军时只有应将走法合法走完一步后不能送将双方将帅不能直接照面。这些条件用局部规则很难一次算清楚而“先走一步再判断走完后自己的帅是否处于被将军状态”是统一而可靠的办法。实现上就是makeMove和unmakeMove一对函数。makeMove保存被吃棋子和终点原状态更新棋盘unmakeMove做逆操作。合法性判断函数检查走完后本方将帅的位置是否会被对方任一棋子攻击到被攻击就回滚并丢弃这个走法。// 保存一步棋所需信息 struct MoveRecord { int from; // 起点索引 int to; // 终点索引 int captured; // 被吃掉的棋子编码0 表示没吃子 int moved; // 移动的棋子编码 }; void makeMove(const MoveRecord mv) { board[mv.to] board[mv.from]; board[mv.from] 0; } void unmakeMove(const MoveRecord mv) { board[mv.from] mv.moved; board[mv.to] mv.captured; }在实际走法生成阶段moved可以在调用前从board[from]补上。判断一个走法是否合法只需三步记录现场makeMove然后isKingInCheck(side)看自己的帅是否被将。若被将则说明这一步送将了回滚丢弃否则真正加入走法列表。一开始我也嫌这种“先走再退”效率低后来发现象棋分支规模并不大而这类校验逻辑写法统一、不容易漏规则运行速度完全够用。3. 人机对弈引擎从 Alpha-Beta 剪枝到评估函数的三层递进一旦有了走法生成“AI 思考”其实就是搜索模拟若干步之后选一个对自己最有利的走法。初学者最容易在这里陷入“评估函数很玄”的误区——实际上先跑通一个会下棋的引擎只需要三步评估函数、Alpha-Beta 搜索、开局库。第一步决定棋力下限第二步决定深度第三步决定开局不会随手丢子。3.1 评估函数子力分值、位置价值与进兵加成评估函数返回一个 int正数代表红方有利负数代表黑方有利。最简单的可靠版本是“子力 位置值”。子力表直接定义成数组下面这张表是我常用的基础分棋子帅/将仕/士相/象车马炮兵/卒子力分10000200200900450450150这个表里“帅/将”给 10000 不是为了吃子而是让搜索在任何情况下都不会主动弃帅。车 900、炮 450、马 450 是常见开局期比例残局时炮的价值会略低于马但初版不必做这么细致先把引擎跑起来再调。位置值最少要处理“兵过河”和“马的位置”。我常给红方兵加一张 5 行 9 列的位置加分表兵越靠近对方九宫分越高过河之前分低过河后每进一步加 10~20 分。黑方棋子直接按行号做一次镜像映射把黑兵的位置表倒过来用就能让评估函数同时服务双方。// 评估当前局面返回红方视角的分数 int evaluate() { int score 0; for (int i 0; i 90; i) { int piece board[i]; if (piece 0) continue; int type piece % 10; // 兵种1帅 2仕 3相 4车 5马 6炮 7兵 int val baseValue[type]; // 子力分 // 简单位置分兵过河每进一步 10 int r i / 9; if (type 7 r 5 isRed(piece)) { val (4 - r) * 15; // 红方兵越靠近对方九宫分越高 } if (type 7 r 5 !isRed(piece)) { val (r - 5) * 15; // 黑方卒同理镜像 } score isRed(piece) ? val : -val; } return score; }参数说明baseValue数组下标对应棋子类型piece % 10取出兵种。红方棋子加正分黑方取负分。这种评估只做整数加法和少量比较速度极快一个局面在微秒级就能算完所以后续搜索才能多展开几层。不建议初版就加入“棋子灵活度”“威胁区域”这类动态因子慢慢调棋力时再加不迟。3.2 Alpha-Beta 剪枝从极小极大到剪枝的落地评估函数把每个叶子局面变成数字后理论上用极小极大搜索就能选走法红方要最大化黑方要最小化。但纯极小极大在象棋里展开到 4 层就已经是千万级别节点必须加 Alpha-Beta 剪枝。剪枝的核心一旦发现某个分支的后续着法已经让另一方无法接受就不再继续深入这个分支。我习惯用负极大Negamax形式。负极大把“红方最大化、黑方最小化”统一成“当前走子方总是最大化”代码非常简洁// Alpha-Beta 搜索返回当前走子方的最佳评估分 // 参数: // depth - 剩余搜索层数根节点传 6 表示看六步 // alpha - 当前方能接受的最低分初始 -INF // beta - 对方能接受的最高分初始 INF // side - 当前走子方1 为红方-1 为黑方 int alphaBeta(int depth, int alpha, int beta, int side) { if (depth 0) { // 到达叶子红方视角的分数乘 side 转成当前方视角 return side 1 ? evaluate() : -evaluate(); } int moves[128]; int count 0; generateAllMoves(side, moves, count); // 没有走法被将死或困毙 if (count 0) { return side 1 ? -100000 (MAX_DEPTH - depth) : 100000 - (MAX_DEPTH - depth); } orderMoves(moves, count); // 走法排序重要 int best -INF; for (int i 0; i count; i) { MoveRecord mv; mv.from moves[i] 8; // 起点 mv.to moves[i] 0xFF; // 终点 mv.captured board[mv.to]; // 记录吃子 mv.moved board[mv.from]; // 记录移动的棋子 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; }三个参数必须理解透alpha是当前方已知的最优下界beta是对方能接受的最坏上界。负极大里每次递归都把窗口翻转取负所以当score beta时剪枝成立。side是 1 或 -1注意不要让红黑判断出现两套代码统一用“当前方视角最大化”这一步能省很多 bug。走法排序为什么放在剪枝前面因为 alpha-beta 的剪枝效率高度依赖“先尝试好走法”。排序越接近最优越早触发beta截断剪掉的节点越多。我的排序规则很简单吃子优先被吃棋子分高的排在前面。用 C 的sort或手写插入排序都行几百个节点的排序开销完全可接受。3.3 开局库与走法排序让引擎不再一开局就翻车搜索可以解决中残局但开局阶段棋子繁多、局面空旷纯搜索容易走出“先平炮再飞象”这类零散缓手。常见做法是引入开局库把若干开局局面哈希到一个表表里记录“这个局面下推荐走什么、权重多少”。很多流传的开局库会存成.obk等二进制格式但自己实现一个文本格式的足够用# 开局库格式局面哈希 走法 权重 # 走法用起点x终点表示例如 44x34 表示红炮从44走到34 0x6A3F2B1C012D34A8 44x34 100 0x6A3F2B1C012D34A8 44x14 80开局库的加载逻辑很简单把每行解析成uint64_t hash、int fromTo、int weight放进一个std::unordered_mapuint64_t, std::vectorBookMove。走棋时先查当前局面的哈希如果命中就直接从表里按权重随机挑一个走法不再进搜索。这样程序在前几回合的落子稳定且不太离谱玩家对局体验明显好很多。配合开局库还要做走法排序。我的orderMoves函数逻辑很直接先按“吃子价值”降序被吃的是车优先、兵最次再按“走到位置的价值”降序。这一步能让剪枝效果翻倍因为搜索总是先试最有前途的走法更容易触发beta截断。这里提一句开局库哈希要跟后续 Zobrist 哈希保持一致否则开局库和置换表会各自算一套局面键白白增加维护成本。4. 用 VC 搭出能落子的界面MFC 绘图与鼠标交互引擎再强没有界面也下不了棋。VC 环境下的落地做法通常有两种MFC 文档视图或者 Win32 对话框。MFC 的优势是消息映射和视图刷新一套现成代码量少。这里按 MFC 的 CView 类讲但关键代码段拿到 Win32 下也是同一套 GDI 思路。4.1 双缓冲绘制棋盘与棋子先画到内存 DC 再整体上屏界面第一坑是闪烁。直接在OnDraw里调用pDC-Rectangle、pDC-Ellipse画几十个棋子每移动一步重新画一遍屏幕会闪得像老式显示器。原因是逐次 GDI 输出被系统立刻呈现到客户区每一次绘制之间白底闪过。解决方法是双缓冲先建一个内存 DC把棋盘和棋子全部画进去最后用一次BitBlt把整块位图拷贝到窗口。void CChessView::OnDraw(CDC* pDC) { // 创建内存 DC 和位图W/H 是客户区尺寸 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, W, H); CBitmap* pOld memDC.SelectObject(bmp); // 画背景、棋盘线、河界文字 DrawBoard(memDC); // 画所有棋子最后画当前选中高亮 DrawPieces(memDC); // 一次性拷到屏幕 pDC-BitBlt(0, 0, W, H, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); bmp.DeleteObject(); memDC.DeleteDC(); }关键参数是棋盘的几何常量我一般把交叉点间距kCell设成 45 像素棋盘左上角交叉点放在(kMarginX, kMarginY) (30, 30)。第 row 行、第 col 列交叉点的像素坐标就是kMarginX col * kCell和kMarginY row * kCell。棋子是圆形绘制时以这个交叉点为中心半径留kCell * 0.45左右避免相邻棋子重叠。绘制棋子时注意用memDC.SetBkMode(TRANSPARENT)否则文字周围会有一块白底。棋子圆环用Ellipse内部写“车”“马”“炮”等汉字红方用红字黑圈黑方用黑字红圈。如果手头有棋子图片资源就直接DrawBitmap到交叉点对齐的矩形里效果比自绘文字好很多。4.2 鼠标选子、走子与悔棋消息映射与状态机MFC 处理鼠标只需要重写OnLButtonDown。但不要让每次点击都直接改棋盘要用一个简单的状态机没有选中棋子时点击只能选己方棋子已经选中棋子时点击目标位置要么执行走子要么取消选中。这个状态机用两个成员变量就能撑起来m_selected当前选中的起点索引-1 表示未选中和m_selectedMoves当前可选终点集合用于高亮。void CChessView::OnLButtonDown(UINT nFlags, CPoint point) { // 引擎思考期间禁止点击 if (m_thinking) return; int row (point.y - kMarginY kCell / 2) / kCell; int col (point.x - kMarginX kCell / 2) / kCell; if (row 0 || row 10 || col 0 || col 9) return; int idx row * 9 col; if (m_selected -1) { // 未选中只能选己方棋子 if (board[idx] ! 0 isRed(board[idx]) m_humanSide RED) { m_selected idx; GenerateMovesForPlayer(idx, m_selectedMoves); // 生成可选走法 } } else { // 已选中尝试走子 if (idx m_selected) { m_selected -1; // 取消选中 } else if (isValidMove(m_selected, idx)) { MoveRecord mv { m_selected, idx, board[idx], board[m_selected] }; makeMove(mv); m_history.push_back(mv); m_selected -1; Invalidate(); // 走完就轮到 AI StartAIThinking(); } else { m_selected -1; // 非法走法直接取消 } } Invalidate(); CView::OnLButtonDown(nFlags, point); }坐标换算里 kCell / 2是关键。point落在交叉点附近时直接用(point.y - kMarginY) / kCell会取到反方向的行号四舍五入可以显著降低误点概率。更稳妥的做法是先判断point是否落在以交叉点为中心的kCell * 0.8范围内再确认选择。另外m_humanSide用来支持“玩家执黑、AI 执红”的设定否则代码里写死红色是玩家换个命令行参数就要动一堆逻辑。悔棋功能维护一个std::vectorMoveRecord每次走子压栈悔棋时弹出最近两步玩家一步、AI 一步并依次unmakeMove。这里最容易出的问题是只弹了玩家的一步留下的 AI 走法让棋盘状态错乱所有情况都要成对处理。4.3 人机对弈主循环把界面、引擎和规则串起来主循环不一定要用whileMFC 里靠事件驱动就够了。用户走完一步流程是校验走法合法 → 更新棋盘 → 刷新界面 → 让引擎走一步。其中引擎搜索耗时可能几百毫秒到几秒直接放在 UI 线程会卡死窗口。常见做法是开一个工作线程执行搜索搜索完成后向主窗口PostMessage一个自定义消息在消息处理里更新棋盘。UINT AIMoveThreadProc(LPVOID param) { CChessView* view (CChessView*)param; // 拿到当前棋局副本避免和 UI 线程冲突 AIMoveResult result view-SearchBestMove(); // 搜索完成发消息回主线程 ::PostMessage(view-GetSafeHwnd(), WM_AI_MOVE_DONE, 0, (LPARAM)new AIMoveResult(result)); return 0; } void CChessView::StartAIThinking() { m_thinking true; AfxBeginThread(AIMoveThreadProc, this); } LRESULT CChessView::OnAIMoveDone(WPARAM wParam, LPARAM lParam) { AIMoveResult* result (AIMoveResult*)lParam; MoveRecord mv { result-from, result-to, board[result-to], board[result-from] }; makeMove(mv); m_history.push_back(mv); m_thinking false; Invalidate(); delete result; return 0; }SearchBestMove内部是调alphaBeta取最佳走法但要注意搜索期间不能直接读写界面类的board否则用户线程和搜索线程会互相踩内存。我的做法是搜索前把board拷贝到一个局部数组搜索全部基于局部数组进行搜索结束后只把结果发回主线程这样完全避开锁问题。PostMessage是异步的比SendMessage安全不会让工作线程卡在等待 UI 响应上。引擎思考期间m_thinking true会拦截鼠标点击同时界面可以显示一个“思考中…”的状态文字。这个布尔变量要在StartAIThinking开头置位、OnAIMoveDone里复位顺序错了就会出现“AI 还没下完玩家就能继续点棋”的错乱局面。5. 常见问题排查五个每次都能让程序翻车的坑这里把实际改代码中最常撞到的五个问题按“现象 → 原因 → 解决”写清楚。这些问题不是某一套源码独有的而是几乎所有 VC 象棋程序都会经历的血泪经验。5.1 鼠标点边缘位置棋子飞出去了现象点击棋盘边框附近区域棋子被放到了不存在的行或列甚至数组索引变成负数导致崩溃。原因坐标换算没有做边界过滤。point落在顶部边距或底部边距时(point.y - kMarginY) / kCell会算出超出 0~9 范围的行再去访问board就会越界。解决先判断point是否落在棋盘有效矩形内再换算行列换算时用带四舍五入的写法。顺序必须“先过滤再换算”如果先算行列再判断得到负数时已经来不及拦了。我后来会在OnLButtonDown入口处统一写一套ScreenToBoard函数返回bool表示是否命中有效交叉点命中才往下走。5.2 搜索到 5 层就像死机现象深度调到 5 或 6走一步棋要几十秒界面完全卡住玩家以为程序无响应。原因多数情况下不是 CPU 不行而是搜索树里没有有效剪枝。没有alpha/beta截断时纯极小极大到 5 层需要遍历数十万甚至上百万节点同时走法生成里的数组反复分配、评估函数里重复计算也会拖慢。解决确认用了负极大形式的剪枝走法排序优先吃大子评估函数保持整数加减。如果这些都做了还是慢把默认深度降到 4再按时间用迭代加深控制。我踩过一次很隐蔽的坑——排序函数里比较函数写成“吃子价值小的在前”不仅没帮到剪枝反而让搜索顺序接近最坏情况速度直接慢了一倍。5.3 走子后界面刷新白屏闪烁现象每次走子或悔棋窗口闪烁、棋子拖影甚至白屏一下再画出来。原因直接在OnDraw里向屏幕 DC 画图系统显示背景是白色的重绘时先清屏再画棋盘棋子人眼看到的就是闪烁。MFC 默认还会响应WM_ERASEBKGND二次重绘。解决双缓冲所有绘制先画到内存CDC在OnEraseBkgnd里直接return TRUE禁止背景刷新最后用BitBlt一次上屏。闪烁问题基本消失。还有一个细节不要在整个客户区Invalidate()改成InvalidateRect只刷新棋盘区域重绘面积小了闪烁也会进一步减轻。5.4 AI 思考时窗口无响应现象轮到电脑走棋时窗口变成“白框”拖窗口都卡点棋盘没反应甚至直接提示未响应。原因搜索函数在 UI 线程同步执行递归期间消息循环被阻塞。棋力低时几毫秒感觉不到深度一高立刻暴露。解决把搜索放到工作线程用AfxBeginThread或std::thread都行。搜索完成后通过PostMessage发回主线程更新界面思考期间用布尔标志拦截鼠标。注意工作线程里不要直接访问任何CWnd成员只操作棋盘的局部拷贝结果用消息或返回值传出来。这条是很多初学者会犯的错其实加上线程后不但界面流畅还能加“取消搜索”功能。5.5 VC 版本和运行库导致的编译/启动报错现象源码在别人机器上能编译自己换了 VS 版本后报一堆C4996或链接错误生成的可执行文件拿到别的电脑双击没反应提示缺少VCRUNTIME140.dll之类。原因不同 VC 工具集的运行库不通用老工程用 VC 6.0 或 VS2008 编译出的程序依赖对应版本的运行库新机器不一定自带。解决如果源码历史较老先在升级向导里把工程转成当前 VS 版本目标机器装对应的 Visual C Redistributable 是最直接的补丁。代码层面尽量用安全函数比如sprintf_s替代sprintf、strcpy_s替代strcpy既能过新编译器的安全检查也避免一堆C4996噪音。这里有个额外提醒不要把工程切到 x64 就完事如果源码里有内嵌汇编或依赖long字节数的代码64 位下很可能编译不过先以 Win32 平台跑通再考虑迁移。5.6 引擎走出“送将”这类非法棋现象引擎在残局里走出让帅直接暴露在对方炮口下的棋或者走出后己方将帅被对方下一步“隔着士都能吃”的局面。原因走法生成只做了局部规则判断没有做全局合法性校验。车、马、炮的走法各自合法但组合成一步棋之后可能让己方老将处于被将军状态。解决在搜索入口统一套“先makeMove再isKingInCheck”的合法性过滤非法走法直接不进入搜索。前面的makeMove/unmakeMove在这里就是为这个服务的。注意这条过滤必须对“被将军时必须应将”一起处理否则老将本身已经处于被将军状态时生成走法会漏掉“只能撑士或将帅移开”这类限定。把这套校验放在generateAllMoves末尾而不是每次查一方的所有走法都重跑一遍。6. 让引擎再强一点Zobrist 哈希、置换表与迭代加深棋力想再上一个台阶不需要急着上更深的搜索先把三个机制加上Zobrist 哈希、置换表、迭代加深。它们配合起来的效果是同一个局面不重复搜索、搜索深度可控、层与层之间能复用结果整体棋力提升非常明显。Zobrist 哈希的核心是给每个“棋子类型 位置”分配一个 64 位随机数棋盘所有非空点异或起来就是这个局面的哈希值。走一步棋时哈希值可以增量更新把起点棋子的随机数异或掉再把终点棋子的随机数异或进去。这个哈希最妙的地方在于增量更新特别快比每次重新遍历 90 个点算全局哈希快一个数量级。// Zobrist 表第一个维度是棋子编码 0~17第二个维度是位置 0~89 uint64_t zobrist[18][90]; // 初始化用随机数填表 void initZobrist() { std::mt19937_64 rng(0x9E3779B97F4A7C15ULL); for (int i 0; i 18; i) { for (int j 0; j 90; j) { zobrist[i][j] rng(); } } } // 走一步后增量更新哈希 void updateHash(uint64_t hash, const MoveRecord mv) { hash ^ zobrist[board[mv.from]][mv.from]; // 移除原位置的子 hash ^ zobrist[board[mv.to]][mv.to]; // 移除终点被吃的子 hash ^ zobrist[mv.moved][mv.to]; // 把移动的棋子放到终点 }注意move执行顺序先按当前棋盘状态移除起点和终点的旧子再把移动的棋子的随机数异或到新位置。这里的board[mv.from]和mv.moved必须是执行makeMove之前的值否则更新出来的哈希就和实际棋盘对不上了。置换表就是“局面哈希 → 搜索记录”的映射。搜索入口先查表如果这个局面之前搜到过且当时搜的深度不小于当前需求就直接复用分数。换来的收益很大残局里大量重复的局面不必重复计算搜索速度能提升数倍。它的实现用一个std::unordered_mapuint64_t, TransEntry就够了TransEntry里存深度、分数、剪枝类型精确值 / 上界 / 下界。写入时注意剪枝返回的alpha/beta分数并不精确需要区分标记读出时也要先做深度和类型的双重校验否则会拿到不可靠的分数。迭代加深则优雅得多从深度 1 开始逐层增加到目标深度每层搜索前记录时间超时自动返回上一层的结果。这样既控制了单步思考时间上一层搜出的最佳走法又能作为下一层的首选走法走法排序天然更优剪枝效率也更高。// 迭代加深主入口 int iterativeDeepening(int maxDepth, int timeLimitMs) { auto start std::chrono::steady_clock::now(); int bestMove 0; int lastScore 0; for (int d 1; d maxDepth; d) { int score searchRoot(d, bestMove); // 搜完第 d 层返回最优走法 lastScore score; if (std::chrono::steady_clock::now() - start std::chrono::milliseconds(timeLimitMs)) { break; // 超时不再加深 } } return bestMove; // 返回最近一层完整搜索出的走法 }我现在写棋类引擎一定先跑通最小测试再加深固定若干盘开局让两个 AI 互相下先验证走法生成不出现非法着法再调评估函数权重最后才加置换表和迭代加深。这样即使棋力不是最强程序的稳定性和可调试性始终保持在线。希望今天的这些参数和坑位能帮你也少走几步弯路。本文还有配套的精品资源点击获取