最近接到一个小需求:写一个 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代表需要连成几颗棋子才能获胜。五子棋就是SIZE=15、NEED=5,井字棋就是SIZE=3、NEED=3。
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就能跑,算是一块非常好用的万能积木。