☰
黑白棋C++ Qt实战:AI对手与Alpha-Beta剪枝
2026/10/15 13:59:53 网站建设 项目流程

简介:这是一份基于C++与Qt框架实现的黑白棋(翻转棋)完整项目源码,面向具备C++基础、希望深入GUI应用开发与游戏逻辑设计的初学者及进阶学习者。项目覆盖棋盘二维数组建模、合法落子与翻转判定、游戏结束与计分规则、鼠标事件交互、界面信息展示、存档读档序列化,以及基于最小-最大搜索配合Alpha-Beta剪枝或蒙特卡洛树搜索的AI对手设计,是贯通数据结构、算法与Qt控件运用的综合实践案例。资源包共28个文件,约1.12MB,以cpp源文件与h头文件为核心,辅以o目标文件、pro工程配置、ui界面文件、Makefile构建脚本及exe可执行程序,另含少量图片与数据库文件,结构完整可直接编译运行。目前已有363人学习下载,适合对照源码理解Qt信号槽机制、QGraphicsView绘图与事件响应流程,并在此基础上扩展自定义棋盘尺寸、动画效果或更强AI策略。

1. 黑白棋 C++ Qt:一个把 AI 对手塞进桌面的完整对局项目

很多人第一次写棋类程序,卡住的不是规则,而是「怎么让电脑像个对手」。这份黑白棋 C++ Qt 项目就是冲着这个痛点来的:它把棋盘绘制、落子合法性判断、翻转逻辑、悔棋、AI 搜索和界面刷新串成了一条完整链路,编译出来就是一个能直接对弈的桌面程序。适合两类人:一类是刚学完 C++ 语法、想找一个有真实交互的项目练手的新手;另一类是想研究极小化极大搜索、Alpha-Beta 剪枝在棋类里怎么落地的开发者。它不依赖任何在线服务,所有计算都在本地完成,拿到源码配好 Qt 环境就能跑起来,改 AI 难度、换棋盘配色、加计时器都有清晰的切入点。

2. 技术选型与工程结构:为什么是 C++ 加 Qt 这套组合

2.1 选型逻辑:界面和算力各归各的

黑白棋这个题材有个特点,规则简单但搜索空间不小。8×8 棋盘,开局合法点只有 4 个,中盘能到十几个,如果 AI 要往后看 6 到 8 层,节点数会迅速膨胀。这种场景下用脚本语言写界面没问题,但搜索一深就顶不住。C++ 的价值在于把搜索循环压到毫秒级,Qt 的价值在于用QWidget加QPainter就能画出棋盘和棋子,信号槽机制天然适合「点击 → 落子 → 刷新」这种事件流。

常见做法是把项目拆成三层:界面层负责画和收事件,规则层负责判断能不能下、下了翻哪些子,AI 层负责搜索和评估。三层之间用普通 C++ 类通信,不互相依赖 Qt 的 GUI 模块,这样规则层和 AI 层可以单独写单元测试,不用起窗口。

2.2 目录结构:一个能直接编译的 Qt 工程长什么样

这份源码是标准的 Qt Widgets 工程,用.pro文件组织,没有引入 CMake,对新手更友好。典型结构如下:

othello/ ├── othello.pro # qmake 工程文件,声明 QT += widgets ├── main.cpp # 程序入口,创建 QApplication 和主窗口 ├── mainwindow.h/.cpp # 主窗口,持有棋盘控件和 AI 对象 ├── boardwidget.h/.cpp # 棋盘绘制与鼠标事件处理 ├── game.h/.cpp # 规则核心:落子、翻转、胜负判断 ├── aiplayer.h/.cpp # AI 搜索:极小化极大 + Alpha-Beta └── resources.qrc # 图标等资源(可选)

.pro里通常只写几行:

QT += core gui widgets TARGET = othello TEMPLATE = app SOURCES += main.cpp mainwindow.cpp boardwidget.cpp game.cpp aiplayer.cpp HEADERS += mainwindow.h boardwidget.h game.h aiplayer.h

这里QT += widgets是关键,少了它QApplication和QWidget都找不到。TEMPLATE = app表示生成可执行程序而不是库。如果后面要加音效,再补QT += multimedia。

2.3 核心数据结构:棋盘用一维数组还是二维数组

棋盘状态用int board[8][8]最直观,0 表示空,1 表示黑,2 表示白。但搜索时频繁拷贝整个二维数组开销不小,常见优化是把它压成一维int board[64],索引row * 8 + col,拷贝时直接memcpy64 个 int,比嵌套循环快。评估函数里遍历棋盘也只需要一层循环。

// game.h 中的棋盘表示 class Game { public: static const int SIZE = 8; int board[SIZE * SIZE]; // 0 空 1 黑 2 白 int currentPlayer; // 当前该谁下 Game(); void reset(); bool isValidMove(int pos, int player) const; bool applyMove(int pos, int player); // 返回是否成功 int countPieces(int player) const; bool isGameOver() const; };

isValidMove是规则层最核心的函数,它要沿八个方向检查:从落子点出发,某个方向上是否先遇到对方连续棋子、再遇到己方棋子。只有满足这个模式,落子才合法,那些被夹住的对方棋子才会翻转。applyMove在合法性通过后执行翻转并切换currentPlayer。

3. 规则核心拆解:落子合法性判断与八方向翻转

3.1 合法性判断:八个方向逐个扫

黑白棋的规则一句话能说清,但实现时八个方向的边界处理最容易翻车。下面这段是isValidMove的典型写法:

// 方向向量:上、下、左、右、四个斜向 static const int DIR[8][2] = { {-1,-1},{-1,0},{-1,1},{0,-1}, {0,1},{1,-1},{1,0},{1,1} }; bool Game::isValidMove(int pos, int player) const { int r = pos / SIZE, c = pos % SIZE; if (board[pos] != 0) return false; // 已有子不能下 int opp = (player == 1) ? 2 : 1; for (int d = 0; d < 8; ++d) { int nr = r + DIR[d][0], nc = c + DIR[d][1]; int steps = 0; // 先走一步,必须踩到对方棋子 while (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && board[nr * SIZE + nc] == opp) { nr += DIR[d][0]; nc += DIR[d][1]; ++steps; } // 再走一步,必须落在己方棋子上,且中间至少夹了一个对方子 if (steps > 0 && nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && board[nr * SIZE + nc] == player) { return true; } } return false; }

逻辑说明:对每个方向,先沿着方向走,只要踩到的是对方棋子就继续走并计数;一旦走出边界或遇到非对方棋子就停下。停下后检查是否在界内、是否为己方棋子、且steps > 0。三个条件同时满足才算这个方向能夹住。八个方向任意一个成立,落子就合法。

参数说明:pos是 0 到 63 的一维索引,player是 1 或 2。DIR数组把八个方向写死,避免在循环里写八段重复代码。steps用来区分「紧挨着就是己方子」这种不合法情况,因为中间必须至少有一个对方子。

3.2 翻转实现:先收集再统一改

判断合法之后,翻转要沿所有成立的方向把对方棋子改成己方。常见错误是边判断边改,导致后续方向判断被污染。稳妥做法是先收集所有要翻转的位置,再统一写回:

bool Game::applyMove(int pos, int player) { if (!isValidMove(pos, player)) return false; int r = pos / SIZE, c = pos % SIZE; int opp = (player == 1) ? 2 : 1; std::vector<int> toFlip; for (int d = 0; d < 8; ++d) { int nr = r + DIR[d][0], nc = c + DIR[d][1]; std::vector<int> line; while (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && board[nr * SIZE + nc] == opp) { line.push_back(nr * SIZE + nc); nr += DIR[d][0]; nc += DIR[d][1]; } if (!line.empty() && nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && board[nr * SIZE + nc] == player) { toFlip.insert(toFlip.end(), line.begin(), line.end()); } } board[pos] = player; for (int p : toFlip) board[p] = player; currentPlayer = opp; return true; }

逻辑说明:每个方向先用line暂存可能翻转的对方棋子,只有确认该方向末端是己方棋子,才把line并入toFlip。落子点和所有翻转点最后一次性写入,保证中间状态不被破坏。

参数说明:toFlip用std::vector<int>存一维索引,避免存坐标对。currentPlayer在成功落子后切换,失败时不变,这样界面层可以直接根据返回值决定是否刷新。

3.3 胜负判断:不是没子可下就结束

黑白棋的结束条件有两个:棋盘下满,或者双方都没有合法落子点。只判断「当前玩家没得下」是不够的,因为可能只是当前玩家被堵死,对方还有得下,这时应该跳过当前玩家而不是结束游戏。

bool Game::isGameOver() const { if (countPieces(0) == 0) return true; // 棋盘满了 // 双方都没有合法点才算结束 for (int p = 0; p < SIZE * SIZE; ++p) { if (isValidMove(p, 1) || isValidMove(p, 2)) return false; } return true; }

这里countPieces(0)统计空格数,为 0 说明下满。否则遍历所有格子,只要任意一方有合法点,游戏就没结束。界面层在每次落子后调用它,决定是否弹出结算。

4. AI 对手实现:极小化极大搜索与 Alpha-Beta 剪枝

4.1 评估函数:位置权重比子数更重要

新手写黑白棋 AI 最容易犯的错,是拿「当前谁子多」当评估。黑白棋前期子多反而是劣势,因为容易被对方夹住。合格的评估函数通常给每个格子一个权重,角最高,角旁边的星位是负分,边次之,中心一般。

// 8x8 位置权重表,角 100,角邻 -20,边 10,其余 1 static const int WEIGHT[64] = { 100,-20, 10, 5, 5, 10,-20,100, -20,-50, -2, -2, -2, -2,-50,-20, 10, -2, 1, 1, 1, 1, -2, 10, 5, -2, 1, 0, 0, 1, -2, 5, 5, -2, 1, 0, 0, 1, -2, 5, 10, -2, 1, 1, 1, 1, -2, 10, -20,-50, -2, -2, -2, -2,-50,-20, 100,-20, 10, 5, 5, 10,-20,100 }; int evaluate(const Game& g, int player) { int score = 0; for (int i = 0; i < 64; ++i) { if (g.board[i] == player) score += WEIGHT[i]; else if (g.board[i] != 0) score -= WEIGHT[i]; } return score; }

逻辑说明:己方棋子按权重加分,对方棋子按权重减分,空格不计。这样 AI 会主动抢角、避开角邻,而不是无脑吃子。权重表是经验值,改大角分会让 AI 更贪角,改大边分会让它更重视边线。

参数说明:player是 AI 自己的颜色,函数返回的是站在 AI 视角的分数,越大越好。搜索时如果轮到对方,取负号即可。

4.2 搜索主体:Alpha-Beta 把无效分支砍掉

极小化极大搜索会枚举所有走法,深度一上去节点爆炸。Alpha-Beta 剪枝在搜索过程中维护alpha(己方能保证的下界)和beta(对方能保证的上界),一旦发现某个分支不可能更好就提前返回。

int alphaBeta(Game g, int depth, int alpha, int beta, int player) { if (depth == 0 || g.isGameOver()) return evaluate(g, player); int best = -INF; bool hasMove = false; for (int pos = 0; pos < 64; ++pos) { if (!g.isValidMove(pos, player)) continue; hasMove = true; Game next = g; // 拷贝一份,避免污染原状态 next.applyMove(pos, player); int val = -alphaBeta(next, depth - 1, -beta, -alpha, (player == 1) ? 2 : 1); best = std::max(best, val); alpha = std::max(alpha, val); if (alpha >= beta) break; // 剪枝 } if (!hasMove) { // 当前玩家无子可下,跳过,交给对方 return -alphaBeta(g, depth - 1, -beta, -alpha, (player == 1) ? 2 : 1); } return best; }

逻辑说明:best记录当前玩家能拿到的最好分数。每试一个走法,递归时把alpha和beta取负并交换,这是负极大值写法的标准技巧,能让同一段代码同时处理「取最大」和「取最小」。alpha >= beta说明这个分支对方不会让它发生,直接break。

参数说明:depth是剩余搜索深度,INF取一个足够大的常数如 100000。hasMove用来处理「当前玩家无子可下」的情况,此时不返回评估值,而是跳过当前玩家继续搜,否则 AI 会在被堵死时误判局面。

4.3 难度分级:靠深度和随机扰动

一个 AI 如果永远走最优解,新手会被打崩。常见做法是给难度分级:简单难度搜 1 到 2 层,并在候选走法里按概率随机选一个次优解;中等搜 4 层;困难搜 6 层以上。深度每加 2,耗时大约涨一个数量级,所以困难难度要配合剪枝和走法排序。

int AI::chooseMove(const Game& g, int player, int level) { int depth = (level == 1) ? 2 : (level == 2) ? 4 : 6; int bestVal = -INF, bestPos = -1; std::vector<std::pair<int,int>> moves; // (分数, 位置) for (int pos = 0; pos < 64; ++pos) { if (!g.isValidMove(pos, player)) continue; Game next = g; next.applyMove(pos, player); int val = -alphaBeta(next, depth - 1, -INF, INF, (player == 1) ? 2 : 1); moves.push_back({val, pos}); if (val > bestVal) { bestVal = val; bestPos = pos; } } if (level == 1 && moves.size() > 1) { // 简单难度:从前三手里随机,制造「会犯错」的对手 std::sort(moves.begin(), moves.end(), [](auto& a, auto& b){ return a.first > b.first; }); int k = std::min<int>(3, moves.size()); return moves[rand() % k].second; } return bestPos; }

逻辑说明:先枚举所有合法走法并算出分数,困难难度直接取最高分。简单难度把走法按分数排序后,从前三名里随机挑,这样 AI 偶尔会走次优解,但不会走明显送角的蠢棋。

参数说明:level是难度编号,depth随难度递增。moves存分数和位置,方便排序和随机。注意rand()前要srand一次,否则每次开局随机序列一样。

5. 避坑与常见问题:编译、卡顿、AI 不落子怎么排查

5.1 编译报错找不到 QApplication

现象:main.cpp里#include <QApplication>报找不到头文件,或者链接时报undefined reference to QApplication。

原因:.pro里没写QT += widgets,或者本地 Qt 环境没配好,qmake 用的不是对应版本。

解决:先确认.pro第一行有QT += core gui widgets,然后清理重建:删掉Makefile和build目录,重新执行qmake再make。如果还不行,检查 Qt Creator 里选的 Kit 是不是桌面版而不是交叉编译版。

5.2 界面卡死,点一下要等好几秒

现象:困难难度下 AI 思考时窗口无响应,鼠标点不动。

原因:搜索在主线程里同步执行,Qt 的事件循环被阻塞,界面自然不刷新。

解决:常见做法是把 AI 搜索放到QThread或QtConcurrent::run里,搜完通过信号把落子位置发回主线程。临时方案是在搜索前调QApplication::processEvents(),但这只是让界面勉强刷新,不解决根本问题。更稳的是限制深度,困难难度控制在 6 层以内,配合走法排序把耗时压到 1 秒内。

5.3 AI 明明有位置却选择跳过

现象:轮到 AI 时它不落子,直接跳过,但棋盘上明显有合法点。

原因:isValidMove里方向判断的边界条件写错,比如nr < SIZE写成了nr <= SIZE,导致某些方向永远返回 false。也可能是currentPlayer切换逻辑有误,AI 拿到的是对方的颜色。

解决:写一个小的测试用例,手动摆一个已知局面的棋盘,逐个位置打印isValidMove的返回值,和规则对照。重点检查八个方向的边界和steps > 0条件。颜色问题就在chooseMove入口打印一次player,确认和界面当前玩家一致。

5.4 悔棋之后 AI 的搜索状态错乱

现象:悔棋一步后,AI 的走法变得很奇怪,甚至重复之前的下法。

原因:悔棋只改了界面棋盘,没有同步回Game对象,或者 AI 内部缓存了上一局的搜索状态。

解决:悔棋要同时回滚Game::board和currentPlayer,最省事的做法是用一个std::vector<Game>存历史状态,悔棋就pop_back恢复到上一个快照。AI 每次chooseMove都从传入的Game重新开始搜,不保留跨回合的缓存,就不会错乱。

5.5 棋盘画出来是歪的,鼠标点不准

现象:棋子画的位置和鼠标点击落子位置对不上,越往右下角偏得越多。

原因:QPainter绘制时用了固定像素坐标,但窗口可缩放,实际棋盘区域和绘制区域不一致。

解决:在paintEvent里根据当前控件尺寸动态算格子边长,鼠标事件里用同一个公式反算行列。常见做法是取min(width, height)作为棋盘边长,居中绘制,行列计算用(x - offsetX) / cellSize。把这两个公式抽成同一个函数,绘制和点击共用,就不会偏。

6. 二次开发改造:从能跑到好用,我踩过的几个坑

拿到这份源码跑通之后,真正有意思的是改。我一般会从三个方向下手:AI 强度、交互体验、代码结构。

先说 AI 强度。原始评估函数只有位置权重,中盘还行,残局容易翻车。一个低成本改进是加「行动力」项,也就是己方合法走法数量减去对方合法走法数量,乘以一个小系数加进评估。行动力高的局面通常更主动。改的时候注意系数别太大,否则 AI 会为了多几个走法而放弃角。另一个改进是走法排序,在alphaBeta里先试角位和边位,让剪枝更早生效,同样深度下能快两三倍。

// 在 evaluate 里追加行动力项 int mobility = 0; for (int i = 0; i < 64; ++i) { if (g.isValidMove(i, player)) mobility++; else if (g.isValidMove(i, opp)) mobility--; } score += mobility * 5; // 系数 5 是试出来的,太大反而变弱

交互体验上,最值得加的是「提示合法落子点」。在boardwidget的paintEvent里,遍历所有格子,对当前玩家的合法点画一个小圆点或半透明高亮。这个功能对新手极友好,实现也就十几行。再加一个「最后一步落子标记」,用不同颜色描边,复盘时一眼能看出对方刚下在哪。

代码结构上,如果打算长期改,建议把规则层和 AI 层从 Qt 依赖里彻底剥离,game.h和aiplayer.h不 include 任何 Qt 头文件。这样可以用纯 C++ 写测试,跑几万局 AI 对 AI 的自动对弈,统计胜率来验证改动是否真的变强。我习惯在改评估函数后跑 1000 局快棋,胜率提升不到 3% 就不保留,避免凭感觉调参。

最后说一个我踩过的坑:有次为了追求搜索深度,把困难难度调到 8 层,结果中盘一步要算七八秒,用户体验直接崩了。后来改成迭代加深,先搜 4 层拿到一个走法,再逐步加深,配合时间上限,超时就返回当前最优。这样既不会卡死,又能在简单局面下搜得更深。从那以后我每次调 AI 参数,都强制跑一遍「限时对弈」测试,确认最坏情况下的响应时间在可接受范围内。希望这些改造思路能帮到你,拿到源码后先从跑通和对弈开始,再挑一个方向动手改。

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

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

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

立即咨询