尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C++五子棋控制台源码解析:从二维数组到权值贪心AI实战

发布时间:2026/9/25 4:32:36

资讯中心
01
ARTICLE

C++五子棋控制台源码解析:从二维数组到权值贪心AI实战

C++五子棋控制台源码解析:从二维数组到权值贪心AI实战
简介C实现的五子棋小游戏源码包同时支持人机对战与人人对战两种模式面向C初学者和游戏开发入门者可从中学习项目整体架构、AI决策思路与交互流程设计。压缩包仅106KB共收录7个文件3个C源码文件、3个可直接运行的exe执行程序与1个说明txt。源码细分为鼠标版、键盘版、人对人版、人对机版等多个版本既能直接体验也方便对照代码查看不同输入方式与对战流程的差异。资源已有178人学习浏览适合作为C图形界面与逻辑分离的练习参考。代码覆盖棋盘类与棋子类的面向对象设计、人机对战中的AI走子策略、玩家落子校验以及界面刷新等关键环节读者可在此基础上继续优化AI算法如引入Minimax或Alpha-Beta剪枝或扩展更多玩法是巩固C编程和游戏开发基本功的实用素材。1. 从控制台黑框框开始的 C 五子棋一份能跑的人机与人人对弈源码拿到一份 C 五子棋源码最实际的场景往往是晚上十一点还在赶课程设计或者刚学完数组和函数想找个能立刻编译通过的小项目练手。这份资源带的就是你想到的那两种对弈模式人人和人机。人机模式下的对手用的是权值贪心不是随机乱下人人模式又在同一个棋盘上保留了完整判定与换手逻辑拿来拆正好能覆盖 C 的基础语法、二维数组、函数传参和简单状态机。你不用先装图形库一个 dev C 或者 VS 都能直接跑主逻辑全在控制台里完成适合 C 入门、课程设计和想亲手改一套小游戏的读者。2. 棋盘建模与坐标体系为什么用二维数组而不是点集控制台五子棋的棋盘建模基本就是三种路子固定大小二维数组、动态二维数组、坐标点集。这套源码用的是int board[15][15]我当时拆的时候还专门想过为什么不用点集——后来跑起来就明白了五子棋判定要求“沿方向连续计数”数组能直接用下标偏移定位邻居点集反而要来回查集合代码绕一圈还容易出错。棋盘尺寸固定为 15 路这是五子棋最常见的规格直接写死比动态分配省事也让后续的胜负判定和 AI 搜索少传一个尺寸参数。2.1 数据结构选型静态二维数组是控制台五子棋的最优解const int BOARD_SIZE 15; // 标准五子棋棋盘 int board[BOARD_SIZE][BOARD_SIZE] {0}; // 0空 1黑 2白全部初始化为空先看这个声明BOARD_SIZE是编译期常量15 代表 15×15 路棋盘board是全局的二维数组元素值 0、1、2 分别表示空、黑子、白子。全局变量的好处是主循环、判定函数、AI 函数都能直接访问不需要每个函数都传一个巨型参数。用{0}初始化能确保开局棋盘干干净净这一步漏了后面会出一堆“莫名奇妙的残棋”问题。对比一下常见替代方案建模方式代码量访问速度典型问题int[15][15]最少最快边界条件要手动管vectorvectorint中等略慢初始化繁琐棋盘尺寸本就不变点集/哈希集合多中等方向计数时反复查存在性逻辑绕我的习惯是控制台小游戏一律静态二维数组。你要改成 19 路围棋棋盘也不是不行把BOARD_SIZE换成 19 就行但这套源码里所有循环、判定、AI 候选点筛选都依赖这个常量别只改一处。2.2 落子函数与坐标约束把输入翻译成数组下标用户看到的是第 1 行到第 15 行、第 1 列到第 15 列但数组下标从 0 数到 14。这个换算不做对棋子在视觉上就会整体偏移一格。所以我一般会单独写一个落子函数把“校验”和“写入”捆在一起bool placePiece(int board[][BOARD_SIZE], int row, int col, int player) { // 先判边界row/col 从 0 开始 if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return false; // 出界落子失败 } if (board[row][col] ! 0) { return false; // 已有棋子落子失败 } board[row][col] player; // 0 空 1 黑 2 白 return true; }函数返回bool主循环直接拿它判断“这步到底成没成”。先边界后占用顺序别反如果先查board[row][col] ! 0而 row 已经越界数组读到未定义内存Dev C 可能不报错但 VS 的调试版会直接崩。参数player传 1 或 2和棋盘值的约定保持一致后面 AI 评分、胜负判定都用同一套枚举不需要额外映射。主循环里的调用长这样int row 0, col 0; cin row col; row--; col--; // 用户从 1 开始数数组从 0 开始必须在这里同步减掉 if (placePiece(board, row, col, curPlayer)) { // 落子成功判断胜负再换手 curPlayer (curPlayer 1 ? 2 : 1); // 黑白轮换 } else { // 落子失败不换手提示重新输入 cout 该位置不可落子请重新输入 endl; }这串逻辑里最容易漏的就是row--; col--;。我见过不少初版源码把这句写在placePiece内部结果 AI 内部坐标也被减了一次导致 AI 永远偏一格正确做法是只在用户输入入口做一次换算内部分层全部用“从 0 开始的数组下标”这样人机模式和人人模式吃到同一套坐标基准。2.3 玩家轮换用三目运算符维护当前执子方curPlayer这个变量贯穿整个对局它决定轮到谁落子也决定胜负判定时比对哪个颜色。用三目切换比curPlayer 3 - curPlayer更直白因为 1 和 2 并不是简单的数学互补int curPlayer 1; // 1黑总是先手 void switchPlayer() { curPlayer (curPlayer 1 ? 2 : 1); }这里有个细节黑棋先手是五子棋的固定规则所以curPlayer初始值永远是 1。在人人模式里换手是轮流执行在人机模式里人类下完一手、AI 下完一手看起来也是轮流但 AI 那步不能走cin输入流程而是走单独的chooseAIMove函数。模式切换时你只需要在“是否走控制台输入”这个分支上做区分棋盘数据结构和判定逻辑完全复用一个版本这比复制两份代码冒充两种模式要干净得多。3. 五子连珠判定全盘扫描与局部检测的两种写法胜负判定是五子棋源码的心脏。很多初版代码把“扫描整个棋盘”写在落子循环里每下一步就全盘检查一遍15×15 的棋盘这么做也只是 225 个点性能完全能接受。但如果你后面想加 AI 评估这个 O(N²) 的判定会在候选点评分里被放大几十倍所以我会把两种写法都摆出来一种通用、一种高效配合场景选着用。3.1 方向向量五子棋判定的唯一核心五子棋要查 8 个方向的连子水平、垂直、主对角线、副对角线每个方向有正反两边。8 个方向可以压缩成 4 组方向向量这也是这套源码里最值得抄的一段// 四个方向水平、垂直、主对角线、副对角线 const int dx[4] { 0, 1, 1, 1 }; const int dy[4] { 1, 0, 1, -1 };理解这组数组的关键dx表示行增量dy表示列增量。水平方向是行不变、列加一所以是(0, 1)垂直方向是行加一、列不变所以是(1, 0)主对角线是行和列同时加一(1, 1)副对角线是行加一、列减一(1, -1)。每个方向向正负两端延伸就覆盖了 8 个朝向。这个方向向量的写法在 AI 评分、落子提示、悔棋预览里全会复用值得单独抄出来当公共常量。3.2 局部检测以最后落子点为锚向两端延伸这种写法是效率最高的判胜方式每次落子后只有新落的这颗棋可能形成五连所以只检查它周围即可。bool checkWin(int board[][BOARD_SIZE], int row, int col) { int player board[row][col]; if (player 0) return false; // 空点不可能成五连 for (int dir 0; dir 4; dir) { int count 1; // 当前落子先算一颗 // 正方向最多探 4 步 for (int step 1; step 4; step) { int nr row dx[dir] * step; int nc col dy[dir] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! player) break; count; } // 反方向同样探 4 步 for (int step 1; step 4; step) { int nr row - dx[dir] * step; int nc col - dy[dir] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! player) break; count; } if (count 5) return true; // 达成五连 } return false; }几个容易问的问题为什么只探 4 步而不是 5 步因为当前落点本身已经占用一颗正反方向各 4 颗加起来最多 9 颗只要其中连续 5 颗同色就赢了。为什么遇到异色或边界用break而不是continue因为棋子是连续摆放的中间断开一颗再往外找已经失去连续性继续探测纯属浪费。为什么用count 5而不是count 5棋盘上可能出现六连、七连的极端情况普通家庭规则直接判胜没必要强制拆开。3.3 全盘扫描不依赖落子点的兜底写法局部检测依赖“最后一次落子位置”作为参数。调试时如果你只拿到一个棋盘状态、想知道某个颜色是否已经赢了全盘扫描更直接bool scanBoardForWin(int board[][BOARD_SIZE], int player) { for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (board[i][j] ! player) continue; // 只检查右、下、右下、左下四个方向 for (int dir 0; dir 4; dir) { bool win true; for (int k 1; k 5; k) { int ni i dx[dir] * k; int nj j dy[dir] * k; if (ni 0 || ni BOARD_SIZE || nj 0 || nj BOARD_SIZE) { win false; break; } if (board[ni][nj] ! player) { win false; break; } } if (win) return true; } } } return false; }注意这里每个起点只查四个正方向不再查反方向。因为反方向的连子一定会在更早的起点被检查到如果 (5,5) 到 (9,5) 是五连从 (5,5) 出发向下就能发现如果从 (9,5) 向上查那 (5,5) 之前那颗棋已经被遍历过了。全盘扫描加反方向会造成重复计数虽然不影响胜负结果但会让代码看起来多了一倍无意义的循环。两种判定的取舍我给个对比表对比项局部检测全盘扫描调用时机每次落子后任意时刻时间复杂度固定探 4 方向 × 正反 4 步接近 O(1)遍历全盘O(N²)代码量略多少适合场景主循环每步判胜调试、AI 终局评估、模式切换后校验控制台五子棋主循环建议用局部检测AI 评分时如果需要对大量候选点算“如果下这里能不能赢”全盘扫描可以作为底层校验函数复用。两段代码配合方向向量基本覆盖了这套源码里 80% 的判定需求。4. 人机 AI 的贪心落子从权值表到进攻防守叠加人机模式是这套源码最有嚼头的部分。不做搜索树、不做蒙特卡洛只靠“给每个空点打分最高分落子”的贪心策略就能打出有模有样的对局。核心是两件事一是怎么给“连子模式”定分二是怎么把进攻和防守统一到一个分里。4.1 先给连子模式打分权值表的差距要拉开AI 评估一个空点时要想象“如果这里放一颗我的棋子这个方向上能形成多强的形状”。活三、冲四、活四这些术语落到代码里就是“连续同色子数”加“两端是否被封堵”的组合。// 给某个方向上的连续同色子模式打分 // blockedLeft / blockedRight 表示两端是否被对手棋子或边界堵住 int patternScore(int consecutive, bool blockedLeft, bool blockedRight) { if (consecutive 5) return 100000; // 已成五连直接赢 if (consecutive 4) { if (!blockedLeft !blockedRight) return 50000; // 活四无法防守 if (!blockedLeft || !blockedRight) return 10000; // 冲四对手必须挡 return 0; // 两边都堵废棋 } if (consecutive 3) { if (!blockedLeft !blockedRight) return 5000; // 活三 if (!blockedLeft || !blockedRight) return 1000; // 眠三 return 0; } if (consecutive 2) { if (!blockedLeft !blockedRight) return 500; // 活二 if (!blockedLeft || !blockedRight) return 100; // 眠二 return 0; } return 10; // 只有一颗孤子它未来可能发芽但当前什么也不是 }这里最关键的工程经验是分值差距要拉开。活四给 50000、活三给 5000、冲四给 10000三档之间都是 5 到 10 倍的量级。如果分差太小比如活三和冲四都只差两位数AI 就会在该“进攻成四”还是“防守对方活三”之间摇摆下出来的棋忽攻忽守人机体验非常智障。我调这套参数时踩过一轮最后确认必须让“马上能赢的形状”一眼压过“潜在发展的形状”。4.2 双视角评估进攻分与防守分共同决定落子一个空点AI 放上去对自己形成活三如果这个点被对手抢到对手可能直接冲四。所以评估函数要站在两个视角各算一次分再叠加。常见做法是临时落子、算分、还原现场// 假设 AI 颜色是 1对手颜色是 2 int evaluatePoint(int board[][BOARD_SIZE], int row, int col) { if (board[row][col] ! 0) return -1; // 被占据的点直接排除 int total 0; // 视角一假设 AI 落在这里 board[row][col] AI_COLOR; total scoreAround(board, row, col, AI_COLOR); // 视角二假设对手落在这里 board[row][col] HUMAN_COLOR; total scoreAround(board, row, col, HUMAN_COLOR) * 9 / 10; board[row][col] 0; // 还原现场别影响后面候选点的评估 return total; }scoreAround的作用是把某个点周围四个方向上的patternScore累加起来。为什么对手方向只乘9 / 10这是在给进攻和防守定权重。如果两边各乘 1.0AI 会变成一个纯防守机器人整天挡对手的棋自己毫无攻势防守乘 0.9 之后AI 在“自己能成冲四”和“对方活三”之间选择时会稍微偏向进攻对局节奏更接近人类。这个 0.9 不是数学推导出来的是实战手感调出来的参数你可以自己改成 0.85 或 1.0 感受区别。4.3 候选点与 AI 主循环真正可运行的选点逻辑有了evaluatePointAI 落子就是一次全盘遍历取最大分。不过全盘 225 个空点一大半离战局十万八千里远评分结果全是“单子”的 10 分白白浪费计算。常做的优化是只收集周围 3×3 内有子的空点作为候选vectorpairint,int collectCandidates(int board[][BOARD_SIZE]) { vectorpairint,int cand; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (board[i][j] ! 0) continue; // 只考虑空点 bool nearPiece false; for (int di -1; di 1; di) { for (int dj -1; dj 1; dj) { int ni i di; int nj j dj; if (ni 0 || ni BOARD_SIZE || nj 0 || nj BOARD_SIZE) continue; if (board[ni][nj] ! 0) { nearPiece true; break; } } if (nearPiece) break; } if (nearPiece) cand.push_back({i, j}); } } if (cand.empty()) cand.push_back({BOARD_SIZE / 2, BOARD_SIZE / 2}); // 开局兜底 return cand; }这个函数把 AI 的评估范围从 225 个点压到几十个点。注意最后的兜底逻辑棋盘空空如也时候选集是空的直接返回天元位置(7,7)比让 AI 落(0,0)更像人下棋。AI 主循环再把候选点逐个代入evaluatePoint选分最高的那个pairint,int chooseAIMove(int board[][BOARD_SIZE]) { vectorpairint,int candidates collectCandidates(board); pairint,int best candidates[0]; int bestScore -1; for (auto [r, c] : candidates) { int s evaluatePoint(board, r, c); if (s bestScore) { bestScore s; best {r, c}; } } return best; // AI 在这个坐标落子 }这个 AI 的搜索深度是 1也就是只看“我下这步的局部收益”完全不考虑对手下一步会怎么回应。对控制台五子棋来说这个强度刚好落子在毫秒级人类感觉不到卡顿。想再往上加强可以把evaluatePoint换成一层极小极大搜索即“假设我下这里对手会选对他最有利的点回应我再评估那个局面”但这会让搜索时间从毫秒涨到几百毫秒先在现有架构上把贪心调顺比盲目套搜索树重要得多。5. 常见问题与避坑排查控制台五子棋最容易翻车的 5 处这套源码拆下来真正难的不是看懂逻辑而是自己在空白工程里重新敲一遍时踩的那些坑。下面 5 个场景基本是我见过的高频翻车现场每一条都在说“现象是什么、为什么发生、怎么修”你照着排查比从头读代码快得多。5.1 落点偏移一格坐标换算忘减 1现象用户输入“8 8”黑子落在第 9 行第 9 列所有棋子整体向右下偏移一格棋盘边缘的落子更是直接出界。原因用户看到的是从 1 数到 15 的坐标而数组下标从 0 数到 14。输入没减 1写入位置就理所当然偏了一格。解决在读取用户输入的代码处统一执行row--; col--;并且只执行这一次。不要在落子函数里再次减否则 AI 通过内部坐标调用placePiece时会发生二次偏移。用第 2 章的placePiece做入口后主循环里所有“用户输入 → 落子”都走同一换算这个问题基本绝迹。5.2 第二手就报胜利方向计数没有复位现象对局刚开始不久明明棋盘上只有三四颗棋子程序突然宣布某方获胜。原因胜负判定里的count变量定义在了方向循环的外面。第一方向的计数累加到 3进入第二方向时count没有归 1带着旧值继续加稍微探到一颗同色棋就把总数顶上 5于是提前触发胜利。解决每次进入dir循环强制重置int count 1。这是五子棋判胜最容易犯的经典错误比坐标偏移还隐蔽因为它只在特定方向组合下才触发不是必现 bug。写判断函数时可以先写一个错误版本再对照修正或者直接在checkWin里把count声明放进for (int dir...)内部从语法上杜绝共用旧值。5.3 重开一局棋盘上还留着旧棋memset 用错现象一局结束后选“再来一局”新棋盘上旧棋子还在而且集中在某些区域并不是全部残留。原因新对局开始前没有把棋盘内存清干净。源码里如果棋盘是全局变量第二次对局时它仍保留上一局的 0/1/2 数据局部变量声明时如果忘记重新{}初始化读到的也是栈上的旧值。解决对局开始时执行一次性清盘memset(board, 0, sizeof(board)); // 整个二维数组清零比双重循环快且直观memset是按字节连续覆盖内存二维数组在内存里就是连续的 225 个 intsizeof(board)直接拿到整个数组的字节数写起来比15 * 15 * sizeof(int)更稳也避免漏改尺寸常量。重开对局前调用一次旧棋就彻底消失。5.4 AI 把子下到已有棋子的位置候选集没过滤现象人机模式下AI 偶尔会把棋子叠在人类刚下的棋子上导致棋盘出现两个颜色同时占一格后续判定全乱。原因AI 选点函数遍历了所有坐标但没判断该位置是否为空。显示层只渲染最后写入的颜色看起来像“AI 的棋子消失了”实际是重叠写入把前一颗覆盖了。解决在collectCandidates和chooseAIMove的最前面都加空点过滤if (board[i][j] ! 0) continue;这是最直接的过滤。更稳的做法是 AI 选点后落子前再调用一次placePiece二次校验如果返回false就强制排除该点重新选。双保险能防止候选集生成逻辑改动时引入新 bug。5.5 行列顺序反了用户习惯被 (x, y) 带偏现象提示文字写“请输入行和列”用户输入“3 5”棋子却落到了第 5 行第 3 列。玩家反复尝试后认定程序有 bug。原因很多人从小到大接触的坐标习惯是从数轴学来的(x, y)即先列后行。而五子棋源码里用row和col表示行列读入顺序是先行后列。变量名如果写成x和y解析时更容易把第一个数字当列。解决提示语不要写“请输入横纵坐标”而是明确写“请输入行 列例如8 8”。源码里变量名统一用row、col禁止出现x、y这类语义模糊的命名读入后先row--; col--;再落子保证解析顺序和用户看到的提示一字不差。这个坑纯属人机交互设计问题但会让试玩者第一印象大打折扣。6. 把这套源码变成自己的悔棋、存档与 AI 扩展源码跑通只是第一步真正把项目变成自己的靠的是顺着现有结构加功能。这里给三个我实际改过的方向全部基于前面的棋盘模型不破坏原有架构。6.1 用历史栈实现悔棋棋类游戏最刚需的功能就是悔棋。现有棋盘只有“当前状态”没有“过去状态”所以需要一份历史记录。我用一个vector模拟栈struct Move { int row; int col; int player; // 记录下这颗棋的是黑方还是白方 }; vectorMove history; void undoMove() { if (history.empty()) return; Move last history.back(); history.pop_back(); board[last.row][last.col] 0; // 擦掉这颗棋 curPlayer (curPlayer 1 ? 2 : 1); // 换回上一手 }每次落子成功把坐标和玩家压进history悔棋时弹出栈顶并恢复棋盘。人机模式下 AI 的落子也压栈玩家悔一次只是擦了 AI 的棋需要连续悔两次才能回到自己落子前。要不要支持“连悔两步”取决于你对局体验的定位。6.2 存档与读档把棋盘序列化到文本文件控制台游戏的存档不需要 JSON直接文本输出最省事void saveGame(const char* path) { ofstream fout(path); for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { fout board[i][j] ; } fout \n; } fout curPlayer; // 顺手存下当前轮到谁 fout.close(); }15 行乘 15 列共 225 个数字读档时按同样顺序填入board再恢复curPlayer就能无缝续局。这个格式简单到可以用记事本直接检查哪一行出问题比二进制格式好排查得多。6.3 给人人模式加一个“落子提示”这个技巧是最划算的扩展人人模式里加一个命令输入h就调用现成的chooseAIMove把建议落点打印出来if (mode MODE_PVP input h) { auto [hr, hc] chooseAIMove(board); cout 提示落点行 hr 1 列 hc 1 endl; continue; // 不消耗本轮 }等于把 AI 从前台挪到幕后变成人人对弈里的人工外挂。这个技巧也能反过来验证 AI 强度让两个人用提示功能对战观察提示方胜率比看一百行评分日志直观多了。从那以后我每次拿到这类控制台源码都会先做三件事重新编译一遍、按“人人 → 人机 → 重开一局”的顺序把模式全跑一遍、再故意输入非法坐标试试它会不会崩。这个习惯已经帮我拦下了好几次翻车——坐标偏移、计数复用、清盘遗漏全是在这一步现形的。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。