资讯动态

Python五子棋人机对战:α-β剪枝与pygame实战调优

发布时间:2026/10/26 11:05:49 来源:尧图企业网站定制
简介围绕α-β剪枝与五子棋人机对战整理的Python实战代码包适合已掌握Python基础、希望理解博弈树搜索与pygame界面交互的学习者练手。项目将极大极小值搜索与α-β剪枝结合重点落在电脑走步的候选点位生成、评估函数设计以及搜索效率优化上同时实现先后手选择、胜负判断、棋盘信息显示和重新开局等完整流程。压缩包共3个文件以2个py源码和1个txt说明为主整体约15KB体量轻便便于直接阅读、运行与二次修改。已有4707人学习下载。读者可从中获得可运行的人机对战五子棋方案、pygame按钮与棋盘绘制思路、AI落子决策的代码骨架以及评估函数与剪枝逻辑的对照参考适合课程设计、算法练习和人工智能入门项目借鉴。1. 半秒就落子的 AI为什么反而让人不想跟它下把 python 五子棋 人机对战 pygame 这套东西跑起来最先撞见的不是界面问题而是一个很反直觉的现象AI 落子不到半秒你却已经不太想跟它下了。原因不在 pygame 的绘制也不在鼠标响应而在 α-β剪枝背后那张评估表——它先把「该堵哪、该冲哪」排了个序剩下的才是搜索。这份资源给的就是一条完整链路15 路棋盘的落子与胜负判定、带候选点裁剪的极小极大搜索、棋型打分函数以及一层薄薄的 pygame 交互壳。它适合两类人一类是刚学完递归、想找个能跑通的博弈项目练手的另一类是自己写过五子棋 AI但总觉得「深度一加就卡死、减了又变傻」的。棋盘状态的表示方式、剪枝真正生效的位置、评估权重的量级关系才是这份代码里值得逐行拆的部分。2. 棋盘状态怎么表示二维数组、四方向扫描和落子的可回滚棋盘怎么存决定了后面搜索能不能写得干净。这份代码用的是最朴素的 15×15 二维 list乍看不如位棋盘bitboard精巧但它换来的是评估函数可以照着「行、列、斜线」的直觉去写改一个棋型权重不用先翻译成位掩码。本节把状态层拆成四块存储选型、落子与悔棋、胜负判定、评估需要的行视图。2.1 为什么没用 bitboard可读性换掉的那点常数开销15 路棋盘一共 225 个交叉点用 Python 的大整数做位棋盘在理论上完全可行一次落子就是board | 1 (r * 15 c)胜负判定还能用移位与掩码一次性比对整行。真到自己改棋型的时候问题就来了你要判断「活三」得先想清楚这条斜线的掩码该左移几格、两端的空位怎么用~抠出来调一次权重改一次位运算血泪经验基本都攒在这。二维 list 的代价主要落在两处。一是内存访问grid[r][c]在 CPython 里要走一次 list 索引再走一次比位运算慢一个量级但单次也就几十纳秒一次深度 4 的搜索里总共几万次读取摊下来是毫秒级。二是拷贝如果搜索时用「复制棋盘再落子」的写法每层复制 225 个元素的嵌套 list节点数一多就是灾难。所以这份代码在搜索里统一用「落子 → 递归 → 撤销」的原地改法全程只有一块棋盘。真正要注意的是扩展性。棋盘尺寸写在常量里改成 19 路时胜负判定的扫描窗口、评估函数里提取行的循环边界、候选点邻域的范围三处必须同步改漏一处就会出现「19 路棋盘上 AI 只在左上角 15 路范围内活动」这种翻车现场。2.2 落子、悔棋与历史栈BOARD_SIZE 15 EMPTY, BLACK, WHITE 0, 1, 2 class Board: def __init__(self, sizeBOARD_SIZE): self.size size # 0 空 1 黑 2 白二维 list 便于按行列扫描棋型 self.grid [[EMPTY] * size for _ in range(size)] # 历史栈搜索撤销、悔棋、重复局面检测都靠它 self.history [] def place(self, r, c, player): if not (0 r self.size and 0 c self.size): return False if self.grid[r][c] ! EMPTY: return False self.grid[r][c] player self.history.append((r, c, player)) return True def undo(self): if not self.history: return False r, c, _ self.history.pop() self.grid[r][c] EMPTY return True def is_full(self): return len(self.history) self.size * self.sizeplace做了两层防御越界检查和占位检查返回布尔值而不是抛异常因为搜索过程中会大量调用它异常的开销在这个热路径上不划算。undo只弹栈顶不做其他清理——这一点在接入置换表之后会变成坑第 5 章会专门说。参数上有两个可以动的size换成 19 时is_full的判断自动跟着变EMPTY/BLACK/WHITE用 0/1/2 而不是字符串是为了能用3 - player这个技巧快速取反方写起来短跑起来也快。如果非要用枚举那就得写player.opponent多一次属性访问在几万次调用的循环里是实打实的差别。history存的是三元组而不是只存坐标因为悔棋时要知道原来落的是谁如果只存坐标就得从棋盘上反查而反查时那颗子已经被清掉了。2.3 胜负判定四方向扫描与两个边界DIRS ((1, 0), (0, 1), (1, 1), (1, -1)) # 竖、横、主对角、副对角 def is_win(grid, r, c, player, need5): size len(grid) for dr, dc in DIRS: count 1 for sign in (1, -1): nr, nc r dr * sign, c dc * sign while 0 nr size and 0 nc size and grid[nr][nc] player: count 1 nr dr * sign nc dc * sign if count need: # 用 而不是 五子棋里长连也算胜 return True return False这段代码有两个容易写错的地方第一处是count的初值。落子之后从当前点出发当前点本身算一个所以count 1然后向正负两个方向延伸。如果写成 0四连就会被误判成五连。第二处是need和的关系。标准五子棋里六连、七连通常也算赢无禁手规则下用 5会让长连漏判反过来如果要做带禁手的规则 5才对而且(1, -1)这个负斜方向必须保留很多手写的判定只写了三个方向AI 就永远看不到副对角线上的杀棋。DIRS的定义顺序影响很小但建议把出现频率高的方向放前面——竖着和横着的连线在实际对局里更常见短路返回能省一点。2.4 评估函数的输入把棋盘切成行视图棋型识别本质上是字符串匹配把一行棋转成0011012000这样的串找011110活四、01110活三会直观得多。def iter_lines(grid): 产出棋盘上所有可能成五的线15 行、15 列、21 条主对角、21 条副对角 size len(grid) for r in range(size): yield [grid[r][c] for c in range(size)] for c in range(size): yield [grid[r][c] for r in range(size)] for k in range(-(size - 5), size - 4): # 只保留长度 5 的斜线 line [grid[r][r - k] for r in range(size) if 0 r - k size] if len(line) 5: yield line for k in range(4, 2 * size - 5): line [grid[r][k - r] for r in range(size) if 0 k - r size] if len(line) 5: yield line对角线起点用k偏移量枚举-(size-5)到size-5是主对角方向副对角用k r c从 4 枚举到2*size-6。这两个范围如果写错边角上的棋型就统计不到表现出来就是「AI 在角落下棋时明显变弱」——这类现象很难直接联想到枚举边界只能靠固定局面回放去定位。每调用一次评估就重切一遍所有线代价是 O(线数 × 线长)15 路棋盘约 70 条线、平均长度 10 出头一千次左右的基本操作。深度 4、每次十几个候选点叶子节点上千个这个开销会占掉一半以上的思考时间。优化方向有两个一是增量维护落子时只更新经过该点的 4 条线二是评估时只扫描候选点周边的局部窗口不做全盘。前者改动大后者精度略降选哪个取决于你对棋力的要求。3. α-β剪枝到底剪掉了什么从 negamax 到候选点排序很多人第一次写剪枝写完发现「加了 α-β 反而更慢」多半不是剪枝写错了而是走法没排序。剪枝能不能生效取决于好棋是不是先被搜到顺序不对α 和 β 永远不收敛剪枝条件形同虚设。这章把搜索层从递归骨架、候选点生成、评估表、排序技巧四块讲透。3.1 negamax 骨架与 α≥β 的剪枝条件极小极大写成两份代码一份求最大、一份求最小很啰嗦用 negamax 把「对手的分数取负」这件事统一掉代码能短一半。INF float(inf) WIN_SCORE 10_000_000 MAX_DEPTH 4 def negamax(board, depth, alpha, beta, player): 返回值恒为「站在当前 player 视角」的分数 moves gen_moves(board, player) if not moves: return 0, None # 和棋 if depth 0: return evaluate(board, player), None best_score, best_move -INF, moves[0] for (r, c) in moves: board.place(r, c, player) if is_win(board.grid, r, c, player): # 越早赢分越高避免「能赢但拖着」的退化行为 score WIN_SCORE - (MAX_DEPTH - depth) else: score, _ negamax(board, depth - 1, -beta, -alpha, 3 - player) score -score board.undo() if score best_score: best_score, best_move score, (r, c) if best_score alpha: alpha best_score if alpha beta: break # 把这层剩下的走法全剪掉 return best_score, best_move递归传参时-beta, -alpha的顺序不能反这是 negamax 的标准写法子节点用的是「对手视角」的窗口所以上下界要取反并交换。写反了不会报错只会让剪枝失效搜索变慢但结果仍然正确——这类 bug 最难查因为它不产生错误答案。WIN_SCORE - (MAX_DEPTH - depth)这一项是「越快赢分越高」。如果直接返回固定的WIN_SCOREAI 会出现在「三步必胜」和「一步必胜」之间随机选的情况因为两者分数一样。加上深度修正之后浅层的胜利分数更高AI 自然倾向于尽早结束。alpha beta用不用等号情况下继续搜也只会得到同样或更差的分数剪掉没有损失。3.2 候选点生成不剪邻域深度 4 都跑不完225 个空点全展开每层 225 个分支深度 4 是 225⁴ ≈ 25 亿个节点。即使有 α-β 平均压到平方根量级也是几万个节点乘以每节点上千次评估必然卡死。实际做法是只在已有棋子的邻域内选点。def gen_moves(board, player, radius2, limit12): grid, size board.grid, board.size cand set() for r in range(size): for c in range(size): if grid[r][c] EMPTY: continue for dr in range(-radius, radius 1): for dc in range(-radius, radius 1): nr, nc r dr, c dc if (dr or dc) and 0 nr size and 0 nc size \ and grid[nr][nc] EMPTY: cand.add((nr, nc)) if not cand: # 空盘直接下天元别去排序 225 个点 return [(size // 2, size // 2)] scored [] for (r, c) in cand: # 自己的进攻价值 对手在此处的威胁价值堵点 attack evaluate_point(grid, r, c, player) defend evaluate_point(grid, r, c, 3 - player) scored.append((attack defend * 0.9, (r, c))) scored.sort(keylambda x: x[0], reverseTrue) return [mv for _, mv in scored[:limit]]radius控制邻域范围2 表示离已有棋子两格以内的空点才算候选。这个值直接决定分支数radius1 时候选点通常 8~20 个radius2 是 20~50 个radius3 能到 80 以上。实战里 radius2 配合limit截断已经够用。limit是硬截断按分数排序后只保留前 N 个。这个参数是性能和棋力的直接交换limit12 时深度 4 大约几百毫秒limit20 就奔着一秒以上去。注意limit太小会漏杀——对手的「冲四」如果排在 12 名之外AI 就看不见表现成「眼睁睁看着对方连成四子」。defend * 0.9那个系数是防守权重。取 0.9 意味着「堵对手的收益略低于自己进攻」AI 会更主动取 1.1 则偏保守容易被牵着走。这个数在 0.8~1.2 之间调超过这个范围棋风会明显变形。3.3 棋型评估表五连、活四、冲四、活三的分值梯度评估函数决定 AI 的「价值观」。这份代码用的是「连子数 封堵端数」的二维查表比逐个匹配字符串快也够用。# key (连续同色子数, 两端被堵的端数) SHAPE_SCORE { (5, 0): 10_000_000, # 五连直接赢 (4, 0): 1_000_000, # 活四两头都能成五必赢 (4, 1): 100_000, # 冲四只有一头能成五 (3, 0): 50_000, # 活三下一手变活四 (3, 1): 5_000, # 眠三 (2, 0): 1_000, # 活二 (2, 1): 100, (1, 0): 10, (1, 1): 1, } def evaluate_point(grid, r, c, player): 假设 player 落在 (r, c)统计四个方向能形成的最大棋型分 size len(grid) total 0 for dr, dc in DIRS: count, blocks 1, 0 for sign in (1, -1): nr, nc r dr * sign, c dc * sign while 0 nr size and 0 nc size and grid[nr][nc] player: count 1 nr dr * sign nc dc * sign # 走到的第一个非己方点出界或对方子都算被堵 if not (0 nr size and 0 nc size) or grid[nr][nc] ! EMPTY: blocks 1 total SHAPE_SCORE.get((min(count, 5), blocks), 0) return total分值梯度的关键是量级差不是具体数字。活四 100 万、冲四 10 万差 10 倍意味着「一个活四 两个冲四」活三 5 万两个活三 10 万刚好等于一个冲四这样 AI 会优先做双三而不是单冲四。如果梯度写成 100 万和 90 万AI 就会分不清轻重出现「有活四不冲去堵对方眠三」这种看着很蠢的走法。min(count, 5)是把六连、七连压回五连档否则(6, 0)这种 key 在表里查不到返回 0长连反而变成最低分。evaluate的全盘版本就是把所有己方棋子的evaluate_point累加再减去对手的累加乘以权重def evaluate(board, player): mine sum(evaluate_point(board.grid, r, c, player) for r in range(board.size) for c in range(board.size) if board.grid[r][c] player) theirs sum(evaluate_point(board.grid, r, c, 3 - player) for r in range(board.size) for c in range(board.size) if board.grid[r][c] 3 - player) return mine - int(theirs * 0.95)这里的 0.95 是全局防守权重。取 1.0 会变成极端保守AI 全程跟棋取 0.85 以下则容易忽略对手的杀棋。0.9~0.95 是常见区间具体看你想让 AI 什么风格。3.4 走法排序比加深度更划算的一步同样的深度排序好的搜索节点数能差 3~5 倍。上面gen_moves里的排序只是静态的更进一步的做法是记录历史启发class Searcher: def __init__(self): self.history_score {} # (r, c) - 累计剪枝收益 self.killer {} # depth - 最近一次引发剪枝的走法 def order(self, moves, depth): def key(mv): if mv self.killer.get(depth): return (2, 0) return (1, self.history_score.get(mv, 0)) return sorted(moves, keykey, reverseTrue) def bump(self, mv, depth): self.history_score[mv] self.history_score.get(mv, 0) depth * depth self.killer[depth] mvbump在每次alpha beta触发时调用把导致剪枝的走法记下来。depth * depth的加权让浅层的剪枝收益更受重视——浅层剪枝省掉的节点更多。killer move 优先的意义在于同一个深度上能引发剪枝的走法往往在兄弟节点里也能引发剪枝因为局面相似。这个假设在五子棋上成立得不错。需要提醒的是这些启发式状态是跨局残留的。换了一盘新棋如果不重置history_score和killer第一手的排序会带着上一局的记忆虽然不影响正确性但会让开局行为不稳定测试时很难复现。4. pygame 交互层坐标换算、事件循环和把搜索挪出主线程搜索再强界面卡成 PPT 也没人愿意下。这一层的核心矛盾是pygame 的事件循环必须跑在主线程而深度 4 的搜索要几百毫秒同步调用就会让窗口「未响应」。解决办法不复杂但几个坐标细节和线程边界容易翻车。4.1 棋盘绘制与像素坐标换算import pygame CELL 40 # 相邻两条线的间距 MARGIN 40 # 棋盘外边距 STONE_R 17 # 棋子半径略小于 CELL/2 才不粘连 def to_pixel(r, c): 棋盘坐标 - 屏幕像素线交叉点 return MARGIN c * CELL, MARGIN r * CELL def to_grid(x, y, size15, snapCELL * 0.45): 屏幕像素 - 棋盘坐标超出阈值返回 None点在线中间不落子 c round((x - MARGIN) / CELL) r round((y - MARGIN) / CELL) if not (0 r size and 0 c size): return None px, py to_pixel(r, c) if abs(px - x) snap or abs(py - y) snap: return None return r, csnap是吸附阈值取CELL * 0.45意味着点击位置离交叉点超过 18 像素就不算落子。没有这个判断玩家点在两个交叉点正中间时会落到最近的那个体感很怪——尤其手机触屏或高 DPI 屏上。round而不是intint(-0.5)得到 0round(-0.5)也得到 0但在负偏移时行为不一致用round更符合「就近取整」的直觉。绘制顺序建议是背景 → 网格线 → 星位 → 已落棋子 → 最后一手标记。最后一手加个小圆圈人机对战时能一眼看清 AI 刚下在哪这个小细节对体验影响比想象中大。4.2 主循环与状态机def main(): pygame.init() screen pygame.display.set_mode((MARGIN * 2 CELL * 14, MARGIN * 2 CELL * 14)) board Board() human, ai BLACK, WHITE turn human ai_thinking False result None # None / BLACK / WHITE / draw running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN and event.key pygame.K_r: board Board() # 重开新建对象别去逐格清空 turn, result, ai_thinking human, None, False elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if turn ! human or ai_thinking or result: continue # 三道闸门缺一个就出乱子 pos to_grid(*event.pos) if pos and board.place(*pos, human): if is_win(board.grid, *pos, human): result human else: turn ai # ... 绘制 ... pygame.quit()continue前面那三个条件是本层的核心防护turn ! human挡住 AI 回合的误点ai_thinking挡住搜索期间的连点result挡住终局后的继续落子。少任何一个都会出现「一局下出两颗同色子」或者「赢了还能继续下」的问题。重开用board Board()新建对象而不是遍历清空grid。前者一次性丢弃所有状态包括历史栈后者容易漏清history导致悔棋时撤到上一局的棋。4.3 搜索放子线程队列回传与 pygame 的线程边界pygame 的绘制和事件处理必须在主线程但纯计算可以扔出去。最简单的做法是起一个线程跑搜索结果通过queue.Queue回传。import threading, queue def think_async(board, depth, player, out_q): # 复制一份棋盘快照避免子线程与主线程同时改同一块内存 snapshot Board(board.size) snapshot.grid [row[:] for row in board.grid] snapshot.history list(board.history) try: score, move negamax(snapshot, depth, -INF, INF, player) out_q.put((ok, move, score)) except Exception as e: # 子线程异常不会冒泡到主线程 out_q.put((err, str(e), None)) # 主循环里 ai_thinking True q queue.Queue() threading.Thread(targetthink_async, args(board, MAX_DEPTH, ai, q), daemonTrue).start() # 每帧非阻塞地看一眼结果 try: status, move, score q.get_nowait() if status ok and move: board.place(*move, ai) if is_win(board.grid, *move, ai): result ai else: turn human ai_thinking False except queue.Empty: pass # 还没算完这一帧继续画「思考中」三个细节值得说。daemonTrue保证窗口关闭时子线程不会拖住进程退出。快照复制是必须的——如果直接传board主线程的绘制循环和子线程的place/undo会同时改grid虽然 CPython 有 GIL但「先读后写」的复合操作不是原子的偶尔会读到半更新的棋盘表现成 AI 下在已经有子的位置。异常捕获也不能省。子线程里抛出的异常默认只打到 stderr主线程完全不知情ai_thinking会永远停在 True玩家点什么都没反应。用队列把错误带回来至少能打印出来定位。5. 排查清单AI 长考、不冲活四、五连不判胜的五个现场下面这五条是按「调试时最先撞上」的顺序排的每条都给出可复现的现象方便对号入座。5.1 现象AI 第一步思考几十秒后面反而快原因空盘时gen_moves里没有任何已有棋子cand为空如果代码没写「空盘直接返回中心」这个分支而是退化成遍历全盘 225 个空点并逐个评估再乘上深度 4 的分支节点数直接爆炸。更隐蔽的情况是cand为空时返回了全部空点排序开销本身就吃掉了大部分时间。解决在gen_moves开头显式判断if not board.history: return [(size // 2, size // 2)]用历史栈长度判断比遍历棋盘便宜得多。顺便把「AI 第一步」单独加进回归测试这一步的耗时应该和后续手数在同一量级。5.2 现象AI 明明有活四却跑去堵对方的眠三原因SHAPE_SCORE的量级没拉开。活四给 10 万、冲四给 9 万看上去活四更高但搜索时对手的眠三在两层之后可能变成活四累积分数反超。另一种原因是evaluate里只算了己方分数没减对手分数AI 完全看不见威胁。解决先查梯度活四和冲四至少差一个数量级再查evaluate是否做了「己方 − 对手 × 权重」。如果两者都对就去看gen_moves的limit——活四点如果排在第 13 位被截断AI 根本搜不到它。调大limit或者临时关掉截断验证一次能快速定位。5.3 现象屏幕上明明五颗子连成一线游戏却没结束原因三种可能。一是is_win用了count need长连六子以上判不出来二是DIRS里少了(1, -1)副对角线方向的胜利完全检测不到三是调用了is_win但传错了player比如悔棋后仍然拿旧的玩家值去判。解决把四个方向都写成单元测试每个方向各构造一个五连局面用断言跑一遍。这一步花不了十分钟但能一次性排掉三个隐患。另外把count need改成显式注释说明这里是「长连也算胜」的规则选择。5.4 现象悔棋之后 AI 走了一步莫名其妙的棋原因board.undo()只清了grid和history但搜索器里的history_score、killer以及可能存在的置换表按 Zobrist 哈希缓存局面分数没跟着清。置换表里还留着「这个局面下最佳走法是 X」而这个局面在悔棋后已经不存在了。解决把所有「跨手残留」的状态收进一个Searcher.reset()悔棋和重开时统一调用。判据很简单任何不是从board推导出来的缓存都必须能在一次调用里清空。这也是我自己写搜索时最常忘的一步后悔药就是提前写个reset并在两个入口都调上。5.5 现象连点两下鼠标棋盘上多出两颗黑子原因事件循环里没有ai_thinking锁。玩家落子后turn改成ai但在 AI 线程返回结果之前第二次点击仍然满足「轮到我」的条件——因为turn还没来得及被改回去或者改回去了但 AI 那一手还没落。解决加一个显式的ai_thinking布尔量在提交搜索前置 True在结果出队后置 False事件处理里作为第一道闸门。注意不要用turn ai代替因为「轮到 AI」和「AI 正在算」是两个不同状态前者是逻辑归属后者是执行状态混用会在 AI 算完之后留一个短暂的窗口期。6. 把深度 4 的搜索压进半秒三个旋钮和一套回归自测调参最怕的是改完觉得「好像变强了」换个局面又觉得「好像变弱了」。可靠的做法是把三个旋钮固定成一组配置用同一批局面回放看耗时和选择两件事。三个旋钮分别是搜索深度MAX_DEPTH、候选宽度limit、邻域半径radius。它们对耗时的影响不是线性的粗略关系如下旋钮变量起点值调大的后果什么时候该调小搜索深度MAX_DEPTH4每加 1 层节点数涨 5~8 倍棋力提升明显但边际递减界面出现肉眼可见的卡顿候选宽度limit12更不容易漏杀但线性拉长每层耗时AI 漏看对手冲四时反而要调大邻域半径radius2中盘候选点暴涨远端布局变好开局和中盘前期建议保持 2我一般按这个顺序试先把radius定在 2然后从MAX_DEPTH2、limit8开始每跑一轮记录一次平均耗时找到「还没卡但已经够用」的那一档最后再单独加深度验证。经验是深度从 4 到 5 带来的棋力提升远小于把limit从 12 提到 20 带来的提升因为后者直接减少了「看不见的杀棋」。回归自测不用复杂框架固定几个局面快照就够了CASES [ # (落子序列, 轮到谁, 期望走法, 说明) ([(7, 7), (7, 8), (8, 8), (6, 6)], WHITE, (8, 7), 堵活三), ([(7, 7), (7, 8), (8, 8), (8, 9), (6, 6)], WHITE, (9, 9), 自己冲四优先), ([(7, 7)], WHITE, (7, 8), 第二手贴身), ] def run_cases(): for seq, player, expect, note in CASES: b Board() for i, (r, c) in enumerate(seq): b.place(r, c, BLACK if i % 2 0 else WHITE) import time t0 time.perf_counter() score, move negamax(b, MAX_DEPTH, -INF, INF, player) dt time.perf_counter() - t0 flag OK if move expect else MISS print(f{flag} {note:12s} 选择{move} 期望{expect} 耗时{dt*1000:.0f}ms)expect不是「唯一正确答案」而是「我认为至少该走这里」的下限。改完权重跑一遍如果原本 OK 的条目变成 MISS说明这次改动把某个优先级打乱了回去查SHAPE_SCORE的量级或者defend权重。耗时那一列要连着看趋势不要只看绝对值如果某一档突然从 300ms 跳到 1200ms多半是候选点在某类局面下暴涨比如中盘密集区radius2展开出 60 多个点limit截断发生在排序之后排序本身就吃掉了时间。这时候的优化方向是把排序里的evaluate_point换成更便宜的近似值而不是继续调小limit——后者会直接削弱棋力。从那以后我每次动评估权重都强制把这三条用例跑一遍再提交因为分数梯度这种东西改一个数字的时候觉得「只是微调」实际影响的是整棵搜索树的排序靠肉眼看棋是看不出来的。这套资源里搜索和界面的耦合已经切得比较开negamax不依赖 pygame可以脱离窗口单独跑拿它做参数实验最省事。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑