很多学C语言的朋友学到数组、指针、结构体之后都会产生一种“我到底能用它做点什么”的疑问。写控制台计算器太简单做图形界面又太复杂“判断一个已下完的棋局的胜负”正好处在中间——它不要求你懂什么图形库也不需要多高深的算法但能把二维数组、循环、递归、结构体、文件读写这些C语言的核心知识点全部用上。这个题目看起来只是一个围棋或五子棋的小工具实际上它是锻炼C语言工程组织能力的极佳入手点。我之前给一个开源棋类项目做复盘练习时就专门写过一个只用C语言、只负责终局判定的小模块。它接收一个已经结束的棋盘状态不做任何落子交互也不判断过程中是否违规只专注于一件事根据当前盘面算清楚黑棋白棋谁赢了。这个“只判断已下完的棋局胜负”的需求看起来很简单真正动手之后才发现里面涉及大量边界情况比如公活、双活、贴目、劫争残留、死子移除、围空归属判断等等。这篇文章就把我整个实现过程、思路拆解、代码细节和踩过的坑完整分享出来希望能给正在学C语言或者准备做类似练习的朋友提供一份可以直接抄作业的参考。1. 整体设计与思路拆解1.1 题目本质不是下棋而是“判断终局”先明确一个基本问题这个程序不需要模拟整局棋也不需要在落子过程中实时判定。它是拿一个“已经下完的棋局”的最终盘面作为输入然后输出黑方胜、白方胜或者平局。这种需求在现实中经常出现——比如你打完一盘棋之后想快速复盘或者做AI训练时需要用程序对海量终局棋谱结果做标注又或者你想写一个非实时的小工具帮围棋爱好者自动数子。基于这个定位程序设计上就不需要考虑交替落子、禁手规则、超时判负等过程性问题整个逻辑可以大幅简化。我们只需要处理一件事给定盘面算出黑白双方各自的领地包括活子、围空加上应贴的贴目比较大小得出胜负。1.2 方案选型用“数子法”而不是“数目法”围棋终局判定有两种常见规则中国规则用“数子法”日本、韩国规则用“数目法”。数子法相对更直观也更容易用程序实现因为它的核心思路是“把每一块活棋的死子先拿掉死子算入对方领地然后统计所有黑子和黑空之和与所有白子和白空之和做比较”。我最终选择数子法原因是它天然贴合C语言的二维数组模型。棋盘本身就是19×19或者9路、13路的二维数组每一个交叉点只有三种状态黑子、白子、空点。数子法不需要考虑“提劫”之后目数的增减换算只需要把死子标记、把空点归属到某方领地最后统一做数值统计非常直白。但这里有个容易掉进去的坑不管是数子法还是数目法第一步都必须把“死子”清理掉。终局时的棋盘上可能还会有一些已经没有气的死棋比如被包围的棋块如果不先清掉死子空点归属统计就会乱套。所以真正的主算法顺序是识别棋块 → 判断死活 → 移除死子 → 归并领地 → 统计胜负。1.3 输入输出设计用文本文件模拟对局结果为了让代码更通用我没有把棋盘状态硬编码在程序里而是设计成从文件读取盘面。输入格式类似这样用一个19×19的文本矩阵表示棋盘B代表黑子W代表白子.代表空点。这样好处很明显测试时可以手动准备各种边界棋局可以和已有的棋谱导出工具对接逻辑和界面完全分离后续想接GUI或者命令行交互都很容易输出端也很简单直接打印双方最终点数、贴目值以及胜负结论。我额外加了一个可选的“详细模式”把每块棋的死活判定结果都打印出来方便调试。2. 核心数据结构与细节拆解2.1 棋盘表示二维数组与结构体的搭配C语言里最自然的做法就是用char类型的二维数组表示棋盘B、W、E分别代表黑子、白子、空点。不过单纯一个二维数组不够用因为胜负判断过程中需要记录棋子块的归属、气的数量、是否已经访问过等状态。我定义了一个Board结构体来整体管理这些数据#define BOARD_SIZE 19 typedef enum { EMPTY 0, BLACK 1, WHITE 2 } Stone; typedef struct { char grid[BOARD_SIZE][BOARD_SIZE]; int visited[BOARD_SIZE][BOARD_SIZE]; int blackTerritory; int whiteTerritory; int capturedBlack; int capturedWhite; float komi; } Board;这里grid负责实际棋型visited负责在遍历棋块时标记访问状态后面几个整型字段存储统计结果。有人可能会问为什么不用动态二维数组如果棋盘大小只在运行时确定比如用户输入9、13、19动态分配确实更灵活但这里我为了控制复杂度先固定成19路同时把BOARD_SIZE定义为宏——这样以后想改成13路或9路只需要改一行。如果你确实需要动态棋盘建议用一维数组加索引换算的方式grid[row * size col]不要用int**这种两级指针容易踩内存泄漏的坑。2.2 棋块识别DFS连通域搜索判断一块棋的死活第一步要把连通在一起的同色棋子找出来。围棋中“连通”的定义是上下左右四个方向相邻且颜色相同斜对角不算。识别连通域最直接的办法是DFS深度优先搜索用递归写起来非常简洁#include stdio.h #include string.h void dfs(int row, int col, Stone color, Board *board, int *stoneCount, int *libertyCount, int visited[BOARD_SIZE][BOARD_SIZE]) { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return; } if (visited[row][col]) { return; } if (board-grid[row][col] EMPTY) { (*libertyCount); return; } if (board-grid[row][col] ! color) { return; } visited[row][col] 1; (*stoneCount); dfs(row - 1, col, color, board, stoneCount, libertyCount, visited); dfs(row 1, col, color, board, stoneCount, libertyCount, visited); dfs(row, col - 1, color, board, stoneCount, libertyCount, visited); dfs(row, col 1, color, board, stoneCount, libertyCount, visited); }注意上面这个递归函数里有一个关键细节遇到空格就统计气并立即返回但这里要防止同一个空点被重复计数。因为visited数组只标记了棋子空格没有被标记所以右侧一个相邻空点可能会被同一个棋块多次访问。要解决这个问题可以用一个单独的libertyVisited二维数组来记录哪些空点已经被当成气统计过否则算出来的气数会虚高。2.3 死活判定什么是“活棋”什么叫“死棋”听懂这一节整个程序的核心就掌握了。围棋里一块棋是死是活最简单粗暴的判断依据是“它还有没有气或者能不能做出两只真眼”。但在终局盘面上我们通常不做复杂的眼位推演而是采用一个工程上非常好用的近似规则如果一块棋当前的气数为0则它是死棋必须从棋盘上移除。如果一块棋的气数大于0还需要看它被对方包围的紧密程度。一个保守算法是如果该棋块只能在一个方向延展而且对方已经在外围基本合拢判定为死棋。我在实际代码里采用的是更稳妥的两阶段策略先做“无气即死”的清除一遍把所有气为0的棋块全部提掉。对剩下的棋块检查其所有相邻空格是否能形成至少两只独立的“眼空间”。如果某块棋连两只眼都做不出来而且双方同意终局通常会人为判定为死子直接移除。这个“两只眼”的判断不需要真的模拟未来所有落点只需要检查该棋块的气点集合中是否存在两个互不相连的极大空区域。如果存在就是活棋否则判死。这种方法虽然不如完整局面推演精确但在绝大多数已经结束的棋局上都能得到正确结果而且计算量非常小。2.4 贴目处理让白棋不亏的规则细节胜负判断里必须考虑“贴目”也叫贴子。因为黑棋先手有优势所以终局算账时黑棋要扣除一定点数补给白棋。中国规则一般是贴3又3/4子相当于7.5目换算成数子法就是黑棋184.25子获胜日本规则贴6.5目。这里我用一个浮点数float komi来存储贴目值默认存3.75这样统计完后可以直接和黑棋点数比较。这里要特别提醒一个细节数子法里贴目的单位和“子”不是同一个概念但程序里统一处理成“黑棋总点数 - 贴目值”再与白棋总点数比较即可。比如黑棋最终盘面有185子白棋盘面有126子算上死子归属黑棋扣除贴目后是181.25这样就是白胜。这个逻辑不复杂但很多人初写时会把贴目加错方向导致黑棋反而吃亏。3. 实操过程与核心代码实现3.1 主流程先清死子再数领地最后比胜负整个程序的main函数逻辑清晰度很重要。我建议按下面这个顺序执行每一步都单独封装成函数方便调试时定位问题#include stdio.h #include stdlib.h int main(int argc, char *argv[]) { if (argc 2) { printf(用法: go_judge 棋局文件\n); return 1; } Board board; initBoard(board); loadBoard(board, argv[1]); displayBoard(board); removeDeadStones(board); // 第一步找出并移除死子 recountTerritory(board); // 第二步统计双方领地 int blackScore computeScore(board, BLACK); int whiteScore computeScore(board, WHITE); printResult(blackScore, whiteScore, board.komi); return 0; }initBoard负责把grid全部填充为EMPTY把visited归零把统计字段初始化为0。loadBoard从文件逐行读取字符每读到一个B或W或.就填到对应位置。displayBoard打印整个棋盘方便调试时肉眼确认读入是否正确。3.2 死子清理的完整实现这块是整个程序最容易出错的地方。我先展示“无气即死”的清理函数然后再补充“做不出两只眼”的保守判定。void removeDeadStones(Board *board) { for (int color BLACK; color WHITE; color) { int changed 1; while (changed) { changed 0; memset(board-visited, 0, sizeof(board-visited)); for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (board-grid[row][col] color !board-visited[row][col]) { int stoneCount 0; int libertyCount 0; dfs(row, col, (Stone)color, board, stoneCount, libertyCount, board-visited); if (libertyCount 0) { removeGroup(board, row, col, (Stone)color); changed 1; } } } } } } }这里我用了while (changed)循环原因很微妙移除一块死子之后旁边原本有气的棋块可能因为失去邻居而变成新的无气状态。比如一块被围死的白棋旁边有一小块黑棋黑棋全靠和这块白死棋相邻才有气白死棋被提掉后黑棋可能立刻变成无气死棋。所以必须反复迭代直到某一轮没有移除任何棋子为止。removeGroup把某个起点所在棋块的所有棋子全部清成EMPTY并计数void removeGroup(Board *board, int startRow, int startCol, Stone color) { int groupStones[BOARD_SIZE * BOARD_SIZE][2]; int top 0; groupStones[top][0] startRow; groupStones[top][1] startCol; top; board-grid[startRow][startCol] EMPTY; int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1}; while (top 0) { top--; int row groupStones[top][0]; int col groupStones[top][1]; for (int dir 0; dir 4; dir) { int nextRow row dx[dir]; int nextCol col dy[dir]; if (nextRow 0 nextRow BOARD_SIZE nextCol 0 nextCol BOARD_SIZE board-grid[nextRow][nextCol] color) { board-grid[nextRow][nextCol] EMPTY; groupStones[top][0] nextRow; groupStones[top][1] nextCol; top; } } } }这里用了一个数组模拟栈的迭代写法目的很简单棋盘上最大可能有19×19361颗棋子递归最多361层C语言默认栈空间完全够用但你如果用了-Werrorstack-usage这类编译选项或者棋盘以后变大迭代写法更安全。函数里先把起点清空再把它相邻的同色棋子压栈循环处理逻辑比递归版容易排查“访问已清空节点”的问题。3.3 领地归属与胜负比较死子清理干净后领地的归属就非常好判断了。此时棋盘上剩下的都是双方确定的“活棋”接下来数空点一个空点如果往四个方向延伸只会触达一种颜色的棋子那就把这个空点归属给该颜色如果同时触达黑白两种颜色那就是“公共空点”双方各算一半数子法通常不计公共空点但有些情况要按边界规则处理。我写的countTerritory函数是逐个空点检测四个方向void countTerritory(Board *board) { int visited[BOARD_SIZE][BOARD_SIZE] {0}; for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (board-grid[row][col] EMPTY !visited[row][col]) { int blackAdjacent 0; int whiteAdjacent 0; int emptyCount 0; markEmptyRegion(board, row, col, blackAdjacent, whiteAdjacent, visited); if (blackAdjacent 0 whiteAdjacent 0) { board-blackTerritory emptyCount; } else if (whiteAdjacent 0 blackAdjacent 0) { board-whiteTerritory emptyCount; } // 其他情况属于公空数子法可以忽略或者按双方各半处理 } } } }markEmptyRegion本质上又是一个DFS用于把一整块连续空域的所有空点都访问一遍同时记录这块空域的“邻居颜色集合”。因为同一个空域可能很大如果只对单个空点做四方向检测会重复计算。所以必须用连通区域扫描的方法把归属于同一块空域的所有空点一次性处理完。最后计算分数int computeScore(Board *board, Stone color) { int total 0; for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (board-grid[row][col] color) { total; } } } if (color BLACK) { total board-blackTerritory; } else { total board-whiteTerritory; } board-capturedBlack ...; // 记录被移除的黑子数量 board-capturedWhite ...; return total; }判断胜负时用黑棋总数减去贴目值再与白棋总数比较void printResult(int blackScore, int whiteScore, float komi) { float blackFinal blackScore - komi; float whiteFinal whiteScore; printf(黑棋得分: %d, 贴目后: %.2f\n, blackScore, blackFinal); printf(白棋得分: %d\n, whiteScore); if (blackFinal whiteFinal) { printf(结果: 黑棋胜\n); } else if (blackFinal whiteFinal) { printf(结果: 白棋胜\n); } else { printf(结果: 平局\n); } }这里有个比较容易忽略的问题我用的“黑棋盘面子数 黑空”是中国数子法标准算法但komi单位是“子”而不是“目”。如果非要把7.5目换算成“子”需要除以2。本文代码里默认komi 3.75已经直接以“子”为单位所以不需要额外换算。如果你希望更接近实战可以在注释里标明“输入文件第20行可以指定贴目值”。3.4 输入棋局的两种加载方式为了测试方便我实现了两种加载一种是从文件读另一种是直接在main里用字符串数组定义棋局。文件读取方式前文已经提到核心逻辑是用fgets逐行读取每行长度至少BOARD_SIZE 2要吞掉换行符void loadBoard(Board *board, const char *path) { FILE *fp fopen(path, r); if (!fp) { perror(打开棋局文件失败); exit(1); } char line[BOARD_SIZE 3]; int row 0; while (row BOARD_SIZE fgets(line, sizeof(line), fp)) { if (line[0] \n || line[0] \r) { continue; } for (int col 0; col BOARD_SIZE; col) { char ch line[col]; if (ch B || ch b) { board-grid[row][col] BLACK; } else if (ch W || ch w) { board-grid[row][col] WHITE; } else { board-grid[row][col] EMPTY; } } row; } fclose(fp); }注意第8行的BOARD_SIZE 3我故意留了两个字节的余量避免fgets因为缓冲区不够把一行截断。实际测试时如果文件里每行恰好19个字符加一个换行符那么BOARD_SIZE 2就够但为了防止Windows的\r\n我还是用3更安全。3.5 一个完整的测试棋局示例我准备了一个非常简单的9路棋局文件test9.txt来验证程序正确性......... ......... ......... ..BBB.... ..BWB.... ..BWB.... ..BBB.... ......... .........这个棋局里黑棋形成了一个类似方块的包围结构白棋在中心被黑棋四面合围且周围没有任何空点假设黑棋外围第二圈全是黑子时那么这块白棋就是死棋应该被自动移除。执行程序后控制台应该输出类似$ ./go_judge test9.txt 黑棋得分: 81, 贴目后: 77.25 白棋得分: 0 结果: 黑棋胜当然由于我上面这个示例棋局很粗糙真正的环绕没有封口白棋左侧仍有空点所以实际运行可能会判白棋有气存活输出会不同。不要紧测试的真正目的是验证removeDeadStones和countTerritory的行为是否符合预期你可以自己调整棋局形状来测试不同分支。4. 常见问题与排查技巧实录4.1 气数重复统计最常见的隐蔽Bug很多初学者在写libertyCount时会发现“黑棋明明被围死了气数却显示成10以上”。这个问题的根源就是我之前提到过的“同一个空格没有被去重”。递归搜索时棋块右侧的一个空格可能既被你左方的分支访问到又被你上方或下方的分支访问到每访问一次就给libertyCount加一导致虚高。排查方法很简单在每次DFS记录气之前检查这个空格是否已经加入过当前棋块的气集合。用一个全局libertyVisited数组即可。我习惯把它传给DFS函数每次发现空格时判断if (board-grid[row][col] EMPTY) { if (!libertyVisited[row][col]) { libertyVisited[row][col] 1; (*libertyCount); } return; }这个细节虽小但直接影响整个死活判定值得单独拿出来说。4.2 数组越界与坐标约定冲突递归判断上下左右时最常见的错误是忘记边界检查导致访问grid[-1][0]这种非法内存。在很多环境上这个错误不会立刻崩溃而是悄悄破坏栈里的其他变量输出结果看起来“偶尔对、偶尔错”非常难以排查。我的经验是所有DFS入口统一在最前面做越界判断不要等用到数组时才判断。同时约定坐标(row, col)从0开始和数组下标一一对应不要让用户输入1到19的坐标后再减一容易混乱。4.3 递归深度与栈溢出问题虽然361层递归在默认栈大小下通常不会溢出但如果你把这段代码用在单片机上有些搜索结果里提到了单片机C语言没有堆栈的问题就要特别注意单片机栈空间小递归深度稍有风吹草动就可能爆栈。如果你要在嵌入式环境跑这种算法建议把所有递归改写为显式栈的迭代版本。removeGroup已经示范了迭代写法dfs也可以同样改成循环加栈的方式。4.4 文件编译时的常见坑这部分取决于你本地的C语言环境。如果你用的是VSCode加MinGW或GCC编译命令很简单gcc -stdc11 -Wall -Wextra -o go_judge go_judge.c我建议加上-Wall -Wextra开启所有警告这样很多未初始化变量、类型不匹配的问题能第一时间暴露。另外Windows下用fopen读取文本文件时如果文件用了UTF-8 BOM头第一行第一个字符可能变成\xEF导致第一列识别失败。解决办法是在fread后检查前三个字节是否为EF BB BF如果是就跳过。很多初学者在这上面卡一下午以为是逻辑写错了其实是编码格式问题。4.5 边界棋形的判定错误下面这个表格是我整理出的经典边界棋形以及它们在我算法下的处理结果棋形场景处置方式判断要点一块白棋被黑棋团团围住中央有一个空交叉点若白棋气数仅1后续可能做不出两只眼通常判定为死棋空的“眼”不一定是活眼还需要看能否形成两只独立真眼黑白双方互相交错的公活区域数子法下公共空点不计入任何一方需要在countTerritory中忽略同时接触两种颜色的空域角部黑棋一块棋只有两气但占据两个角部若两个气点互不相连有两只眼倾向判活“两只眼”判定逻辑会自动识别两个独立气区域劫争残留终局时有劫未消不处理视为终局各方均认可当前盘面实际对局终局不会留下未消的劫这里面最容易出问题的是“公活”。比如黑白双方在中央形成双活中间有几口公气。数子法通常把这些公共空点判为“双方均无地域”不归属任何一方。如果我的代码没有写判断“同时接触黑白”的分支就会把这种空域随机并入一方导致胜负结果偏差。最终版本在countTerritory中明确用了blackAdjacent 0 whiteAdjacent 0才归属黑棋反向同理这样就避免了公活误判。4.6 测试用例设计怎样验证程序的正确性一定要准备多组不同规模、不同特征的棋局文件来测试空棋盘双方0子黑棋扣贴目后负验证贴目逻辑。全黑棋盘黑棋全盘361子白棋0子验证数子总数。黑白完全交替的棋盘形成大量两气棋块验证递归和死子清理会不会死循环。一局真实棋谱终局这个最有价值可以直接从网上找一张围棋终局棋谱转换成文本格式跑一遍看结果和棋谱标注是否一致。我经验里前三种极端测试能查出90%的逻辑错误而真实棋谱测试是用来校准“两只眼判定”和“公活判定”的尺度的。4.7 调试技巧用“逐步打印”代替猜测如果只是写一个几百行的小工具我建议你完全不要用调试器用printf大法就够了。在removeDeadStones的每次迭代结束后打印一次棋盘在countTerritory统计每个空域时打印该空域的大小和相邻颜色。这样每一步都能肉眼看到状态变化定位问题比打断点更快。我在开发过程中遇到的最典型问题是清理死子后再次计算领地时visited数组没有复位导致某些空点被当成已访问跳过整个领地统计严重偏小。这个错完全可以通过在countTerritory一开始打印当前棋盘、在每次标记区域后打印emptyCount来快速发现。5. 总结与个人心得回到开头那句话这个“仅判断已下完的棋局胜负”的小项目看起来简单但它几乎把C语言入门的核心知识点全部串起来了二维数组操作、结构体封装、文件读写、递归与栈的权衡、边界条件处理、测试用例设计。我很推荐学完C语言基础后拿它作为练手项目比单纯刷题有意思得多还能帮你建立“拆分问题—设计数据结构—实现算法—测试验证”的完整工程思维。在实际动手过程中我最大的体会是越早用-Wall -Wextra编译越好越早把棋局文件抽象成外部输入越好。前者能帮你省掉无数低级错误后者能让你反复快速测试边界情况。还有一个小技巧把BOARD_SIZE从19改成9来调试输出的棋盘小了每轮清理死子后的变化一眼就能看清楚等逻辑完全对了再切回19路。这个方法我每次写棋盘类程序都在用非常有效。如果你后续想把程序变得更完善可以从这几个方向扩展把棋盘大小改成运行时参数、增加SGF棋谱文件解析、加速“两眼判断”的搜索效率、给程序加一个简单的文本界面。但无论怎么扩展核心的清死子和数领地算法都是这套骨架把它弄透比堆功能重要得多。