资讯详情

C语言实现Renju五子棋AI:禁手判断与博弈搜索全攻略

📅 2026/10/14 19:12:45 | 华诺云谱 👁 阅读
C语言实现Renju五子棋AI:禁手判断与博弈搜索全攻略
简介这份资源为C语言实现的Renju连珠规则五子棋AI面向棋类AI开发者与算法学习者完整示范了博弈树搜索与棋型评估的落地流程。包体十分精简共3个C源文件、仅11KB分别提供Linux运行版、Linux竞赛版与Windows版便于对照不同平台下的代码组织、宏开关及条件编译写法。已有466人学习适合快速研读、移植也适合作为课程设计或比赛方案底本。AI集成了Minimax搜索、启发式评估、Alpha-Beta剪枝、预评估、迭代加深、VCT/VCF、PVS、杀手启发式搜索以及“非完全动态”的多线程并行等关键技术兼顾禁手与连珠规则可运行于常规对弈和比赛环境。阅读源码既能理解多种搜索加速手段如何组合、如何评估候选点也能获得实战排错与重构思路是学习五子棋AI、Renju规则的实用参考资料。1. 先说清楚Renju规则五子棋AI难在哪以及为什么非C语言不可普通五子棋AI把合法空位都让黑棋下黑棋可以靠冲四和活三的组合一路压着白棋。但换成Renju规则连珠后事情立刻变了黑棋被加了三道禁手——双三、双四和长连哪一条踩中直接判负。这意味着你拿普通五子棋的搜索逻辑跑AI会在自己局面大优的时候突然走出禁手然后莫名其妙输掉。C语言在这里的优势不是语法漂亮而是速度和可控性同样的评估函数用C实现可以在相同深度下比解释型实现快一个量级棋盘和递归栈都在你手里内存与边界行为完全可预期。这篇文章是一条完整落地路径从棋盘数据结构和禁手判断到极小化极大搜索与评估函数再到处处是坑的工程优化目标是让你在本地跑通一个完整按Renju规则下棋的AI并且知道每一步为什么要这么做。适合自己动手做对弈程序的开发者也适合拿来做课程设计或比赛项目的同学对标参考。2. 用C语言实现Renju合法落子与禁手判断数据结构与三个判定函数先说一个反直觉的点Renju和Gomoku的差别不在于棋盘大小而在于“哪些落子是合法的”。普通五子棋里黑棋随便下但Renju规则下黑棋的合法落子集合被画了一个圈AI的搜索过程只有在这个圈里跑才有意义。整棵博弈树的地基是“禁手判断正确”。2.1 棋盘表示与方向遍历用坐标和数组把规则翻译给机器我常用的表示方法很简单一个15x15的二维数组。0表示空1表示黑棋2表示白棋。不要一开始就上位棋盘、zobrist哈希这些花活先把规则跑通再说。#define N 15 static int board[N][N]; /* 0空1黑2白 */ static const int dirs[4][2] { {0, 1}, /* 水平 */ {1, 0}, /* 垂直 */ {1, 1}, /* 主对角线 */ {1, -1} /* 副对角线 */ }; static int in_bounds(int x, int y) { return x 0 x N y 0 y N; } static void put_stone(int x, int y, int player) { board[y][x] player; } static void remove_stone(int x, int y) { board[y][x] 0; }方向数组是后面所有判定函数都要反复用的东西四个方向分别对应五子棋的横、竖、两条斜线。判断连线时只需要沿每个方向往两端数格子就能拿到连续棋子的长度。in_bounds一开始就要写好因为后面每一个循环都要拿它挡边界。C语言里数组越界是未定义行为写错一次可能只在特定棋形下才崩溃极难排查。一句话说清为什么用二维数组而不是更高级的结构五子棋的搜索过程需要频繁落子和悔子二维数组的读写是 O(1)这个特性在递归搜索里的收益非常直接。位棋盘bitboard在跳棋和国际象棋里很有用但五子棋棋型判断是围绕“某一条线”扫描的二维数组配合方向遍历反而是最容易写对、也最容易验证的方案。2.2 禁手判断实现主动防守黑棋的“伪最优解”禁手的核心是三条长连超过5颗连续、双四同时形成两个成五机会、双三同时形成两个活三。这里有两个被初学者反复踩的边界先写出来如果黑棋落子后直接形成五连那这一手是取胜不判禁手哪怕是同一手也造成六连以上仍按五连优先判胜。“双四”里的四既包括两端堵不住的活四也包括只有一端开口的冲四“双三”里的三必须达到活三标准眠三不算。我先说为什么不能用朴素的“连续计数”来判断禁手。像OXXXXO这种两端都被白棋堵死的四颗黑子连续计数会认为它是“长四”但实际上它既不是活四也不是冲四更不构成双四。如果用简单的长度加两端状态去套会把大量无效棋型误判成禁手导致AI该赢的棋不敢赢。所以更可靠的做法是枚举“五元组窗口”。/* 统计在 (x,y) 放置黑子后包含该点的四方向五连窗口数 */ static int count_win_windows(int x, int y) { int d, off, cnt 0; for (d 0; d 4; d) { int dx dirs[d][0], dy dirs[d][1]; /* 含 (x,y) 的长度为5的窗口起点相对坐标偏移 -4 到 0 */ for (off -4; off 0; off) { int sx x off * dx; int sy y off * dy; int ex sx 4 * dx; int ey sy 4 * dy; int i, ok 1; if (!in_bounds(sx, sy) || !in_bounds(ex, ey)) continue; for (i 0; i 5; i) { int px sx i * dx; int py sy i * dy; if (board[py][px] ! 1) { ok 0; break; } } if (ok) cnt; } } return cnt; }count_win_windows的思路是把 (x,y) 当作已经落下的黑子沿四个方向把所有包含这个点的长度为 5 的窗口全部枚举一遍统计五格全是黑棋的窗口数量。这个数字的含义很直接——如果大于等于 2说明这手棋同时形成了两个以上成五的可能也就是双四如果等于 1说明这手棋直接成五是合法获胜步。长连判定相对简单沿四个方向统计包含 (x,y) 的连续同色棋子最大长度static int count_overline(int x, int y) { int d, max_len 0; for (d 0; d 4; d) { int len 1, i; for (i 1; i N; i) { int nx x i * dirs[d][0], ny y i * dirs[d][1]; if (!in_bounds(nx, ny) || board[ny][nx] ! 1) break; len; } for (i 1; i N; i) { int nx x - i * dirs[d][0], ny y - i * dirs[d][1]; if (!in_bounds(nx, ny) || board[ny][nx] ! 1) break; len; } if (len max_len) max_len len; } return max_len; }双三的判定不能直接用五元组窗口因为三还没到成五那一步。我单独写一个方向级计数函数对每个方向扫描含 (x,y) 的连续黑子段只要“连续三颗且两端都是空位”就算一个活三static int count_three_types(int x, int y) { int d, open_threes 0; for (d 0; d 4; d) { int dx dirs[d][0], dy dirs[d][1]; int len 1, open_end 0, i; /* 朝一个方向延伸 */ for (i 1; i N; i) { int nx x i * dx, ny y i * dy; if (!in_bounds(nx, ny) || board[ny][nx] 2) break; if (board[ny][nx] 0) { open_end; break; } len; } /* 朝另一个方向延伸 */ for (i 1; i N; i) { int nx x - i * dx, ny y - i * dy; if (!in_bounds(nx, ny) || board[ny][nx] 2) break; if (board[ny][nx] 0) { open_end; break; } len; } if (len 3 open_end 2) open_threes; } return open_threes; }注意这是简化版。真正的连珠裁判还会考虑跳三如_X_X_X_这类形态需要枚举更多窗口才能补全。对工程落地来说先处理线性连续棋型已经能打赢相当多对局而且这个版本足够你理解禁手判断的骨架。完整的禁手判断函数把三类结果合起来模拟落子、判断、悔子static int is_forbidden(int x, int y) { int result; if (board[y][x] ! 0) return 1; put_stone(x, y, 1); /* 模拟黑棋落子 */ if (count_win_windows(x, y) 1) { result 0; /* 直接成五不判禁手 */ } else if (count_overline(x, y) 6) { result 1; /* 长连禁手 */ } else if (count_win_windows(x, y) 2) { result 1; /* 双四禁手 */ } else if (count_three_types(x, y) 2) { result 1; /* 双三禁手 */ } else { result 0; } remove_stone(x, y); return result; }逻辑顺序是有讲究的先看成五再看长连再看双四最后看双三。实战里最容易错的是长连和五连同时出现的局面一定要把五连优先级放最前面。我建议你把is_forbidden单独做成模块在接入搜索之前先手工摆好棋谱跑单测双三一组、双四一组、长连一组、五连优先一组各准备两个用例。禁手判定的正确性直接决定AI会不会在关键时刻下出“自杀棋”这一步省不得。3. 让AI会思考极小化极大搜索与α-β剪枝的落地写法进入AI核心之前先明确一个边界Renju五子棋AI的棋力主要来自三件事——搜索深度、评估函数准确性、候选点生成质量。极小化极大是骨架所有优化都是在给它减枝和提速。3.1 为什么五子棋AI最终都回到博弈树搜索五子棋是零和博弈黑棋想赢白棋想阻止黑棋赢。每一步落子后双方轮流行动直到出现五连、禁手或平局。这种状态空间非常适合用极小化极大搜索建模黑棋节点取子节点中的最大值白棋节点取子节点中的最小值。由于黑棋有禁手每个黑棋落子点还要额外做合法性检查这让Renju AI比普通五子棋AI多了一层天然剪枝——一部分看似凶悍的进攻点被直接排除。搜索深度怎么定在我的实测里15x15棋盘上评估函数没做复杂优化时4层搜索是入门水平能挡住新手的大部分直接冲四6层深度配合好的评估函数能看出活三和冲四的组合杀招但单步耗时上升到秒级。大多数课程设计和比赛项目5层是安全和棋力的平衡点。这个数字取决于候选点收敛到什么程度——如果每层平均生成 20 个候选点5层就是 20^5 320 万个叶子节点在C里配合剪枝通常能在 1 秒左右跑完再深就要靠后面的优化。3.2 落子候选点生成先缩小战场再谈搜索效率全盘搜索是新手最容易犯的错误。15x15只有225个格点但乘以深度后呈指数爆炸。我一般用一个朴素但有效的启发式只生成距离已有棋子不超过2格的空位作为候选点。五子棋的任何战斗都围绕现有棋子展开远端空位在一两手尺度内不可能影响当前局面。typedef struct { int x, y; } Point; static int has_nearby_stone(int x, int y) { int dx, dy; for (dy -2; dy 2; dy) { for (dx -2; dx 2; dx) { int nx x dx, ny y dy; if (in_bounds(nx, ny) board[ny][nx] ! 0) return 1; } } return 0; } static int gen_candidates(Point *out, int max_count) { int x, y, cnt 0; for (y 0; y N cnt max_count; y) { for (x 0; x N; x) { if (board[y][x] 0 has_nearby_stone(x, y)) { out[cnt].x x; out[cnt].y y; cnt; } } } return cnt; }max_count我用 32因为 15x15棋盘上任意棋局中距离棋子2格以内的空位通常不会超过这个数。这个参数不是越大越好——它直接决定每层搜索宽度宽度从 32 减半到 16总耗时能降到四分之一。如果某局面候选点超过 32说明双方棋子已形成缠斗这时评估函数往往比搜索深度更管用先截断是对的。候选点的次序也很关键。搜索中先评估的点如果能让 α/β 区间收窄剪枝效率会大幅提升。我通常在生成候选点后用“该点到最近棋子的曼哈顿距离”做一次升序排列距离越小越优先这个策略能减少约一半的节点扩展量static int manhattan_to_nearest(int x, int y) { int dx, dy, best N * 2; for (dy 0; dy N; dy) { for (dx 0; dx N; dx) { if (board[dy][dx] ! 0) { int dist abs(x - dx) abs(y - dy); if (dist best) best dist; } } } return best; } static int point_cmp(const void *a, const void *b) { const Point *pa (const Point *)a; const Point *pb (const Point *)b; int da manhattan_to_nearest(pa-x, pa-y); int db manhattan_to_nearest(pb-x, pb-y); return da - db; }manhattan_to_nearest的实现就是二重循环找最近棋子开销很小但收益很大因为搜索树的上层节点里靠近棋子的点更容易产生有力进攻或防守α-β剪枝会更早触发。3.3 极小化极大 α-β剪枝的完整C实现核心搜索函数写成递归形式。为了可读性胜负检测和评估函数先假设已存在#define INF 100000000 static int alpha_beta(int depth, int alpha, int beta, int player) { Point cands[32]; int i, cnt, best, val, winner; winner check_board_winner(); if (winner 1) return 1000000 depth; if (winner 2) return -1000000 - depth; if (depth 0) return evaluate_board(); cnt gen_candidates(cands, 32); qsort(cands, cnt, sizeof(Point), point_cmp); best (player 1) ? -INF : INF; for (i 0; i cnt; i) { int x cands[i].x, y cands[i].y; if (player 1 is_forbidden(x, y)) continue; /* 黑棋不能落在禁手点 */ put_stone(x, y, player); val alpha_beta(depth - 1, alpha, beta, 3 - player); remove_stone(x, y); if (player 1) { if (val best) best val; if (best alpha) alpha best; } else { if (val best) best val; if (best beta) beta best; } if (alpha beta) break; /* 剪枝 */ } return best; }这段代码有四个关键点需要说明depth表示当前还能搜几层递归边界处调用评估函数。进入下一层时3 - player把 1 变 2、2 变 1完成黑白交替。winner检查发生在递归入口而不是落子之后这样能让已经分出胜负的节点从根上截断不再往下扩展。返回时加上depth的权重是为了让同样取胜的局面里步数少的优先AI会更主动追求速胜。alpha和beta是剪枝区间。黑棋节点只更新alpha白棋节点只更新beta当区间为空时说明对方下一步已经不可能被当前节点接受直接剪掉这是整个α-β剪枝的精髓。黑棋禁手点直接continue跳过。规则允许黑棋主动下禁手然后等对手抓禁但AI没必要这么走跳过既保证合法性又缩小搜索宽度。调用入口一般包成这个函数Point ai_move(int player) { Point cands[32]; int i, cnt, best_val -INF; Point best_move {-1, -1}; if (board[7][7] 0) return (Point){7, 7}; /* 开局占天元 */ cnt gen_candidates(cands, 32); qsort(cands, cnt, sizeof(Point), point_cmp); for (i 0; i cnt; i) { int x cands[i].x, y cands[i].y; int val; if (player 1 is_forbidden(x, y)) continue; put_stone(x, y, player); val alpha_beta(DEPTH - 1, -INF, INF, 3 - player); remove_stone(x, y); if (val best_val) { best_val val; best_move (Point){x, y}; } } return best_move; }第一层不剪枝因为需要遍历所有候选点找出唯一最优解不能因为某个分支提前超过 β 就放弃整个搜索。DEPTH我定义成宏先设 5实测单步时间可接受后再加到 6。工程上还有一个细节check_board_winner没必要每层全盘扫描只需要检查上一层落子位置周围的四个方向。这个优化在搜索深度提高后很明显10层的递归里能省掉大量重复扫描。4. 把棋型特征量化为分数评估函数设计与棋型分数表搜索只负责回答“这一步怎么走更优”但“优”本身由评估函数定义。很多人把评估函数想得很玄其实它就是一个暴力打分器把当前棋盘按横、竖、两条斜线切成一条条一维序列然后对每条序列里的线段形态查表打分最后汇总。棋力差异就藏在查表分值的设计里。4.1 从零设计一张棋型分数表活三值多少、眠三值多少先列一张我常用的分数表它不是标准答案但经过大量对局验证方向是对的棋型含义分数五连五颗同色连续10000000活四两端都开放的四个1000000冲四一端开放的四个50000活三两端开放的三个5000眠三一端开放的三个500活二两端开放的二个100眠二一端开放的二个10为什么活四给 1000000 而不是 100000因为在五子棋里活四等价于必杀对手无论堵哪一端另一端都能成五。活四和活三之间必须有一个数量级差距不能让AI为了一个活三放弃已有的必杀形势。冲四和活三的关系也要拉开冲四是一次性威胁活三具有持续性实战中活三的威胁通常高于普通冲四但冲四能逼迫对手让活三略低于冲四但差距不大是合理的。这里有一个Renju规则特有的坑黑棋的分数不能照搬白棋。黑棋有禁手很多在普通五子棋里看起来很强的双三、双四形态对黑棋反而是死路。我处理的方法是评估时对黑棋棋型单独处理如果确认是禁手相关棋型黑棋这部分分数直接打七折。这个细节让AI执黑时更谨慎不会为了追求华丽棋型把局面走死。4.2 用滑动窗口扫描棋盘把四方向特征压缩成数值实现评估函数的第一步是把棋盘拆成一维线段。15x15 的棋盘有 15 行 15 列 29 条主对角线 29 条副对角线一共 88 条线。对每一条线做一次从左到右的扫描找到连续的相同棋子段记录长度和两端状态static const int kScore[5][3] { /* open_ends0, open_ends1, open_ends2 */ {0, 0, 0}, /* len1 */ {0, 10, 100}, /* len2 */ {50, 500, 5000}, /* len3 */ {5000, 50000, 1000000}, /* len4 */ {10000000, 10000000, 10000000} /* len5 */ }; static int evaluate_line(int *line, int length) { int i 0, score 0; while (i length) { int player, len, start; int left_block, right_block, open_ends; if (line[i] 0) { i; continue; } player line[i]; len 0; start i; while (i length line[i] player) { len; i; } left_block (start 0 line[start - 1] ! 0); right_block (i length line[i] ! 0); open_ends (left_block ? 0 : 1) (right_block ? 0 : 1); if (len 5) { score (player 1) ? 10000000 : -10000000; } else { int v kScore[len - 1][open_ends]; score (player 1) ? v : -v; } } return score; }扫描过程中left_block和right_block的判断是评估函数最容易出错的地方只有相邻位置被对方棋子或棋盘边界堵住才算“封堵”如果是空位就是开口。有些实现会把“空一格再堵住”也算封堵那就过度拟合了——跳二的棋型在五子棋里依然有很强的棋力不能直接等同眠二。整体评估就是把 88 条线依次跑一遍把两个玩家的分数相减。一般让评估函数返回black_score - white_score正数代表黑方优势。注意不能把黑方的禁手惩罚塞进evaluate_line因为禁手是“黑棋落子瞬间”的规则必须在搜索的落子合法性里处理评估函数只处理静态棋型。围绕这个查询表有两个需要特别当心的陷阱。第一是int溢出。上面分数表里最大值 10000000搜索中如果局面阶段性地积累了几千万分加上depth权值也不会爆掉 int 的 21 亿上限。但如果你把五连的分数调成 10^9再把冲四调成 10^7累计起来就可能溢出。分数表跨度过大时优先级会变得不稳定。我见过有人把五连设成 100000000结果AI为了一个潜在的活三宁可放弃已经到手的必胜冲四——因为中间节点的分数量级太高让搜索误判了威胁程度。第二个陷阱是斜线加权。按 88 条线独立扫描会让一个段在交叉处被重复计算。比如棋盘正中的一个棋子会同时出现在行、列、两条对角线四条线里中心影响力被计了四次。这不是bug反而是一种有效的中心加权。但分数表如果设计得太大这种重复会使中心优势过度膨胀。我的折中办法在查表时给斜线方向的棋型分数乘一个 0.8 系数因为对角线上的棋型通常比横竖线更难形成连线。这个系数不是银弹但它让开局AI更愿意占天元和星位而不是一味往角上跑。5. 避坑指南Renju规则五子棋AI最容易翻车的6个常见问题把搜索和评估写出来只是第一步真正让项目“活着”的是把这些隐蔽问题提前打掉。下面这些是我实际跑程序时踩过的坑按现象、原因、解决来写。5.1 搜索层数一深棋盘状态“幽灵化”现象设置深度 6 后AI落到某一步时棋盘上突然多出几颗不存在的棋子或者落子撤销后残留棋子局势判断一团乱。原因递归搜索中put_stone和remove_stone必须严格配对任何一条提前return的分支都会破坏棋盘状态。搜索深度提高后这种配对被破坏的概率也在增加。解决搜索函数里只允许在remove_stone之后 return或者把棋盘状态作为参数传到下一层递归虽然每层复制 225 字节开销不小但在调试期能极大减少心智负担。我自己的习惯是在alpha_beta入口先加断言assert(board[y][x] player);确保悔子前后状态一致。否则段错误往往发生在十几层之后靠 printf 逐层打印极难排查。5.2 AI主动下出黑棋禁手双三判定标准太宽松现象AI执黑时明明可以通过冲四制胜程序却选择了一个双三落点之后被白棋抓禁判负。原因count_three_types把len3 open_ends2的线性三段都当成活三但实战中很多形态是“形似活三、实则被压制”。例如_XXX_X_里三个黑子的两端看似开放实际上中间有个断点让白棋能从容应对这应该算眠三而非活三。解决严格定义活三——连续三颗黑子两端必须都是空位且这两个空位落子后都能直接形成冲四。按照这个定义去补全count_three_types而不是只看长度和两端是否为空。刷棋谱时把_XXX_X_、_X_XX_这种跳跃形态单独立一组测试用例它们最善于骗过朴素的计数逻辑。5.3 对手执黑时白棋不知道“抓禁”现象对手黑棋下出禁手后白棋完全无动于衷继续按普通五子棋思路防守。原因评估函数里没有把“对方已经处于禁手状态”当作评分项。规则里黑棋一旦落成禁手白棋下一步可以直接指出并对局结束。解决在evaluate_board里加一道检测——每当轮到白方落子检查黑棋上一手是否落在禁手点。如果是白方直接返回一个极大正数作为评估值。更简单的方式是搜索入口对黑棋候选点做了禁手过滤本就不会生成禁手点但白棋的应对策略里要显式识别“对手已经被禁”的局面否则会出现对手被禁后AI还在棋盘上胡乱下的错觉。5.4 搜索一步要20秒候选点没收敛现象深度 5 的AI在残局阶段单步耗时急剧上升超过 20 秒。原因残局阶段棋子密集has_nearby_stone对几乎所有空位都返回 1生成的候选点数量接近满盘空位数max_count上限托不住。解决残局时收紧候选点范围把has_nearby_stone的半径从 2 改成 1只认相邻格子。同时把gen_candidates的max_count从 32 降到 16。这样虽然会漏掉个别“隔空进攻”的手筋但残局长考反而更容易超时保持快速反应比追求完美更实际。5.5 递归深度到7程序直接段错误现象把DEPTH调到 7第一手还没算完程序就崩了GDB显示栈溢出。原因每次递归都会在栈上分配Point cands[32]和多个局部变量深度 7 时每层约 1KB 栈空间在默认 8MB 栈的Linux上勉强能活如果评估函数里的临时变量再膨胀会顶破栈上限。解决三个方向。一是把cands从局部数组改成函数内静态数组或堆分配避免每次递归都压栈二是用迭代加深代替一次性深层搜索到时间限制就返回当前最优三是如果确实要跑深度 8 以上搜索用pthread_attr_setstacksize增大线程栈而不是只改递归深度。实际项目中我推荐第二种迭代加深不只栈安全还天然支持时间控制这一点第 6 章会展开。5.6 评估分数怎么调都不对黑白视角没有归一化现象AI在某些局面下完全“瞎走”既不看自己的进攻也不挡对方的活三。原因evaluate_line返回的是绝对值黑白双方用同一个查分表但双方棋型价值不对称——黑棋的双三会走向禁手白棋的双三是纯粹威胁。把两个分数直接相减会让AI错估局面。解决在evaluate_board里显式拆分黑子分数和白子分数黑棋分数乘以一个小于 1 的系数再相减例如return black_score * 0.92 - white_score;。这个系数代表“黑棋因为禁手而折损的进攻效率”具体取值可以在自对弈中观察AI的落子风格调整。不用太纠结精度关键是让AI明白黑棋和白棋的棋型不对等。6. 从能跑到能赢迭代加深与候选点排序的立竿见影优化把前面几章代码串起来你已经有一个能走棋但不一定强的AI了。这一章给两个能马上落地的优化一个验证棋力的方法。6.1 迭代加深用时间预算换搜索深度固定深度搜索有个尴尬残局候选点少深度 5 只花 0.2 秒开局候选点多可能需要 3 秒。迭代加深的做法是从深度 1 开始逐层往上搜每层记录当前最优步直到超过时间预算Point ai_move_with_time(int player, int max_ms) { int depth; Point best {-1, -1}; for (depth 1; depth MAX_DEPTH; depth) { int before clock(); best ai_move_fixed_depth(player, depth); int elapsed clock() - before; if (elapsed max_ms * CLOCKS_PER_SEC / 1000) break; } return best; }迭代加深的额外收益很多人没意识到前一层的搜索结果可以作为当前层的候选点排序让 α-β 剪枝在高层就命中关键分支。对五子棋这种分支因子不算太大的游戏迭代加深几乎不浪费时间——深度 N 层的总耗时本来由最深层主导。6.2 候选点排序让剪枝在第一波就命中最优排序键的选择强烈影响剪枝率。我用三类优先级组合是否直接形成五连、是否防守对方成五、是否靠近已有棋子再加上中心倾向static int move_priority(int x, int y, int player) { int score 0; int opp 3 - player; if (would_form_five(x, y, player)) score 100000; if (would_form_five(x, y, opp)) score 50000; if (has_nearby_stone(x, y)) score 100; score - manhattan_to_nearest(x, y) * 2; return score; }给分设计让“己方成五”最高“挡对方成五”次之然后是贴近战场的点。这个排序决定了搜索树的第一分支有多聪明剪枝率能从 40% 提升到 70% 以上。would_form_five可以直接复用count_win_windows的思路。6.3 一个成本很低的验证方法自对弈随机测试验证AI棋力不用做界面。写一个主循环让两个AI互相对弈每次只打印每步耗时和最终胜负。跑 20 局如果超过 3 局出现黑棋禁手失误先回头修is_forbidden如果超过 5 局在明显优势局面拖到百手以上多半是冲四和活三权重失衡。这个测试脚本大概 100 行是你调整所有参数的最低成本实验场。我第一次做Renju AI时把活三和冲四权重调得过分接近结果AI鬼迷心窍一样乱冲四把对手防守棋越养越肥。后来把活四调到冲四的20倍冲四调到活三的10倍所有诡异行为瞬间消失。这种调参玄学在没有自对弈基线时很容易走偏但有了自动对弈胜负率你每次改参数都能立刻看到效果。希望这个思路能帮你在自己的实现里少走几步弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑