☰
Python实现中国象棋AI:搜索算法与评估函数实战
2026/10/3 2:46:18 网站建设 项目流程

简介:这是一份基于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 pygame

2.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(fen=START_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 board

FEN的规则很简单:用数字表示连续的空格数,“/”分隔一行。开局的第二行是“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, j+1)或(i, j-1);纵向走两格,马腿在(i+1, 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_check

make_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 best

depth等于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 迭代加深:把思考时间装进盒子里

迭代加深的思路很朴素:先用depth=1搜一遍,再用depth=2搜一遍,逐步加深,直到时间预算用完。好处是任何时刻中断都有上一层的bestmove可用,不会因超时返回空结果。配合alpha-beta后,更深一层搜索可以复用上一层的走法顺序,剪枝效率会更高:

def think(self, max_depth=6, time_budget=1.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分,值越大优先级越高,reverse=True后从高分开始搜。

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 score

p.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在depth=3时走了一步“送将”的棋,下一步就会被对方炮直接打掉老将。 原因:走法生成只判断了棋子能不能落在目标格,没有判断走完后己方老将是否处于被将军状态。深度搜索时,这个非法走法会带着一个虚高的评估值向上传播,导致AI坚定地认为自己走出了一步好棋。 解决:所有走法进入搜索前必须过一遍is_legal过滤。这一步要严格串行,每生成一个走法就模拟走一步再回退。

5.2 搜索深度加一层,时间爆炸三倍

现象:depth从3提到4,思考时间从0.5秒变成5秒,depth=5直接算不出来。 原因:中国象棋分支因子接近40,没有剪枝时深度每加1时间乘以40。即使有alpha-beta,走法顺序差也会导致剪枝失效,几乎退化回全量搜索。而且没有迭代加深的话,超时连一个可用走法都拿不到。 解决:走法排序优先吃子,再加迭代加深。注意order_moves里排序不仅在根节点做,搜索内部每一层都要在递归进入前对当前走法列表排序。

5.3 长将判负被当成和棋

现象:AI被对方连续将军十几步以后,置换表或重复局面检测直接返回“和棋”,但按中国象棋规则这是长将判负。 原因:直接把国际象棋的“三次重复局面作和”规则搬到中国象棋。中国象棋里长将、长杀、长捉都是禁止着法,要判负而不是判和。 解决:在搜索里记录将军状态,对连续将军次数计数,达到阈值直接判负。实现方式是在局面哈希表里同时存一个has_checked字段,连续两次以上将军且局面重复时,将军方判负。

5.4 Windows终端打印棋盘乱码

现象:代码在Windows命令行下print棋盘,控制台直接报UnicodeEncodeError,或者棋盘变成一堆乱码方块。 原因:Windows终端默认编码是GBK,代码里用中文字符打印棋子时编码不匹配。这不是AI逻辑问题,却最影响调试体验。 解决:程序入口加一行sys.stdout.reconfigure(encoding='utf-8')。如果还想更省事,棋盘打印直接用“RNBAKABNR”这类ASCII字符代替中文,终端兼容性最高。

5.5 评估函数把残局估反了

现象:AI在残局阶段明明子力大优,却一直不进攻,甚至反复后退。 原因:评估函数全程使用中局位置表和固定子力价值。残局时炮的价值被高估,马过嫩的积极性被低估。位置表还在奖励车守在己方底线,而不是配合进攻。 解决:按phase函数切换评估参数。残局模式重写三件事:炮价值大幅下调、增加“逼近将帅”的距离奖励、车按对方九宫位置加分。这节调完,AI才真正会把棋盘下到终局。

6. 验证与进阶:让AI在自对弈里持续变强

6.1 自对弈是AI棋力最快的验证方式

评估参数改完到底有没有变强,最快的验证办法是让AI自己跟自己下几十盘,统计胜率变化。写一个简单的自对弈循环,红方黑方都用同一个think接口,每步棋记录到列表里方便复盘:

def self_play(seconds=0.5): board = Board(START_FEN) moves = [] while not board.is_terminal(): mv, _ = board.think(time_budget=seconds) board.make_move(mv) moves.append(mv) result = board.result() print(f"result: {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永远不会超时发呆。

最后说一个我自己的教训:评估函数的价值不是单靠“子力+位置”堆出来的,而是靠自对弈结果反复调出来的。我早期把炮的价值调到600,AI开局就疯狂吃卒送炮,复盘一看全是被高价值误导的短视走法。后来把评估参数全部抽成独立字典,每次自对弈后改参数再跑,几个晚上就把AI从乱走调到了能跟普通人下十几个回合不崩盘的程度。做这个项目千万别急着上神经网络,先把搜索和评估吃透,棋力是靠一局局自对弈喂出来的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询