资讯动态

C语言五子棋课设实战:从二维数组棋盘到AI评分算法

发布时间:2026/10/26 8:12:15 来源:尧图企业网站定制
简介一份基于C语言的五子棋实践项目包含双人对战、单人对战AI与悔棋功能面向C语言初学者及游戏开发学习者帮助巩固语法、数组、函数、栈与算法设计等核心知识点。资源共33个文件以源程序.c/.cpp、Visual Studio工程文件.sln/.vcxproj、编译产物.exe/.pdb/.obj及运行截图.png为主压缩包约52.65MB。已有2643人学习下载足见其作为课程设计或自学项目的参考价值。内容除完整可编译的代码外还附带了可直接运行的五子棋程序与界面截图便于对照验证效果AI对战部分涉及搜索算法与剪枝思路悔棋功能演示了用栈记录历史状态的方法棋盘更新、五子连珠判定、非法输入处理等模块均有清晰实现适合深入剖析并在此基础上扩展功能。1. 一个C语言五子棋项目值不值得自己写从课程设计到工程习惯期末课程设计的名单里C语言五子棋几乎是和“学生成绩管理系统”并列的常客。这个 .zip 里装的通常不是一个能拿去参加比赛的成品而是一份把数组、循环、函数、指针全部串起来的课设作业双人对战负责流程控制单人对战负责算法悔棋则逼你把数据结构真的用起来。很多人拿着源码交完作业就删了真正值得做的是借它把“会写C语法”变成“能独立拼出一个可运行的小程序”。这篇按 C 语言实现五子棋最常见的结构展开从棋盘、判胜、悔棋到最简单的电脑 AI把可以直接抄的代码和踩过的坑一起说清楚。适合两类人准备答辩课设的学生和想用一个小项目巩固 C 语言基础代码习惯的初学者。2. 棋盘与胜负判定C语言数组才是这个项目的骨架五子棋项目拆开看最核心的数据结构就是一张棋盘。棋盘选什么数据结构直接决定后面判胜、悔棋、AI 的代码怎么写。常见做法是用一个二维数组下标映射横纵坐标数组里存三种状态空、黑子、白子。这一步想清楚整个项目就稳了一半。2.1 用 int 二维数组存 225 个交叉点15×15 比 19×19 更合适标准五子棋是 15 路棋盘也就是 15×15 共 225 个交叉点。有人为了显得“专业”选 19×19但在控制台程序里19 列宽度很容易触发换行打印出来的棋盘歪七扭八15×15 在默认窗口宽度下刚好一行放下行号列号也能对齐。所以先用 15×15。#define SIZE 15 #define EMPTY 0 #define BLACK 1 #define WHITE 2 int board[SIZE][SIZE]; void init_board(void) { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { board[i][j] EMPTY; } } } int place_stone(int x, int y, int player) { if (x 0 || x SIZE || y 0 || y SIZE) { return 0; } if (board[x][y] ! EMPTY) { return 0; } board[x][y] player; return 1; }逻辑说明board[i][j]里 i 是行j 是列。place_stone做了两件必须做的事越界检查和重复落子检查。返回值 1 表示成功0 表示失败这样外面可以直接根据返回值决定要不要切换到下一位玩家。参数说明这里选int而不是char是为了后面 AI 评分时做加减乘除方便用char省内存但每次比较都要强转课设项目没有必要。数组是全局变量虽然封装性差点但五子棋这种单文件小项目全局数组能让所有函数直接访问棋盘省去传参的麻烦这也是控制台小游戏里最常见的写法。你没看错这个项目从头到尾不需要malloc一次因为棋盘大小在编译期就固定了这反而避开了一堆内存管理问题——对刚接触 C 语言内存管理的人来说是件好事。2.2 胜负判定四个方向各扫一遍别写八个 if五子棋判断胜负最容易想到的思路是刚落了子往上下左右、斜上斜下八个方向数有没有连续五颗。新手常见写法是八个方向各写一个 while 循环代码很长还容易漏。更干净的做法是只定义四个方向向量每个方向往正向和反向各扫一遍。int check_win(int x, int y, int player) { int dirs[4][2] { {0, 1}, // 水平方向 {1, 0}, // 垂直方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; for (int d 0; d 4; d) { int count 1; for (int sign -1; sign 1; sign 2) { for (int step 1; step 5; step) { int nx x dirs[d][0] * step * sign; int ny y dirs[d][1] * step * sign; if (nx 0 || nx SIZE || ny 0 || ny SIZE) { break; } if (board[nx][ny] player) { count; } else { break; } } } if (count 5) { return 1; } } return 0; }逻辑说明sign变量先取 -1 再取 1表示先朝一个方向数再朝相反方向数。step最多到 4因为刚落的子已经算一个往两边各数 4 颗就是极限。count 5而不是count 5是因为棋盘边界或对手棋子会截断延伸但足够五连就判胜。这里最容易被 C 语言数组越界坑到的地方是必须先做边界判断再访问board[nx][ny]。两个操作顺序反了程序不会立刻崩但会在边角落子时读到野地址出一些看似随机的问题。我见过不少人的判胜函数在棋盘中央正常一到边线就偶尔失灵原因基本都是这个。2.3 打印棋盘两位数坐标的对齐问题控制台五子棋的棋盘打印看起来简单实际上有个很烦的细节10 以后是两位数和前面的个位数对不齐。解决方法是统一用printf(%2d)占两位宽度输出。void print_board(void) { printf( ); for (int i 0; i SIZE; i) { printf(%2d , i 1); } printf(\n); for (int i 0; i SIZE; i) { printf(%2d , i 1); for (int j 0; j SIZE; j) { if (board[i][j] EMPTY) { printf( . ); } else if (board[i][j] BLACK) { printf( X ); } else { printf( O ); } } printf(\n); } }逻辑说明第一行先打印列号然后每一行开头打印行号。棋盘内部用空格包裹棋子符号保证每个格子宽度一致。参数说明这里用 . 、 X 、 O 三个字符串三者宽度都是 3 个字符所以列号即便用%2d 也能和格子对齐。如果你想换成黑白实心圆点● ○要注意中文字符的宽度问题那个在后面的避坑章节专门讲。3. 双人对战与悔棋命令循环、输入解析与历史栈双人对战是整个项目的“主骨架”。它把人机对战里的输入输出流程跑通后面接 AI 只是替换掉其中一个落子来源而已。这一章的核心是两件事怎么用循环把一局游戏串起来以及怎么用栈实现悔棋。3.1 主菜单与游戏主循环用 while 而不是 do-while程序入口先给一个模式选择菜单然后用switch分发到双人或单人模式。游戏本身是一个无限循环每轮做四件事打印棋盘、读输入、落子、判胜。int main(void) { printf( C语言五子棋 \n); printf(1. 双人对战\n); printf(2. 单人对战电脑先手\n); int mode 0; scanf(%d, mode); getchar(); if (mode 1) { play_two_player(); } else { play_vs_ai(); } return 0; } void play_two_player(void) { init_board(); int player BLACK; char line[64]; while (1) { print_board(); printf(玩家%d落子输入 x y悔棋输入 u, player BLACK ? 1 : 2); fgets(line, sizeof(line), stdin); if (strncmp(line, u, 1) 0) { undo_two_player(player); continue; } int x, y; if (sscanf(line, %d %d, x, y) ! 2) { printf(输入格式不对请重新输入\n); continue; } // 用户习惯按1~15输入内部数组下标要减1 if (!place_stone(x - 1, y - 1, player)) { printf(该位置已有棋子或越界\n); continue; } if (check_win(x - 1, y - 1, player)) { print_board(); printf(玩家%d获胜\n, player BLACK ? 1 : 2); break; } player (player BLACK) ? WHITE : BLACK; } }逻辑说明这里刻意用while (1)而不是do-while因为落子失败、输入非法时都要重新循环用do-while结构反而不自然。fgets加sscanf的组合是处理控制台输入的关键scanf直接读数字会留下换行符在缓冲区后面再读字符串时直接读到空串。参数说明x、y是用户按 1~15 输入的坐标而二维数组下标是 0~14所以落子和判胜前都要减 1。这里很容易漏掉漏掉的直接后果是用户输 1 1 却落在 (0,0)看起来整体偏移了一位。建议把x - 1和y - 1写成一个局部变量避免同一行代码里出现多次减法可读性会好很多。3.2 悔棋的历史栈用数组模拟后进先出悔棋本质上就是“撤销最后一步”。最匹配的数据结构是栈每落一子就压栈悔棋时从栈顶弹出一颗子。C 语言里不需要写链表一个固定大小数组加一个栈顶指针就够因为一局最多下 225 手。typedef struct { int x; int y; int player; } Move; Move history[SIZE * SIZE]; int history_top 0; void record_move(int x, int y, int player) { history[history_top].x x; history[history_top].y y; history[history_top].player player; history_top; } int undo_move(void) { if (history_top 0) { return 0; } history_top--; int x history[history_top].x; int y history[history_top].y; board[x][y] EMPTY; return 1; } void undo_two_player(int *player) { if (!undo_move()) { printf(没有可悔的棋\n); return; } *player history[history_top].player; }逻辑说明record_move在每次成功落子后调用把坐标和当前玩家压栈。undo_move弹出栈顶并清空棋盘对应位置。关键的细节在undo_two_player最后一行悔棋之后当前玩家应该回到被撤销那一步的落子者。参数说明为什么是history[history_top].player而不是把玩家简单取反假设玩家1下了黑子轮到玩家2此时栈顶是 playerBLACK。悔棋后需要玩家1重新下所以当前玩家要设成栈顶记录的 BLACK。如果只是取反就会变成玩家2落子等于悔完棋交换了先后手整局逻辑全乱。这个细节是悔棋最常见的翻车点。3.3 人机模式下的悔棋只允许悔 AI 刚走的那一步人机模式里悔棋的语义要比双人模式小心。如果玩家落子后 AI 立刻应了一步玩家再悔棋通常的预期是撤销 AI 这部让玩家重新考虑。实现起来就是只弹一次栈。void play_vs_ai(void) { init_board(); int player BLACK; // 玩家执黑电脑执白 char line[64]; while (1) { print_board(); if (player BLACK) { printf(玩家落子输入 x y悔棋输入 u); fgets(line, sizeof(line), stdin); if (strncmp(line, u, 1) 0) { if (!undo_move()) { printf(没有可悔的棋\n); } // 撤销的是AI刚落的子玩家重新下 player BLACK; continue; } int x, y; if (sscanf(line, %d %d, x, y) ! 2) { printf(输入格式不对\n); continue; } if (!place_stone(x - 1, y - 1, player)) { printf(该位置已有棋子或越界\n); continue; } record_move(x - 1, y - 1, player); if (check_win(x - 1, y - 1, player)) { print_board(); printf(玩家获胜\n); break; } player WHITE; } else { ai_move(); record_move(ai_last_x, ai_last_y, WHITE); if (check_win(ai_last_x, ai_last_y, WHITE)) { print_board(); printf(电脑获胜\n); break; } player BLACK; } } }这段代码把 AI 的落子位置记录到ai_last_x、ai_last_y两个全局变量里AI 函数在下完子时更新这两个变量。这样判胜和悔棋都不需要重新扫描整张棋盘去找最后一颗白子。4. 单人对战让电脑会用评分法进攻和防守单人模式是课设答辩时的亮点。AI 的实现方案有很多从完全随机的“弱智 AI”到极大极小搜索的“理论 AI”都有。我建议大多数人选评分法代码量小、逻辑直观、答辩时讲得清而且实际对局能堵会攻不至于一眼假。4.1 为什么不是 alpha-beta 搜索很多人一提到 AI 就想到博弈树。五子棋的搜索树分支大不做剪枝根本没法看做了剪枝代码也要几百行而且需要评估函数。可评估函数本身才是决定棋力的关键——没有好的棋型打分搜再深也不用。评分法的本质就是只做“当前这一手”的棋型评分不往后搜。它的棋力当然不如搜索引擎但作为课设足够交差并且代码量只有搜索算法的三分之一。等答辩完想深入再在评分法基础上加一层递归搜索也不迟。4.2 棋型评分表数字定死逻辑才不乱AI 要回答的问题只有一个某个空点值不值得下。值为几由这个点放上己方或对方棋子后在四个方向上形成的“棋型”决定。先定义一套分值表。#define SCORE_FIVE 1000000 // 连五 #define SCORE_LIVE4 100000 // 活四两端都没被堵 #define SCORE_RUSH4 10000 // 冲四一端被堵的四连 #define SCORE_LIVE3 10000 // 活三 #define SCORE_SLEEP3 1000 // 眠三 #define SCORE_LIVE2 1000 // 活二 #define SCORE_SLEEP2 200 // 眠二为什么活四 100000 而冲四只要 10000因为活四下一步必胜必须给极高权重冲四虽然也能形成压迫但对方还有机会堵一端。活三 10000 和冲四同分代表“如果当前局面有这个棋型大概率能逼出个冲四”。分值不用追求极致精确只要数量级拉开AI 的落子倾向就对了。然后写评估函数。这个函数不修改棋盘真实状态而是“假设”把棋子落在(x,y)上从该点出发往两端数连续同色棋子再看两端是空、边界还是堵死。int evaluate_line(int x, int y, int dx, int dy, int player) { int count 1; int open 0; // 正向延伸 int nx x dx; int ny y dy; while (nx 0 nx SIZE ny 0 ny SIZE board[nx][ny] player) { count; nx dx; ny dy; } if (nx 0 nx SIZE ny 0 ny SIZE board[nx][ny] EMPTY) { open; } // 反向延伸 nx x - dx; ny y - dy; while (nx 0 nx SIZE ny 0 ny SIZE board[nx][ny] player) { count; nx - dx; ny - dy; } if (nx 0 nx SIZE ny 0 ny SIZE board[nx][ny] EMPTY) { open; } if (count 5) return SCORE_FIVE; if (count 4) { if (open 2) return SCORE_LIVE4; if (open 1) return SCORE_RUSH4; } if (count 3) { if (open 2) return SCORE_LIVE3; if (open 1) return SCORE_SLEEP3; } if (count 2) { if (open 2) return SCORE_LIVE2; if (open 1) return SCORE_SLEEP2; } return 0; } int evaluate_point(int x, int y, int player) { int dirs[4][2] { {1, 0}, // 水平 {0, 1}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; int total 0; for (int d 0; d 4; d) { total evaluate_line(x, y, dirs[d][0], dirs[d][1], player); } return total; }逻辑说明evaluate_line以(x,y)作为将要落下的那颗子count初始为 1。正向延伸和反向延伸各有一个循环循环结束后判断端点是否为空空则open。open代表这条线有几个开口它是区分“活三”和“眠三”的关键。参数说明dirs数组只定义了四个基方向因为水平、垂直、主对角线、副对角线这四种线已经覆盖了所有五连可能。反向延伸靠调用时把dx, dy取负不需要额外定义方向数组。4.3 AI 落子函数进攻分乘 3防守分乘 2有了单点评估函数AI 就可以遍历棋盘上所有空点每个点算两套分数以己方棋子角度算进攻分以对方棋子角度算防守分。两者加权求和取最高分的位置。int ai_last_x, ai_last_y; void ai_move(void) { // 第一步直接占天元 if (history_top 0) { ai_last_x 7; ai_last_y 7; board[ai_last_x][ai_last_y] WHITE; record_move(ai_last_x, ai_last_y, WHITE); return; } int best_score -1; int best_x 0, best_y 0; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] ! EMPTY) continue; int attack evaluate_point(i, j, WHITE); int defend evaluate_point(i, j, BLACK); int score attack * 3 defend * 2; if (score best_score) { best_score score; best_x i; best_y j; } } } ai_last_x best_x; ai_last_y best_y; board[best_x][best_y] WHITE; record_move(best_x, best_y, WHITE); }逻辑说明先手第一步固定下天元(7,7)也就是棋盘正中心这个位置对 AI 来说最有发展空间。后续每一步遍历所有空点把所有能形成活三、冲四、活二等棋型的位置都打分最后落子在分数最高的位置。参数说明attack * 3 defend * 2这个权重是课设里很实用的经验值。进攻权重大于防守AI 会更积极做棋不会一味跟在玩家屁股后面堵。如果你觉得 AI 太弱把防守分权重提高到attack * 2 defend * 3AI 会变得很怂特别爱堵玩家但自己的棋型起不来。实测下来 3:2 是比较均衡的配比。有一个细节必须注意best_score初始化为 -1不是 0。因为空点的分数理论上最小也是 0初始化为 0 也能跑但当整个棋盘全是空点时所有点分数都是 0AI 会落子在(0,0)而不是中心。这个坑就是导致 AI 第一手走角落的元凶。5. 五子棋实战中的 6 个常见问题排查写控制台游戏真正耗时间的往往不是逻辑而是“环境问题”。这一章把 C 语言五子棋最常见的几个坑按现象、原因、解决的顺序列清楚都是能直接拿来对照的。5.1 中文乱码VSCode 编译运行乱码Visual Studio 却正常现象在 VSCode 里用 MinGW 编译运行所有中文提示变成乱码或者只显示半个汉字换 Visual Studio 就正常。原因源文件保存编码和 Windows 控制台代码页不一致。VSCode 默认 UTF-8而 Windows 命令行控制台默认 GBKVisual Studio 默认用系统 ANSI 代码页所以没这个问题。解决统一输出编码。在main函数开头加上#ifdef _WIN32 #include windows.h SetConsoleOutputCP(65001); #endif这段代码把控制台输出代码页切到 UTF-8配合 VSCode 的 UTF-8 源文件就不会乱码。如果你用的是printf输出中文并且不想动代码在命令行先执行chcp 65001再运行程序也可以但每次开新终端都要执行一次不如在代码里写死。5.2 scanf 残留换行符第二次输入坐标直接被跳过现象第一次输入坐标后按回车程序没有任何提示就进入下一步或者输入悔棋u之后下一次输入不生效循环直接跳过一次。原因scanf(%d %d, x, y)读取完数字后回车键的换行符还留在输入缓冲区。下一次调用scanf(%c, ...)或fgets时读到的就是那个换行符而不是玩家真正输入的内容。解决全项目统一用fgets读一行再sscanf解析。fgets遇到换行符会把它一并读走不会残留。这也是我在这篇文章代码里一直用fgets加sscanf的原因。如果代码里已经混用了scanf可以在每次scanf后加一句while (getchar() ! \n);来清空缓冲区但那种写法不如直接换代。5.3 悔棋之后轮到谁乱了双人品棋变轮流现象双人模式中玩家2下完悔棋玩家1正准备落子屏幕上却提示玩家2继续下。原因悔棋时只恢复了棋盘没恢复player变量。落子后player已经切换到下一位悔棋直接把棋盘清掉switch 逻辑却不知道要把player改回去。解决悔棋接口不要只清理棋盘要把被撤销那一步的落子者返回出来赋值给当前玩家。代码里用*player history[history_top].player;就是从栈里取出上一步是谁下的然后让那个人重新下。这个操作在双人模式和单人模式里语义不同单人模式只撤销 AI 那步所以不需要切换玩家直接保持玩家回合即可。5.4 判胜函数数组越界边角落子时胜负判断失灵现象在棋盘中央下棋一切正常在边线或角落落子后明明连成五子却不判胜偶尔还出现程序崩溃。原因check_win里的延伸循环访问了board[nx][ny]但nx或ny已经超出 0~14 的范围。C 语言不检查数组边界越界读到的是相邻内存的随机值结果完全不可预测。解决在访问数组前必须先判断边界。正确顺序是if (nx 0 || nx SIZE || ny 0 || ny SIZE) { break; } if (board[nx][ny] player) { count; } else { break; }很多新手写成先判断board[nx][ny]再判断边界一旦越界就白判断了。边界检查永远是数组访问的前置条件这条在 C 语言任何项目里都适用。5.5 system(cls) 闪屏每一步都清屏重绘太伤眼睛现象用system(cls)每次落子后清空屏幕命令行窗口闪烁严重下到中盘眼睛很累。原因清屏是整体重绘命令行做不到局部刷新只能全部清掉再逐行打印 15 行棋盘。解决接受闪烁把打印频率降低——比如 AI 落子后玩家输入前才打印玩家落子后 AI 反应很快时先不打印等 AI 完成再一起显示。要彻底解决Windows 下可以用SetConsoleCursorPosition把光标定位到固定坐标重绘棋盘区域但代码量明显增加。对课设来说接受闪烁换代码简洁是合算的。5.6 AI 第一手走角落评分遍历拿到全 0现象AI 先手时第一手直接落在(0,0)角落而不是天元或靠近中心的位置。原因AI 遍历所有空点每个点四个方向都没有相邻棋子evaluate_point返回 0。best_score初始化为 0所有点分数都不大于 0best_x、best_y始终停留在第一个遍历到的点。因为双重循环从(0,0)开始所以 AI 下了角落。解决两个方案一起做更稳。一是best_score初始化为 -1保证至少有一个点会被选中二是在ai_move函数开头判断history_top 0第一手直接固定走天元(7,7)不进主循环。6. 把判胜和 AI 评分抽出来做自动验证给你的代码一个后悔药项目写完答辩前最慌的一件事是“改了一个小地方不知道有没有把别的地方改坏”。五子棋的胜负判定和 AI 评分函数都是纯逻辑不依赖任何全局 UI 状态完全可以写一个简单的验证函数每次改完跑一遍比人肉下单测试靠谱得多。做法很简单写一个test函数手动摆出已知棋型断言check_win的返回值。void test_win(void) { init_board(); // 摆五颗黑子在(7,3)到(7,7) for (int j 3; j 7; j) { board[7][j] BLACK; } int ret check_win(7, 5, BLACK); printf(水平五连测试%s\n, ret 1 ? 通过 : 失败); }类似的测试可以覆盖垂直五连、对角线五连、恰好四连不判胜、边角落子不越界、悔棋后棋盘恢复。把这几个用例写进一个函数在main里用#ifdef包起来只在调试时编译#ifdef DEBUG_TEST test_win(); test_ai_score(); return 0; #endif这样平时正常游戏不走测试加-DDEBUG_TEST编译时自动跑一遍全部分支。用 gcc 编译时gcc -DDEBUG_TEST gobang.c -o test.exe编译通过且测试输出全为“通过”再不带DEBUG_TEST编译发布版本。这个方法花十分钟写好能给后面所有改动兜底是我在多个课设项目里最常用的后悔药。AI 评分函数的验证可以打补丁式检查临时固定几个棋型——活三、冲四、活二各摆一组打印evaluate_point的返回值确认活三分数高于活二、冲四高于眠三符合分档预期即可。分数不对先检查evaluate_line里open的判断很多时候是端点没判空导致把活三当成眠三。说到习惯我自己写控制台 C 项目时最深的教训就是“不要等到写完整程序才编译”。先写完棋盘和判胜立刻编译测试再写输入循环又编译一次最后才接 AI。每一步都增量验证翻车概率会小很多。五子棋这个项目麻雀虽小但这个流程和大型项目完全一致希望这个能帮到你交出一份能跑、能讲、还能当场改出效果的课设。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑