☰
用Python从零实现中国象棋AI:核心算法与源码解析
2026/10/1 22:45:11 网站建设 项目流程

简介:基于Python语言开发的中国象棋AI设计源码,是一套面向AI学习者和棋类游戏开发者的完整工程示例,重点解决棋局模拟、策略生成与智能决策等问题,适合作为课程设计、算法研究或业余项目的参考。压缩包共43个文件,包含10个Python源码、16个GIF与15张JPG图片、1个说明文档及1个gitignore配置,整体仅805KB;图片用于棋盘棋子展示与操作反馈,py文件则承载搜索算法、评估逻辑与界面渲染。工程按AI决策、棋规核心、界面交互三个模块划分,结构清晰,便于按需阅读与二次开发,说明文档同时提供运行指南,可快速启动命令行或图形界面。对希望深入理解象棋AI实现路径的读者,这份源码涵盖了棋盘表示、走法生成、局面评估到UI展示的完整链路,已有398人学习下载,可直接运行体验,也可作为算法改进、功能扩展或教学演示的起点。

1. 中国象棋AI的设计源码,为什么值得自己写一套

很多朋友拿到一份“基于Python语言开发的中国象棋AI设计源码”,第一反应是:代码这么多,能直接跑吗?跑起来之后,AI是不是只会乱走?其实,中国象棋AI的核心并没有想象中那么玄。把棋盘表示、走法生成、评估函数和搜索算法这四块写清楚,一套能下满一整盘棋的Python程序就能在命令行里跑起来,代码量通常不到五百行。我见过太多人一上来就找免费python源码大全,结果不是缺规则,就是评估函数写得像黑匣子,最后只能感叹“这AI怎么这么菜”。这篇文章的价值在于:你会跟着一步步搭出能够复现的源码,知道每个参数为什么这么设,碰到翻车能自己排查。

2. 棋盘表示与走法生成:把中国象棋规则变成Python能跑的数据结构

2.1 用0x88棋盘而不是二维数组:索引计算和越界检查一次解决

许多人习惯用board[10][9]这样的二维列表表示棋盘,直观但走法生成时非常麻烦:每个方向都要判断行、列是否越界,还得把 (row, col) 转成一维索引。中国象棋AI源码里,常见做法是用“0x88棋盘”。这个名称来自十六进制 0x88,它把一个 16x16 的虚拟网格线性化,中国象棋棋盘只占用左侧 9 列。这样索引是一个 0 到 255 的整数,第 r 行第 c 列对应r*16+c,有效的格子满足(idx & 0x88) == 0。因为第 8、9 列在二进制高四位里被标记了,按位与可以直接判断越界,比“if row < 0 or row > 9”快得多。

BOARD_WIDTH = 16 BOARD_HEIGHT = 16 def in_board(idx: int) -> bool: return (idx & 0x88) == 0 def pos_to_idx(row: int, col: int) -> int: return row * 16 + col def idx_to_pos(idx: int) -> tuple: return idx // 16, idx % 16

这段代码先说结论:用 0x88 布局后,车、炮这类“滑行棋子”的走法生成会统一成“沿方向步进,越界即停”的逻辑,不需要再写四层 if。参数说明:BOARD_WIDTH=16是因为十六进制网格固定 16 列;pos_to_idx里的row*16保证相邻行索引差 16,左右相邻差 1。当你后续做局面哈希时会发现,0 到 255 的索引正好可以拼成一个 256 字节的紧凑局面结构。

2.2 走法生成器:马脚、象眼、炮架与将帅对脸

走法生成是整个AI引擎的地基。中国象棋规则里最容易写错的不是车炮,而是马和象的“憋腿”逻辑。我们先把棋子编码定义好,红方为 1~7,黑方为 9~15,这样通过piece > 8就能判断颜色。

EMPTY = 0 R_KING, R_ADVISOR, R_ELEPHANT, R_HORSE, R_CAR, R_CANNON, R_PAWN = range(1, 8) B_KING, B_ADVISOR, B_ELEPHANT, B_HORSE, B_CAR, B_CANNON, B_PAWN = range(9, 16)

马的走法可以预计算八个“日字”偏移和对应的马腿偏移:

HORSE_OFFSETS = [ (-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1) ] HORSE_LEG = [ (-1, 0), (-1, 0), (0, -1), (0, 1), (0, -1), (0, 1), (1, 0), (1, 0) ]

这里HORSE_LEG中每个元素是马腿相对于马当前格子的偏移。比如马跳(-2,-1)时,马腿在(-1,0)。若腿的位置非空,则该方向不能走。生成车炮走法时,我们用“步进搜索”:

def _sliding_moves(idx, board, offsets): moves = [] r, c = idx_to_pos(idx) for dr, dc in offsets: nr, nc = r + dr, c + dc while in_board(pos_to_idx(nr, nc)): t = pos_to_idx(nr, nc) if board[t] == EMPTY: moves.append(t) else: moves.append(t) break nr += dr nc += dc return moves

逻辑说明:这个函数被车、炮等棋子共用。车走法就是直接放行空位、遇到棋子则吃掉并停止。炮的走法不同,它需要“跳过”一个炮架才能吃子。所以炮的走法要额外处理第一段“找炮架,不吃子”,再走第二段“跳过炮架后吃子”。为了不混乱,我一般把炮独立写成一个生成函数。

def _cannon_moves(idx, board, offsets): moves = [] r, c = idx_to_pos(idx) for dr, dc in offsets: nr, nc = r + dr, c + dc jumped = False while in_board(pos_to_idx(nr, nc)): t = pos_to_idx(nr, nc) if board[t] == EMPTY: if jumped: moves.append(t) else: if not jumped: jumped = True else: moves.append(t) break nr += dr nc += dc return moves

参数说明:jumped标记是否已经跳过炮架。没吃炮架前遇到棋子就切换标记,继续向前;第二次遇到棋子才可吃。这里有一个容易踩的坑:炮不吃子时不能跨越炮架,所以空位只有jumped == True时才加入。兵、相、士、帅可以用预计算表实现。红兵过河前只能向上一步,过河后可以左、右、上;黑兵则方向相反。相走“田”字且不能过河,象眼是田字中心点。帅只能走九宫四格且对脸需要另外判断。

“将帅对脸”是走法生成里最容易漏的规则:帅和将之间不能有其他棋子。我建议不在走法生成阶段硬扣,而是每次着法后调用一个is_king_safe(board, side)检查己方帅是否被对方将威胁。这样代码清晰,也方便调试。虽然会多算一些非法着法,但搜索深度不高时性能可接受。

3. 评估函数:让AI知道局面好坏的直觉从哪来

3.1 子力价值表:车马炮兵相士帅到底值几分

评估函数是AI的“直觉”。没有评估函数,搜索算法只会看“能不能吃子”,局面优劣完全无法区分。中国象棋AI源码里最经典的评估就是给每个棋子一个基础价值,通常以“兵”为 1 个单位。网上常见的一套价值是:车 900,马 400,炮 450,象 120,士 120,兵 200(过河兵 300),帅是无穷大但不宜设成 INT_MAX,否则搜索会拼死保帅导致奇怪走法。

PIECE_VALUE = { R_KING: 0, B_KING: 0, R_CAR: 900, B_CAR: 900, R_HORSE: 400, B_HORSE: 400, R_CANNON: 450, B_CANNON: 450, R_ELEPHANT: 120, B_ELEPHANT: 120, R_ADVISOR: 120, B_ADVISOR: 120, R_PAWN: 200, B_PAWN: 200, }

我把帅的价值设为 0,因为搜索层会用“被将军后无法逃脱”作为胜负判断,而不是靠价值堆。真正的价值要加位置表。注意黑方棋子的价值与红方相同,但位置表要上下镜像,因为黑方在棋盘下方,朝向不同。

3.2 位置价值表:为什么马在河边比在角落强

仅仅看子力价值还远远不够。同样的马,在边角和在河口位置差别极大。我给每个棋子定义一张 10x9 的位置价值表,红方按坐标读取,黑方则把坐标上下反转。这一步能显著提升AI水平,代码量却很小。

HORSE_POS_TABLE = [ [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 10, 20, 20, 20, 10, 0, 0], [0, 0, 20, 40, 50, 40, 20, 0, 0], [0, 10, 40, 60, 70, 60, 40, 10, 0], [0, 20, 50, 80, 90, 80, 50, 20, 0], [0, 20, 60, 80, 100, 80, 60, 20, 0], [0, 30, 70, 90, 100, 90, 70, 30, 0], [0, 20, 50, 70, 90, 70, 50, 20, 0], [0, 0, 20, 40, 50, 40, 20, 0, 0], ]

这里第一行是棋盘第 0 行(红方底线)。黑方使用时要镜像:row = 9 - row。位置表的数值直接加到评估结果里。聪明的读者会发现,这张表本身就是在鼓励马往中炮位和河口跳。如果你希望AI更稳健,可以把中路价值调高;希望AI喜欢侧翼进攻,就把边线价值调高。

评估函数最终返回红方视角的分数:red_value - black_value。搜索算法里,红方最大化这个分数,黑方最小化。代码很简单:

def evaluate(board): score = 0 for idx, piece in enumerate(board): if piece == EMPTY: continue row, col = idx_to_pos(idx) val = PIECE_VALUE[piece] if piece <= 7: # red score += val score += POS_TABLES[piece][row][col] else: score -= val score -= POS_TABLES[piece - 8][9 - row][col] return score

这里有一个坑:黑方棋子在PIECE_VALUE字典里虽然与红方同值,但如果你的棋子编码方式不是对称的,必须分开设置。用piece-8映射到红方位置表后,用9-row镜像。这样评估的“直觉”才能中立。

3.3 机动性与威胁评估:让AI别只看自家棋盘

我最初写的评估函数只统计子力和位置表,AI下出来的棋很“木”,明明炮在别人马口里还无动于衷。后来我加入了机动性:计算当前棋子的合法着法数量,乘以一个小系数,比如 3~5 分。这个改动让AI懂得“活马”比“死马”好。威胁评估则是加分项:如果某棋子能攻击对方价值更高的棋子,给一点奖励。这些不需要写太多代码,在generate_moves里顺手统计就行。

def evaluate_with_mobility(board, side): score = evaluate(board) moves = generate_moves(board, side) score += len(moves) * 5 # 己方机动性奖励 return score

参数说明:len(moves)*5的权重不能太大,否则AI会为了多两步走动弃子。我调试时通常把权重放在 3~8 之间。这个技巧让AI在下到中残局时更有“意识”,尤其是局面胶着时,多一先手往往就是优势。

4. 搜索算法:Minimax、Alpha-Beta和迭代加深的落地配置

4.1 从Minimax到Alpha-Beta:先写一个能用的递归框架

评估函数给出静态判断,搜索算法负责“往后多想几步”。最经典的Minimax:红方回合选分数最大的着法,黑方回合选分数最小的着法。但纯Minimax指数爆炸,中国象棋分支因子常在三四十以上,深度 4 层就要评估上千万个局面。Alpha-Beta剪枝能把搜索规模砍掉一大半,是必须做的。

def search(board, depth, alpha, beta, side): if depth == 0: return evaluate(board) moves = generate_moves(board, side) if side == RED: best = -float('inf') for m in moves: do_move(board, m) score = search(board, depth-1, alpha, beta, BLACK) undo_move(board, m) best = max(best, score) alpha = max(alpha, best) if beta <= alpha: break return best else: best = float('inf') for m in moves: do_move(board, m) score = search(board, depth-1, alpha, beta, RED) undo_move(board, m) best = min(best, score) beta = min(beta, best) if beta <= alpha: break return best

逻辑说明:alpha是红方能保证的最大分数下界,beta是黑方能保证的最小分数上界。当beta <= alpha,说明当前分支已经不可能影响最终选择,直接剪掉。需要特别注意do_move和undo_move必须成对调用,否则棋盘状态会污染整个搜索树。我在调试时经常因为漏了undo_move,导致同一分支叠加了多层走子,结果AI棋力忽上忽下。

参数说明:depth从 4 开始比较稳妥,普通电脑可以跑 4~6 层。如果希望更高深度,就要优化着法排序和剪枝效率。

4.2 剪枝参数与着法排序:为什么排序比剪枝本身更影响效率

Alpha-Beta剪枝的效果高度依赖着法排序。如果最好的着法排在前面,剪枝率极高;如果最差着法先试,剪枝率几乎为零。快速排序最简单的是:先统计每一着的“吃子收益”,比如吃车的走法排最前,其次吃马吃炮,普通走法最后。

def order_moves(moves, board): scored = [] for m in moves: target = board[m[1]] score = PIECE_VALUE.get(target, 0) scored.append((score, m)) scored.sort(reverse=True, key=lambda x: x[0]) return [m for _, m in scored]

这里的target是吃掉的棋子的价值。价值大的着法排前。这个排序是“最便宜但最有效的优化”,比我试过许多玄学启发式都稳。配合上alpha-beta,同样深度下搜索时间常常能减少一半以上。

4.3 迭代加深和时间控制:让AI在“限时”内找到最佳着法

固定深度搜到一半,用户感觉卡顿很难受。常见做法是迭代加深:先搜深度 1,再搜深度 2,直到时间用完。每多一层就用上一层的着法顺序作为排序基础,这样上一层的最好着法排在前面,剪枝效率越来越高。

def iterative_search(board, max_depth, time_limit): start = time.time() best_move = None for depth in range(1, max_depth + 1): alpha, beta = -float('inf'), float('inf') moves = order_moves(generate_moves(board, RED), board) for m in moves: do_move(board, m) score = search(board, depth-1, alpha, beta, BLACK) undo_move(board, m) if score > alpha: alpha = score best_move = m if time.time() - start > time_limit: return best_move return best_move

逻辑说明:time_limit通常是 1~3 秒。iterative_search第一层搜索会得到一个立即能下的着法,即使超时也有保底,这个设计是我保留的“后悔药”。需要强调的是:best_move只在score > alpha时更新,这样即使某一层中途超时,返回的也是当前层已搜索部分的最大值,而不是最后一个随机着法。

5. 避坑/常见问题排查:自写象棋AI最容易翻车的5个地方

5.1 走法列表太长?多半是你把“过河兵”走法重复生成了

现象:AI每层搜索的走法数量异常多,同样的局面生成几百个着法,速度慢到无法接受。原因:兵在过河后可以向前、向左、向右,但如果你不加方向判断,把三个方向无条件生成,甚至与“未过河只能向前”混在一起,就会重复或非法。解决:生成兵着法时先判断是否过河,红兵row >= 5表示已过河,黑兵row <= 4。保证每个目标位置只加入一次。

def _pawn_moves(idx, board, side): row, col = idx_to_pos(idx) moves = [] if side == RED: if row > 0 and board[pos_to_idx(row-1, col)] == EMPTY: moves.append(pos_to_idx(row-1, col)) if row >= 5: # 过河可横移 if col > 0 and board[pos_to_idx(row, col-1)] == EMPTY: moves.append(pos_to_idx(row, col-1)) if col < 8 and board[pos_to_idx(row, col+1)] == EMPTY: moves.append(pos_to_idx(row, col+1)) # 黑方类似,方向相反 return moves

这个现象提醒我:先打印一次generate_moves的数量,红方初始局面应该有 44 个着法左右。如果多了,一定是生成逻辑重复或没处理炮架。

5.2 评估函数让AI“送子”?问题出在将军威胁没进搜索

现象:AI明明有子可吃,却走了一步无关棋,然后被对手吃掉了大子。我查找原因是评估函数里没有“将军”惩罚,搜索到某些分支时虽然会被将军,但深度不足导致杀棋没被看到。解决:在搜索叶子结点前,增加一个“将军检测”作为静态判断。如果己方帅被将军且无着法可解,直接返回一个极低分数,比如-100000 + depth,让搜索尽量避开。

def search(...): if depth == 0: if is_king_in_check(board, side): return -100000 + depth return evaluate(board)

这里+depth是一个常见的“优先速胜”技巧:同样被杀,晚一步死的分数稍高。你可能会问,为什么不直接让走法生成器过滤掉被将军的着法?那样更干净,但会多出大量检查代码。我选择在搜索顶部统一判断,既避免走法生成太复杂,又能让搜索对“将军躲闪”更敏感。

5.3 递归深度达到Python限制:设置setrecursionlimit也不够

现象:搜索深度开到 6,程序直接报RecursionError: maximum recursion depth exceeded。设置sys.setrecursionlimit(10000)后,反而容易导致进程崩溃或段错误。原因:Python递归层数过多会压爆 C 栈,尤其当你使用do_move/undo_move这类互相嵌套的函数时,栈帧特别重。解决:把递归深度限制在 6 层以内;如果必须更深,可以改用“置换表 + 迭代加深”的组合,让搜索在4~5层内高效解决大部分局面,而不是靠硬提升深度。另一个技巧是把do_move的逻辑内联进search,减少一层函数调用。

5.4 重复局面导致搜索死循环:局面哈希与三重复合判和

现象:AI在优势局面下不断来回走同一个子,直接和棋,甚至在某条搜索分支里陷入无穷递归。原因:没有处理重复局面,搜索树里可能出现一个局面在几条路径后再次出现。解决:用一个transposition_table保存已搜索局面的哈希值和搜索深度,同时引入“三重复合”检测:同一局面出现三次且每次都轮到同一方走,强制判和。中国象棋正式规则里有无着法判和,但三重复合最容易实现。

def zobrist_key(board): key = 0 for idx, piece in enumerate(board): if piece: key ^= ZOBRIST[(idx, piece)] return key

在search开头查表,如果当前深度已经搜索过且搜索深度大于等于当前深度,直接返回存储值。这样既避免重复计算,也能阻止死循环。

5.5 与界面程序联动时“黑匣子”式调试:记录日志比看棋谱更有效

现象:AI下出一步完全看不懂的臭棋,你盯着棋谱复盘半天也找不到原因。原因:搜索内部状态是黑匣子,光看最终走法很难定位是评估问题还是搜索剪枝问题。解决:给搜索加一个调试开关,输出每层最佳着法、评估值和剪枝情况。我会在迭代加深的每一层打印一行:depth=4 score=120 move=c2c5 time=0.85s。这样能看出AI是不是在某一层突然误判。另外,把“易用”的吃子着法强制加入候选列表,也能避免排序出错导致的好棋被剪掉。

6. 从源码到可玩版本:命令行对弈、参数调优与验证技巧

当搜索和评估都已经跑通,下一步就是把AI封装成一个可以交互的程序。最简单的方法是在命令行里,人类输入“e2e4”这样的起点到终点坐标,AI调用iterative_search返回走法。这里要注意坐标映射:中国象棋通常用红方棋谱坐标,输出到界面时需要翻转行号。

参数推荐值说明
max_depth4~6超出后评估时间增长明显,优先调排序
time_limit1~3 秒迭代加深的截止时限,太快会落子草率
机动性权重3~5过大导致AI过于活跃,弃子换先
置换表大小1~2 MB用dict即可,太大内存反而拖慢

验证AI是否变强,不要只和自己下棋,那样容易出现“左右互搏”的盲区。一个实用技巧是让AI先手和后手各下一盘,记录红方胜率。如果红方明显占优,说明评估函数对先手方有偏差,需要检查镜像位置表。把验证脚本固定下来,随便改一个参数就重新跑一遍,才能避免“感觉变强了”的错觉。

我自己做象棋AI的习惯是:每次改完评估函数,先跑十盘“同参数对战”,再跑三盘“不同深度对战”,最后用日志里最慢的一步衡量性能。这样调下来,AI的棋风会逐渐从乱走到有章法。希望帮到你。

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

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

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

立即咨询