☰
C语言五子棋人机对弈实战:从数组到AI评分全解析
2026/10/15 14:35:01 网站建设 项目流程

简介:C语言五子棋人机对弈程序是一份面向C语言初学者、课程设计学生及算法爱好者的完整实践资源,解决如何在控制台环境下用C语言实现具备人机对弈功能的五子棋游戏。压缩包内共两个文件,包含一个cpp源代码文件和一个可直接运行的exe可执行程序,资源包仅109KB,下载后无需配置环境即可运行体验。已有1958人学习使用。源码围绕二维数组构建棋盘,实现了落子合法性判断、五子连珠胜负检测、坐标输入交互及游戏主循环控制;AI模块采用启发式评估与搜索剪枝思路,体现了极小极大搜索、Alpha-Beta剪枝等基础博弈算法的实际应用。通过阅读该项目,可以深入理解数组、循环、函数模块化、递归等C语言核心知识,同时掌握将复杂规则拆解为清晰流程的编程方法。适合作为课程设计模板、编程实训作业或入门级人机博弈项目参考。

1. C语言五子棋人机对弈:一个能写进简历的练手项目

C语言五子棋人机对弈程序,拆开看就是一个二维数组、一条判赢逻辑和一个打分函数。它没有图像识别,也不需要图形库,却能把数组越界、输入校验、函数拆分、状态管理这些C语言基本功一次练透。很多人做的第一个完整项目就是它,因为收益足够直接:在命令行里跑起来,能跟朋友下一盘,还能说“这AI是我写的”。这套程序的AI不是真的在思考,而是用评分表做贪心决策,但把防守逻辑理顺之后,普通玩家想赢它并不轻松。适合正在做课设、准备笔试面试、或者刚学完指针和结构体想找实战的人。本文我直接按自己习惯的实现路线讲:从棋盘存储、判赢算法,到AI评分和排错,每个步骤都给出能跑的C代码和参数调整思路。

2. 棋盘与数据结构:15×15棋盘怎么存、怎么打印、怎么校验

2.1 为什么选15×15而不是19×19棋盘

标准五子棋棋具是15×15,19路是围棋棋盘尺寸,两者混用是新手常见失误。15×15对控制台程序很友好,打印出来宽度合适,判赢遍历的复杂度也更低。代码里把棋盘大小定义成宏,以后想改19路只需改一处,不影响AI逻辑。数据结构用最直接的二维数组,0表示空位,1表示黑棋,2表示白棋,玩家先手执黑还是AI执黑由主循环决定,棋盘存储层不需要关心先后手。

#include <stdio.h> #define BOARD_SIZE 15 #define EMPTY 0 #define BLACK 1 #define WHITE 2 int board[BOARD_SIZE][BOARD_SIZE];

逻辑说明:这是一个全局二维数组,行和列的范围都是0到14。打印时给用户看1到15的坐标,内部存储用0到14,这样打印和存储之间只差一个减法,不容易搞混。宏定义的好处是后面所有循环、边界判断、AI扫描都依赖BOARD_SIZE,调整棋盘规格时不用到处改数字。

参数说明:BOARD_SIZE建议保持15。如果改成19,打印宽度变大,控制台在小窗口下会换行错位,而且评分表的候选点数量会从225涨到361,AI计算量增加约60%,对一个小项目来说不值得。

2.2 棋盘初始化与打印:让玩家在控制台看得见棋盘

棋盘初始化用两层循环全部置空,打印时加上行列号。行号列号显示成1到15,空位用点号,黑棋用大写X,白棋用大写O。控制台的等宽字体下,这种打印方式最好辨认。

void initBoard() { for (int i = 0; i < BOARD_SIZE; i++) { for (int j = 0; j < BOARD_SIZE; j++) { board[i][j] = EMPTY; } } } void printBoard() { printf(" "); for (int col = 0; col < BOARD_SIZE; col++) { printf("%2d ", col + 1); } printf("\n"); for (int row = 0; row < BOARD_SIZE; row++) { printf("%2d ", row + 1); for (int col = 0; col < BOARD_SIZE; col++) { if (board[row][col] == EMPTY) { printf(" . "); } else if (board[row][col] == BLACK) { printf(" X "); } else { printf(" O "); } } printf("\n"); } }

逻辑说明:initBoard在每局开始时调用一次,避免上一局的棋子残留。printBoard每轮落子后调用,顶部一行的列号能让玩家快速定位坐标,左侧的行号同理。棋盘数组是全局的,所以打印函数不需要接收参数,直接访问全局board即可。

参数说明:棋子符号可以按个人喜好换,有的实现用*和#,但X和O最接近五子棋的黑白认知。列号数字占2位,配合每个格子3个字符宽度,整体对齐不会乱。如果改了棋盘大小,打印函数会自动适配,因为循环上界用的是宏。

2.3 玩家落子与坐标校验:输入格式错不乱崩

玩家输入“行 列”两个整数,程序先减1转成内部下标,再判断是否越界、是否重复落子。这里最容易翻车的是scanf读入非法字符后的死循环,所以输入函数必须处理非数字输入。

typedef struct { int row; int col; } Point; int readPlayerMove(Point *p) { int row, col; printf("请输入落子坐标(行 列,中间用空格):\n"); while (scanf("%d %d", &row, &col) != 2) { while (getchar() != '\n'); // 把这一行残留的非法字符全部清掉 printf("输入格式错误,请重新输入(行 列):\n"); } p->row = row - 1; p->col = col - 1; return 1; } int isValidMove(int row, int col) { if (row < 0 || row >= BOARD_SIZE) return 0; if (col < 0 || col >= BOARD_SIZE) return 0; if (board[row][col] != EMPTY) return 0; return 1; }

逻辑说明:readPlayerMove返回1表示读入成功,调用方拿到Point后必须再交给isValidMove做合法性判断,不能直接落子。内部while循环专门处理scanf读不到两个整数的情况,getchar会不断吞掉残留字符直到换行符,这样程序不会因为输入字母而卡死。坐标减1这步是关键,人看到的是1到15,数组下标是0到14,少做这步会出现“玩家明明点对了位置,棋却下偏一格”的怪事。

参数说明:scanf的返回值是成功读入的变量个数,所以判断!= 2能同时捕获“只输入了一个数字”和“输入了字母”两种情况。getchar清理只针对当前行,如果输入的是“abc”,换行符前的所有字符都会被吞掉,循环重新提示输入,不会进入到下一轮逻辑。

3. 胜负判定:五连检查为什么不能只盯落子点

3.1 方向数组与连续计数:一段代码覆盖四个方向

判赢是五子棋程序的第一个大坑。最简单可靠的方案是只检查最后一步落子的四个方向,而不是每次遍历整个棋盘。因为任何一局棋的胜利,最后一手必然落在获胜连子上,所以从最后一手出发向两端数连续同色棋子,四个方向只要有一个方向连子数大于等于5就赢了。

int checkWin(int row, int col, int player) { int dirs[4][2] = { {1, 0}, // 竖直方向 {0, 1}, // 水平方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; for (int d = 0; d < 4; d++) { int count = 1; int dx = dirs[d][0]; int dy = dirs[d][1]; int r = row + dx; int c = col + dy; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == player) { count++; r += dx; c += dy; } r = row - dx; c = col - dy; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == player) { count++; r -= dx; c -= dy; } if (count >= 5) return 1; } return 0; }

逻辑说明:dirs数组用四组方向增量表示竖、横、两条对角线,这是围棋和五子棋判型里最常见的方向表示法。每个方向先沿正方向数,再沿反方向数,中间落子点本身算1个,最终count就是该方向上的连续同色棋子总数。四个方向只要有一个方向count等于5,直接返回赢。注意边界判断必须放在数组访问之前,否则越界后读取到内存垃圾,可能造成误判或程序崩溃。

参数说明:count >= 5而不是count == 5,因为六连棋在普通规则下也算赢。如果以后想实现五子棋专业规则中的长连禁手,这里需要改成精确判断,但基础版不需要考虑。方向数组的顺序不影响结果,但习惯上把竖直和水平放前面,后面两个对角线方向,调试时更符合直觉。

3.2 棋型概念:活三、冲四、活四为何影响AI决策

判赢只需要数连子数,但AI评估还需要知道每个方向上“连子两侧的状态”,这就引出了棋型概念。下表中列的是AI评分前必须能区分的五种基本形态。

棋型形态示意胜负意义
成五XXXXX立即获胜
活四XXXX两端都空,对手无法同时堵住,必胜
冲四XXXXX 或 XXXX只有一端敞开,对手必须堵,但还有机会
活三XXX下一步能形成活四,威胁很大
眠三XXX_或_XX_X下一步只能形成冲四,威胁可控

AI判定“活”和“冲”的关键在于计算两端开口数。一个方向如果连续同色棋子两侧都是空位,开口数就是2;只有一侧空,开口数就是1;两侧都被堵则开口数为0。有了开口数,才能判断当前棋型的威胁程度。很多新手判赢只数连子不数两端,导致AI把冲四当成活四处理,下了不该下的防守位置。

3.3 判型函数:连子数和两端开口一起返回

为了让AI能用人能理解的棋型思维去评估,我习惯写一个方向分析函数,返回当前方向上以某个点为中心形成的“连子数×10 + 开口数”编码,然后在高层次函数里把这个编码映射成棋型分数。

int inBoard(int row, int col) { return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } int analyzeLine(int row, int col, int dx, int dy, int player) { int count = 1; int openEnds = 0; int r = row + dx; int c = col + dy; while (inBoard(r, c) && board[r][c] == player) { count++; r += dx; c += dy; } if (inBoard(r, c) && board[r][c] == EMPTY) openEnds++; r = row - dx; c = col - dy; while (inBoard(r, c) && board[r][c] == player) { count++; r -= dx; c -= dy; } if (inBoard(r, c) && board[r][c] == EMPTY) openEnds++; return count * 10 + openEnds; }

逻辑说明:analyzeLine和checkWin的遍历方式几乎一样,区别在于返回值。它用编码同时表达“连了几个”和“开口几个”,比如返回52就表示当前方向上有5连且两端都空,这在标准规则下就是必胜。返回41表示4连且一端开口,即冲四;42表示4连且两端开口,即活四。

参数说明:这个编码只作为中间表达,不建议直接当分数用。编码值在后面的评分函数里被转换成具体分值,成五、活四、冲四之间的分值差异决定了AI的决策倾向。需要特别注意analyzeLine只能识别连续的X形,跳子形态如“XX_X”会被当成两端都是X的2连和1连,基础版AI无法识别这种复杂棋型,后面会有应对方法。

4. AI核心:评分表驱动的人机对弈,先防后攻更省心

4.1 棋型打分表:从成五到活二的权值怎么定

AI落子逻辑本质是对每一个空位打分,选分最高的点落子。打分表决定AI性格,下面是我用的一套基础分值,数值之间的比例关系比绝对值更重要。

棋型分值设置理由
成五100000立即赢,必须最高
活四50000对手堵不住,下一手必胜
冲四10000对手必须堵,能换来先手
活三5000下一步可形成活四,威胁大
眠三1000只能形成冲四,威胁有限
活二500开局和中盘的铺路棋
眠二100有潜力的单点连接
单子10几乎没有威胁

成五和活四的差距不要拉得太大,否则AI在“自己已经能成五”的时候,可能因为活四分数接近而成五分数超高,逻辑上没问题,但实际中可能出现AI判断出的制胜点没被候选点覆盖的情况。这种情况靠第4.3节的必赢检测解决,不靠调分。

4.2 候选点筛选与评估函数:AI怎么决定下一步

最直接的AI是扫描全盘所有空位,每个空位算一个总分。总分由两部分组成:如果AI在这里落子,自己四个方向能形成的棋型分;如果玩家在这里落子,AI将面临的威胁分。前者乘以进攻权重,后者乘以防守权重,两个权重默认都设为1。

int evaluatePoint(int row, int col, int player) { int total = 0; int dirs[4][2] = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; board[row][col] = player; // 临时把棋子放上去 for (int d = 0; d < 4; d++) { int code = analyzeLine(row, col, dirs[d][0], dirs[d][1], player); total += scoreFromCode(code); } board[row][col] = EMPTY; // 评估完立刻恢复,不能污染真实棋盘 return total; } int scoreFromCode(int code) { int count = code / 10; int openEnds = code % 10; if (count >= 5) return 100000; if (count == 4) { if (openEnds == 2) return 50000; if (openEnds == 1) return 10000; } if (count == 3) { if (openEnds == 2) return 5000; if (openEnds == 1) return 1000; } if (count == 2) { if (openEnds == 2) return 500; if (openEnds == 1) return 100; } return 10; }

逻辑说明:evaluatePoint先把空位临时填上player的棋子,然后调用analyzeLine分别评估四个方向,最后恢复空位。这个临时落子的技巧非常关键,它保证评估逻辑和真实落子后的形态完全一致,不会出现“打分时说能成五,真下下去却差一格”的割裂感。scoreFromCode把上一章的编码值翻译成分数,openEnds为0的连子两端都被堵死,这种形状没有继续发展的空间,分数直接落到单子级别。

参数说明:进攻权重和防守权重不写死在evaluatePoint里,而是在AI决策主函数外面用两个宏控制。常见的做法是攻击权重和防守权重都取1,让AI公平看待自己和对手的棋型;如果AI表现得过于激进,可以尝试把防守权重调到1.2到1.5。但必须先保证必赢必堵检测逻辑正确,再动权重,否则容易掩盖真正的问题。

4.3 必赢与必堵检测:让AI不在关键回合犯糊涂

纯打分AI有个毛病:当玩家已经形成“四子一线”时,AI评估每个空位的防守分数,理论上会去堵,但分数可能不够高,或者被另一处进攻点干扰。解决方法是把决策拆成三个层次:先找自己一步能赢的位置,再找对手一步能赢的位置,最后才做全盘打分。

Point findImmediateWin(int player) { Point p = {-1, -1}; for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (board[row][col] != EMPTY) continue; if (evaluatePoint(row, col, player) >= 50000) { p.row = row; p.col = col; return p; } } } return p; } Point aiMove(int aiPlayer, int humanPlayer) { Point p = findImmediateWin(aiPlayer); if (p.row != -1) return p; p = findImmediateWin(humanPlayer); if (p.row != -1) return p; int bestScore = -1; Point best = {-1, -1}; for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (board[row][col] != EMPTY) continue; int score = evaluatePoint(row, col, aiPlayer); score += evaluatePoint(row, col, humanPlayer); if (score > bestScore) { bestScore = score; best.row = row; best.col = col; } } } return best; }

逻辑说明:aiMove先调用findImmediateWin检查AI自己的成五和活四机会,只要存在就直接落子,不再看其他位置。第二步检查玩家是否也有成五或活四机会,如果有,必须堵。这两步都扫描全盘,后面的打分循环才进入常规落子选择。这种“先必赢,再必堵,最后评分”的顺序是五子棋AI最实用的骨架,代码量不大,但棋力提升非常明显。

参数说明:findImmediateWin里的阈值50000对应活四,意思是只要这个位置落下去能形成活四或成五,就当作必胜点。实际中AI有成五点应该先赢,有活四也几乎必胜,所以阈值设在50000合理。如果只设成100000,AI可能错过活四制胜,只认成五,棋力会明显变弱。

5. 五子棋人机对弈避坑:5个让程序翻车的经典问题

5.1 现象:AI明明能赢却不下制胜一手

现象是AI在前几步表现正常,到了中盘突然在一个无关位置落子,把必赢的成五机会漏掉。原因通常是两个:一是候选点筛选范围太窄,AI只在有棋子的周边搜索,而制胜点恰好离最近棋子超过两格;二是评估函数里活四和成五分数不够高,被其他位置的活三干扰。解决办法是在打分之前全盘扫描必赢点,不受候选点限制。我自己的经验是,只要把findImmediateWin放在aiMove最前面,这个问题基本不会再出现。如果还有,检查是不是阈值写高了,导致活四没被识别为必胜。

5.2 现象:输入一个字母后程序像卡住一样

现象是玩家输入abc后,屏幕上不停滚动“请输入落子坐标”,看起来像死循环。原因是scanf没有读到两个整数,返回0,但输入缓冲区里的字母还在,下轮scanf继续失败,于是无限循环。解决办法是每次读入失败后,用while (getchar() != '\n');把整行残留字符吞掉。注意这条语句会把换行符也读走,所以放在成功读入的分支后面没有影响,只放在失败分支里最安全。

while (scanf("%d %d", &row, &col) != 2) { while (getchar() != '\n'); printf("输入格式错误,请重新输入:\n"); }

逻辑说明:getchar每次读一个字符,直到读到换行符停止,这一行里的所有残留内容都被消费掉。下一轮scanf面对的是一个干净的输入行。如果不做这步清理,scanf会一直读到同样的非法字符,循环无法结束。

5.3 现象:运行到一半数组越界,棋盘上多出“垃圾棋”

现象是程序运行几个回合后,棋盘的某些格子上出现不明的数字或符号,甚至直接崩溃。原因多在判赢或判型函数里,循环里先访问board[r][c]再判断r和c是否越界。比如从棋盘边界的棋子向上数,r变成-1,board[-1][c]访问了数组前面的内存,读到的就是垃圾数据。解决办法是边界判断必须放在数组访问前面。

while (inBoard(r, c) && board[r][c] == player) { count++; r += dx; c += dy; }

逻辑说明:C语言里&&是短路运算符,inBoard返回0时,后面的board[r][c]根本不会执行,所以这样写能确保数组访问永远在下标合法的范围内。我之前踩过坑,顺序反写成board[r][c] == player && inBoard(r, c),边界上的棋子一多就翻车,所以现在所有判型函数都统一先写inBoard。

5.4 现象:AI只防守不进攻,几回合后必输

现象是AI每一步都在堵玩家的棋,自己始终没有形成连续进攻,最后玩家在另一个方向活三加冲四,AI顾此失彼。原因是防守权重设置过高,每个空位的evaluatePoint(aiPlayer)乘以进攻权重后,永远小于evaluatePoint(humanPlayer)乘以防守权重,AI变成纯防守机器人。解决办法是先去掉权重系数,让进攻分和防守分等权相加,再看AI表现。如果AI还是只防不攻,检查必赢检测里是不是只检查了堵对方,没有自己找活四和成五。

int score = evaluatePoint(row, col, aiPlayer); score += evaluatePoint(row, col, humanPlayer);

逻辑说明:等权相加的含义是,AI认为自己的活三和对方的活三同等重要,这符合五子棋的基本策略。更精细的做法是检测到对方已经有活四时,防守优先级自动提升,但这一步已经由findImmediateWin完成了,打分函数里不需要重复处理。

5.5 现象:悔棋后AI连续走两步

现象是玩家请求悔棋后,棋盘上AI的棋没消失,下一轮却由AI继续落子,等于AI连下了两手。原因是悔棋只撤销了“最后一步”,而这最后一步恰好是AI刚下的;或者悔棋函数不知道该撤销几手。解决办法是维护一个落子历史栈,每下一步就压入坐标和玩家标记,悔棋时弹出两步,一步给AI,一步给玩家。

typedef struct { int row; int col; int player; } MoveRecord; MoveRecord moveHistory[BOARD_SIZE * BOARD_SIZE]; int historyCount = 0; void undoTwoMoves() { if (historyCount < 2) return; MoveRecord m1 = moveHistory[--historyCount]; board[m1.row][m1.col] = EMPTY; MoveRecord m2 = moveHistory[--historyCount]; board[m2.row][m2.col] = EMPTY; }

逻辑说明:每次成功落子后,把坐标和player写入moveHistory,historyCount加一。悔棋时连续弹两条记录,先弹的是AI的落子,再弹的是玩家的落子,顺序由双方轮换决定,不能只弹一条。这个栈的容量足够存满一局225手,不需要动态扩容。

6. 进阶:剪枝提速与随机对弈验证,值得继续投入的两个方向

6.1 候选点剪枝:让AI的落子速度变成“秒出”

全盘扫描225个位置在15×15棋盘中速度尚可,但每个位置要评估四个方向,每个方向又向两边延伸,实际开销比预期大。优化思路是只评估已有棋子周围两格以内的空位,因为远离棋子的位置在中盘阶段不可能产生有效威胁。开局时没有棋子,单独把棋盘中心当作唯一候选点即可。

int isNearby(int row, int col) { for (int i = -2; i <= 2; i++) { for (int j = -2; j <= 2; j++) { int r = row + i; int c = col + j; if (inBoard(r, c) && board[r][c] != EMPTY) { return 1; } } } return 0; }

逻辑说明:isNearby检查当前空位周围5×5范围内是否存在棋子,存在才把这个位置加入评分循环。AI决策时跳过所有isNearby返回0的空位,候选点从225锐减到通常几十个,肉眼可见地变快。这个优化不会降低棋力,因为距离所有棋子两格以上的位置在当前局面下没有实际意义。

6.2 用随机对弈验证AI棋力:胜率比感觉可靠

调AI时最容易凭感觉判断“这步好像变强了”,其实误差很大。我更推荐写一个自动对弈函数,让两个不同权重的AI自己下100局,统计胜率。

int simulateOneGame(int ai1, int ai2) { initBoard(); int current = BLACK; int moves = 0; while (moves < BOARD_SIZE * BOARD_SIZE) { Point p; if (current == ai1) { p = aiMove(ai1, ai2); } else { p = aiMove(ai2, ai1); } board[p.row][p.col] = current; if (checkWin(p.row, p.col, current)) { return current; } current = (current == BLACK) ? WHITE : BLACK; moves++; } return EMPTY; }

逻辑说明:simulateOneGame让两个AI轮流落子,走满225手还没有人赢就判平。跑100局统计黑棋和白棋的胜率,能快速判断权重调整是否有效。注意黑棋有先手优势,如果黑胜率明显偏高是正常的,重点看调整前后的胜率变化,而不是绝对值。这个方法也能自动复现第5.1节那种“AI漏掉制胜点”的翻车场景,因为随机对弈里一旦AI没走必赢点,对手有机会翻盘,结果一目了然。

6.3 还能继续做:悔棋、棋谱、禁手规则

目前的程序已经具备完整对弈能力,值得继续投入的方向有三个:一是把moveHistory扩展成完整的棋谱记录,支持保存和复盘;二是实现悔棋菜单,让玩家可以悔一步或两步,配合上面的undoTwoMoves函数;三是加入五子棋专业规则里的禁手判断,这对AI的要求会从“会下”变成“下得好”,棋力验证也更有说服力。我做过最快的AI优化是先保证必赢必堵检测完备,再用随机对弈验证,而不是一直调评分表里的数字。评分表要改就一次只改一个数值,否则几个参数互相影响,最后只能靠玄学调参。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询