十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++实现智能五子棋:从棋盘建模到Alpha-Beta剪枝实战

C++实现智能五子棋:从棋盘建模到Alpha-Beta剪枝实战 简介这是一份基于C实现的智能五子棋课程设计资源支持人机对战与双人对战两种模式内置棋盘绘制、落子判定、胜负检测及人机决策等核心模块两类对战流程分别实现便于理解不同交互逻辑适合正在完成期末大作业、课程设计或希望进行项目实战练习的计算机相关专业学生。资源共18个文件以8个cpp源码和7个头文件为主另有1个可直接运行exe程序、1张界面预览png及README说明文档压缩包仅118KB结构清晰紧凑可按模块对照阅读源码与说明便于下载后直接运行学习。项目经导师指导与助教审定评审得分98分代码经过本地编译与严格调试能稳定运行降低了上手门槛。目前已有105人学习下载适合作为C编程、博弈算法设计与软件工程实践的综合参考范例。1. 智能五子棋比你想的更吃 C 基本功一个 15×15 的五子棋盘只有 225 个交叉点比围棋小一个数量级可一旦要写成“智能”程序问题就从规则判断变成了决策质量程序怎么知道下一步下在哪儿最常见的答案是用 C 写一个评估函数把活三、冲四、双三点这些棋形量化成分数再叠加一层有限深度的搜索剪枝让 AI 不是只看眼前一步。只要这一步设计合理人机对战和双人对战就只是入口不同胜负判断和棋盘管理完全可以共用一套代码。因此这个方向既不是“小游戏”也不只是“算法题”尤其适合作为高分大作业基础部分解决规则和交互加分部分全在搜索效率与棋力调优。下面按从底向上的顺序把棋盘、判胜、AI、双模式整合和验收逐层拆开。2. 棋盘建模与胜负判定先把规则写成不会出错的 C 代码写五子棋的第一步不是 AI而是一张不会出错的棋盘。很多大作业翻车不在算法而在于悔棋后回合错乱、判胜只往一个方向数、AI 搜索时落子没撤销。这些问题都源于棋盘类本身没有把状态边界管好所以先把 Board 写扎实。2.1 棋盘的数据结构二维数组怎么选、怎么存15×15 的棋盘用char二维数组最简单.表示空X和O表示黑白。虽然std::bitset225更省内存但评估函数要频繁读取坐标二维数组的可读性和可调试性更好。用std::arraystd::arraychar, BOARD_SIZE, BOARD_SIZE而不是 C 风格数组是为了能放进标准容器也方便后续拷贝给 AI 搜索做临时盘面。#include array #include vector constexpr int BOARD_SIZE 15; struct Move { int row; int col; int score 0; }; class Board { public: Board() { reset(); } void reset() { for (auto row : grid_) row.fill(.); history_.clear(); lastWin_ false; } bool inside(int r, int c) const { return r 0 r BOARD_SIZE c 0 c BOARD_SIZE; } bool canPlace(int r, int c) const { return inside(r, c) grid_[r][c] .; } bool place(int r, int c, char piece) { if (!canPlace(r, c)) return false; grid_[r][c] piece; history_.push_back({r, c, 0}); lastWin_ isWin(r, c, piece); return true; } void undo() { if (history_.empty()) return; Move last history_.back(); history_.pop_back(); grid_[last.row][last.col] .; lastWin_ history_.empty() ? false : isWin(history_.back().row, history_.back().col, grid_[history_.back().row][history_.back().col]); } bool lastMoveWon() const { return lastWin_; } bool isFull() const { return static_castint(history_.size()) BOARD_SIZE * BOARD_SIZE; } char at(int r, int c) const { return grid_[r][c]; } bool isWin(int r, int c, char piece) const; private: std::arraystd::arraychar, BOARD_SIZE, BOARD_SIZE grid_{}; std::vectorMove history_; bool lastWin_ false; };这里把棋盘封装成Board而不是在main里摆一个全局数组核心原因是 AI 搜索要反复“试下”再撤销。history_既承担悔棋也让轮次可以这样推导执行过多少步下一步就轮到谁。lastWin_是一个很关键的捷径AI 搜索每走一步后只需要问“这一步赢没赢”不必把整个棋盘扫一遍找胜负。2.2 判胜四个方向双向计数判胜不要用滑动窗口套“五连”最直接的写法是从最后落子点出发在一个方向上往两边数相同棋子。四个方向分别是水平、垂直、主对角线、副对角线某一方向合计大于等于 5 就赢。static const int DIRS[4][2] { {1, 0}, // 水平 {0, 1}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; bool Board::isWin(int r, int c, char piece) const { for (int d 0; d 4; d) { int dr DIRS[d][0]; int dc DIRS[d][1]; int cnt 1; for (int step 1; ; step) { int nr r dr * step; int nc c dc * step; if (!inside(nr, nc) || grid_[nr][nc] ! piece) break; cnt; } for (int step 1; ; step) { int nr r - dr * step; int nc c - dc * step; if (!inside(nr, nc) || grid_[nr][nc] ! piece) break; cnt; } if (cnt 5) return true; } return false; }注意必须正反两个方向都数。五子棋最后一步可能落在一条长串的正中间如果只往一个方向数XXXX_这种局面里最后一子落在第三个 X 上正方向只有一个反方向有两个只数一边就会漏判。这个函数单次最坏检查 14×4 个格子对每步落子后的胜负判断完全够用。2.3 状态管理轮转、悔棋与搜索回滚规则上最容易做错的是回合切换。不要单独存一个int currentPlayer而应该用history_.size() % 2推出当前该谁下。原因很简单悔棋时如果只弹栈、忘记回改currentPlayer下一步就会让同一个玩家连走两子。搜索回滚是另一个高频坑。AI 搜索时place和undo必须严格成对一旦某个分支提前返回落子没有撤销后续分支就会基于一个脏棋盘继续搜索。我一般会在undo的调试版本里加一个assert(!history_.empty())AI 搜索跑一遍不崩基本能保证状态流程是对的。此外判胜函数只回答了“是否 5 连”并不区分活四、冲四、活三。真正要区分这些形态的是 AI 评估函数而不是规则层。把这两件事拆开后面调棋力时才不会把规则和策略搅在一起。3. 人机对战的核心C 实现的评估函数与 Alpha-Beta 剪枝人机对战的“智能”来自两个部分一个是评估函数它告诉程序某个局面大概值多少分另一个是搜索它让程序不只看当前这一步而是提前想几步。两者缺一不可只有评估没有搜索遇到“这步不堵下一步就输”的局面就反应不过来只有搜索没有评估搜索到底也不知道该选哪个局面。3.1 棋形评分表活三、冲四、活四该给多少分评估的基础是一张棋形分表。给定一段连续的同色棋子长度len再看它两端是否开放两端都空是活棋只有一端空是眠棋。分数必须拉开数量级否则 AI 分不清“先冲四”和“先造活三”哪个更紧急。int scoreLine(int len, bool open1, bool open2) { if (len 5) return 1000000; int openCnt open1 open2; if (len 4) { if (openCnt 2) return 500000; // 活四对手一步都缓不过来 if (openCnt 1) return 50000; // 冲四 return 0; } if (len 3) { if (openCnt 2) return 10000; // 活三 if (openCnt 1) return 1000; // 冲三/眠三 return 0; } if (len 2) { if (openCnt 2) return 500; // 活二 if (openCnt 1) return 50; return 0; } if (len 1) return 10 * openCnt; return 0; }这里的数值关系比绝对值重要活四是冲四的 10 倍冲四是活三的 5 倍活三又是一般的 10 倍以上。如果分数间隔太小比如活四和冲四只差 200 分AI 可能为了顺手堵一个冲四而放弃已经形成的活四分数拉开后决策倾向才稳定。3.2 对每个空位打分Eval 函数怎么算双方优势一个常见的实现方式不是去扫描整盘棋的连续段而是对每个空位单独算一次“如果这个位置是我的棋子能形成多强的棋形”。这种方式代码短且天然覆盖了交叉点上的多重威胁。int pointScore(const Board b, int r, int c, char player) { static const int dr[4] {1, 0, 1, 1}; static const int dc[4] {0, 1, 1, -1}; int total 0; for (int d 0; d 4; d) { int cnt 1; // 假设在 (r,c) 落子 int nr r dr[d]; int nc c dc[d]; while (b.inside(nr, nc) b.at(nr, nc) player) { cnt; nr dr[d]; nc dc[d]; } bool open1 b.inside(nr, nc) b.at(nr, nc) .; nr r - dr[d]; nc c - dc[d]; while (b.inside(nr, nc) b.at(nr, nc) player) { cnt; nr - dr[d]; nc - dc[d]; } bool open2 b.inside(nr, nc) b.at(nr, nc) .; total scoreLine(cnt, open1, open2); } return total; } int evaluate(const Board b, char ai) { int score 0; char human (ai X) ? O : X; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (b.at(r, c) ! .) continue; score pointScore(b, r, c, ai); score - pointScore(b, r, c, human) * 2; } } return score; }防守权重乘 2 是个实用经验。五子棋中后手方一旦漏掉对手的活三局面会迅速失控因此评估函数应当对对手威胁更敏感。如果调试时发现 AI 过于保守走到哪都在堵棋可以把权重降回 1如果 AI 经常只顾自己进攻、被人摸到必胜点就把它加到 3。3.3 让 AI 多算两步Minimax 搜索与 Alpha-Beta 剪枝光是选evaluate最大的点AI 只能看到一步实战中一个常见失败是它看到自己有活三却没发现对方先冲锋四再活三。解决办法是加一层搜索树用「我下完你下、你下完我再下」的对抗式搜索评估未来局面。Alpha-Beta 剪枝在不改变搜索结果的前提下把很多不可能影响最终选择的子树切掉是 C 实现人机对战最常见的做法。搜索前先要收窄候选点。如果每层都枚举 225 个空位深度 4 的搜索树会爆炸。一般只考虑距离现有棋子 2 格以内的空位并按“进攻分 防守分”排序让 Alpha-Beta 优先搜索最像好棋的落点。#include algorithm #include climits std::vectorMove getCandidateMoves(const Board b, char ai, int dist 2) { std::vectorMove moves; char human (ai X) ? O : X; bool hasPiece false; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (b.at(r, c) ! .) { hasPiece true; break; } } } if (!hasPiece) return {{7, 7, 0}}; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (b.at(r, c) ! .) continue; bool near false; for (int nr std::max(0, r - dist); nr std::min(BOARD_SIZE - 1, r dist); nr) { for (int nc std::max(0, c - dist); nc std::min(BOARD_SIZE - 1, c dist); nc) { if (b.at(nr, nc) ! .) near true; } } if (!near) continue; int attack pointScore(b, r, c, ai); int defend pointScore(b, r, c, human); moves.push_back({r, c, attack defend}); } } std::sort(moves.begin(), moves.end(), [](const Move a, const Move b) { return a.score b.score; }); return moves; }候选点生成时排序关键在attack defend这个分数一个点既能让己方成四又能堵住对方成五那它通常是优先级最高的一手。排序做得好Alpha-Beta 剪枝的收益非常大可能把搜索节点数减少到原来的五分之一甚至更少。接下来是核心的负极大值搜索。每一步从当前方视角返回盘面价值上一层取负号天然实现了“我大你就小”的对抗关系。const int WIN 1 29; int negamax(Board b, int depth, int alpha, int beta, char side, char ai) { if (depth 0) { return (side ai) ? evaluate(b, ai) : -evaluate(b, ai); } auto moves getCandidateMoves(b, ai); for (const Move mv : moves) { b.place(mv.row, mv.col, side); int val; if (b.lastMoveWon()) { val (side ai) ? WIN depth : -WIN - depth; } else { char next (side X) ? O : X; val -negamax(b, depth - 1, -beta, -alpha, next, ai); } b.undo(); if (val beta) return beta; if (val alpha) alpha val; } return alpha; }WIN depth的细节是搜索深度还剩余多少越早赢剩余深度越大分数越高越晚输同样越不“亏”。这样搜索在同时存在“一步赢”和“四步赢”时会果断选择一步赢。剪枝条件val beta表示当前分支已经比父节点已知的最差情况更差没必要继续展开直接返回。3.4 影响棋力和速度的三个参数在 C 大作业里棋力不是玄学而是三个参数共同作用的结果参数常见取值调大时调小时搜索深度 depth4棋力上升时间指数级上升2 层时只会做局部防守候选点邻域 dist2更好的“远见”节点更多只盯着老棋可能漏掉连续跳三防守权重2偏保守擅长堵棋偏进攻容易漏杀我的建议是固定dist 2先调depth。Release 模式下-O2编译开局阶段的空点较多深度 4 可能耗时几百毫秒中盘子多了以后候选点减少反而更快。如果演示机性能一般不要直接砍深度先把dist从 3 收到 2搜索节点数会明显降下来。4. 双人对战与人机对战的代码拼装抽象 Player 接口棋盘和 AI 都写完后最后一道工程题是让双人对战和人机对战共用一套主循环。很多代码会把“读键盘”和“AI 算棋”写在两个完全不同的循环里结果修一个 bug 要改两遍。更稳的做法是抽象出一个IPlayer接口外部统一问它“下一步下哪”。4.1 先用一个接口隔离“谁来落子”class IPlayer { public: virtual Move nextMove(Board board) 0; virtual ~IPlayer() default; }; class HumanPlayer : public IPlayer { public: Move nextMove(Board board) override { int r 0, c 0; while (true) { std::cout 请输入行列0-14用空格或逗号隔开: ; std::cin r c; if (board.canPlace(r, c)) return {r, c}; std::cout 非法位置重新输入\n; } } }; class AiPlayer : public IPlayer { public: explicit AiPlayer(char aiPiece) : ai_(aiPiece) {} Move nextMove(Board board) override { AlphaBeta engine; return engine.bestMove(board, 4, ai_); } private: char ai_; };HumanPlayer不持有任何对局状态因此双人模式里可以让两个玩家对象都指向同一个HumanPlayer它的逻辑仍是“从控制台输入一步”。AiPlayer的构造参数指定它是黑还是白这样电脑下黑棋、下白棋都只需要换一个对象。4.2 双人模式和人机模式共用的对局循环主循环的逻辑不关心落子来自键盘还是搜索树只负责轮流调用当前玩家的nextMove然后落子、判胜、判断平局。void runGame(bool vsAi, bool humanFirst true) { Board board; board.reset(); HumanPlayer human; AiPlayer aiWhite(O); IPlayer* players[2] {nullptr, nullptr}; if (vsAi) { players[0] humanFirst ? human : aiWhite; players[1] humanFirst ? aiWhite : human; } else { players[0] human; players[1] human; } int turn 0; while (true) { printBoard(board); char piece (turn 0) ? X : O; Move mv players[turn]-nextMove(board); board.place(mv.row, mv.col, piece); if (board.lastMoveWon()) { printBoard(board); std::cout ((turn 0) ? 黑方 : 白方) 胜利\n; break; } if (board.isFull()) { std::cout 平局\n; break; } turn ^ 1; } }注意players数组中同一个HumanPlayer出现两次并没有问题因为HumanPlayer没有成员状态每次调用都只是从控制台读取。AiPlayer在nextMove内部会重新构造临时引擎或者你也可以把AlphaBeta作为成员保存两种都不影响主循环。真正要小心的是turn ^ 1之前必须确保落子成功而board.place已经带了canPlace判断这样非法输入在HumanPlayer里就被拦截了。4.3 命令行棋盘输出与编码问题控制台版本的棋盘不建议用复杂图形库用 ASCII 字符最省心X代表黑棋O代表白棋旁边打印行列坐标。行号和列号是 0-14玩家输入7,7表示天元。打印函数每隔 5 行加一条分隔线能明显降低看错坐标的概率。这里有一个很容易踩的 Windows 控制台坑如果源文件用 UTF-8 保存但控制台代码页是 936●○这类字符会输出乱码。最简单的处理是统一用常用字符避免在字符串里出现中文如果一定要显示中文提示可以用 Visual Studio 的/utf-8编译选项或者在代码里调用system(chcp 65001 nul)后者不是标准 C但在大作业演示环境里通常可用。写报告时可以强调这一点属于“环境适配”不是核心算法。最后在main里只需要一个模式选择int main() { int mode; std::cout 1 人机对战 2 双人对战\n; std::cin mode; if (mode 1) runGame(true, true); else runGame(false); }人机对战再加一个“先手还是后手”的选择逻辑上都是往runGame的两个参数里传值不需要额外分支。到这里双人对战、人机对战、悔棋、重开这些“给人看”的功能都已经齐了。5. 收尾把“智能”写进报告与答辩的验证技巧大作业最后阶段代码能跑只是及格要拿高分需要让老师相信“这个程序真的是智能的而不是随便判个胜率”。与其口头吹不如准备一张固定棋局的测试表现场跑给老师看。5.1 用固定棋局做回归五个关键测试用例构造一个只包含几颗棋子的迷你局面然后断言 AI 的落点是否符合预期。测试 1是己方有冲四AI 必须直接下成五测试 2是对方有冲四AI 必须堵住唯一的取胜点测试 3是对方有活三AI 即使自己也有一手活二也应当先堵测试 4是双方各有一个冲四AI 应当先下自己的冲四因为先手直接赢测试 5是无明显威胁时AI 应该落在能形成双三结构的点上。void expectMove(const char* name, Board b, int expectRow, int expectCol, char ai) { AlphaBeta engine; Move mv engine.bestMove(b, 4, ai); if (mv.row expectRow mv.col expectCol) { std::cout name passed\n; } else { std::cout name failed, get mv.row , mv.col expected expectRow , expectCol \n; } }这个测试函数的价值不只是验证 AI它还会暴露出评估函数里的分数冲突。比如 AI 在测试 2里选择去冲四而不是堵对手基本可以断定己方冲四的分数定得太高或者防守权重没有生效。5.2 现场展示前必调的三个开关第一编译一定要用 Release 加-O2。同一套代码 Debug 下可能 3 秒才走一步Release 下不到 300 毫秒这个体验差距对评分影响很大。第二如果 AI 在演示时频繁长考把dist从 2 改成 1视觉上“变快了”棋力损失并没有想象中严重。第三检查WIN常量是否大于evaluate可能返回的任何累加分数。一个常见错误是评估函数里单点分数最高 100 万遇到多个活四叠加时总分超过WINAI 会为了“总分高”而放弃已经到手的胜利。5.3 回答“它到底智不智能”的正确姿势答辩时最容易被追问的是“你这个 AI 是深度学习吗”答案是否定的也不必是。直接说实现思路手工设计的棋形评估函数结合深度为 4 的 Alpha-Beta 剪枝搜索。它的智能体现在两个可验证的地方一是能发现直接落子获胜的棋二是能在有限深度内预判出对手的连续威胁。最后还可以补一句搜索效率依赖候选点排序和剪枝顺序这是 C 数据结构与算法能力的体现而不是调一个现成库。这样讲既诚实也把大作业的技术密度表达到位。本文还有配套的精品资源点击获取
返回列表