ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言实现有禁手五子棋控制台AI:数据结构、禁手判定与搜索剪枝

C语言实现有禁手五子棋控制台AI:数据结构、禁手判定与搜索剪枝 简介这是一份纯C语言实现的控制台有禁手五子棋项目内置AI对战功能适合正在学习C语言、博弈树搜索与算法设计的开发者也可作为课程设计或期末项目的参考。资源包含完整可编译的源码支持人机对战其中AI引擎、开局库、哈希表等模块划分清晰能帮助读者理解电脑落子策略与禁手判定规则。在文件组织上21个文件约2.6MB包括8个头文件和6个C源文件涵盖数据结构、AI搜索、模式生成与哈希表等模块另附带VS工程文件、Makefile、可执行程序和说明文档便于编译运行。目前已有194人学习适合用来学习经典棋类AI的实现思路以及如何用纯C语言搭建一个带控制台交互的完整小游戏。通过阅读源码和工程结构读者可以掌握棋盘状态的存储方式、禁手判断的算法细节、AI决策的搜索流程并能够在此基础上扩展双人对战或网络联机功能。1. 有禁手五子棋为什么比无禁手更适合写进控制台AI控制台五子棋大多是无禁手版本黑棋只要连到五子就赢AI的搜索也只需要找“有没有五”。真要把AI做得有点棋味有禁手规则反而更值得做。黑棋被限制不能下双三、双四、长连棋局对黑棋来说不是简单的“先到先得”而是一套“进攻必须带防守”的约束优化。用纯C语言实现这种规则最难得的部分不在图形界面而在棋盘数据结构、禁手判定和AI搜索的衔接。这篇文章按“怎么存、怎么判、怎么搜、怎么交互”的顺序把一条能跑通的路线讲清楚适合想练C语言又不满足于“画个棋盘”的开发者。“Five-to-five-Renju”这种命名通常意味着要交付一个可在普通控制台环境编译运行的课程设计项目所以我下面给出的代码片段都尽量避免外部依赖。2. 用C语言管理棋盘与禁手判定数据结构与规则实现2.1 棋盘存储二维数组还是位棋盘常见五子棋用15×15棋盘。直接board[15][15]最简单但后续扫描四个方向时要计算行列写起来容易出错。我一般用一维数组int board[225]索引idx row * SIZE col。这样在搜索算法里可以快速移动指针也方便用memcpy快照整个局面用于回溯。对于纯C语言的控制台项目位棋盘例如64位整型×4在理论上更快但可读性差而且禁手判断需要大量位运算不如一维数组配合方向扫描来得直接。#define SIZE 15 #define BOARD_SIZE (SIZE * SIZE) #define EMPTY 0 #define BLACK 1 #define WHITE 2 typedef struct { int cell[BOARD_SIZE]; int turn; // 当前该谁下BLACK 或 WHITE int last; // 最后一手的位置-1 表示还没有 int step; // 已下子数 } game_t; const int dirs[4][2] { {0, 1}, // 横 {1, 0}, // 竖 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; static inline int idx(int row, int col) { return row * SIZE col; }这段代码里dirs四个方向覆盖了五子棋需要检测的所有连线。用一维数组后要从一个位置沿方向移动需要先拿到row pos / SIZE和col pos % SIZE再按方向逐格移动这样越界判断最直观。如果你追求搜索速度可以预计算next_pos[BOARD_SIZE][4]保存每个位置四个方向相邻格的索引非法位置设为 -1在深层循环里能省掉除法和比较。方向row偏移col偏移对应线001横110竖211主对角线31-1副对角线2.2 禁手判定三三、四四、长连的检测顺序禁手规则只约束黑棋黑棋不能落子形成“三三”“四四”“长连”白棋没有限制。注意“三三”不是指两个活三同时出现必须是有两个独立的活三眠三不算。检测一个候选点是不是禁手点时先把黑子临时放进该点然后依次做判断是否已经形成五连以上。若形成五连直接返回“不是禁手”因为连五优先于禁手。判断长连沿四个方向连续同色数是否大于等于6。如果是禁手。判断四四统计该点形成的“冲四”与“活四”总数大于等于2则禁手。判断三三统计该点形成的“活三”总数大于等于2则禁手。顺序很重要先立五连再查长连。如果不先判断黑棋落下一步形成五连但同时也可能形成三三的罕见情况会被误杀。int count_len(int *cell, int pos, int d) { int row0 pos / SIZE, col0 pos % SIZE; int len 1; int r, c; r row0 dirs[d][0]; c col0 dirs[d][1]; while (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] BLACK) { len; r dirs[d][0]; c dirs[d][1]; } r row0 - dirs[d][0]; c col0 - dirs[d][1]; while (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] BLACK) { len; r - dirs[d][0]; c - dirs[d][1]; } return len; }count_len从临时落子点向两个方向数同色棋返回该方向上的连续长度。这里针对黑棋禁手检测写死了BLACK如果要通用应把棋子类型作为参数传入。在实际项目里我还会把两个端点外的状态也带出来用来区分“活四”“冲四”和“活三”。活三的判断通常用一个结构体typedef struct { int len; // 连续子数 int open[2]; // 两端是否空位 } shape_t; shape_t scan_shape(int *cell, int pos, int d) { shape_t s; int row0 pos / SIZE, col0 pos % SIZE; int len 1, r, c; // 正方向 r row0 dirs[d][0]; c col0 dirs[d][1]; while (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] BLACK) { len; r dirs[d][0]; c dirs[d][1]; } s.open[0] (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] EMPTY); // 反方向 r row0 - dirs[d][0]; c col0 - dirs[d][1]; while (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] BLACK) { len; r - dirs[d][0]; c - dirs[d][1]; } s.open[1] (r 0 r SIZE c 0 c SIZE cell[idx(r, c)] EMPTY); s.len len; return s; }如果scan_shape返回len 3且open[0] open[1]才算一个活三。很多初学者会把“眠三”也当成“三”去参与三三判定结果AI会认定大量禁手点导致黑棋没法下。正确做法是is_three只统计“能通过再下一子变成冲四或活四的三”也就是两端为空的三连。2.3 落子合法性校验的完整流程做一个统一入口is_forbiddenint is_forbidden(int *cell, int pos) { int d, cnt; cell[pos] BLACK; // 五连优先 for (d 0; d 4; d) { if (count_len(cell, pos, d) 5) { cell[pos] EMPTY; return 0; // 形成五连合法 } } // 长连 for (d 0; d 4; d) { if (count_len(cell, pos, d) 6) { cell[pos] EMPTY; return 1; // 长连禁手 } } // 四四 cnt 0; for (d 0; d 4; d) { if (is_four(cell, pos, d)) cnt; } if (cnt 2) { cell[pos] EMPTY; return 1; } // 三三 cnt 0; for (d 0; d 4; d) { if (is_three(cell, pos, d)) cnt; } if (cnt 2) { cell[pos] EMPTY; return 1; } cell[pos] EMPTY; return 0; }注意每次判断前先放黑子结束恢复EMPTY保证调用方不需要复制整份棋盘。is_four的实现类似只是在len 4且至少一端为空时返回1。这里最容易漏的是“四四”中的活四加冲四组合两个方向一个形成活四、一个形成冲四同样算禁手。3. 控制台AI的评分函数与搜索剪枝让程序在终端里“会下棋”3.1 棋型编码与基础分有禁手五子棋AI和无禁手AI在评分函数上有一个显著区别黑棋必须避开禁手点白棋反而可以利用黑棋的禁手来防守。所以评分函数不能只数“连五”还要对禁手点做惩罚。第一步是定义基础棋型。我常用的评估维度对整个棋盘按四个方向扫描统计每个方向上的连续同色棋块。按两端状态分类得到“活四”“冲四”“活三”“眠三”“活二”“眠二”等。一个参考分值表如下棋型分值说明活四100000白棋胜势黑棋禁手点冲四10000对手必须堵活三5000有连续进攻潜力眠三1000能制造冲四活二500开局布局价值眠二100有限威胁AI对某个局面的评估值等于当前一方所有棋型分之和乘以己方系数再减去对手同样计算后的分值。通常我写成int evaluate(game_t *g) { int score 0; score evaluate_for(g, g-turn) * 10; score - evaluate_for(g, g-turn BLACK ? WHITE : BLACK) * 12; return score; }防守权重大一点因为先手黑棋有禁手限制AI执黑时一旦犯错就会被判负。这个系数要后期调。evaluate_for内部扫描棋盘上每个落子点周围两格范围内的直线避免全盘四次双重扫描。3.2 极小极大搜索与alpha-beta剪枝的C实现控制台AI不建议一上来就写蒙特卡洛树搜索C语言处理起来状态管理复杂。最稳妥的做法是极小极大搜索加alpha-beta剪枝。我们在当前局面的基础上生成候选空点递归搜索双方下子后的评分。候选点生成不遍历全棋盘而是选取已有棋子周围2格内的空点数量通常不超过20个。int alphabeta(game_t *g, int depth, int alpha, int beta, int my_turn) { if (depth 0) return evaluate(g); int moves[64], scores[64]; int n gen_moves(g, moves, 64); if (n 0) return evaluate(g); order_moves(g, moves, scores, n); for (int i 0; i n; i) { make_move(g, moves[i]); int val alphabeta(g, depth - 1, alpha, beta, !my_turn); undo_move(g, moves[i]); if (my_turn) { if (val alpha) alpha val; } else { if (val beta) beta val; } if (alpha beta) break; } return my_turn ? alpha : beta; }gen_moves负责收集候选空点order_moves按照棋型分排序剪枝效率会翻倍。make_move里除了落子还要顺带维护last和step。如果AI执黑gen_moves内部必须先调用禁手检查禁手点直接不生成。这里注意递归里moves数组长度是64如果候选点超过64会截断控制台规模下足够如果你想要更稳可以改成动态分配。参数说明depth用偶数通常比较安全因为AI先手回合和白棋回合都搜索同样的深度最终评分反映的是“双方都按最优走完depth步”的结果。alpha初始化为INT_MINbeta初始化为INT_MAX。剪枝发生在alpha beta时表示当前节点已经不可能改变高层决策直接返回。3.3 搜索深度与时间开销的平衡对于纯C语言的控制台程序深度3到4是常见范围。如果候选点20个深度4在最坏情况要评估20^4个局面也就是16万次评估性能不错的机器上大约0.5到1秒。实际加剪枝后可能只有几千到几万次但禁手判定和落子合法性检查会额外占用时间。常见优化手段有三个evaluate不要全盘扫描。在make_move时维护一个增量分或者每次只扫描落子点周围2格内的线。对候选点按上一轮评分排序剪枝效率明显提升。限制候选点宽度比如只保留order_moves后前12个点。这一步属于把AI从“能下”带到“可以看”的关键调整。我习惯准备一个命令行参数--depth 4 --width 20便于在不同配置的电脑上反复测试也方便验收时演示不同AI强度。4. 在控制台界面里跑通人机对弈输入输出、光标控制与存档4.1 坐标输入与错误处理控制台五子棋最影响体验的不是AI强度而是输入。用scanf(%d%d, row, col)最简单但用户会输入“h8”这种棋盘坐标。我一般两者都支持解析第一个字符是字母还是数字。int parse_pos(const char *s, int *row, int *col) { if (s[0] A s[0] Z) { *col s[0] - A; *row atoi(s 1) - 1; } else if (s[0] a s[0] z) { *col s[0] - a; *row atoi(s 1) - 1; } else { return sscanf(s, %d %d, row, col) 2; } return *row 0 *row SIZE *col 0 *col SIZE; }输入字符列号A/a0B/b1C/c2......O/o14棋盘坐标按国际象棋习惯横轴用A-O纵轴用1-15。atoi要检查越界否则空字符串会解析成0。这里返回1表示合法坐标0表示非法。实际项目里还应该处理“该位置已经有子”“该点是黑棋禁手”的情况这些在is_move_legal里统一判断。4.2 用ANSI转义序列绘制棋盘控制台绘制棋盘直接printf换行即可。在Linux/macOS终端和Windows 10以上终端都支持ANSI转义序列可以用来清屏、隐藏光标和设置颜色。void draw_board(game_t *g) { printf(\x1b[2J\x1b[H); // 清屏并回到左上角 printf( A B C D E F G H I J K L M N O\n); for (int r 0; r SIZE; r) { printf(%2d, r 1); for (int c 0; c SIZE; c) { int v g-cell[idx(r, c)]; if (v BLACK) printf( X); else if (v WHITE) printf( O); else printf( .); } printf(\n); } if (g-last 0) { printf(last: %c%d\n, A g-last % SIZE, g-last / SIZE 1); } }\x1b[2J是清屏\x1b[H重置光标。这个序列在Windows的旧版CMD里可能不工作可以在编译时加#define _CRT_SECURE_NO_WARNINGS并使用system(cls)。对于控制台课程设计通常能跑在Visual Studio或Code Runner环境即可。4.3 文件读写把对局存成文本C语言文件读写操作是课程设计常考的点。五子棋对局保存只需要记录双方落子顺序。自定义格式第一行标记版本之后每行一个坐标。int save_game(game_t *g, const char *path) { FILE *fp fopen(path, w); if (!fp) return -1; fprintf(fp, Five-to-five-Renju 1.0\n); fprintf(fp, %d %d\n, g-step, g-turn); // 后续遍历 move_history 数组逐行写入坐标 fclose(fp); return 0; }fopen第二个参数用w覆盖写如果要追加到同一个文件可以换成a适合记录多轮对战。纯C语言没有内置列表容器所以历史棋步用int history[225]存索引读取时逐行恢复。这里我在文件头写入了Five-to-five-Renju字符串也是为了符合项目命名习惯方便识别棋谱格式。5. 调参技巧从“能下”到“像人”的AI打磨5.1 评估函数权重怎么调权重表是AI风格的核心。把冲四权重设置比活三高AI会更倾向于连续进攻把防守系数调高AI会优先堵对手。这里给一组我常用的初始值int weights[] {100000, 50000, 5000, 1000, 500, 100};如果AI总是看不出别人的活三原因往往是活三分给低了导致对方活三时AI认为跑别处去布局更划算。把活三提到6000或7000它就会去堵。调权重时不要一次改多个每次只改一项然后跑20盘自对弈。也可以写一个简单的自对弈脚本让两个AI执黑执白互相下比较胜率和步数。5.2 用禁手惩罚限制AI的黑棋走法AI执黑时评分函数里要体现禁手。还有更稳妥的方法在gen_moves生成候选点时直接将黑棋禁手点排除这样搜索树上根本不会出现非法分支也不需要在评估函数里处理禁手分数。int gen_moves(game_t *g, int *moves, int max) { int n 0; for (int i 0; i SIZE * SIZE; i) { if (g-cell[i] ! EMPTY) continue; if (!has_neighbor(g, i, 2)) continue; if (g-turn BLACK is_forbidden(g-cell, i)) continue; if (n max) moves[n] i; } return n; }这里有个细节is_forbidden会临时修改cell[pos]但函数结束时已经恢复EMPTY所以在gen_moves循环里调用它是安全的。has_neighbor检查该空点周围2格内是否有任意棋子可以跳过盘面上大量无用空点显著减少搜索宽度。5.3 验证禁手规则正确性的最小用例调试禁手判定最有效的方式是写一批固定局面。比如黑棋两路活三的交叉点应被判禁手黑棋双四交叉点应被判禁手黑棋五连与长连同时出现时五连优先合法黑棋落子形成五连即使同时有三三也判合法。把这些用例写成命令行模式每次编译后自动跑void test_forbidden(void) { game_t g {0}; // 构造一个双三局面 // 例如在 (7,7) 放黑棋两侧各有活三结构 // ... if (is_forbidden(g.cell, idx(7, 7))) { printf(PASS: 双三禁手\n); } else { printf(FAIL: 双三未识别\n); } }测试用例要覆盖活三的定义与眠三的区别、四四中“活四加冲四”的情况、长连的边界值六连是禁手五连不是。只要这几个用例过了绝大多数算法课验收就没问题。最后再说一个容易忽略的点白棋没有禁手尤其是白棋可以任何位置形成长连获胜所以输入合法性校验里is_forbidden只对黑棋生效不能错误地拦下白棋。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进