资讯动态

Python实现中国象棋AI:搜索算法与评估函数实战

发布时间:2026/10/3 2:46:21 来源:尧图企业网站定制
简介这是一份基于Python实现的中国象棋AI设计源码面向对博弈树搜索、策略评估与游戏AI开发感兴趣的Python学习者与开发者适合用于算法研究、课程设计或作为智能棋类项目的起点。压缩包内共43个文件包含16个GIF动图、15个JPG静态图、10个Python源文件及readme说明与Git配置整体仅805KB轻量易部署。源码按Chess_AI、Chess_Core、Chess_UI三个模块组织分别承担棋局评估与搜索决策、棋盘规则与棋子移动逻辑、图形界面与命令行交互覆盖了从底层棋盘表示到上层AI决策的完整链路。通过阅读和运行这些代码可以清晰理解中国象棋AI的设计思路掌握评估函数、决策树搜索、局面生成等关键实现并可直接在图形界面或命令行中体验人机对弈。该项目已吸引398人学习下载对于希望快速上手策略类游戏AI的开发者而言是一份结构清晰、可直接参考的实战案例。1. 用Python写中国象棋AI源码背后到底要解决什么问题网上搜“基于Python语言开发的中国象棋AI设计源码”你大概率会得到两种结果要么是C或Java写的编译门槛先劝退一半人要么是只有界面没有AI内核的demo点开两下就发现是个空壳。用Python写的完整方案相对少见但它恰恰适合把中国象棋AI的思考过程拆开来看——规则引擎怎么写、走法生成怎么算、搜索怎么剪枝、评估函数怎么定每一层都能用几十行代码讲明白这就是这个标题最大的价值。做出来的成果不是一个玩具棋盘。一套完整的Python中国象棋AI至少包含棋盘模型、走法生成、搜索算法、评估函数四块可以当AI搜索算法的课程设计也可以当毕业论文的主体改造空间比想象中大得多。适合的读者是Python基础语法已经过关、想用真项目理解搜索AI的人或是正在找中国象棋AI源码参考、想自己改造而不是黑盒调用的人。2. 棋盘模型与走法生成规则引擎必须一次写对中国象棋的规则比国际象棋多几条“腿”——蹩马腿、塞象眼、炮要隔子打、将帅不能对脸、还有长将判负的特殊裁定。这些规则如果不在棋盘模型这一层处理干净后面搜索算法再优秀都会在下棋中途翻车。常见做法是自建一个Board类把棋盘、走法生成、合法性校验、胜负判定全部收在同一个类里搜索和评估都只跟这个类打交道。2.1 自己造轮子还是用现成库先回答这个问题第一反应是找python-chess这样的现成轮子。我自己做过之后给出的建议是第一版一定自己写规则引擎后面再考虑换库。原因有三个中国象棋的特殊规则要靠自己实现才能讲清楚评估函数要和棋盘模型强耦合直接用库反而难接网上能找到的中国象棋Python参考代码绝大多数也是自建棋盘类跟别人的代码对接比对接库容易自己写一遍规则后面排查“AI送将”这类问题时有完全可控的代码可查。环境准备不需要额外安装第三方棋盘库Python 3.9以上标准库就够。只有做到界面那一步时才需要pygame先顺手装掉不亏python --version pip install pygame2.2 FEN局面载入一行代码把棋局摆好用10行×9列的二维数组表示棋盘约定大写字母代表红方小写字母代表黑方R/H/C/E/A/K/P对应车/马/炮/士/象/帅/兵黑方用r/h/c/e/a/k/p。红方在下方坐标(9,4)是红帅的位置黑方在上方坐标(0,4)是黑将的位置。这样固定视角后走法生成的坐标计算可以统一处理。START_FEN rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1 def parse_fen(fenSTART_FEN): board [[ ] * 9 for _ in range(10)] rows fen.split()[0].split(/) for i, row in enumerate(rows): col 0 for ch in row: if ch.isdigit(): col int(ch) else: board[i][col] ch col 1 return boardFEN的规则很简单用数字表示连续的空格数“/”分隔一行。开局的第二行是“9”说明黑方卒线上面是一整行空位。解析时遇到数字就跳过对应列数遇到字母就落子。这个函数是整个棋盘模型的入口后续打印棋盘、生成走法、判断胜负都以这个board数组为基准。2.3 走法生成蹩马腿、象眼与炮架的实现走法生成是所有AI逻辑的地基。马和炮是最容易写错的两个棋子先看马的实现def horse_moves(self, i, j, side): moves [] offsets [(-2,-1), (-2,1), (2,-1), (2,1), (-1,-2), (-1,2), (1,-2), (1,2)] for di, dj in offsets: ni, nj i di, j dj if not (0 ni 10 and 0 nj 9): continue # 马腿位置横向走两格时在中间列纵向走两格时在中间行 if abs(di) 2: leg_i, leg_j i di // 2, j else: leg_i, leg_j i, j dj // 2 if self.board[leg_i][leg_j] ! : continue if self.board[ni][nj] or not self.same_side(self.board[ni][nj], side): moves.append(((i, j), (ni, nj))) return moves马腿的判断是这里最容易出错的点。走“日”字时马腿在移动距离为2的那个方向的中间一格横向走两格马腿在(i, j1)或(i, j-1)纵向走两格马腿在(i1, j)或(i-1, j)。很多第一次写的代码把马腿位置算错AI走几步就会走出“马穿过棋子”的非法棋。炮的走法稍微复杂一点因为要区分“吃子”和“走子”两种路径def cannon_moves(self, i, j, side): moves [] for di, dj in [(1,0), (-1,0), (0,1), (0,-1)]: ni, nj i di, j dj jumped False while 0 ni 10 and 0 nj 9: if self.board[ni][nj] : if not jumped: moves.append(((i, j), (ni, nj))) else: if not jumped: jumped True else: if not self.same_side(self.board[ni][nj], side): moves.append(((i, j), (ni, nj))) break ni di nj dj return moves炮的规则一句话概括不吃子时只能走直线空位吃子时必须且只能隔一个“炮架”。jumped标志就是用来区分这两种状态的——第一次遇到棋子时把jumped置为True第二次遇到的棋子就是可吃的目标。这个写法比用一个单独变量记炮架位置更不容易出错。生成走法之后还有一道关键过滤合法走法检查。中国象棋不允许“自陷”——走完一步后自己老将暴露在对方火力下。判断方法是模拟走一步然后检查己方老将是否被攻击def is_legal(self, move, side): self.make_move(move) in_check self.is_in_check(side) self.unmake_move(move) return not in_checkmake_move和unmake_move要成对出现这一步模拟不走实际棋盘而是走完立即回退。is_in_check里除了检查八种棋子的攻击范围还要检查将帅对脸——双方老将在同一列且中间无任何棋子时判为被攻击状态。这个检查很多人漏掉结果AI能走出对脸将军甚至对脸送死的棋。3. 搜索引擎的Python实现minimax、alpha-beta与迭代加深规则引擎负责“能走什么棋”搜索引擎负责“走哪步最好”。中国象棋的平均分支因子大约在40左右3步搜索就有6万多个局面5步搜索是上亿。所以搜索算法不能只做全量枚举必须剪枝、排序、控制时间。这一章从最简单的递归写法一路升级到能真实对弈的版本。3.1 递归搜索的最小可运行版本minimax的核心思想我方选分最高的走法对方从后续局面里选对我方最不利的走法。每一层递归都在切换视角红方最大化黑方最小化def minimax(self, depth, side): if depth 0: return self.evaluate(side) moves self.gen_legal_moves(side) if not moves: return -INF if self.is_in_check(side) else 0 best -INF if side RED else INF for mv in moves: self.make_move(mv) score self.minimax(depth - 1, BLACK if side RED else RED) self.unmake_move(mv) if side RED: best max(best, score) else: best min(best, score) return bestdepth等于0时直接返回评估值不再向下搜索。没有合法走法时有两种情况被将死返回负无穷困毙返回0。这段代码能跑但性能很差——没有任何剪枝搜索树会被完整展开。对于中国象棋这种分支因子大的棋种depth到4就已经需要几十秒钟。3.2 alpha-beta剪枝把搜索树砍掉一半alpha-beta剪枝和minimax的决策逻辑完全一致只是多了两个边界参数alpha记录红方能保底的最大分数beta记录黑方能保底的最小分数。当某个分支已经证明不会比当前已知结果更好就提前砍掉def alpha_beta(self, depth, alpha, beta, side): if depth 0: return self.evaluate(side) moves self.gen_legal_moves(side) if not moves: return -INF if self.is_in_check(side) else 0 if side RED: best -INF for mv in moves: self.make_move(mv) score self.alpha_beta(depth - 1, alpha, beta, BLACK) self.unmake_move(mv) best max(best, score) alpha max(alpha, best) if beta alpha: break return best else: best INF for mv in moves: self.make_move(mv) score self.alpha_beta(depth - 1, alpha, beta, RED) self.unmake_move(mv) best min(best, score) beta min(beta, best) if beta alpha: break return best剪枝发生的时机就是代码里的if beta alpha: break。红方层发现当前分支的分数已经超过黑方才更新的beta上限说明黑方在上一层根本不会选择这条路继续搜下去是浪费时间。理论上alpha-beta可以让有效分支因子从40降到10以下这也是同样depth下最快能感受体感差异的优化。3.3 迭代加深把思考时间装进盒子里迭代加深的思路很朴素先用depth1搜一遍再用depth2搜一遍逐步加深直到时间预算用完。好处是任何时刻中断都有上一层的bestmove可用不会因超时返回空结果。配合alpha-beta后更深一层搜索可以复用上一层的走法顺序剪枝效率会更高def think(self, max_depth6, time_budget1.0): root_moves self.gen_legal_moves(self.side) best_move None best_score -INF start time.time() for depth in range(1, max_depth 1): self.order_moves(root_moves) # 按吃子价值排序剪枝效率更高 current_best None current_score -INF for mv in root_moves: self.make_move(mv) score self.alpha_beta(depth - 1, -INF, INF, BLACK if self.side RED else RED) self.unmake_move(mv) if score current_score: current_score, current_best score, mv if time.time() - start time_budget: break best_move, best_score current_best, current_score return best_move, best_score提示迭代加深不是浪费算力。上一层的结果给下一层提供走法排序参考剪枝效率的提升往往能抵消重复搜索的开销。移动排序是这章最容易忽略的细节。MVV-LVA的基本思路是吃大子的走法优先搜被小兵吃的车和主动送掉的马都不值得先算。排序做得好alpha-beta剪枝效果能翻倍。做法是先算一步棋离开根节点后第一层就按棋子价值排序移动。这里order_moves里给吃子走法加10分给将军走法加5分值越大优先级越高reverseTrue后从高分开始搜。4. 评估函数决定AI棋力上限的参数设计搜索算法决定AI“看多远”评估函数决定AI“怎么看当前局面”。中国象棋没有现成的评估API这个函数必须自己设计它直接决定了AI的风格和强度上限。很多项目卡在这一步——搜索写得不错但评估只有子力加减AI下棋就像个只会吃子的大老粗。4.1 子力价值为什么车不是马的“两倍”那么简单先列出我自己最常用的一组基准子力值棋子价值备注将/帅10000被吃即判负必须给极大值车900横纵畅通价值和稳定性最高马450受蹩马腿限制中局强、残局弱炮450开局中局强残局缺少炮架会快速贬值兵/卒150过河后随位置加30~70士/象200防守棋子主要影响将帅安全车设定为900而不是马的两倍是因为车的价值非常稳定马在边线、被蹩腿、残局无士象支持时价值会跌到300以下炮在残局甚至不如过河兵。所以评估函数不能只用一个固定值表必须叠加位置和局面阶段系数——这是评估函数“玄学”的开始。4.2 位置价值表把“棋感”写成一张二维表位置价值表就是把“马跳窝心是臭棋、车占肋道是好棋”这类经验量化成查表数据。对每个棋子准备一张10行×9列的分数表红方用原表黑方用上下翻转后的镜像表。下面是马的位置表在红方视角下的样子HORSE_POS [ [2, 4, 6, 6, 6, 6, 4, 2, 2], [3, 5, 8, 9, 9, 8, 5, 3, 3], [4, 7,10,11,11,10, 7, 4, 4], [5, 8,10,12,12,10, 8, 5, 5], [4, 7, 9,10,10, 9, 7, 4, 4], [3, 6, 8, 9, 9, 8, 6, 3, 3], [2, 5, 7, 8, 8, 7, 5, 2, 2], [1, 4, 6, 7, 7, 6, 4, 1, 1], [1, 3, 5, 6, 6, 5, 3, 1, 1], [1, 3, 5, 6, 6, 5, 3, 1, 1], ]细看这些数值河界附近和中路位置分最高边线、自家底线分数低。原因是马过河后控制范围大但同时容易被对方卒和炮针对所以不是一味鼓励深入而是在河界上下保持一个梯度。车、炮、兵的位置表同理车重点给肋道和底线加分炮给中炮位置和对方卒林线加分兵给过河后的中间突破位置加分。def evaluate(self, side): score 0 for i in range(10): for j in range(9): p self.board[i][j] if p : continue val PIECE_VALUE[p.upper()] pos_table POS_TABLE[p.upper()] pos pos_table[i][j] if p.isupper() else pos_table[9 - i][j] if (p.isupper() and side RED) or (p.islower() and side BLACK): score val pos else: score - val pos return scorep.isupper()和p.islower()分别对应红黑棋子黑方取位置表时把i翻转成9-i。这段代码核心逻辑只有十几行但加上四张完整位置表后AI的棋力已经明显从“乱杀”变成“按棋理下棋”。4.3 参数怎么调用自对弈结果反推评估参数没有一次调对的我一般按“三步走”。第一步先固定子力价值只调位置表的量级——位置分和子力分要保持在同一个数量级否则出现“为了一个位置分优势白送一个马”的蠢棋。第二步加局面阶段系数按残局剩余棋子总量切换中局表和残局表。第三步做自对弈验证每次只动一个参数胜率至少稳定提升5%才保留。def phase(self): total sum(1 for row in self.board for p in row if p ! ) if total 22: return opening if total 12: return middle return endgame残局阶段尤其要注意马和炮的价值变化。残局炮的价格应该从450降到300马从450升到500以上因为无士象遮挡时马的机动性远好于炮而炮缺炮架就废了一半。阶段性评估是让AI“明白”残局的关键不做这步AI会在残局阶段拿着巨大的子力优势反复走出不杀棋的平棋。5. 中国象棋AI的五个翻车点现象、原因与排查方法搜索和评估都写完只是开始真正让项目卡住的是运行时的各种边界问题。这一章的五个坑是我自己踩过、也在帮别人调试时反复见过的每一条都是“现象→原因→解决”的结构。5.1 AI走完就把老将送到炮口现象AI在depth3时走了一步“送将”的棋下一步就会被对方炮直接打掉老将。 原因走法生成只判断了棋子能不能落在目标格没有判断走完后己方老将是否处于被将军状态。深度搜索时这个非法走法会带着一个虚高的评估值向上传播导致AI坚定地认为自己走出了一步好棋。 解决所有走法进入搜索前必须过一遍is_legal过滤。这一步要严格串行每生成一个走法就模拟走一步再回退。5.2 搜索深度加一层时间爆炸三倍现象depth从3提到4思考时间从0.5秒变成5秒depth5直接算不出来。 原因中国象棋分支因子接近40没有剪枝时深度每加1时间乘以40。即使有alpha-beta走法顺序差也会导致剪枝失效几乎退化回全量搜索。而且没有迭代加深的话超时连一个可用走法都拿不到。 解决走法排序优先吃子再加迭代加深。注意order_moves里排序不仅在根节点做搜索内部每一层都要在递归进入前对当前走法列表排序。5.3 长将判负被当成和棋现象AI被对方连续将军十几步以后置换表或重复局面检测直接返回“和棋”但按中国象棋规则这是长将判负。 原因直接把国际象棋的“三次重复局面作和”规则搬到中国象棋。中国象棋里长将、长杀、长捉都是禁止着法要判负而不是判和。 解决在搜索里记录将军状态对连续将军次数计数达到阈值直接判负。实现方式是在局面哈希表里同时存一个has_checked字段连续两次以上将军且局面重复时将军方判负。5.4 Windows终端打印棋盘乱码现象代码在Windows命令行下print棋盘控制台直接报UnicodeEncodeError或者棋盘变成一堆乱码方块。 原因Windows终端默认编码是GBK代码里用中文字符打印棋子时编码不匹配。这不是AI逻辑问题却最影响调试体验。 解决程序入口加一行sys.stdout.reconfigure(encodingutf-8)。如果还想更省事棋盘打印直接用“RNBAKABNR”这类ASCII字符代替中文终端兼容性最高。5.5 评估函数把残局估反了现象AI在残局阶段明明子力大优却一直不进攻甚至反复后退。 原因评估函数全程使用中局位置表和固定子力价值。残局时炮的价值被高估马过嫩的积极性被低估。位置表还在奖励车守在己方底线而不是配合进攻。 解决按phase函数切换评估参数。残局模式重写三件事炮价值大幅下调、增加“逼近将帅”的距离奖励、车按对方九宫位置加分。这节调完AI才真正会把棋盘下到终局。6. 验证与进阶让AI在自对弈里持续变强6.1 自对弈是AI棋力最快的验证方式评估参数改完到底有没有变强最快的验证办法是让AI自己跟自己下几十盘统计胜率变化。写一个简单的自对弈循环红方黑方都用同一个think接口每步棋记录到列表里方便复盘def self_play(seconds0.5): board Board(START_FEN) moves [] while not board.is_terminal(): mv, _ board.think(time_budgetseconds) board.make_move(mv) moves.append(mv) result board.result() print(fresult: {result}, moves: {len(moves)}) return result, moves统计五盘里红黑胜负比例比单盘输赢稳定得多。改一个参数就重跑一轮自对弈把结果记在备注里这是我调评估函数时最大的习惯。6.2 置换表让搜索树学会“记仇”置换表用局面哈希做key把已经算过的深度和分数存下来。同样局面再次出现时直接取结果不用重复搜索。实现要点是存depth、score和边界类型精确值/上界/下界只命中depth不低于当前需求的条目trans {} def alpha_beta(self, depth, alpha, beta, side): key self.zobrist_hash() entry trans.get(key) if entry and entry.depth depth: if entry.flag exact: return entry.score if entry.flag lower: alpha max(alpha, entry.score) if entry.flag upper: beta min(beta, entry.score) if alpha beta: return entry.score ...置换表注意只存评估值和搜索深度不要存走法结果避免把上一层的不稳定信息传播给下一层。配合迭代加深后AI在同depth下能肉眼可见地多算一两层。6.3 开局库与多线程思考最后一公里的两个建议开局库很容易做手工录入前十几回合的常见走法搜索开始前查表命中就直接返回。这能省下开局阶段的大量搜索时间把算力留给中残局。多线程我是这样处理的主线程负责计时搜索线程跑到时间预算后直接丢弃未完成的结果取上一层的bestmove返回保证AI永远不会超时发呆。最后说一个我自己的教训评估函数的价值不是单靠“子力位置”堆出来的而是靠自对弈结果反复调出来的。我早期把炮的价值调到600AI开局就疯狂吃卒送炮复盘一看全是被高价值误导的短视走法。后来把评估参数全部抽成独立字典每次自对弈后改参数再跑几个晚上就把AI从乱走调到了能跟普通人下十几个回合不崩盘的程度。做这个项目千万别急着上神经网络先把搜索和评估吃透棋力是靠一局局自对弈喂出来的。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑