跳转到内容

Lesson 23: 五子棋 CNB

练习任务

本课包含三个递进练习,从棋盘绘制到胜负判断,最终实现完整的五子棋对弈:

练习 1(画棋盘):用 #define SIZE 15 定义棋盘大小,声明 static char board[SIZE][SIZE] 二维数组。实现 init_board()memset 将所有格子初始化为 '.'(空),实现 print_board() 用二重 for 循环打印 15×15 棋盘,包含列号首行和每行的行号前缀。

练习 2(判断胜负):实现 check_dir(r, c, dr, dc, player) 从位置 (r,c) 沿方向 (dr,dc) 检查是否有连续 5 个同色棋子;实现 check_win(player) 遍历棋盘所有位置,对 4 个方向(水平 (0,1)、垂直 (1,0)、右下对角 (1,1)、左下对角 (1,-1))逐一调用 check_dir。输入 15 行字符串(B 表示黑子,W 表示白子,. 表示空),输出 blackwhitenone

练习 3(完整对弈):在已有函数基础上实现 main 游戏主循环:while(1) 循环中用 turn % 2 决定当前玩家('X''O'),scanf 读入落子坐标 r c,验证合法性后放置棋子,调用 check_win 判断胜负,调用 board_full 判断平局,每轮 turn++ 轮换。

提示:棋盘是二维数组的经典应用场景——回顾 Lesson 14 中学到的方向数组 (dr, dc) 和边界检查 onboard,它们在本课中直接复用。五子棋判定只需扫描 4 个方向(水平、垂直、两条对角线),每个方向检查连续 5 子。游戏主循环本质上是一个「回合制状态机」——轮流落子、检查胜负、直到终局。

核心知识点

  • 二维数组声明与索引 — char board[SIZE][SIZE]board[i][j] 访问第 i 行第 j 列
  • memset 批量初始化 — memset(board, '.', sizeof(board)) 将所有格子设为空
  • 二重 for 循环打印 — 外层行、内层列,每行末尾换行
  • 方向数组 — (dr, dc) 编码水平 (0,1)、垂直 (1,0)、右下对角 (1,1)、左下对角 (1,-1) 四个方向
  • 方向扫描判定 — check_dir(r, c, dr, dc, player) 沿方向依次检查连续 5 格
  • for k = 0..4 扫描模式 — nr = r + k*dr, nc = c + k*dc 递进检测
  • check_win 全局遍历 — 遍历棋盘每个位置,四个方向逐一判定
  • while(1) 游戏主循环 — 无限循环直到胜负或平局
  • turn % 2 轮换机制 — 用模运算在黑白玩家间切换
  • scanf 交互输入 — 从标准输入读取落子坐标
  • 边界检查 onboard — 访问 board[r][c] 前验证索引在 0~SIZE-1
  • 空位检查 empty — 落子前确认目标位置为 '.'
  • board_full 平局判定 — 遍历所有格子,无空位即满
  • break 跳出循环 — 胜负或平局后退出主循环
  • 回合制游戏状态机 — 输入→验证→落子→检查→轮换的循环模型

代码框架

练习 1:画棋盘

print_board.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

static char board[SIZE][SIZE];

void init_board(void)
{
    // 用 memset 将 board 所有元素设为 '.'
}

void print_board(void)
{
    // 打印列号首行: "   0  1  2 ... 14"
    // 二重 for 循环: 每行先打印行号 "%2d ", 再打印 " %c" 每个格子
}

int main(void)
{
    init_board();
    print_board();
    return 0;
}

练习 2:判断胜负

check_win.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

char board[SIZE][SIZE + 1];

int check_dir(int r, int c, int dr, int dc, char player)
{
    // for k = 0..4:
    //     nr = r + k * dr;  nc = c + k * dc;
    //     越界 → return 0; 不同色 → break; 同色 → count++
    // count >= 5 → return 1
}

int check_win(char player)
{
    int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}};
    // 遍历棋盘每个 (i, j)
    //     如果 board[i][j] == player,对 4 个方向调 check_dir
    //     任一方向返回 1 → return 1
    // return 0
}

int main(void)
{
    // 读入 15 行字符串
    for (int i = 0; i < SIZE; i++)
        scanf("%s", board[i]);

    if (check_win('B'))
        printf("black\n");
    else if (check_win('W'))
        printf("white\n");
    else
        printf("none\n");

    return 0;
}

练习 3:完整对弈

gomoku.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

static char board[SIZE][SIZE];

void init_board(void)  { memset(board, '.', sizeof(board)); }

int onboard(int r, int c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
int empty(int r, int c)   { return board[r][c] == '.'; }

int check_dir(int r, int c, int dr, int dc, char player)
{
    // 同上练习 2
}

int check_win(char player)
{
    // 同上练习 2
}

int board_full(void)
{
    // 遍历所有格子,有 '.' → return 0; 全满 → return 1
}

int main(void)
{
    init_board();

    int turn = 0;
    while (1)
    {
        // char cur = (turn % 2 == 0) ? 'X' : 'O';
        // printf 提示当前玩家
        // scanf 读 r c
        // 如果 !onboard || !empty → continue
        // board[r][c] = cur
        // 如果 check_win(cur) → 打印胜者 + break
        // 如果 board_full() → 打印平局 + break
        // turn++
    }

    return 0;
}

填充框架的关键思考:check_dirk 从 0 到 4 共检查几个格子?board_full 的返回条件和 empty 函数的关系是什么?主循环中如果 scanf 读到的坐标不合法怎么办?turn++ 放在循环末尾还是开头?

TIP

先不要往下翻看参考解答。建议按顺序完成三个练习:先打通棋盘打印(确保二维数组操作熟练),再攻胜负判断(方向扫描是核心算法),最后组装游戏循环(理解回合制状态机)。用 clings hint 查看提示,用 clings test 验证输出。


深度讲解

1. 二维数组:五子棋的数据基石

1.1 棋盘表示:char board[SIZE][SIZE]

五子棋的标准棋盘是 15×15 的网格,每个交叉点有三种状态:空、黑子、白子。用二维数组天然对应:

board_declare.c
c
#define SIZE 15

char board[SIZE][SIZE];
// board[i][j] 表示第 i 行第 j 列的交叉点
// '.' = 空, 'X' = 黑子, 'O' = 白子

为什么用 char 而不是 int:棋盘格子只有 3 种状态,一个字节足够。charint 节省 75% 内存(15×15×1 = 225 字节 vs 15×15×4 = 900 字节),且在打印时可以直接用 %c 输出,无需映射。

棋盘坐标约定

棋盘坐标系统 (board[row][col]):
           col
  row  ┌───┬───┬───┬───┬───┬───┐
    │0,0│0,1│0,2│ ...     │0,14│
       ├───┼───┼───┼───┼───┼───┤
       │1,0│
       ├───┼───┼───┼───┼───┼───┤

       ├───┼───┼───┼───┼───┼───┤
       │14,0│   │14,14│
       └───┴───┴───┴───┴───┴───┘

row 0 14 (共 15),col 从 0 到 14 ( 15)

这与 Lesson 14 迷宫棋盘的坐标系统完全一致——二维数组的第一个下标是行,第二个是列。你在 Lesson 14 中学到的行优先遍历、边界检查等概念在本课中直接复用。

1.2 memset:一次性初始化整个棋盘

memset_init.c
c
#include <string.h>

char board[SIZE][SIZE];

// 方式 1:逐格初始化(繁琐)
for (int i = 0; i < SIZE; i++)
    for (int j = 0; j < SIZE; j++)
        board[i][j] = '.';

// 方式 2:memset 批量填充(简洁高效)
memset(board, '.', sizeof(board));

memset 的三个参数:

  • board — 目标内存起始地址
  • '.' — 要填充的字节值
  • sizeof(board) — 填充的字节总数

memset 的底层行为:它逐字节填充,不关心数据类型。memset(board, '.', sizeof(board)) 会把 board 的 225 个字节全部设为 '.'(ASCII 码 46)。

memset_internals.c
c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char board[3][3];

    memset(board, '.', sizeof(board));

    // 验证:所有格子都是 '.'
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 3; j++)
            printf("%c ", board[i][j]);
        printf("\n");
    }
    // 输出:
    // . . .
    // . . .
    // . . .

    return 0;
}

为什么用 sizeof(board) 而不是 SIZE * SIZEsizeof(board) 直接获取数组在内存中的实际大小(字节数),不会因为 SIZE 宏的修改而出错。这是「让编译器替你计算」的防御性编程实践。

WARNING

memset 只适用于逐字节相同的填充。对于 int 数组,memset(arr, 0, sizeof(arr)) 可以(全零),但 memset(arr, 1, sizeof(arr)) 会把每个 int 填充为 0x01010101 而不是 1!因为 memset 按字节填充,而 int 占 4 个字节。


2. 方向数组:四方向扫描的抽象

2.1 为什么需要方向扫描

五子棋的胜负条件是任意方向连续 5 子——包括水平、垂直、两条对角线共 4 个方向。如果为每个方向单独写检查逻辑:

naive_four_directions.c
c
// 水平方向(向右)——硬编码
int count = 0;
for (int k = 0; k < 5; k++)
    if (board[r][k + c] == player) count++;
    else break;
if (count >= 5) return 1;

// 垂直方向(向下)——又写一遍
count = 0;
for (int k = 0; k < 5; k++)
    if (board[r + k][c] == player) count++;
    else break;
if (count >= 5) return 1;

// 右下对角线——第三遍
count = 0;
for (int k = 0; k < 5; k++)
    if (board[r + k][c + k] == player) count++;
    else break;
if (count >= 5) return 1;

// 左下对角线——第四遍!
count = 0;
for (int k = 0; k < 5; k++)
    if (board[r + k][c - k] == player) count++;
    else break;
if (count >= 5) return 1;

四个方向写了四段几乎相同的代码——只有 rc 的偏移量不同。这是代码坏味道「复制粘贴编程」的典型案例。

2.2 方向数组抽象:dr 和 dc

观察四个方向的偏移规律:

方向dr (行偏移)dc (列偏移)含义
水平0+1同一行,列增加
垂直+10行增加,同一列
右下对角+1+1行列同时增加
左下对角+1-1行增加,列减少

把这四个方向的 (dr, dc) 对存入数组:

direction_array.c
c
// 方向数组:每个方向用 (dr, dc) 编码
int dirs[4][2] = {
    {0,  1},   // 水平向右
    {1,  0},   // 垂直向下
    {1,  1},   // 右下对角线
    {1, -1},   // 左下对角线
};

有了方向数组,四个方向的扫描统一为一段代码:

unified_scan.c
c
int check_dir(int r, int c, int dr, int dc, char player)
{
    int count = 0;
    for (int k = 0; k < 5; k++)
    {
        int nr = r + k * dr;    // 递进行
        int nc = c + k * dc;    // 递进列

        if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
            return 0;            // 越界 → 不可能五连
        if (board[nr][nc] == player)
            count++;
        else
            break;               // 不连续 → 停止
    }
    return count >= 5;           // 5 个以上即胜
}

这是本课最核心的算法抽象——用方向数组将四个方向的检查统一为一次函数调用。回顾 Lesson 14,你曾在迷宫的方向检查中使用过同样的 (dr, dc) 编码。区别在于:

  • Lesson 14:check(row, col, dir) 检查一步是否可走
  • Lesson 23:check_dir(r, c, dr, dc, player) 检查连续 5 步是否同色

2.3 check_dir 的执行追踪

以下用一个 5 连的水平局面追踪 check_dir(0, 0, 0, 1, 'X') 的执行过程:

棋盘状态:
  col  0   1   2   3   4
  row 0: X   X   X   X   X
  row 1: .   .   .   .   .

check_dir(0, 0, 0, 1, 'X'):
  dr=0, dc=1 (水平向右)

  k=0: nr=0+0*0=0, nc=0+0*1=0 board[0][0]='X' count=1
  k=1: nr=0+1*0=0, nc=0+1*1=1 board[0][1]='X' count=2
  k=2: nr=0+2*0=0, nc=0+2*1=2 board[0][2]='X' count=3
  k=3: nr=0+3*0=0, nc=0+3*1=3 board[0][3]='X' count=4
  k=4: nr=0+4*0=0, nc=0+4*1=4 board[0][4]='X' count=5

  count >= 5 return 1

如果中间有断点:

棋盘状态:
  col  0   1   2   3   4
  row 0: X   X   .   X   X

check_dir(0, 0, 0, 1, 'X'):
  k=0: board[0][0]='X' count=1
  k=1: board[0][1]='X' count=2
  k=2: board[0][2]='.' break (不连续!)

  count=2 < 5 → return 0 ✗

关键设计break 确保连续性——五连要求的是不间断的同色棋子,中间有空格或异色棋子就失败。

2.4 为什么只扫描四个方向(不是八个)

五子棋只需要 4 个方向而不是 8 个。以「水平」为例:从任意位置向右扫描 (0,1) 就覆盖了水平五连的所有情况,不需要再向左扫描 (0,-1)——因为 check_win 会遍历棋盘上每一个位置。如果一个水平五连从列 3 开始到列 7,那么在遍历到 (row, 3) 时向右扫描就会发现它。向左扫描是冗余的。

水平五连: X X X X X

        col=3      col=7

遍历到 (row,3) 时:
  check_dir(row, 3, 0, 1, 'X') → 向右扫描 5 格 → 成功!

不需要遍历到 (row,7) 时向左扫描——那会做重复检查

同理

  • 垂直方向:只需 (1,0)(向下),不需要 (-1,0)(向上)
  • 右下对角:只需 (1,1),不需要 (-1,-1)
  • 左下对角:只需 (1,-1),不需要 (-1,1)

这是方向扫描中的「半方向」原则——每个直线方向只需检查一个方向,因为遍历起点覆盖了所有可能性。

2.5 方向数组与 Lesson 14 的 struct direction 对比

在 Lesson 14 中你使用了带名称的结构体数组:

c
struct direction { int dr; int dc; char name[16]; };
dir_t dirs[4] = {
    {-1, 0, "up"}, {1, 0, "down"},
    {0, -1, "left"}, {0, 1, "right"},
};

本课中方向更简单——不需要名称,只需要纯偏移量:

c
int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}};

两种形式的选择原则:需要人类可读的方向名时用 struct(如 Lesson 14 输出 "direction up is ok!"),只需要算法中用偏移量时用纯整数数组(本课的方向仅用于循环扫描)。这是够用就好的设计原则——不引入不需要的复杂度。


3. check_win:全局遍历与胜负判定

3.1 完整扫描算法

check_win_full.c
c
int check_win(char player)
{
    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] != player)
                continue;                     // 不是当前玩家 → 跳过

            for (int d = 0; d < 4; d++)       // 尝试 4 个方向
                if (check_dir(i, j, dirs[d][0], dirs[d][1], player))
                    return 1;                 // 找到五连!
        }

    return 0;   // 遍历全部,未找到
}

算法复杂度:最坏情况遍历 15×15 = 225 个位置,每个位置尝试 4 个方向,每个方向检查 5 个格子。225 × 4 × 5 = 4500 次比较——对于现代 CPU 微不足道。

3.2 优化:只从棋子位置开始检查

optimized_check_win.c
c
int check_win(char player)
{
    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] != player)
                continue;          // ← 关键优化:跳过空格和对方棋子

            for (int d = 0; d < 4; d++)
                if (check_dir(i, j, dirs[d][0], dirs[d][1], player))
                    return 1;
        }
    return 0;
}

if (board[i][j] != player) continue 这一行是重要的早期剪枝——如果一个格子不是当前玩家的棋子,就没必要从它开始做方向扫描。在开局阶段棋盘大部分为空时,这个优化节省了大量无用扫描。

3.3 胜负判定的完整流程图

输入: player ('X' 'O')

遍历 i = 0..14 (行)

  遍历 j = 0..14 (列)

  board[i][j] == player ?
    ├─ continue (下一个格子)
    └─

    遍历 d = 0..3 (4 个方向)

    check_dir(i, j, dirs[d][0], dirs[d][1], player) ?
      ├─ 返回 1 return 1 (找到五连!)
      └─ 返回 0 尝试下一个方向

    4 个方向全失败 下一个格子

所有格子遍历完 return 0 (无人获胜)

4. 游戏主循环:回合制状态机

4.1 while(1) + break 模式

游戏主循环是典型的「无限循环 + 条件退出」模式:

game_loop_basic.c
c
int turn = 0;          // 回合计数器

while (1)              // 无限循环
{
    // 1. 确定当前玩家
    char cur = (turn % 2 == 0) ? 'X' : 'O';

    // 2. 提示并读取输入
    printf("Player %c, enter row col: ", cur);
    int r, c;
    scanf("%d %d", &r, &c);

    // 3. 验证合法性
    if (!onboard(r, c) || !empty(r, c))
    {
        printf("Invalid move, try again.\n");
        continue;      // 不合法 → 重新输入(不换人)
    }

    // 4. 落子
    board[r][c] = cur;

    // 5. 检查胜负
    if (check_win(cur))
    {
        printf("Player %c wins!\n", cur);
        break;         // 有人赢了 → 退出循环
    }

    // 6. 检查平局
    if (board_full())
    {
        printf("Draw!\n");
        break;         // 棋盘满了 → 退出循环
    }

    // 7. 轮换
    turn++;
}

4.2 turn % 2:用模运算轮换玩家

turn % 2 是回合制游戏中的经典技巧:

turnturn % 2玩家
00X
11O
20X
31O
.........

为什么用 % 2 而不是 if-else 切换布尔变量turn % 2 携带了回合数信息——你不仅知道当前是谁的回合,还知道这是第几回合。如果以后需要显示回合数、判断先手优势等,turn 变量可以直接使用。

turn_counter_benefit.c
c
int turn = 0;
while (1)
{
    char cur = (turn % 2 == 0) ? 'X' : 'O';
    printf("Round %d, Player %c's turn\n", turn / 2 + 1, cur);
    // ...
    turn++;
}

4.3 continue 与 break 的区别

在主循环中,continuebreak 分别处理不同的控制流:

continue_vs_break.c
c
while (1)
{
    scanf("%d %d", &r, &c);

    if (!onboard(r, c) || !empty(r, c))
        continue;   // ← 跳回 while(1) 开头,重新输入
                     //    不执行后续代码,不换人

    board[r][c] = cur;

    if (check_win(cur))
        break;      // ← 跳出 while(1),游戏结束

    turn++;          // continue 时这行不会执行
}
关键字行为本课中的使用场景
continue跳过本次循环剩余代码,进入下一次迭代输入不合法 → 重新输入
break跳出整个循环有人获胜 或 棋盘满 → 结束游戏

4.4 游戏状态机模型

五子棋的对弈过程可以用有限状态自动机(FSM)来建模——回顾 Lesson 21 词法分析器中的状态机概念,游戏循环本质上也是一个状态机:

          ┌─────────────────────────────────┐


    ┌──────────┐   合法落子   ┌──────────┐
 等待输入  │───────────→│  放置棋子
    └──────────┘             └──────────┘

                    检查状态
                  ┌───┴───┐

             ┌──────┐ ┌──────┐
 不合法输入 获胜 继续
          └─────────────│ 结束 轮换  │───┘
                        └──────┘ └──────┘

                         ┌──┴──┐
 平局
 结束
                         └─────┘

这个状态机模型帮助你理解主循环的设计逻辑:输入 → 验证 → 落子 → 检查 → 轮换,循环往复直到终局状态。


5. 辅助函数:边界、空位、棋盘满

5.1 onboard:边界检查

onboard_func.c
c
int onboard(int r, int c)
{
    return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
}

这是 Lesson 14 中 is_valid 的简化版——只检查边界,不检查内容。返回值为「真/假」的布尔语义。与 Lesson 14 对比:

函数Lesson 14Lesson 23
边界检查is_valid(row, col)onboard(r, c)
内容检查chessboard[nr][nc] == 0empty(r, c)
综合检查check 中一起做在主循环中分别调用

分离边界检查和内容检查是更好的设计——每个函数职责单一,组合使用更灵活。

5.2 empty:空位检查

empty_func.c
c
int empty(int r, int c)
{
    return board[r][c] == '.';
}

'.' 是本课约定的「空位」标记。在判断落子合法性时,需要同时满足 onboard(r, c) && empty(r, c)。这两个条件用 && 连接——回顾 Lesson 14 中讨论过的短路求值:如果 onboard 返回假,empty 不会被调用,避免了越界访问。

5.3 board_full:平局判定

board_full_func.c
c
int board_full(void)
{
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
                return 0;     // 找到一个空位 → 还没满
    return 1;                 // 没有空位 → 满了
}

逻辑:「找到空位 → 没满」比「找满位」更高效——找到一个空位就可以提前返回,无需遍历全部 225 个格子。

NOTE

15×15 = 225 个格子。如果棋盘下满(225 手)仍无人五连,即为平局。在实战中平局极少见——通常在中盘就分出胜负了。


6. 程序组装:从零件到完整游戏

6.1 完整的五子棋程序

将前面讨论的所有函数组装在一起:

gomoku_full.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

static char board[SIZE][SIZE];

// ── 初始化 ──
void init_board(void)
{
    memset(board, '.', sizeof(board));
}

// ── 边界与空位检查 ──
int onboard(int r, int c)
{
    return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
}

int empty(int r, int c)
{
    return board[r][c] == '.';
}

// ── 方向扫描 ──
int check_dir(int r, int c, int dr, int dc, char player)
{
    int count = 0;
    for (int k = 0; k < 5; k++)
    {
        int nr = r + k * dr;
        int nc = c + k * dc;

        if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
            return 0;
        if (board[nr][nc] == player)
            count++;
        else
            break;
    }
    return count >= 5;
}

// ── 胜负判断 ──
int check_win(char player)
{
    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] != player)
                continue;
            for (int d = 0; d < 4; d++)
                if (check_dir(i, j, dirs[d][0], dirs[d][1], player))
                    return 1;
        }
    return 0;
}

// ── 棋盘满判定 ──
int board_full(void)
{
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
                return 0;
    return 1;
}

// ── 主循环 ──
int main(void)
{
    init_board();

    int turn = 0;
    while (1)
    {
        char cur = (turn % 2 == 0) ? 'X' : 'O';
        printf("Player %c, enter row col: ", cur);

        int r, c;
        scanf("%d %d", &r, &c);

        if (!onboard(r, c) || !empty(r, c))
        {
            printf("Invalid move! Try again.\n");
            continue;
        }

        board[r][c] = cur;

        if (check_win(cur))
        {
            printf("Player %c wins!\n", cur);
            break;
        }

        if (board_full())
        {
            printf("Draw!\n");
            break;
        }

        turn++;
    }

    return 0;
}

6.2 编译与运行

bash
gcc -Wall -Wextra -o gomoku gomoku.c
./gomoku

交互示例:

Player X, enter row col: 7 7
Player O, enter row col: 7 8
Player X, enter row col: 7 6
Player O, enter row col: 7 9
Player X, enter row col: 7 5
Player O, enter row col: 7 10
Player X, enter row col: 7 4
Player X wins!

6.3 使用文件重定向自动测试

READEME 中提到的 ./fivechess < step.txt 是一种自动化测试技巧。将落子序列写入文件:

# step.txt: 每行两个整数,交替 X 和 O 的落子位置
7 7
8 8
7 8
8 9
7 9
8 10
7 10
8 11
7 11
bash
./gomoku < step.txt

程序会从 step.txt 逐行读取坐标,无需手动输入。这在调试和自动化测试中非常有用——同一个 step.txt 可以反复验证。


7. 进阶扩展:机器对战与 AI 思考

7.1 管道对战架构

READEME 中描述的机器对战使用了 Unix 管道的进程间通信:

进程 p1                      进程 p2

  读取 p2 的落子 ←───管道───  输出自己的落子
  计算下一步
  输出自己的落子 ───管道──→  读取 p1 的落子
  计算下一步
  ...                          ...
bash
# 创建两个命名管道
mkfifo p1 p2

# 终端 1: 启动玩家 1
./gomoku_ai < p1 > p2

# 终端 2: 启动玩家 2
./gomoku_ai < p2 > p1

两个进程通过管道互传落子坐标,实现自动对弈。

7.2 think() 函数:简单的 AI 思路

think_func_sketch.c
c
// 最简单的 AI:在所有空位中随机选一个
int think(int *x, int *y)
{
    int empty_count = 0;
    int empty_positions[SIZE * SIZE][2];

    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
            {
                empty_positions[empty_count][0] = i;
                empty_positions[empty_count][1] = j;
                empty_count++;
            }

    if (empty_count == 0) return 0;

    int idx = rand() % empty_count;
    *x = empty_positions[idx][0];
    *y = empty_positions[idx][1];
    return 1;
}

更进一步的 AI 可以基于评分函数:对每个空位评估落子后的「威胁值」——自己即将五连的位置得分最高,对手即将五连的位置(需要堵)次之。

7.3 从二维数组到结构体的演化(连接 Lesson 11)

回顾 Lesson 11 中学习的结构体,一个更工程化的棋盘表示可能用结构体封装游戏状态:

game_state_struct.c
c
typedef struct {
    char board[SIZE][SIZE];
    int turn;
    int move_count;
    char winner;
} gomoku_t;

void game_init(gomoku_t *g)
{
    memset(g->board, '.', sizeof(g->board));
    g->turn = 0;
    g->move_count = 0;
    g->winner = 0;
}

int game_move(gomoku_t *g, int r, int c)
{
    if (!onboard(r, c) || !empty(r, c))
        return 0;

    g->board[r][c] = (g->turn % 2 == 0) ? 'X' : 'O';
    g->move_count++;

    if (check_win(g->board[r][c]))
        g->winner = g->board[r][c];

    g->turn++;
    return 1;
}

用结构体封装游戏状态的好处:所有状态集中管理,方便保存/恢复(存档功能),方便传递给 AI 函数做局面评估。这是从「全局变量 + 函数」到「结构体 + 方法」的设计演化——和 Lesson 11 中 Linux 内核 file_operations 的设计思想一脉相承。


参考解答

练习1: 画棋盘(init_board + print_board)
solution_print_board.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

static char board[SIZE][SIZE];

void init_board(void)
{
    memset(board, '.', sizeof(board));
}

void print_board(void)
{
    int i, j;

    // 打印列号首行
    printf("   ");
    for (j = 0; j < SIZE; j++)
        printf("%2d ", j);
    printf("\n");

    // 打印每一行
    for (i = 0; i < SIZE; i++)
    {
        printf("%2d ", i);          // 行号
        for (j = 0; j < SIZE; j++)
            printf(" %c ", board[i][j]);
        printf("\n");
    }
}

int main(void)
{
    init_board();
    print_board();
    return 0;
}

memset 一次性将 225 个字节全部设为 '.'。打印分两步:先打印列号首行(014),再逐行打印,每行以行号开头。%2d 保证列号对齐,%c 前后各留一个空格使棋盘格子均匀分布。

练习2: 判断胜负(check_dir + check_win)
solution_check_win.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

char board[SIZE][SIZE + 1];

int check_dir(int r, int c, int dr, int dc, char player)
{
    int count = 0;
    for (int k = 0; k < 5; k++)
    {
        int nr = r + k * dr;
        int nc = c + k * dc;

        if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
            return 0;
        if (board[nr][nc] == player)
            count++;
        else
            break;
    }
    return count >= 5;
}

int check_win(char player)
{
    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] != player)
                continue;
            for (int d = 0; d < 4; d++)
                if (check_dir(i, j, dirs[d][0], dirs[d][1], player))
                    return 1;
        }
    return 0;
}

int main(void)
{
    for (int i = 0; i < SIZE; i++)
        scanf("%s", board[i]);

    if (check_win('B'))
        printf("black\n");
    else if (check_win('W'))
        printf("white\n");
    else
        printf("none\n");

    return 0;
}

核心算法:check_dirfor k=0..4 沿方向递进检查 5 个连续格子,越界或不同色则失败。check_win 遍历棋盘,对每个己方棋子的位置尝试 4 个方向。注意 board[SIZE][SIZE + 1] 多出 1 列用于存储字符串结尾的 '\0'

练习3: 完整对弈(游戏主循环)
solution_gomoku.c
c
#include <stdio.h>
#include <string.h>

#define SIZE 15

static char board[SIZE][SIZE];

void init_board(void)  { memset(board, '.', sizeof(board)); }

int onboard(int r, int c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
int empty(int r, int c)   { return board[r][c] == '.'; }

int check_dir(int r, int c, int dr, int dc, char player)
{
    int count = 0;
    for (int k = 0; k < 5; k++)
    {
        int nr = r + k * dr;
        int nc = c + k * dc;

        if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
            return 0;
        if (board[nr][nc] == player)
            count++;
        else
            break;
    }
    return count >= 5;
}

int check_win(char player)
{
    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] != player)
                continue;
            for (int d = 0; d < 4; d++)
                if (check_dir(i, j, dirs[d][0], dirs[d][1], player))
                    return 1;
        }
    return 0;
}

int board_full(void)
{
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
                return 0;
    return 1;
}

int main(void)
{
    init_board();

    int turn = 0;
    while (1)
    {
        char cur = (turn % 2 == 0) ? 'X' : 'O';
        printf("Player %c, enter row col: ", cur);

        int r, c;
        scanf("%d %d", &r, &c);

        if (!onboard(r, c) || !empty(r, c))
        {
            printf("Invalid move! Try again.\n");
            continue;
        }

        board[r][c] = cur;

        if (check_win(cur))
        {
            printf("Player %c wins!\n", cur);
            break;
        }

        if (board_full())
        {
            printf("Draw!\n");
            break;
        }

        turn++;
    }

    return 0;
}

所有函数组装成一个完整的五子棋游戏。while(1) 主循环中,turn % 2 轮换黑白,continue 处理非法输入,break 处理终局。函数职责分离清晰:onboard 检查边界,empty 检查空位,check_dir 做方向扫描,check_win 做全局判定,board_full 做平局判定。

对照检查:二维数组用 board[SIZE][SIZE] 声明了吗?memset 的第三个参数用了 sizeof(board) 吗?check_dirk 从 0 到 4 检查了 5 个格子吗?check_windirs 数组包含 4 个方向了吗?主循环中 continuebreak 用对位置了吗?turn++continue 之后不会错误增加吗?


课堂讨论

  1. check_dir 中为什么用 k < 5 而不是 k <= 5?循环体检查了几个格子?
  2. 方向数组 dirs[4][2] 为什么只需要 4 个方向?向左扫描 (0,-1) 是否需要?
  3. 主循环中 turn++ 放在末尾,非法输入时用 continue 跳过——如果 turn++ 放在 continue 之前会有什么问题?
  4. board_full 用一个 return 0 提前返回,和遍历完再返回有什么性能差异?这种模式叫什么?
  5. 如果要把棋盘大小从 15 改为 19(围棋棋盘),需要改哪些地方?如果 SIZE 已经用 #define 定义了,还需要改什么?
  6. 本课的方向扫描 check_dir 和 Lesson 14 的方向检查 check(row, col, dir) 有什么本质不同?它们各自解决什么问题?

讨论答案

Q1: k < 5 检查了几个格子?为什么不是 k <= 5?

k < 5 时,k 取值 0, 1, 2, 3, 4——共 5 个值,检查 5 个连续格子。五子棋需要恰好 5 个连续同色棋子才能获胜。

c
for (int k = 0; k < 5; k++)
// k=0: 起点本身
// k=1: 第 2 个
// k=2: 第 3 个
// k=3: 第 4 个
// k=4: 第 5 个
// 共 5 个格子

如果写成 k <= 5k 取值 0, 1, 2, 3, 4, 5——共 6 个值,会多检查一个格子。这不是「检查了 6 个」的问题——代码逻辑里 count >= 5 仍然会在第 5 个同色棋子出现时返回成功,第 6 次循环不会执行(因为已经 return 1 了)。但语义上 k < 5 更准确表达了「检查 5 个连续格子」的意图。

常见的 off-by-one 错误就在这种地方——循环边界差 1 就可能导致多检查或少检查一个格子。检查循环边界的口诀:for (int k = 0; k < N; k++) 执行 N 次,for (int k = 0; k <= N; k++) 执行 N+1 次。

Q2: 为什么只需 4 个方向?向左扫描不需要吗?

只需要 4 个方向,因为 check_win 会遍历棋盘上的每一个位置作为起点。

以水平方向为例:假设有一个水平五连 X X X X X 从列 3 到列 7。

列: ... 3 4 5 6 7 ...
      X X X X X

check_win 遍历到 (row, 3) 时:

  • check_dir(row, 3, 0, 1, 'X') 向右扫描 → 成功!

如果也添加了向左扫描 (0, -1)

  • 遍历到 (row, 7) 时也会成功 → 但这是冗余的重复检查

结论:向右扫描 (0,1) 和向左扫描 (0,-1) 覆盖的是同一条直线上的同一组五连,只是起点不同。由于 check_win 遍历所有起点,只需要每个直线方向的「一个朝向」。4 个方向 = 4 条不同的直线(水平线、垂直线、右下对角线、左下对角线),每条直线只需一个朝向。

这在几何上称为「半方向扫描」——每条直线选择一个方向即可覆盖该直线上所有可能的五连。

Q3: turn++ 放在 continue 之前会怎样?

如果 turn++continue 之前,非法输入会导致不该换人时换了人:

c
// 错误版本
while (1)
{
    char cur = (turn % 2 == 0) ? 'X' : 'O';
    scanf("%d %d", &r, &c);

    turn++;   // ← 放这里!

    if (!onboard(r, c) || !empty(r, c))
        continue;    // 非法输入时 turn 已经加了,但不应换人

    // ...
}

错误效果:玩家 X 输入非法坐标 → turn 从 0 变为 1 → continue 回到循环开头 → cur = 'O' → 本该 X 重新输入的回合变成了 O 的回合!

正确: turn++ continue 之后
  X 输入非法 continue 还是 X 的回合

错误: turn++ continue 之前
  X 输入非法 turn++ continue 变成 O 的回合

教训:状态变更(如 turn++)必须放在所有验证通过之后——这是防御性编程的基本原则:先验证,再修改状态。

Q4: board_full 提前返回的性能与模式名称

board_full 使用了「提前返回」(Early Return)模式:

c
int board_full(void)
{
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
                return 0;     // 找到一个空位就立即返回
    return 1;
}

性能分析

  • 最好情况:第一个格子就是 '.' → 1 次比较
  • 最坏情况:棋盘全满 → 225 次比较
  • 平均情况:取决于棋盘填充率

对比「遍历完再返回」的写法:

c
int board_full_slow(void)
{
    int full = 1;
    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
            if (board[i][j] == '.')
                full = 0;
    return full;   // 无论如何都遍历完 225 个格子
}

这个版本无论棋盘多空都会遍历全部 225 个格子。而提前返回版本在棋盘还比较空时(开局阶段)几乎立即返回。

Early Return 模式的优势:

  1. 减少不必要的计算
  2. 代码更清晰——条件判断和处理在一起
  3. 避免使用额外的标志变量(如上面的 full
Q5: 棋盘大小从 15 改为 19 需要改什么?

如果 SIZE 已经用 #define SIZE 15 定义,改为 #define SIZE 19 后:

不需要改的地方(自动适配):

  • char board[SIZE][SIZE] — 数组大小自动变化
  • memset(board, '.', sizeof(board))sizeof 自动计算新大小
  • onboard() — 用 SIZE 做边界检查,自动适配
  • check_dir() — 边界检查用 SIZE
  • check_win() — 遍历上界用 SIZE
  • board_full() — 遍历上界用 SIZE
  • 所有 for (int i = 0; i < SIZE; i++) — 自动适配

这就是用 #define 宏定义尺寸的好处——所有依赖棋盘大小的代码自动跟随变化,只需改一行。

可能需要改的地方

  • 打印格式:19×19 的列号可能需要 %2d 改为 %3d 以保证对齐
  • 输入提示:如果提示中写了「15×15」
  • 五连判定逻辑:k < 5 不需要改(五子棋规则不变),但如果要改为六子棋则需要改

对比:如果把 15 硬编码在各处(魔数),改大小就需要修改每一处出现 15 的地方,极易遗漏。#define SIZE 15 是避免魔数的最佳实践。

Q6: Lesson 14 的 check 和 Lesson 23 的 check_dir 有何不同?

两个函数都使用方向数组 (dr, dc),但解决的问题层次不同:

方面Lesson 14 check(row, col, dir)Lesson 23 check_dir(r, c, dr, dc, player)
检查步数1 步(相邻格子)5 步(连续方向)
检查内容是否可通行(值为 0)是否同色连续
方向来源struct direction(有名称)纯整数数组 dirs[4][2]
应用场景迷宫探索的单步决策五子棋胜负的连续判定
算法扩展DFS/BFS 递归的基础评分函数/AI 评估的基础

本质区别

  • Lesson 14 的 check原子操作——判断「这个方向能走吗?」。它是 DFS 递归的基础构件。
  • Lesson 23 的 check_dir序列操作——判断「这个方向连续 5 格都是同色吗?」。它内部包含了一个 for 循环。

如果把 Lesson 14 的 check 视为 step(),那么 Lesson 23 的 check_dir 就是 step() × 5 的连续调用。两者都是方向数组这一抽象在不同场景下的应用——这正是方向数组作为通用抽象的力量。


课后练习

  1. 打印当前棋盘:在游戏主循环中每次落子后自动打印棋盘状态,让玩家看到当前局面。

    知识点提示:在 board[r][c] = cur 之后调用 print_board()、格式化打印带行列号的 15×15 网格

  2. 禁止重复落子:如果玩家试图在已经有棋子的位置落子,不仅要提示「非法」,还要提示具体原因(越界 or 已有棋子)。

    知识点提示:分别检查 onboardempty,给出不同的错误提示

  3. 五连高亮:修改 check_win,当找到五连时不仅返回 1,还记录下五连的 5 个坐标,在主循环中高亮打印这 5 个位置。

    知识点提示:在 check_dir 中用数组记录 5 个坐标、在 check_win 中保存到全局数组、print_board 对高亮位置特殊输出(如 [X]

  4. 简单 AI 对手:实现 think() 函数,让玩家 X 与电脑 O 对弈。AI 策略:优先堵对手的四连,其次扩展自己的三连,否则随机落子。

    知识点提示:评分函数对每个空位打分、遍历所有空位取最高分、使用 srand(time(NULL)) 初始化随机种子

  5. 棋盘保存与复盘:将每步落子记录到数组 history[225][2] 中,游戏结束后可以将历史记录写入文件,并支持从文件读取回放。

    知识点提示FILE *fp = fopen("replay.txt", "w")fprintf 写入坐标、fscanf 读取回放、fclose 关闭文件

练习1: 自动打印棋盘
ex1_auto_print.c
c
// 在主循环中添加一行即可
board[r][c] = cur;
print_board();    // ← 每次落子后打印

// 完整主循环:
while (1)
{
    char cur = (turn % 2 == 0) ? 'X' : 'O';
    printf("Player %c, enter row col: ", cur);

    int r, c;
    scanf("%d %d", &r, &c);

    if (!onboard(r, c) || !empty(r, c))
    {
        printf("Invalid move! Try again.\n");
        continue;
    }

    board[r][c] = cur;
    print_board();           // 落子后显示棋盘

    if (check_win(cur))
    {
        printf("Player %c wins!\n", cur);
        break;
    }

    if (board_full())
    {
        printf("Draw!\n");
        break;
    }

    turn++;
}

简单但有效——print_board 复用练习 1 的实现。在真实对弈中,棋盘可视化是必需品。

练习2: 区分非法原因
ex2_error_reason.c
c
while (1)
{
    char cur = (turn % 2 == 0) ? 'X' : 'O';
    printf("Player %c, enter row col: ", cur);

    int r, c;
    scanf("%d %d", &r, &c);

    if (!onboard(r, c))
    {
        printf("Out of board! Row and col must be 0-%d.\n", SIZE - 1);
        continue;
    }

    if (!empty(r, c))
    {
        printf("Position (%d,%d) is already occupied!\n", r, c);
        continue;
    }

    board[r][c] = cur;
    // ...
}

!onboard || !empty 拆成两个独立的 if 判断,每个给出不同的错误信息。用户友好性大大提升——知道是打到了棋盘外面还是打到了已有棋子的位置。

练习3: 五连高亮
ex3_highlight_win.c
c
// 全局数组记录五连的 5 个坐标
int win_pos[5][2];
int has_winner = 0;

int check_dir(int r, int c, int dr, int dc, char player)
{
    int count = 0;
    for (int k = 0; k < 5; k++)
    {
        int nr = r + k * dr;
        int nc = c + k * dc;

        if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
            return 0;
        if (board[nr][nc] == player)
        {
            win_pos[count][0] = nr;   // 记录坐标
            win_pos[count][1] = nc;
            count++;
        }
        else
            break;
    }
    return count >= 5;
}

void print_board_highlight(void)
{
    // 将五连坐标标记为高亮
    char display[SIZE][SIZE];
    memcpy(display, board, sizeof(board));

    for (int k = 0; k < 5; k++)
        display[win_pos[k][0]][win_pos[k][1]] = '*';   // 高亮标记

    // 打印时对 '*' 特殊处理
    printf("   ");
    for (int j = 0; j < SIZE; j++)
        printf("%2d ", j);
    printf("\n");

    for (int i = 0; i < SIZE; i++)
    {
        printf("%2d ", i);
        for (int j = 0; j < SIZE; j++)
        {
            if (display[i][j] == '*')
                printf("[%c]", board[i][j]);   // 高亮格式
            else
                printf(" %c ", display[i][j]);
        }
        printf("\n");
    }
}

check_dir 中用 win_pos 记录五连的 5 个坐标。print_board_highlight 创建 display 副本,将五连位置标记为 '*',打印时对高亮位置使用特殊格式 [X]。这让玩家一眼看到胜出的五连。

练习4: 简单 AI 对手
ex4_simple_ai.c
c
#include <stdlib.h>
#include <time.h>

// 评估在 (r,c) 落 player 子的分数
int evaluate(int r, int c, char player)
{
    int score = 0;
    int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}};

    for (int d = 0; d < 4; d++)
    {
        int count = 0;
        for (int k = -4; k <= 4; k++)    // 双向扫描
        {
            int nr = r + k * dirs[d][0];
            int nc = c + k * dirs[d][1];
            if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE)
                continue;
            if (board[nr][nc] == player)
                count++;
            else if (board[nr][nc] != '.')
                count = 0;                // 被对方棋子阻断
        }
        // 连续棋子越多,分数越高(指数增长)
        if (count >= 2) score += count * count;
    }
    return score;
}

int think(int *x, int *y, char ai_player)
{
    char opponent = (ai_player == 'X') ? 'O' : 'X';
    int best_score = -1;
    int best_r = -1, best_c = -1;

    for (int i = 0; i < SIZE; i++)
        for (int j = 0; j < SIZE; j++)
        {
            if (board[i][j] != '.')
                continue;

            // 自己的分数 + 堵对手的分数
            int my_score = evaluate(i, j, ai_player);
            int block_score = evaluate(i, j, opponent);

            int total = my_score * 2 + block_score;  // 进攻权重高于防守

            if (total > best_score)
            {
                best_score = total;
                best_r = i;
                best_c = j;
            }
        }

    if (best_r == -1) return 0;   // 无空位
    *x = best_r;
    *y = best_c;
    return 1;
}

核心思想是评分函数 evaluate:对每个空位,双向扫描 4 个方向,统计如果在这里落子会形成多长的连续。自己的连续长度权重 ×2(进攻优先),对手的连续长度权重 ×1(防守次要)。这是一个简单但有效的启发式 AI。

练习5: 棋盘保存与复盘
ex5_replay.c
c
#include <stdio.h>

#define MAX_MOVES 225

int history[MAX_MOVES][2];
int move_count = 0;

// 在每次合法落子后记录
void record_move(int r, int c)
{
    history[move_count][0] = r;
    history[move_count][1] = c;
    move_count++;
}

// 游戏结束后保存
void save_replay(const char *filename)
{
    FILE *fp = fopen(filename, "w");
    if (!fp) return;

    for (int i = 0; i < move_count; i++)
        fprintf(fp, "%d %d\n", history[i][0], history[i][1]);

    fclose(fp);
    printf("Replay saved to %s (%d moves)\n", filename, move_count);
}

// 从文件加载并回放
void load_and_replay(const char *filename)
{
    FILE *fp = fopen(filename, "r");
    if (!fp) return;

    init_board();
    int r, c, turn = 0;

    while (fscanf(fp, "%d %d", &r, &c) == 2)
    {
        char cur = (turn % 2 == 0) ? 'X' : 'O';
        board[r][c] = cur;

        printf("Move %d: Player %c → (%d,%d)\n", turn + 1, cur, r, c);
        print_board();
        printf("\n");

        if (check_win(cur))
        {
            printf("Player %c wins!\n", cur);
            break;
        }

        turn++;
    }

    fclose(fp);
}

历史记录用二维数组 history[225][2] 存储每步的 (r, c)save_replay 将历史写入文本文件,每行一对坐标。load_and_replay 从文件读取并逐步回放——每读一对坐标就落子、打印棋盘、检查胜负。这构成了一个完整的「存档-回放」系统。


参考资料

"First solve the problem, then write the code." — John Johnson

Released under the MIT License.