简介:基于Visual C++实现的中国象棋桌面程序,涵盖人机、人人、机机三种对弈模式,把象棋规则、MFC图形界面与搜索算法放进同一工程,适合C++学习者、游戏开发入门者及对AI算法感兴趣的人。压缩包为rar格式,共112个文件、1.84MB,其中核心是27个头文件和25个C++源文件;另有25个obj编译中间文件、10个ico图标、6个bmp棋盘与按钮图片、2个exe可执行程序,以及dsp/dsw/rc等工程配置和辅助文件,覆盖从源码、界面资源到可运行程序的完整链路。目前已有220人学习下载。阅读源码可理解棋局合法性判断、走法生成、Alpha-Beta剪枝与评估函数等关键实现;借助界面资源与事件响应代码,也能弄清MFC窗口程序的组织脉络;内含两个cm棋谱文件,方便复盘或验证对弈逻辑。整体规模紧凑,解压后可直接打开exe体验,也适合按模块逐步研读。
1. 中国象棋Visual C++程序:会下棋和会写棋是两回事
自己动手写一个“中国象棋Visual C++程序”,卡住大多数人的从来不是界面,而是棋规的翻译和搜索算法的正确性。很多新手第一天就能把棋盘画出来,第二周却还在跟“马不能蹩腿”这种细节较劲——棋盘只是皮,棋子规则和走法生成才是这个程序真正的主干。
下面按一条能走通的路径展开:先拆出界面层、逻辑层、搜索层,再把棋盘数据结构和着法生成写扎实,接着上Alpha-Beta搜索和评估函数,最后是你在Visual C++环境里一定会遇到的几个坑。适合有C/C++基础、想独立完成课程设计或想弄懂象棋AI原理的人。学完之后,你会得到一个能跟人下、能自己跟自己下、还能评估棋力的完整程序骨架。
2. 棋盘数据结构怎么定:数组布局、边界哨兵与MFC绘图绑定
2.1 三层架构:表现层、逻辑层、决策层不要混在一起
第一版程序很自然地会把所有逻辑塞进鼠标点击事件里:点一下棋盘,判断棋子是否能走,更新数组,重绘。这个写法在只有“车”和“兵”时还能凑合,一旦把所有棋子规则和AI加进来,代码很快就变成一团乱麻。你会在OnLButtonUp里看到几十个if嵌套,每个分支都在改棋盘数组,AI想复用这段逻辑又不敢动,最后只能复制粘贴一份。
常见做法是把这个程序拆成三层。表现层只管画棋盘、接收鼠标输入;逻辑层维护棋局状态,负责走法合法性检查和胜负判定;决策层是AI引擎,根据局面算最佳着法。三层之间的接口是“当前局面数组 + 一个Move结构体”。这样界面的改动不会波及AI,AI的调试也不用每次都点鼠标走棋——直接命令行调search函数,打印每一步的评估值,比对着界面猜快得多。
把“规则”和“画面”解耦还有一个隐藏好处:你可以先写一个纯控制台版本验证规则和AI,全部调通后再接MFC界面。早期卡住的问题,比如AI走出非法着法、搜索回溯后局面残留,在控制台环境里定位比在界面环境里快很多。等控制台版本稳定了,界面就是一个“调用逻辑层并显示结果”的壳。
2.2 中国象棋棋盘的两种数组:9x10还是12x11
棋盘数据结构的常见选择有两种:直接开int board[10][9],或者外层多围一圈哨兵的int board[12][11]。我建议直接用后者。虽然多占几十个字节,但换来的是边界处理统一:越界检查变成一次“这条路上有没有子挡住”,而不是每次先判断坐标是否在合法范围内。
棋子编码用正负号区分红黑,约定红方为正、黑方为负,绝对值表示兵种。这样判断“是不是自己人”只需要一次乘法:board[r][c] * side > 0,side为1表示红方,-1表示黑方。用int存棋盘还有一个好处:可以在调试器里直接看到每个格子的宏值,不用像char数组那样还要解码ASCII。
// 棋子编码:红方为正数,黑方为负数,0表示空格 #define EMPTY 0 #define RKING 1 #define RGUARD 2 // 仕 #define RELEPHANT 3 // 相 #define RHORSE 4 // 马 #define RCAR 5 // 车 #define RCANNON 6 // 炮 #define RPAWN 7 // 兵 #define BKING -1 #define BGUARD -2 #define BELEPHANT -3 #define BHORSE -4 #define BCAR -5 #define BCANNON -6 #define BPAWN -7 const int ROWS = 12; // 10行棋盘 + 上下各1行哨兵 const int COLS = 11; // 9列棋盘 + 左右各1列哨兵 int board[ROWS][COLS];逻辑说明:ROWS和COLS把实际棋盘的范围放到索引(1,1)到(10,9),行0、行11、列0、列10都是哨兵。走法生成时,只要一格的值是-1就说明已经出界,不需要再写if (r < 0 || r > 9)这类判断。参数说明:棋盘用int而不是char,虽然每个格子多占3字节,但换来的是setTextColor时直接判断正负,搜索时乘side判断阵营,代码更直白。
初始化棋盘时,先把整个数组清零,再把最外圈标记为-1,最后按开局摆子:
void initBoard() { for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) board[r][c] = (r == 0 || r == ROWS-1 || c == 0 || c == COLS-1) ? -1 : 0; // 红方底线在行10,列1~9 board[10][1] = RCAR; board[10][2] = RHORSE; board[10][3] = RELEPHANT; board[10][4] = RGUARD; board[10][5] = RKING; board[10][6] = RGUARD; board[10][7] = RELEPHANT; board[10][8] = RHORSE; board[10][9] = RCAR; board[8][2] = RCANNON; board[8][8] = RCANNON; // 红炮 board[7][1] = RPAWN; board[7][3] = RPAWN; board[7][5] = RPAWN; board[7][7] = RPAWN; board[7][9] = RPAWN; // 红兵 // 黑方底线在行1,列1~9 board[1][1] = BCAR; board[1][2] = BHORSE; board[1][3] = BELEPHANT; board[1][4] = BGUARD; board[1][5] = BKING; board[1][6] = BGUARD; board[1][7] = BELEPHANT; board[1][8] = BHORSE; board[1][9] = BCAR; board[3][2] = BCANNON; board[3][8] = BCANNON; // 黑炮 board[4][1] = BPAWN; board[4][3] = BPAWN; board[4][5] = BPAWN; board[4][7] = BPAWN; board[4][9] = BPAWN; // 黑卒 }逻辑说明:这里把实际棋盘的行列映射到索引1..10和1..9。业务代码里所有走法都基于“行1是最上方黑方底线、行10是最下方红方底线”这一约定。参数说明:哨兵值-1既是不可走的标记,也是棋盘数组越界的天然防线,代价是所有遍历都要从行1、列1开始。如果以后想加“上下颠倒”的视角切换,只需要改这一层的行列映射,AI和画图都不用动。
2.3 用MFC把棋盘画出来:OnDraw与坐标换算
在MFC单文档框架里,棋盘绘制通常在视图类的OnDraw里完成。关键在于坐标换算:棋盘数据是行和列,屏幕坐标是像素,中间通过一个格子边长CELL来换算。交叉点坐标是OX+(col-1)*CELL、OY+(row-1)*CELL,其中OX、OY是棋盘左上角的留白。
void CChessView::DrawBoard(CDC* pDC) { const int OX = 40, OY = 40, CELL = 50; CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* oldPen = pDC->SelectObject(&pen); // 10条横线,从行1到行10 for (int r = 1; r <= 10; r++) { pDC->MoveTo(OX, OY + (r - 1) * CELL); pDC->LineTo(OX + 8 * CELL, OY + (r - 1) * CELL); } // 9条竖线,河界处断开 for (int c = 1; c <= 9; c++) { pDC->MoveTo(OX + (c - 1) * CELL, OY); pDC->LineTo(OX + (c - 1) * CELL, OY + 4 * CELL); pDC->MoveTo(OX + (c - 1) * CELL, OY + 5 * CELL); pDC->LineTo(OX + (c - 1) * CELL, OY + 9 * CELL); } // 画棋子 for (int r = 1; r <= 10; r++) { for (int c = 1; c <= 9; c++) { int piece = board[r][c]; if (piece == EMPTY) continue; CString name = PieceToName(piece); int x = OX + (c - 1) * CELL; int y = OY + (r - 1) * CELL; pDC->Ellipse(x - CELL / 2, y - CELL / 2, x + CELL / 2, y + CELL / 2); pDC->SetTextColor(piece > 0 ? RGB(255, 0, 0) : RGB(0, 0, 0)); pDC->TextOut(x - 12, y - 8, name); } } pDC->SelectObject(oldPen); }逻辑说明:横线循环10次是因为中国象棋棋盘有10条横线;竖线分两段画,是因为河界中间没有竖线连接,上半段画到第5行,下半段从第6行开始画。棋子画在交叉点上,所以Ellipse的中心点就是交叉点坐标。参数说明:CELL是相邻交叉点的像素距离,这个值决定整个棋盘在窗口里的大小,你可以把OX、OY、CELL提成成员变量,后续鼠标点击时用同一套换算反算行列,避免两处坐标公式不一致。
鼠标点击的处理也是同一套换算的反向操作。col = (point.x - OX + CELL/2) / CELL + 1,先加半个格子宽度是为了让鼠标落在交叉点附近时都能命中。换算出的行列先检查范围,再交给逻辑层的走法判定,不要在视图类里直接改board数组。
3. 着法生成的C++落地:把车马炮兵仕相帅的规则翻译成代码
3.1 Move结构体:一次走法需要记录哪些信息
着法生成是整个程序的地基,AI搜索、界面校验、悔棋功能都依赖它。一个Move至少要记录起点、终点、以及“吃到什么子”,最后一项是为了在搜索回溯时能还原被吃的棋子。如果你把吃掉的子存进Move里,makeMove和unmakeMove就能严格配对,中间不需要额外的局面快照。
struct Move { int fromRow, fromCol; // 起点 int toRow, toCol; // 终点 int captured; // 被吃掉的棋子,0表示没吃 int score; // 走法排序用,AI搜索时会用到 Move(int fr, int fc, int tr, int tc, int cap) : fromRow(fr), fromCol(fc), toRow(tr), toCol(tc), captured(cap), score(0) {} };逻辑说明:captured字段在生成着法时填入目标格子的棋子值,在执行走法时先保存,回溯时把它写回原目标格。score字段在着法生成阶段先置0,AI搜索开始前根据“吃子价值差”或“历史启发”填充,用于走法排序。参数说明:Move是一个纯数据类型,不持有棋盘指针,这样着法列表可以跨函数传递,也方便在调试时打印。
3.2 生成红车的着法:一份可以直接抄的C++函数
车是最适合当范例的棋子,因为它走法最直观:四个方向直线扫,空位可以走,遇到第一个棋子时,如果是对方就吃,如果是己方就停。边界由哨兵值-1兜住,代码里不需要额外判断坐标是否越界。
#include <vector> using namespace std; // side = 1 红方,-1 黑方;moves 是输出参数 void generateCarMoves(int row, int col, int side, vector<Move>& moves) { // 四个方向:上、下、左、右 int dr[4] = {-1, 1, 0, 0}; int dc[4] = {0, 0, -1, 1}; for (int i = 0; i < 4; i++) { int nr = row + dr[i]; int nc = col + dc[i]; // 没撞到边界哨兵(-1) while (board[nr][nc] != -1) { if (board[nr][nc] == 0) { // 空位,可以走 moves.push_back(Move(row, col, nr, nc, 0)); } else { // 有棋子:对方可吃,己方不能走 if (board[nr][nc] * side < 0) { moves.push_back(Move(row, col, nr, nc, board[nr][nc])); } break; // 不论吃不吃,车被挡住,停止继续扫 } nr += dr[i]; nc += dc[i]; } } }逻辑说明:while循环沿一个方向推进,每到一个新格子先判断是不是哨兵。空位加入着法列表,遇到棋子时判断能不能吃,然后break,因为车不能跳过棋子。参数说明:side在这里只用于阵营判断,如果你在别处统一用isSameSide(a, b)函数,这里可以改成那种更易读的写法。注意moves是引用传递,调用方自己负责清空或累积,不要每次都新建vector。
车能跑通,炮就很接近了:直线扫描的循环结构完全一样,唯一区别是炮的“吃子”需要隔一个炮架。你可以在炮的生成函数里加一个bool hasCannonMount标志,遇到第一个子时空走、标记count为1,遇到第二个子时判断是否可吃。这个逻辑看着简单,但很容易在“两个子之间连续扫描”时写错下标,建议先用固定残局验证。
3.3 特殊规则统一收口:马腿、象眼、将帅照面与炮架
中国象棋的每类棋子都有自己的脾气,新手最容易漏的是马腿和象眼,其次是炮架,最后是将帅照面。先把规则表列出来,写代码时就不用来回翻棋书:
| 棋子 | 走法 | 特殊限制 |
|---|---|---|
| 帅/将 | 九宫内横竖一步 | 双方将帅不得在同一列直面相见 |
| 仕/士 | 九宫内斜走一步 | 不能出九宫 |
| 相/象 | 田字对角 | 不能过河,象眼被堵不能走 |
| 马 | 日字对角 | 马腿位置有子不能跳 |
| 车 | 横竖任意格 | 路径被堵不能走 |
| 炮 | 横竖任意格 | 吃子必须隔一个炮架 |
| 兵/卒 | 过河前只能前进一步 | 过河后可横走或前进,不能后退 |
马的规则最典型,值得单独写一个函数。判断马腿的方法是:如果马的横向位移是2格,马腿就在起点和终点中间的那列相同行;如果纵向位移是2格,马腿就在中间那行的相同列。
bool isLegalHorseMove(int fromR, int fromC, int toR, int toC, int side) { int dr = toR - fromR; int dc = toC - fromC; // 马走日:|dr|和|dc|的组合必须是(1,2)或(2,1) if (!((abs(dr) == 1 && abs(dc) == 2) || (abs(dr) == 2 && abs(dc) == 1))) return false; // 找马腿位置 int legR = fromR, legC = fromC; if (abs(dr) == 2) legR = fromR + dr / 2; // 纵向走两步,腿在中间行 else if (abs(dc) == 2) legC = fromC + dc / 2; // 横向走两步,腿在中间列 // 马腿被堵,不能跳 if (board[legR][legC] != 0) return false; // 目标格不能是自己人 if (board[toR][toC] * side > 0) return false; return true; }逻辑说明:马腿位置和终点是两回事,很多翻车现场就是把腿位置算错成终点旁边那格。这里先判断是否满足“日”字形状,再定位腿,最后检查腿和终点。参数说明:这个函数只做合法性校验,不生成着法,所以调用方需要先保证终点在棋盘范围内,12x11的哨兵数组已经帮你挡了一道。
将帅照面是另一类容易漏的规则:红帅在(10,5),黑将在(1,5),中间没有任何棋子时,红帅不能走到(10,4)或(10,6)吗?其实任何一方都不能走出让两将同列直面且中间无子的局面,因为象棋里“将帅不能照面”是一条独立的禁着,AI尤其容易在搜索时走这种棋,因为搜索树里根本没人拦它。这个检查建议放一个独立函数,在makeMove之前调用,而不是散落在各棋子函数里:
// 检查当前棋盘上双方将帅是否照面,若是则该局面本身就非法 bool isKingFace() { int kingR = -1, kingRow = -1, kingCol = -1; for (int r = 1; r <= 10; r++) { for (int c = 1; c <= 9; c++) { if (abs(board[r][c]) == RKING) { if (kingR == -1) { kingR = r; kingRow = r; kingCol = c; } else { /* 找到两个将,若同列且中间无子则非法 */ } } } } // 完整实现略,核心是检查两将同列时中间有没有子 return false; }逻辑说明:这个函数在搜索的“合法性检查”阶段调用,只要发现将帅照面就直接剪掉这个走法。你还得注意一个细节:照面检查对“送吃”和“吃掉将军”两种情况都生效,所以AI不会傻到主动走到照面位置。第5章会专门讲这个坑的实际表现。
4. AI核心:评估函数、Alpha-Beta搜索与3个必调参数
4.1 评估函数:先给每个棋子定价
AI不知道“车厉害”还是“马厉害”,它只能靠一个分数来判断局面好坏。所以评估函数的第一步是给棋子定基础价值。这个表是几十年棋手经验的浓缩,虽然不精确,但足以让AI避开无谓的兑子:
| 棋子 | 基础分 | 说明 |
|---|---|---|
| 帅/将 | 10000 | 不能吃,价值必须是最大 |
| 车 | 900 | 真正的进攻主力 |
| 马 | 450 | 中局价值高,残局易被憋腿 |
| 炮 | 450 | 开局价值略高于马,残局略低 |
| 相/象 | 200 | 防守子,不过河 |
| 仕/士 | 200 | 防守子,不出九宫 |
| 兵/卒 | 100 | 过河后可以再加50 |
评估函数最简单的实现是把所有己方棋子总分减对方棋子总分,红方返回正分、黑方返回负分。再往前走一步,可以加位置加成:中兵过河、马跳至七路河口、车占肋道,都是值得给个20~30分的位置加分。
// 返回当前局面下红方视角的评估分,正数红优,负数黑优 int evaluate() { int score = 0; for (int r = 1; r <= 10; r++) { for (int c = 1; c <= 9; c++) { int p = board[r][c]; if (p == 0 || p == -1) continue; int base = 0; switch (abs(p)) { case RKING: base = 10000; break; case RCAR: base = 900; break; case RHORSE: base = 450; break; case RCANNON: base = 450; break; case RELEPHANT: base = 200; break; case RGUARD: base = 200; break; case RPAWN: base = 100; break; } // 兵过河加分:红兵行<=7,黑卒行>=4 if (abs(p) == RPAWN) { if (p > 0 && r <= 7) base += 50; if (p < 0 && r >= 4) base += 50; } score += (p > 0) ? base : -base; } } return score; }逻辑说明:评估函数写得越细致,AI棋力提升越明显,但第一次做不要贪。先让分数能反映“子力对比+兵过河”这两个事实,后面再慢慢加位置表。参数说明:基准分是全局常量,你想调棋风就把车的900改成880、马的450改成480,AI会更愿意保留马而弃车,这个在调试时很好用。
4.2 让AI想几步:Alpha-Beta搜索的递归实现
搜索的目标是:假设对方也会用同样的搜索逻辑,找出当前局面下最终能让自己得分最高的那一步。最朴素的实现是Minimax,红方取最大值、黑方取最小值。Alpha-Beta剪枝在Minimax基础上加了一个窗口[alpha, beta],一旦某个分支的分数超出窗口,就可以整支剪掉,不再深入。这个剪枝不是玄学,它保证结果和完整Minimax一样,只是少了大量无用的计算。
const int INF = 100000000; // side = 1 红方,-1 黑方;depth 是剩余搜索深度 int alphaBeta(int depth, int alpha, int beta, int side) { if (depth == 0) { return evaluate(); } vector<Move> moves; generateAllMoves(side, moves); // 走法排序:吃大子优先,排序越好剪枝越狠 sort(moves.begin(), moves.end(), [](const Move& a, const Move& b) { return a.captured > b.captured; }); int best = -INF; for (const Move& mv : moves) { // 如果这一步把自家将帅送走,直接跳过 if (!isLegalAfterMove(mv, side)) continue; 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; }逻辑说明:负极大值写法让红黑双方共用同一个函数,每层递归都把视角翻转一次,返回值永远代表“当前走棋方”的分数。makeMove和unmakeMove必须严格配对,否则搜索树会越走越乱,这是新手最容易出错的点。参数说明:depth从外部传入,比如depth=4表示AI想四步(红、黑、红、黑)。alpha和beta的初始值是-INF和INF,越窄的窗口剪枝越多,但窗口开错会导致结果不准,所以第一版先不要自己调窗口。
4.3 走法排序与搜索深度:剪枝效率的胜负手
Alpha-Beta剪枝的效率严重依赖走法排序。如果每次都是最差走法先搜,剪枝几乎不发生,深度4层的搜索可能卡到十几秒;如果最佳着法先被搜到,同样的深度可能只需几十毫秒。所以排序是必调的,起步就按“吃子价值降序”:吃车的走法排前面,吃兵、吃卒的排后面。
| 参数 | 作用 | 推荐起步值 |
|---|---|---|
| depth | 搜索深度,4层是低配机器的安全值 | 4 |
| 走法排序 | 决定剪枝效率,影响一个数量级 | 吃子优先 |
| 搜索时限 | 防止UI线程卡死,超时返回当前最佳 | 1000ms |
深度从4开始比较稳妥。如果你是i5以上机器,可以试depth=5甚至depth=6,但每加一层耗时大约翻3~5倍,所以建议先把depth固定为4,等排序和剪枝都优化好了再往上加。搜索时限不是一个参数,而是一个“要不要继续搜下去”的终止标志,放在迭代加深的最外层控制,而不是写在alphaBeta递归里。
5. 中国象棋程序的6个常见坑:从界面乱码到AI主动送将
这个章节是我一路调程序攒下来的血泪经验,每一条都真实卡过我,先现象、再原因、最后给解决路径。按顺序排查,能省下至少一周的调试时间。
5.1 字符集不匹配:界面汉字乱码或直接编译报错
现象:界面上的“帅、车、马”显示成乱码,或者代码里写CString s = "车";直接编译报错,说是无法从char*转换到CString。
原因:Visual C++的默认项目字符集是Unicode,你用了窄字符中文字面量,系统不知道该怎么把多字节的中文转成宽字符。
解决:最省事的是去“项目属性 → 配置属性 → 常规 → 项目默认值 → 字符集”,改成“使用多字节字符集”。更规范的写法是统一用_T()宏包住所有中文字符串字面量,比如_T("车"),这样不管项目是Unicode还是多字节都能编译。我第一次写时没管字符集,把整个界面重写了一遍才意识到问题根本不在代码逻辑。
5.2 棋盘重绘闪烁:OnDraw全量重绘的代价与双缓冲修复
现象:每走一步棋,整个窗口都闪一下,棋子多的时候闪烁尤其明显,像老式CRT显示器刷新率不够。
原因:MFC的OnDraw在窗口失效时全量重绘,而每一步棋都会触发Invalidate,等于把棋盘重新画一遍。绘制过程中先擦背景再画线再画棋子,屏幕会短暂出现空白。
解决:双缓冲。在内存里创建一个和窗口等大的画布,把所有内容先画到内存DC,最后一次性BitBlt到屏幕。
void CChessView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* old = memDC.SelectObject(&bmp); // 先画到内存DC DrawBoard(&memDC); // 一次性拷到屏幕 pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(old); }逻辑说明:memDC拥有一张和窗口大小一致的位图,所有画线、画圆的调用都作用在内存里,用户看不到中间过程。参数说明:rect来自GetClientRect,是当前窗口客户区的尺寸;如果你的棋盘固定在某个位置,可以裁剪更小的区域,进一步减少拷贝量。
5.3 走法回溯遗漏:搜索时棋盘状态越搜越乱
现象:AI走完一步后,棋盘上多出几颗棋子,或者少了几颗,莫名其妙地“吃”掉了没有吃过的子。
原因:makeMove之后忘了unmakeMove。最常见的场景是在搜索函数里,某个分支提前return了,恢复棋盘的代码没执行到;或者复盘代码时把unmakeMove注释掉,忘记打开。
解决:makeMove和unmakeMove成对出现,尽可能用RAII或者函数开头确保异常路径也能恢复。另外一个习惯是把“吃掉的子”保存在Move的captured字段里,不要每次从棋盘上读,因为搜索过程中棋盘已经变了多次,原目标格的子早就不是当初那一个了。
5.4 将帅照面漏判:AI为什么主动送将
现象:AI在残局阶段走出完全送死的棋,比如主动把帅走到和黑将同一条直线上,然后程序报“非法走法”,或者直接判负。
原因:合法性检查里没有“将帅不能照面”这条规则。AI搜索时,它只检查目标格能不能去,却不知道两将同列且中间无子是犯规的,于是搜索树里出现大量非法分支,偶尔还会把“吃掉对方将”当成合法走法。
解决:在makeMove之前调用一个isKingFace()检查函数,只要当前局面两将同列且中间无子,就禁止这个走法。排查的时候可以在控制台打印每次搜索root返回的着法,一旦出现将帅同列的情况,九成是这个函数漏了。
5.5 数组越界:边界哨兵救了马腿和炮架
现象:程序运行一段时间后在随机位置崩溃,调试器指向一个看起来完全没问题的赋值语句。
原因:棋盘用了board[10][9],马跳马腿的时候算出负行号或负列号,直接访问了数组前的内存。这个问题最坑的地方在于,越界不一定立刻崩溃,可能等到内存被其他变量覆盖后才炸。
解决:换成12x11数组,把越界路径变成边界哨兵的正常判断。这是最彻底的方案,比在每一处访问前加范围检查靠谱得多。你能看到的现象是“崩溃位置完全随机”,其实根因就是马腿坐标没做边界判断。
5.6 重复局面死循环:同一步棋来回走
现象:AI在一个局面反复来回走,比如边车进退、进退、进退,就是不进攻,直到触发步数限制。
原因:搜索深度有限,AI评估发现当前局面得分都一样,又没有强制它“向前推进”,它就停留在循环里。如果搜索树里出现“红车进一,黑车退一,红车退一,黑车进一”这样的循环,AI会把走子权一直耗费在无进展的动作上。
解决:加重复局面检测。简单做法是把每个局面算一个哈希值,搜索路径上同一哈希出现两次以上就判和或给严重负分;更保守的做法是限制单局步数,比如超过120步无吃子就判和。先做后者,因为实现快,哈希优化留到第6章。
6. 用自对弈验证棋力:三个环节与迭代加深的下一步
6.1 自对弈回归测试:让程序红黑方互相对抗
验证AI最简单可靠的方法是让程序自己跟自己下。写一个autoPlay循环:红方用当前搜索函数走一步,然后切换到黑方视角再走一步,直到分出胜负或达到步数上限。这样每次修改评估函数或搜索逻辑后,都能快速跑一盘看看结果有没有恶化。
void autoPlay(int depthRed, int depthBlack, int maxMoves) { int side = 1; // 红方先走 for (int step = 0; step < maxMoves; step++) { int depth = (side == 1) ? depthRed : depthBlack; Move best = searchRoot(side, depth); makeMove(best); // 打印本步着法和当前评估分,方便观察 printf("step=%d side=%d move=(%d,%d)->(%d,%d) score=%d\n", step, side, best.fromRow, best.fromCol, best.toRow, best.toCol, evaluate()); if (isCheckmate(side)) { printf("checkmate\n"); break; } side = -side; } }逻辑说明:searchRoot是Alpha-Beta的一个封装,负责从根节点搜索并返回最佳Move。自对弈的价值在于回归测试,每改一个参数就固定跑20盘,对比胜率和平均步数,不要依赖“看起来变强了”的感觉。参数说明:depthRed和depthBlack可以设成不同深度,比如红方4层、黑方3层,用来验证“更强的一方胜率是否明显更高”,这是检验评估函数有没有写反的快捷方式。
6.2 残局验证与深度对比:用固定局面测搜索逻辑
用完整对局验证太慢,还容易把“规则错误”和“棋力不足”混在一起。更有效的方式是摆几个固定残局:单车对单士、双炮对双仕、兵单将。每个局面设一个预期结果,AI必须在限定步数内达到,比如单车对单士,30步内必须将死或吃子。跑不过就是搜索或评估出了问题。
深度对比是判断搜索是否正确的另一个手段:同一局面,depth=2和depth=4的第一效果应该一致,depth=4的后续着法应该更好。如果更深的搜索走出了和浅层搜索完全不同的送死棋,说明评估函数里的某条规则写错了,而不是搜索不够深。我一般把depth=2当成“基准正确性测试”,depth=6当成“棋力上限测试”,中间值用来观察棋力增长曲线。
6.3 迭代加深与开局库:我下一步会往哪边优化
自对弈稳定后,下一步最划算的优化是迭代加深:先搜depth=1,再搜2,逐步加深,超过设定时限就返回上一次搜完全程的结果。这样不会出现“depth=6,超时后什么着法都拿不出来”的尴尬。
Move searchRoot(int side, int maxDepth, int timeLimitMs) { vector<Move> allMoves; int bestScore = -INF; for (int d = 1; d <= maxDepth; d++) { int score = alphaBeta(d, -INF, INF, side); // 每次搜完,拿到当前最佳着法并保存 bestScore = score; if (isTimeUp()) break; } // 返回 bestScore 对应的着法 return bestMove; }逻辑说明:每一层搜索都是独立的Alpha-Beta调用,上一层的搜索结果可以排序下一层的走法列表,让剪枝起点更好。迭代加深的核心价值是“随时可以被中止,但永远有一个阶段性结果”。参数说明:timeLimitMs在UI线程里传到后台搜索线程,搜索线程每隔几十毫秒检查一次这个标志;在MFC里可以用PostMessage或AfxBeginThread,不要在OnLButtonUp里同步搜索深度6,否则界面会数秒无响应,用户第一反应是程序死了。
更远的优化方向是Zobrist哈希加置换表:把每个局面哈希后缓存进一个全局表,搜索时先查表,能直接跳过重复计算。开局库也是一条路,把前几步固定到常见开局走法里,省下开局阶段的搜索量。我现在每改完一版,都会先把深度4和深度6的自对弈各跑10盘,再和固定残局对一遍,确认没有回归,才敢说“这版棋力没有退步”。这个习惯帮我挡住了很多次“越改越弱”的翻车,希望帮到你。
本文还有配套的精品资源,点击获取