C++五子棋源码实战:人人对战与人机对战AI搜索优化

发布时间:2026/10/30 12:29:55
C++五子棋源码实战:人人对战与人机对战AI搜索优化

简介:这是一份面向C++初学者与游戏编程爱好者的五子棋实战源码,同时支持人机对战与人人对战两种模式,适合用来理解面向对象设计、AI决策与图形交互等核心概念。压缩包共7个文件,约106KB,包含3个cpp源码文件、3个可执行程序及1份说明文档,源码按鼠标版、键盘版、人对人版、人对机版分别组织,便于对照不同交互方式与对战逻辑的实现差异。已有180人学习下载。读者可从中掌握棋盘与棋子类的抽象方式、人机模式下基于规则或搜索的落子决策思路、玩家输入的捕获与校验流程,以及界面渲染与点击事件响应机制。对于希望提升C++编程技巧、了解游戏开发基本流程的开发者而言,这份源码可作为动手分析与二次优化的实践素材。

1. 从一份 C++ 五子棋源码说起:人机对战和双人对战到底差在哪

很多人第一次搜「C++五子棋源码」,心里想的其实是两件事:一是想找一个能直接编译运行的完整工程,二是想搞清楚人机对战那部分到底怎么写的。我当年也是这么入的坑,下载了一堆源码,结果发现大部分只做了人人对战,棋盘画得挺漂亮,一到 AI 落子就露馅——要么随机下,要么只会堵活三,连冲四都看不出来。所以这篇笔记我打算把这两条线都讲透:人人对战怎么把规则和界面搭稳,人机对战怎么从零写一个能打的评估函数,以及这两套逻辑怎么共用同一个棋盘内核。

先说清楚适用人群。如果你刚学完 C++ 基础语法,想找一个不大不小、能一口气写完的项目练手,五子棋是极好的选择,它比贪吃蛇多一层博弈逻辑,又比完整棋类引擎轻得多。如果你已经能写类和多态,想理解「搜索 + 评估」这套 AI 骨架,那五子棋同样合适,因为它的状态空间小到可以在本地跑出肉眼可见的棋力提升。整篇内容我会按「棋盘内核 → 人人对战 → 人机对战 → 搜索优化 → 避坑 → 进阶」的顺序推进,代码全部用标准 C++,不依赖任何图形库也能跑,想加界面再自己套 Qt 或 EasyX 都行。

2. 棋盘内核与胜负判定:先把数据结构定死

2.1 用一维数组还是二维数组存棋盘

这是第一个要拍板的事。新手最容易随手写int board[15][15],写起来直观,但后面做搜索、做哈希、做镜像对称优化时会很别扭。我一般用一维数组std::vector<int> board,长度 225,索引idx = y * 15 + x。好处有三个:一是缓存友好,遍历时是一段连续内存;二是做 Zobrist 哈希时直接按索引异或,不用嵌套循环;三是传给评估函数时只需要传一个指针加长度,接口干净。

棋盘上每个点的状态用三个值表示:0 空、1 黑、2 白。不要用 char 存 'X' 'O',也不要搞枚举类再转来转去,搜索里每个节点都要读棋盘,任何一次多余转换都会被放大成性能问题。

#include <vector> #include <cstdint> constexpr int BOARD_SIZE = 15; constexpr int BOARD_CELLS = BOARD_SIZE * BOARD_SIZE; // 0 空 1 黑 2 白 using Board = std::vector<int8_t>; inline int toIndex(int x, int y) { return y * BOARD_SIZE + x; } inline int toX(int idx) { return idx % BOARD_SIZE; } inline int toY(int idx) { return idx / BOARD_SIZE; } inline bool inBoard(int x, int y) { return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE; }

这段代码没什么玄学,但inBoard一定要写成内联,因为胜负判定和评估函数里会调用几十万次。int8_t而不是int是为了让整个棋盘只占 225 字节,能塞进 CPU 一级缓存,搜索深度上去之后这个差别很明显。

2.2 胜负判定的四个方向与边界处理

五子棋判定连五,只需要检查刚落子那个点的四个方向:横、竖、主对角线、副对角线。每个方向往两边各延伸最多 4 格,累计同色数量达到 5 就赢。这里有个血泪经验:不要每次判定都从棋盘左上角全盘扫描,那样人人对战还能忍,人机对战里每走一步都全扫会直接拖垮搜索。

bool checkWin(const Board& b, int x, int y, int color) { static const int dx[4] = {1, 0, 1, 1}; static const int dy[4] = {0, 1, 1, -1}; for (int d = 0; d < 4; ++d) { int cnt = 1; for (int step = 1; step <= 4; ++step) { int nx = x + dx[d] * step, ny = y + dy[d] * step; if (!inBoard(nx, ny) || b[toIndex(nx, ny)] != color) break; ++cnt; } for (int step = 1; step <= 4; ++step) { int nx = x - dx[d] * step, ny = y - dy[d] * step; if (!inBoard(nx, ny) || b[toIndex(nx, ny)] != color) break; ++cnt; } if (cnt >= 5) return true; } return false; }

dx/dy四个方向覆盖了所有连五可能,step从 1 到 4 是因为五子棋最多只需要看四格外的同色子。注意副对角线方向是(1, -1),写反了会导致斜向判定失效,这种 bug 在界面上表现为「明明连了五个却不判赢」,非常隐蔽。判定函数只认「刚落子点」,所以调用时机必须是在落子之后立刻调用,不能延迟。

2.3 落子、悔棋与状态回滚

人人对战需要悔棋,人机对战需要搜索时反复落子撤销,这两件事本质是同一个操作:记录每一步的坐标和颜色,撤销时把对应格子清空。我一般维护一个std::vector<std::pair<int,int>> history,落子时 push,悔棋时 pop 并把棋盘置 0。

struct GameState { Board board; std::vector<std::pair<int,int>> history; int current = 1; // 1 黑 2 白 bool place(int x, int y) { if (!inBoard(x, y)) return false; int idx = toIndex(x, y); if (board[idx] != 0) return false; board[idx] = current; history.emplace_back(x, y); current = 3 - current; // 1<->2 切换 return true; } void undo() { if (history.empty()) return; auto [x, y] = history.back(); history.pop_back(); board[toIndex(x, y)] = 0; current = 3 - current; } };

current = 3 - current是切换黑白最省事的写法,比 if-else 干净。搜索里会频繁调用place和undo,所以这两个函数不要做任何多余的事,比如打印日志、检查胜负,胜负检查交给外层。把状态变更和规则判定分开,是后面能顺利加搜索的前提。

3. 人人对战:输入循环、界面刷新与规则收口

3.1 控制台版的最小可玩循环

人人对战的核心就是一个 while 循环:打印棋盘、读入坐标、落子、判胜、切换玩家。控制台版不需要任何图形库,适合先把规则跑通。

#include <iostream> void printBoard(const Board& b) { std::cout << " "; for (int x = 0; x < BOARD_SIZE; ++x) std::cout << (x % 10) << ' '; std::cout << '\n'; for (int y = 0; y < BOARD_SIZE; ++y) { std::cout << (y % 10) << " "; for (int x = 0; x < BOARD_SIZE; ++x) { int v = b[toIndex(x, y)]; std::cout << (v == 0 ? '.' : (v == 1 ? 'X' : 'O')) << ' '; } std::cout << '\n'; } } int main() { GameState gs; gs.board.assign(BOARD_CELLS, 0); while (true) { printBoard(gs.board); std::cout << (gs.current == 1 ? "黑方" : "白方") << "落子 (x y): "; int x, y; if (!(std::cin >> x >> y)) break; if (!gs.place(x, y)) { std::cout << "非法落子\n"; continue; } if (checkWin(gs.board, x, y, 3 - gs.current)) { printBoard(gs.board); std::cout << (gs.current == 1 ? "白方" : "黑方") << "获胜\n"; break; } } return 0; }

注意判胜时用的颜色是3 - gs.current,因为place已经把 current 切到对方了,刚落下的那手棋颜色是切换前的。这个细节写错会导致「赢了不报」或者「对方赢了报自己赢」,是人人对战里最常见的逻辑 bug。输入用std::cin直接读两个整数,非法输入时退出循环,避免死循环刷屏。

3.2 用 Qt 或 EasyX 加界面时要注意什么

如果你想让棋盘好看一点,Qt 的QPainter画格子和棋子是最省事的,EasyX 在 Windows 上更快上手。但不管用哪个,有一条原则:界面层只负责画和收鼠标事件,所有规则判定必须回到GameState里做。我见过太多人把胜负判断写进鼠标点击回调,结果人机对战时 AI 走子不触发回调,胜负就漏判了。

界面刷新时不要每帧重画整个棋盘,只重画变化的那一格。Qt 里用update(QRect)指定区域,EasyX 里用BeginBatchDraw批量绘制。棋子用抗锯齿圆,半径取格子边长的 0.4 倍左右,太大挤在一起,太小看着空。鼠标坐标转棋盘坐标时记得减去边距再除以格宽,并且要做四舍五入而不是直接截断,否则点格子边缘会落到相邻格。

3.3 规则收口:禁手、长连和先手优势

标准五子棋(无禁手)里,黑棋先手优势很大,如果只是自己玩,不加禁手完全没问题。但如果你想让对局公平一点,可以加「黑棋三三禁手、四四禁手、长连禁手」。实现方式是在黑棋落子后,除了判连五,还要统计该点形成的活三数量、冲四数量、连子长度,超过阈值就判黑棋负。

// 简化版:统计某点某方向的连子长度和两端是否开放 struct LineInfo { int count; bool openLeft; bool openRight; }; LineInfo scanLine(const Board& b, int x, int y, int dx, int dy, int color) { LineInfo info{1, false, false}; int nx = x + dx, ny = y + dy; while (inBoard(nx, ny) && b[toIndex(nx, ny)] == color) { ++info.count; nx += dx; ny += dy; } info.openRight = inBoard(nx, ny) && b[toIndex(nx, ny)] == 0; nx = x - dx; ny = y - dy; while (inBoard(nx, ny) && b[toIndex(nx, ny)] == color) { ++info.count; nx -= dx; ny -= dy; } info.openLeft = inBoard(nx, ny) && b[toIndex(nx, ny)] == 0; return info; }

禁手判定是五子棋里最容易写错的部分,因为「活三」的定义涉及递归:一个三如果落一子能变成活四,才算活三。新手建议先不做禁手,把人人对战和人机对战跑通,再回头补。真要做,就单独写一个isForbidden函数,输入棋盘和落子点,输出是否禁手,不要和胜负判定混在一起。

4. 人机对战:评估函数、极小化极大与 Alpha-Beta 剪枝

4.1 棋型打分:活四、冲四、活三怎么给分

人机对战的核心不是搜索,而是评估函数。搜索只是帮你多看几步,评估函数决定了 AI 觉得什么棋「好」。五子棋的棋型可以按威胁程度排序:连五 > 活四 > 冲四 > 活三 > 眠三 > 活二 > 眠二。我一般给的分值是:连五 10000000,活四 1000000,冲四 100000,活三 10000,眠三 1000,活二 100,眠二 10。

int scoreLine(int count, bool openLeft, bool openRight) { if (count >= 5) return 10000000; if (count == 4) { if (openLeft && openRight) return 1000000; // 活四 if (openLeft || openRight) return 100000; // 冲四 return 0; } if (count == 3) { if (openLeft && openRight) return 10000; // 活三 if (openLeft || openRight) return 1000; // 眠三 return 0; } if (count == 2) { if (openLeft && openRight) return 100; if (openLeft || openRight) return 10; return 0; } return 0; }

评估整个棋盘时,遍历所有空点或者所有已落子点的四个方向,累加己方得分减去对方得分。注意要同时评估双方,否则 AI 会只顾进攻不防守。一个常见做法是:对每个点,分别以黑棋和白棋视角扫描四个方向,得到两个分数,己方分数累加,对方分数乘以一个略大于 1 的系数(比如 1.1)后减去,这样 AI 会稍微偏向防守。

4.2 极小化极大搜索的递归骨架

有了评估函数,就可以做搜索。极小化极大(Minimax)的思路是:AI 走一步,假设对手也走最优的一步,如此交替,直到达到搜索深度,返回评估值。AI 选使评估值最大的走法,对手选使评估值最小的走法。

constexpr int MAX_DEPTH = 4; int minimax(GameState& gs, int depth, int alpha, int beta, bool isMax) { // 先检查是否已经连五 if (!gs.history.empty()) { auto [lx, ly] = gs.history.back(); int lastColor = gs.board[toIndex(lx, ly)]; if (checkWin(gs.board, lx, ly, lastColor)) { return lastColor == 1 ? -10000000 + (MAX_DEPTH - depth) : 10000000 - (MAX_DEPTH - depth); } } if (depth == 0) return evaluate(gs.board, 1) - evaluate(gs.board, 2); int best = isMax ? -100000000 : 100000000; for (int idx = 0; idx < BOARD_CELLS; ++idx) { if (gs.board[idx] != 0) continue; int x = toX(idx), y = toY(idx); gs.place(x, y); int val = minimax(gs, depth - 1, alpha, beta, !isMax); gs.undo(); if (isMax) { best = std::max(best, val); alpha = std::max(alpha, best); } else { best = std::min(best, val); beta = std::min(beta, best); } if (beta <= alpha) break; // Alpha-Beta 剪枝 } return best; }

这段代码里有几个关键点。第一,连五检查放在递归开头,用history.back()拿到最后一手,避免全盘扫描。第二,返回胜负分时加上MAX_DEPTH - depth,让 AI 优先选择更快获胜的走法,否则它会赢就行,不管几步。第三,Alpha-Beta 剪枝的条件是beta <= alpha,写在循环内、递归返回之后,剪掉的分支不影响结果。

4.3 候选点生成:别对 225 个空点全搜

上面那段代码对每个空点都递归,15x15 棋盘第一层 225 个点,深度 4 就是 225^4 约 25 亿个节点,根本跑不完。实际工程里必须做候选点裁剪:只考虑已有棋子周围 2 格内的空点。这样第一层通常只有 20 到 40 个候选,深度 4 也能在秒级出结果。

std::vector<int> genMoves(const Board& b, int radius = 2) { std::vector<int> moves; std::vector<bool> used(BOARD_CELLS, false); for (int idx = 0; idx < BOARD_CELLS; ++idx) { if (b[idx] == 0) continue; int cx = toX(idx), cy = toY(idx); for (int dy = -radius; dy <= radius; ++dy) { for (int dx = -radius; dx <= radius; ++dx) { int nx = cx + dx, ny = cy + dy; if (!inBoard(nx, ny)) continue; int nidx = toIndex(nx, ny); if (b[nidx] != 0 || used[nidx]) continue; used[nidx] = true; moves.push_back(nidx); } } } if (moves.empty()) moves.push_back(toIndex(7, 7)); // 空棋盘走天元 return moves; }

radius取 2 是经验值,取 1 会漏掉一些跳活三的防守点,取 3 候选太多影响速度。空棋盘时返回天元,保证 AI 第一手有棋可走。生成候选后,最好再按「该点周围棋子密度」或「该点评估分」排序,把好棋排在前面,Alpha-Beta 剪枝效率会高很多。

5. 人机对战避坑:搜索跑不动、AI 乱下、胜负判错的排查清单

5.1 现象:AI 思考超过十秒还不落子

原因通常是候选点没裁剪,或者搜索深度设太大,或者评估函数里对每个节点都全盘扫描。解决方法是先确认genMoves是否生效,把深度降到 2 看是否能秒出,再逐步加深度。评估函数改成增量更新:落子时只更新该点四个方向的分数,撤销时恢复,不要每次递归都重算全盘。

5.2 现象:AI 明明能赢却去堵对方

原因是评估函数里对方分数权重过高,或者胜负分没有正确返回。检查minimax里连五检查是否在depth == 0之前,如果放在后面,达到深度限制时胜负分会被评估分覆盖。另外确认返回胜负分时颜色判断正确,黑赢返回负、白赢返回正,写反了 AI 会主动送死。

5.3 现象:AI 第一手落在角落或者乱走

原因是空棋盘时genMoves返回空,或者评估函数对空棋盘所有点打分相同,搜索退化成随机。解决方法是空棋盘直接返回天元,或者在评估函数里给中心点加一个位置分,越靠近中心分越高,这样开局不会太离谱。

5.4 现象:人人对战正常,切到人机就崩溃

原因是搜索过程中修改了GameState但没有正确撤销,导致history和board不一致。检查place和undo是否成对出现,递归里每个place后面必须跟一个undo,包括剪枝break之前也要撤销。建议用 RAII 封装一个ScopedPlace,构造时落子,析构时撤销,避免漏写。

5.5 现象:AI 偶尔下在已经有棋子的位置

原因是候选点生成时没有过滤非空点,或者坐标转换toIndex和toX/toY用混了。检查genMoves里b[nidx] != 0的判断,以及所有坐标转换是否统一用同一套函数。这种 bug 在界面上表现为棋子重叠,控制台版会直接覆盖对方棋子,非常隐蔽。

6. 进阶技巧:用 Zobrist 哈希加置换表把搜索深度翻倍

前面那套 Minimax 加 Alpha-Beta,深度 4 在普通笔记本上大概几百毫秒到一秒,深度 6 就明显卡顿。想再往上走,最划算的优化是置换表(Transposition Table)。五子棋搜索里,不同的落子顺序可能到达同一个棋盘状态,置换表把「棋盘状态 → 搜索结果」缓存下来,下次遇到相同状态直接查表,省掉整棵子树。

棋盘状态的哈希用 Zobrist:为每个格子、每种颜色预生成一个随机 64 位数,棋盘哈希就是所有已落子对应随机数的异或。落子时异或一次,撤销时再异或一次,天然支持增量更新。

#include <random> #include <unordered_map> uint64_t zobrist[BOARD_CELLS][3]; // [idx][color] void initZobrist() { std::mt19937_64 rng(20240501); for (int i = 0; i < BOARD_CELLS; ++i) for (int c = 1; c <= 2; ++c) zobrist[i][c] = rng(); } uint64_t boardHash(const Board& b) { uint64_t h = 0; for (int i = 0; i < BOARD_CELLS; ++i) if (b[i]) h ^= zobrist[i][b[i]]; return h; } struct TTEntry { int depth; int value; int flag; }; // flag: 0 精确 1 下界 2 上界 std::unordered_map<uint64_t, TTEntry> tt;

搜索时先算当前棋盘哈希,查tt,如果命中且缓存深度不小于当前剩余深度,直接返回缓存值。否则正常搜索,搜完把结果写回。flag用来区分精确值、下界和上界,Alpha-Beta 里如果返回值落在 alpha 和 beta 之间就是精确值,小于等于 alpha 是上界,大于等于 beta 是下界。

置换表配合候选点排序,深度 6 通常能压到一秒以内,深度 8 在开局阶段也能接受。注意unordered_map在节点量大时会有哈希冲突和内存分配开销,追求极致可以换成固定大小的数组加取模,或者用std::vector加开放寻址。我一般先用unordered_map跑通,确认棋力提升后再换底层容器。

还有一个容易被忽略的点:置换表里的值依赖搜索深度,同一个棋盘在深度 4 和深度 6 下的评估值可能不同,所以查表时必须比较深度,缓存深度不够就重新搜。另外胜负分不要直接存绝对值,存相对于当前搜索层的分值,否则不同深度查表会出错。这些细节我第一次写的时候全踩过,AI 查表后棋力反而下降,排查了半天才发现是深度没比较。

最后说个习惯:每次改完搜索参数,不要只看 AI 赢不赢,要固定几个典型局面(比如活三防守、冲四进攻、双三)单独测,确认 AI 的选择符合预期。棋力提升是渐进的,靠感觉调参很容易越调越乱。希望帮到你。

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