
做五子棋AI这个项目是我特别推荐给算法入门者的一条练手路线。五子棋规则足够简单棋盘只有15x15但搜索空间又不像围棋那么夸张正好用来讲清楚极大极小搜索和α-β剪枝算法这两块博弈树的核心思想界面部分用Qt和C来做轻快直观而且绘图逻辑和算法逻辑能拆得干干净净。这篇是系列的第一篇先把“为什么做”和“界面怎么做”讲透AI引擎的搜索部分放到下一篇展开。无论你是刚学完C想找个实战项目还是对博弈树算法感兴趣但不想啃数学推导这篇文章都能让你跟着思路搭出一个能跑、能点、能落子的五子棋界面并且为后面的AI对抗做好准备。1. 为什么做五子棋AI先从算法视角想清楚1.1 五子棋是博弈搜索的绝佳载体很多人在学极大极小搜索和α-β剪枝算法时会卡住核心原因不是算法本身难而是找不到一个直观的载体。下棋类的棋类游戏是最标准的回合制博弈模型我走一步你走一步双方轮流决策每一步都会改变棋盘状态最终有人获胜或者双方打平——这正是博弈树想表达的东西。五子棋在这个模型里占据一个很舒服的位置。走法数量不多每步可选落点最多也就225个实际操作中搜索深度到4到6层已经是相当能打的水平它不像国际象棋那样有复杂的棋子走法规则也不像围棋那样需要处理大量分支。你不需要先学一堆棋规才能理解搜索过程只要知道“五个连成一线就算赢”就够了。把精力集中在算法本身而不是棋局规则上这对我这种喜欢做减法的人来说特别重要。从数据结构角度看五子棋的棋盘天然就是二维数组空位、黑子、白子可以用0、1、2表示拷贝和恢复棋盘状态非常方便。这个特性对后续实现极大极小搜索特别有利因为搜索过程需要反复临时落子、再撤销落子数据结构的简单直接决定了搜索函数的实现成本。我第一次写AI搜索时用的是二维vector后来改成一维数组发现搜索速度提升明显这块经验在下一篇讲算法时再细说。1.2 极限极小搜索和α-β剪枝到底解决什么问题简单说极大极小搜索的思路是假设双方都足够聪明当前玩家会选择一个对自己评分最高的走法对手则会选择一个让我方评分最低的走法。于是搜索函数会自动做交替——当前层求最大值下一层求最小值再下一层又求最大值。这样层层传递下来最终能把未来几步的局面走向量化成一个分数。但这个朴素算法有个致命问题搜索树的分支会爆炸。每层大概有几十乃至上百个合法落点搜索4层就是百万量级节点再往上要几千万甚至上亿次评估纯暴力搜索在普通电脑上根本跑不动。α-β剪枝就是在极大极小搜索的基础上做优化如果当前分支已经确定比已知的最优结果差那就不用继续搜下去了。结果不变但搜索节点数能减少到原来的几分之一甚至几十分之一这才让“让AI思考两三秒再落子”变得可行。我之所以要在界面设计之后才写算法是因为界面层必须先把棋盘状态和交互逻辑稳定下来算法才有可靠的“输入输出接口”。先搭界面再接入引擎每一步都能肉眼看到反馈调试起来也轻松得多。反过来如果先写搜索算法再临时补界面你会发现算法和界面耦合在一起改一个地方的代码到处报错那种体验我不想再来第二次。1.3 这个项目里界面层要承担哪些职责从项目整体功能拆解来看第一篇的界面部分其实由下面这几块组成棋盘展示绘制15x15网格、交叉点上的星位、已落的黑白棋子。落子交互通过鼠标点击棋盘把像素坐标换算成行列坐标完成落子。状态反馈显示当前该谁下、游戏是否结束、胜方是谁。接口预留把棋盘状态和落子动作封装好让后面的AI引擎可以直接读取和修改状态而不是去操作界面控件。这四块里前三块是“看得见”的部分第四块是“看不见”但最影响后续开发质量的。我见过太多入门项目把棋盘状态放在widget里到处改结果算法一接入就乱了套。所以这篇文章里我会花不少篇幅讲如何把“棋盘数据”和“界面显示”拆开这算是我踩过坑之后总结出来的心得。2. 界面层整体设计棋盘逻辑和显示逻辑必须拆开2.1 为什么选择Qt配合C而不是纯控制台或者Web页面可能有读者会问做五子棋AI用控制台程序不也能跑吗确实能但你想看到AI每一手棋是怎么思考的只能在黑底白字的终端里打印坐标看着就费劲更别说调试极大极小搜索时的各种中间状态了。有图形界面你能直接看到棋盘上的落子趋势直观理解AI为什么这么走这对学习算法本身是有帮助的。选Qt则是看中它三个方面。第一QPainter画2D图形足够顺手画网格、画棋子、绘制坐标变换这些功能都是现成的不需要引入额外库第二Qt的信号槽机制天然适合处理“用户点击棋子”这种事件驱动的逻辑而且能很方便地把界面和业务逻辑解耦第三Qt本身对C的支持非常成熟跨平台也稳定你换台电脑重新编译基本不用改代码。编译器方面Windows下我建议直接用MSVC2019或MSVC2022的64位版本装好对应版本的Visual C Redistributable运行库就行。如果只是自己学习MinGW也可以但后面如果你要接一些第三方C库MSVC的兼容性会好很多。第一次安装Qt时要特别注意Qt 6.x对C17支持更完整如果只是做一个五子棋Qt 6.5 LTS和Qt 5.15.2都能胜任我个人更建议直接上Qt 6省得以后升级。2.2 棋盘Widget的职责边界画和点不负责决策我在一开始设计BoardWidget类时定下一条原则这个类只做两件事——绘制棋盘和响应鼠标点击不持有任何游戏规则、AI逻辑。它需要的数据只有一份棋盘状态引用以及绘制所需的几何参数边距、格子大小等。为什么不直接让BoardWidget去判断“这个位置能不能下”因为后面AI要接管落子而AI根本不关心你的鼠标点在哪里它只关心当前棋盘状态。如果把规则判断写在董事会Widget里AI接入时就得绕过界面反而麻烦。我的做法是让BoardWidget对外暴露两个足够干净的接口setBoardData同步棋盘状态以及cellClicked信号通知外部“用户点了某个行列”。至于这一手合不合法、轮到谁、要不要交给AI去下全部由上层控制器去处理。这种职责划分用一句话总结就是BoardWidget是“显示器鼠标外设”不是“裁判”。把这个边界定清楚后续写AI引擎时你只需要调用棋盘状态类根本不碰界面代码。2.3 数据结构先行15x15棋盘状态怎么存界面显示需要棋盘状态AI搜索也需要棋盘状态所以棋盘状态不应该属于界面而应该是一个独立的数据类。我用一个简单的GoBangBoard类来管理class GoBangBoard { public: static constexpr int kBoardSize 15; enum class Piece : int { Empty 0, Black 1, White 2 }; Piece at(int row, int col) const { return data_[row][col]; } bool place(int row, int col, Piece piece) { if (row 0 || row kBoardSize || col 0 || col kBoardSize) { return false; } if (data_[row][col] ! Piece::Empty) { return false; } data_[row][col] piece; return true; } void clear() { std::fill(data_[0][0], data_[0][0] kBoardSize * kBoardSize, Piece::Empty); } const std::arraystd::arrayPiece, kBoardSize, kBoardSize rawData() const { return data_; } private: std::arraystd::arrayPiece, kBoardSize, kBoardSize data_{}; };这里用一个二维数组存黑子、白子和空位。实际搜索算法我会建议后面改成一维数组因为memcpy恢复现场更快但界面显示阶段二维数组的可读性更好怎么方便怎么来。这个类目前看起来很简单但它已经包含了两个重要能力落子前合法性检测行越界、列越界、坐标非空以及统一的棋盘状态访问入口。等到写AI时极大极小搜索需要在虚拟棋盘上连续落子再撤销到时候只需要复用place方法再加上一个remove方法就能支撑整个搜索过程。3. 棋盘绘制与落子交互的实现细节3.1 几何参数计算边距、格距和棋盘适配画棋盘前要解决一个问题窗体大小变化时棋盘不能跟着乱变形。我采用固定边距加动态格距的方案。假设棋盘区域是正方形的左右上下都留出相同边距margin那么格子尺寸可以用widget的宽度算出int margin 30; double cellSize (widgetWidth - 2.0 * margin) / (kBoardSize - 1);注意除以的是kBoardSize - 1也就是14而不是15。15路棋盘有15根横线和15根竖线相邻线之间有14个间隔所以格距要用14来算。这个细节一开始搞错的话棋子会整体偏移半个格子看起来特别别扭。在resizeEvent里重新计算cellSize_并触发update()这样窗口拉伸时棋盘会自动缩放。如果不重写resizeEvent窗口变化后棋盘还是按旧参数绘制就会出现网格和棋子错位的问题。等会儿提到的像素坐标到棋盘坐标的换算也必须使用同一套几何参数否则点击落子的位置和实际绘制位置对不上。3.2 用QPainter绘制网格、星位和棋子绘制代码我放在paintEvent里主要分三层先画背景和网格再画星位最后画所有已经落下的棋子。void BoardWidget::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 背景 painter.fillRect(rect(), QColor(#DEB887)); // 网格 QPen gridPen(QColor(#5A4A3A), 1); painter.setPen(gridPen); for (int i 0; i kBoardSize; i) { QPointF startH boardToPixel(i, 0); QPointF endH boardToPixel(i, kBoardSize - 1); painter.drawLine(startH, endH); QPointF startV boardToPixel(0, i); QPointF endV boardToPixel(kBoardSize - 1, i); painter.drawLine(startV, endV); } // 星位 drawStarPoints(painter); // 棋子 drawPieces(painter); }boardToPixel负责把行列坐标换算成像素坐标核心代码就这个QPointF BoardWidget::boardToPixel(int row, int col) const { double x margin_ col * cellSize_; double y margin_ row * cellSize_; return QPointF(x, y); }画棋子时要注意黑子不要填纯黑色否则在深色木纹背景下看起来一团死黑白子也最好不要填纯白加一点渐变效果会更有立体感。我自己写的时候用QRadialGradient画棋子稍微加一点高光和阴影视觉效果立刻好很多。这块不用花太多时间但如果界面质感太差后面测试AI时每天对着屏幕也会影响心情。3.3 鼠标落子像素坐标和棋盘坐标怎么互相换算这是界面里最容易踩坑的地方。用户点击窗口上的某个像素点你要算出它最近的是哪个交叉点。换算公式是行列坐标反解像素坐标void BoardWidget::mousePressEvent(QMouseEvent* event) { if (event-button() ! Qt::LeftButton) { return; } QPointF pos event-position(); int row qRound((pos.y() - margin_) / cellSize_); int col qRound((pos.x() - margin_) / cellSize_); if (row 0 || row kBoardSize || col 0 || col kBoardSize) { return; } emit cellClicked(row, col); }这里的qRound做了四舍五入所以即使你点在两格中间它也会自动吸附到最近的交叉点。对玩家来说这就像点到了“正确的位置”实际用起来很舒服。如果想更严格一点可以再加一个判定如果点击位置距离交叉点超过某一阈值比如0.4个格距就忽略这样可以避免用户明明点在格子中央却被错误吸附到旁边。量产品五子棋都是这么处理的。注意Qt 6里QMouseEvent::pos()返回的是相对widget的QPoint而在高分屏缩放开启的情况下有精度要求的场合最好用event-position()它返回的是QPointF能避免整数精度丢失。这个小细节后来在我适配高分屏时帮了大忙后面会专门说。3.4 信号槽解耦界面只发“事件”主窗口只收“结果”BoardWidget本身不判断“该谁落子”它只负责发出cellClicked(int row, int col)信号。主窗口里的Controller逻辑接收到信号后才做判断void MainWindow::onCellClicked(int row, int col) { if (game_.isGameOver()) { return; } if (game_.currentPlayer() ! GoBangBoard::Piece::Black) { // 当前是AI回合玩家不允许落子 return; } if (!boardWidget_-placePiece(row, col, GoBangBoard::Piece::Black)) { // 该位置已经有棋子了 return; } game_.switchPlayer(); // 触发AI走棋下一篇会实现 startAiThinking(); }MainWindow持有Game对象而Game持有GoBangBoard。BoardWidget不直接修改Game的数据而是通过信号通知主窗口由主窗口调用GoBangBoard::place来更新数据再调用boardWidget_-update()刷新显示这样整个数据流是单向的界面输入 → 控制器判断 → 数据变更 → 界面刷新。好处是等AI引擎写好之后AI的落子结果也走同一个流程界面代码不用改。boardWidget_-placePiece其实就是一个便捷方法内部做了两件事调用GoBangBoard::place更新数据再调用update()刷新界面。bool BoardWidget::placePiece(int row, int col, GoBangBoard::Piece piece) { bool ok board_.place(row, col, piece); if (ok) { update(); } return ok; }4. 踩坑实录Qt环境配置和绘图的常见问题4.1 编译器、运行库和Qt版本的那点事很多人第一关就挂在环境上。这里说三个我自己见过最多的坑。第一个坑是“error: microsoft visual c 14.0 or greater is required”。这句话看着像Qt报错其实大多数情况是你在用pip安装某个Python包时需要编译C扩展和Qt环境关系不大。解决办法是装一个Build Tools for Visual Studio里面勾选MSVC工具链和Windows SDK。但如果你的Qt项目本身已经能正常编译就没必要折腾这个别被这个报错误导。第二个坑是编译完的exe在别人电脑上跑不起来提示缺少vcruntime140.dll或者VCRUNTIME140_1.dll。这是因为Qt本身用MSVC编译你的程序也依赖对应版本的C运行库。解决办法有两个要么打包时把运行库一起带上要么写一个简单的部署脚本用windeployqt把Qt相关DLL全部拷贝到程序目录。windeployqt这个工具是Qt官方自带的路径一般在Qt安装目录的bin下面执行起来会自动复制依赖的Qt模块但不会复制MSVC运行库所以还需要额外处理运行库的问题。第三个坑是选择Qt版本的时候犹豫不决。我的建议很简单本地没有商业需求就选开源的版本上直接选Qt 6.5 LTS或更高的6.x版本不要停在5.15。道理很简单Qt 6.x对C17支持更好很多新项目都以Qt 6为主你现在学旧版过几个月还得再适应一次。4.2 重绘闪烁和棋子残影问题绘制棋盘时如果代码写得比较随意很容易出现两个现象窗口拉伸时棋盘疯狂闪烁以及棋子移动后留下残影。闪烁的根本原因是在一次绘制循环里窗口被清屏重画但重画动作和屏幕刷新没有协调好。QWidget默认启用了双缓冲按理说不该闪。但如果你直接对某个子控件反复调用update()或者绘制过程中有大量耗时操作还是会闪。我习惯的做法是把棋盘缓存到一张QPixmap里只有棋子数量变化时才重画这个缓存paintEvent里只做一次drawPixmap。这样即使窗口被频繁重绘也只是简单地贴个图不会有复杂的画线画圆的计算自然也不闪烁。残影的情况更多见的原因是我前面说的坐标换算出错。你画棋子的位置用的是boardToPixel但擦除旧棋子或者判断新棋子位置时用的却是另一套系数比如格子大小更新了但旧棋子坐标没有重新换算结果就是旧棋子显示在错误的位置。这个问题没有捷径只能从代码层面保证所有几何参数只从boardToPixel和pixelToBoard两个函数进出不要自己在paintEvent里临时写一套坐标计算。4.3 高分屏适配为什么棋盘看起来模糊又错位现在很多笔记本都是150%缩放如果你不处理DPIQt的窗口会被系统拉伸看起来模糊。Qt 5需要在main函数里先设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)然后再创建QApplicationQt 6默认开启高分屏支持不用手动设置。但光缩放还不够鼠标坐标的映射也要用浮点。我一开始用的event-pos()在150%缩放下鼠标点击的像素坐标是经过系统缩放的结果落点总和我预期差半个格子。换成event-position()之后坐标精度就对了。还有一个很隐蔽的问题如果窗口背景用了paintEvent里调用fillRect填充在高分屏下可能出现边缘发虚的情况。这是因为Qt默认的缩放模式下widget不需要自适应像素密度时painter渲染坐标系未必和物理像素对齐。最好设置Qt::AA_UseHighDpiPixmaps同时保证绘制时抗锯齿开启能很大程度上缓解这个视觉问题。这块总结成一个检查顺序先确认Qt版本是否默认开启DPI再确认鼠标事件用的浮点坐标最后检查绘制的抗锯齿是否打开。按这个顺序排查90%的模糊和错位问题能解决。4.4 关于“棋盘写好了但AI还没接”的调试技巧在AI引擎实现之前为了让界面能跑起来并验证交互我通常会临时加一个“人类对弈模式”黑棋和白棋都由玩家轮流点击落子。这样就能在还没有AI的情况下把整个落子、状态切换、胜负判断的流程完整测一遍。在MainWindow里加一个临时变量bool humanVsHumanMode_当这个变量为true时无论当前是黑棋还是白棋都由玩家落子。这样做的好处是很快就能发现棋盘绘制、坐标换算、信号连接哪一环有问题而且后面AI接进来时你只需要再写一个AiEngine::move()方法把其中一方的落子来源从“鼠标点击”替换成“AI计算结果”其他代码逻辑完全复用。这也是我一直坚持界面逻辑和算法逻辑分离的原因——好处在联调阶段会体现得非常明显。5. 这一步做完之后AI接口应该挂在哪里界面这层搭完之后下一步接AI引擎前需要先把接口想好。我在设计时给AI引擎预留了一个最小接口就三个方法class AiEngine { public: struct Move { int row -1; int col -1; }; // 根据当前棋盘状态计算最佳落子 virtual Move getBestMove(const GoBangBoard board, GoBangBoard::Piece aiPiece) 0; };MainWindow在轮到AI时调用这个接口拿到Move后只要像播放器落子一样调用placePiece并刷新界面就行了。AI引擎内部有多深的搜索层数、评估函数怎么设计、估值表怎么存都在接口后面自己实现不污染界面代码。这颗我在写界面时就已经把“AI引擎接口”留好了后面实现极大极小搜索和α-β剪枝时你会发现算法部分的代码完全不用改界面只需要在一个纯逻辑类里不断调用GoBangBoard的place和remove方法就够了。真到了那种时候你会回来感谢当初这个决定的。最后再分享一个我在调试界面时的小技巧如果你想直观地验证棋盘绘制和坐标映射是否正确可以临时在鼠标点击的位置画一个半径很小的红色圆点再在对应的交叉点上画一个正常大小的黑白棋子。如果红点和落子位置完全重合说明你的坐标换算没问题如果偏移就回头检查boardToPixel和pixelToBoard是否用了同一套margin和cellSize。这个方法虽然土但排查起来特别快。界面稳定了下一篇就是硬核的算法部分了。