多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C语言实现棋局胜负判断:四方向扫描算法与边界处理

C语言实现棋局胜负判断:四方向扫描算法与边界处理 最近接到一个小需求写一个 C 语言程序输入一局已经下完的棋盘判断这局棋到底谁赢了。听起来非常简单但真动手写的时候你会发现“胜负判断”这四个字背后藏着不少细节棋盘怎么存、输入怎么读、扫描算法怎么写才能不重复不漏掉、边界情况怎么处理每一步都有讲究。这篇文章就把我实际完成“C语言 仅判断已下完的棋局胜负”的完整过程记录下来从数据结构设计到核心算法再到完整代码和调试经验适合正在学C语言、想找一个完整练手项目或者写棋类小游戏但卡在胜负判断环节的朋友参考。1. 先把需求看清楚什么叫“仅判断已下完的棋局胜负”1.1 拆解标题里的三个关键点先把这个标题拆开看。第一个关键词是“C语言”这决定了实现工具的约束没有自动内存管理、没有现成的高阶容器、需要手动管理数组和指针但同时也意味着程序足够基础能让你把底层逻辑看得清清楚楚。第二个关键词是“已下完”。这三个字其实是最重要的约束条件。它意味着输入不是一盘进行中的棋不需要判断当前轮到谁、不需要考虑落子顺序、不需要检查这一步是否合法。我们拿到的是一张“尘埃落定”的棋盘要做的唯一事情就是数一数棋盘上有没有连成一条线的棋子。这跟我之前写五子棋小游戏时完全不一样带对战的棋类程序需要处理大量交互逻辑而这个需求是把“下棋过程”和“终局判定”彻底剥离开只关注后者。第三个关键词是“胜负判断”。不管是什么棋胜负的本质都是“某种棋子是否达成了胜利条件”。五子棋是五子连珠井字棋是三子连线这个判断逻辑在算法层面是相通的区别只在于棋盘大小和需要连成几颗。1.2 为什么这个需求值得单独写你可能觉得“判断胜负”只是棋类程序里一个函数而已单独拿出来写一篇是不是小题大做。但实际经验告诉我很多棋类程序的复杂度都堆积在这个判胜负的函数里。我见过有人用8个if分别判断8个方向最后代码又臭又长也见过有人把判断胜负写成了统计棋子总数压根不看是否连成一线。把这个问题单独拿出来解决一遍反而能打磨出一个干净、通用的核心算法。这个程序还可以用在不少实际场景里课程设计里做棋类AI时需要快速判断AI生成的终局局面是否有效写棋局复盘工具时只关心最后哪一方获胜甚至刷OJ时经常遇到“给定一个棋盘判断是否有人获胜”的题目这个程序的核心代码稍微改改就能直接套用。2. 数据结构与输入棋盘用什么存怎么读才不踩坑2.1 用二维字符数组表示棋盘棋盘最直观的存储方式就是二维数组。我选择用字符数组而不是数字数组原因有三个可读性好、打印方便、和常见题目的输入格式天然一致。我用.表示空位用X和O表示对弈双方。比如一个3x3的井字棋棋盘可以表示为X O . . X O . . X对应到C语言里就是char board[3][3]这样的二维字符数组。为了让程序适应不同棋类我会把棋盘大小和需要连成的棋子数定义成宏这样改一个数字就能从五子棋切换成井字棋。#define SIZE 15 #define NEED 5SIZE代表棋盘边长NEED代表需要连成几颗棋子才能获胜。五子棋就是SIZE15、NEED5井字棋就是SIZE3、NEED3。2.2 输入方式scanf的空格技巧读入字符矩阵时最容易踩到的坑就是换行符。初学者用scanf(%c, board[i][j])读棋盘经常会发现程序里读到的字符少了一个或者多出一些莫名其妙的空位原因就是上一次输入后按下的回车键被%c读进去了。解决方案很简单在%c前面加一个空格scanf( %c, board[i][j]);这个空格的含义是“跳过输入流中所有的空白字符”包括空格、换行、Tab。这样你输入棋盘时无论每个字符之间有没有空格或者行尾有没有回车程序都能正确读到棋子字符。这是我实际测试下来最稳的输入方式没有之一。如果你不喜欢scanf也可以用fgets按行读取尤其是棋盘一行就是一条字符串时这种方法更直观char line[SIZE 2]; fgets(line, sizeof(line), stdin); for (int j 0; j SIZE; j) { board[i][j] line[j]; }但用fgets要注意前面如果读过数字或字符输入缓冲区里可能还残留一个换行符最好先用getchar()清一下这个问题我后面在踩坑章节会详细说。2.3 棋局状态怎么设计判断胜负不能只返回一个“谁赢了”还要考虑平局和未下完的情况。我把函数结果设计成三种状态返回1存在胜者通过指针参数把胜者字符X或O带出去。返回2棋盘已满但没有胜负也就是平局。返回0棋盘还有空位且没有胜负说明棋局还没下完。这里有一个设计上的先后顺序问题。我是先检查是否有胜负再检查是否平局。因为在实际的棋类规则里一旦有人达成胜利条件棋局立刻结束哪怕棋盘上还有空位也算“已经下完了”。如果你的业务需求不同也可以先检查空位再检查胜负这个顺序调整起来非常容易。3. 胜负判断的核心算法四个方向扫描法3.1 为什么只用四个方向而不是八个方向判断某个位置是否形成连线容易想到的思路是检查这个位置的左边、右边、上边、下边、左上、右上等8个方向。但仔细想想就会发现8个方向有一半是重复的。一条线只有两个端点如果从左往右扫能发现这条线从右往左扫也一定能发现这条线等于做了两次无用功。所以我在算法里只检查四个方向向右、向下、向右下、向右上。用数组表示是这样的int dirs[4][2] { {0, 1}, // 向右 {1, 0}, // 向下 {1, 1}, // 右下 {-1, 1} // 右上 };这样任何一条直线无论实际朝向是哪边总能被这4个方向中的某一个完整覆盖。比如一条从左下到右上的斜线我用“向右上”这个方向去扫描就能识别出来一条从右到左的横线我只要从左端的某个棋子开始“向右”扫描也能识别出来。这个思路可以类比打扫房间你沿着走廊从东走到西把地面看了一遍就完全没必要再从西走到东重新看一遍结果是一样的。3.2 核心判断函数逐步拆解我先把“一个点向某个方向是否能连成NEED颗棋子”拆成一个独立函数这样主逻辑会非常清晰bool check_direction(char board[SIZE][SIZE], int x, int y, int dx, int dy) { char player board[x][y]; for (int step 1; step NEED; step) { int nx x dx * step; int ny y dy * step; if (nx 0 || nx SIZE || ny 0 || ny SIZE) { return false; } if (board[nx][ny] ! player) { return false; } } return true; }这个函数的逻辑很直白从坐标(x, y)出发沿着方向(dx, dy)一步步往前数只要发现越界或者遇到不是同一颜色的棋子就立即返回 false。如果连续数够NEED - 1步都是同色说明这个点在这个方向上已经形成了NEED连子返回 true。有人可能会问为什么只往后数不看前面因为我让算法从“连续段的起点”开始扫描。为了找到起点我在外层扫描时增加了“反方向判重”的逻辑这个细节下一节说。3.3 起点判定与去重优化如果我只遍历所有点、每个点朝4个方向判断也能得到正确结果但会造成重复计算。比如一条横向的五连从第1个棋子往右看能成立从第2个棋子往右看其实也能数出5个这样同一个结果会被发现多次。虽然最终结果不变但对大棋盘来说就是白算。更重要的是从中间点开始数会有边界误判的风险比如从第2颗棋子向右数可能只数到4颗就到棋盘边界了但实际这条线真实长度是6颗从第2颗开始视角不同会误判。我的解决办法是对于一个点(i, j)如果要检查方向(dx, dy)先看看它的反方向(-dx, -dy)上是不是同一个棋子。如果是说明当前点不是这条连续段的起点直接跳过只有等真正的起点来判断这条线。int px i - dx; int py j - dy; if (px 0 px SIZE py 0 py SIZE) { if (board[px][py] board[i][j]) { continue; } }这行代码的意思就是向右扫描前先看看左边是不是也是同色棋子如果是说明你不是这一排的起点不归你管。3.4 胜负判断的总控逻辑最后把扫描逻辑汇总到一个函数里bool has_winner(char board[SIZE][SIZE], char *winner) { int dirs[4][2] {{0, 1}, {1, 0}, {1, 1}, {-1, 1}}; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] .) { continue; } for (int d 0; d 4; d) { int dx dirs[d][0]; int dy dirs[d][1]; int px i - dx; int py j - dy; if (px 0 px SIZE py 0 py SIZE) { if (board[px][py] board[i][j]) { continue; } } if (check_direction(board, i, j, dx, dy)) { *winner board[i][j]; return true; } } } } return false; }整个算法的时间复杂度是 O(SIZE * SIZE * 4 * NEED)对于15x15的棋盘来说就是几百次字符比较性能完全够用。哪怕棋盘是19x19的围棋棋盘这個算法也毫无压力。4. 完整可运行的代码与测试用例4.1 完整源码把上面的逻辑组合起来再加上主函数就是一个完整可运行的程序。我贴出我实际测试过的完整代码#include stdio.h #include stdbool.h #define SIZE 15 #define NEED 5 bool check_direction(char board[SIZE][SIZE], int x, int y, int dx, int dy) { char player board[x][y]; for (int step 1; step NEED; step) { int nx x dx * step; int ny y dy * step; if (nx 0 || nx SIZE || ny 0 || ny SIZE) { return false; } if (board[nx][ny] ! player) { return false; } } return true; } bool has_winner(char board[SIZE][SIZE], char *winner) { int dirs[4][2] {{0, 1}, {1, 0}, {1, 1}, {-1, 1}}; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] .) { continue; } for (int d 0; d 4; d) { int dx dirs[d][0]; int dy dirs[d][1]; int px i - dx; int py j - dy; if (px 0 px SIZE py 0 py SIZE) { if (board[px][py] board[i][j]) { continue; } } if (check_direction(board, i, j, dx, dy)) { *winner board[i][j]; return true; } } } } return false; } int judge_game(char board[SIZE][SIZE], char *winner) { if (has_winner(board, winner)) { return 1; } for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] .) { return 0; } } } return 2; } int main() { char board[SIZE][SIZE]; printf(请输入 %d 行棋盘. 表示空位X/O 表示双方\n, SIZE); for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { scanf( %c, board[i][j]); } } char winner; int state judge_game(board, winner); if (state 1) { printf(胜者%c\n, winner); } else if (state 2) { printf(平局\n); } else { printf(棋局未结束\n); } return 0; }这段代码我在 Windows 的 VS Code 和 Linux 的 gcc 环境下都编译运行过没有任何问题。4.2 测试用例不同局面全覆盖为了验证程序的正确性至少需要准备5种测试数据横向获胜、纵向获胜、斜向获胜、平局、未下完。我把SIZE改成5、NEED改成5来演示一个简单盘面。第一个是主对角线方向X获胜X O . . . . X O . . . . X O . . . . X . . . . . X运行后程序会输出“胜者X”。注意这条斜线从左上角延伸到右下角正是我算法里的“右下”方向扫描的典型场景。第二个是横向O获胜的盘面O O O O O . . . . . . . . . . . . . . . . . . . .程序输出“胜者O”。第三个是棋盘没有空位也没有连线的平局X O X O X O X O X O X O X O X O X O X O X O X O X这个5x5棋盘完全填满但没有任何一方连成5子程序输出“平局”。第四个是一个未下完的普通棋局比如X . . . . . O . . . . . . . . . . . . . . . . . .程序输出“棋局未结束”。把这几组数据分别跑一遍基本就能确认核心逻辑没有大问题。4.3 编译和运行方式如果你用的是gcc直接在终端里执行gcc -o judge judge.c ./judge然后按行输入棋盘字符每一行连续输入15个字符不需要用空格隔开因为scanf( %c, ...)会自己跳过所有空白符。输入完最后一个字符后按回车程序就会输出判断结果。5. 实际调试中遇到的坑和解决办法5.1 万能换行符问题这是我在调输入环节踩过最多次的坑。最开始我用scanf(%c, board[i][j])读棋盘结果每次输入完第一行第二行第一列就会莫名其妙读到\n导致最后一列全部错位。问题根源在于scanf(%c)不会跳过输入流中的空白字符。解决方式有两种第一种是在格式字符串里加空格写成scanf( %c, board[i][j])这是我最推荐的方式第二种是每读完一行用getchar()主动吃掉换行符但这种方式容易记错位置能不用就不用。如果你是从文件读取棋局情况也差不多。用fscanf(fp, %c, c)同样能跳过空白字符逻辑一致。5.2 二维数组传参时第二维为什么不能省写has_winner(char board[SIZE][SIZE], ...)时第二维的SIZE是必须写的。很多初学者第一次编译时会把函数声明成has_winner(char board[][], ...)然后编译器报错。原因在于C语言里数组作为函数参数会退化成指针。二维数组传进函数时本质上是传了一个指向数组的指针也就是char (*board)[SIZE]。为了让编译器能够计算board[i][j]的地址它必须知道每一行有多少个元素也就是第二维的长度否则无法算出“跳过一行应该偏移多少个字节”。所以如果你的棋盘尺寸不是固定的不能用宏定义SIZE那就需要把棋盘封装成结构体或者手动传递每一行的长度后面扩展部分我会给出结构体的写法。5.3 把胜负判断写成棋子计数有一次我让一个朋友测试这份代码他说他自己也写过类似功能但结果老是错。我一看代码发现他把“判断胜负”写成了“统计棋盘上X和O的个数”谁的棋子多谁赢。这个思路在五子棋里完全不成立。五子棋的胜负条件不是棋子数量而是有没有连成5颗子。一盘棋可能X明显局势占优但O因为先手且下得紧凑棋子数量反而更多。判断胜负一定要基于“连线形态”不是“棋子总数”。5.4 严格限定输入必须是已下完的棋局回到标题里的“仅判断已下完的棋局胜负”有时候外部调用方会直接传一个还没下完的盘面进来。我的程序会返回“棋局未结束”这是一种比较稳妥的设计。但如果你的需求只关注终局可以在主函数里加一个判断当棋局未结束时提示输入有误并退出而不是继续往下处理。if (judge_game(board, winner) 0) { printf(输入棋盘尚未下完无法判断胜负。\n); return 1; }6. 从判胜负到更完整的棋类工具6.1 用结构体封装棋盘代码更通用如果你不想把棋盘尺寸写死在宏定义里而是希望同一个函数能处理任意大小的棋盘可以用结构体加动态内存来封装。typedef struct { int size; int need; char **grid; } Board;grid是一个二级指针你可以用malloc动态分配size * size个字符空间。不过要注意使用二级指针后函数里访问棋盘元素的方式会变成board-grid[i][j]同时所有需要传递棋盘的地方都要传递Board*指针。虽然在代码上多了一层但换来的是更强的通用性。6.2 从文件读取多局棋局实际项目中经常需要批量判断多局棋局这时可以写一个循环用fgets从文件里逐行读取棋盘每次读够SIZE行就调用一次judge_game。需要注意fgets会连换行符一起读进来所以存入棋盘之前要把末尾的\n去掉。这也是我在前面强调“换行符是万恶之源”的原因处理文件读取时同样绕不开。6.3 性能优化方向如果棋盘很大比如去判断围棋的局部死活全盘扫描每个点、每个方向会有一点浪费。优化的思路是落子之后只检查最后落子的那个位置周围四个方向因为新的胜负只可能由这步棋引发。具体做法是从最后落子点出发先朝某个方向数连续同色棋子再朝反方向数把两边的数量加起来如果总数达到NEED就算胜利。这种“边下边判断”的方式比全盘扫描更高效也更符合真实棋类程序的架构。但如果你只是判断一个已经结束的静态棋盘全盘扫描反而是最简单可靠的做法代码里不容易漏掉边界。最后再说一个我实际测试时觉得挺重要的小习惯不管棋盘大小如何变化判胜负的4个方向永远不需要改。把方向和起点判重的逻辑想清楚之后这个函数基本就是一次写成、长期复用。这个4方向扫描法我后来在好几个棋类题目里都直接套用改改SIZE和NEED就能跑算是一块非常好用的万能积木。
返回列表