用Python从零实现中国象棋AI:核心算法与源码解析
发布时间:2026/10/1 22:45:14来源:尧图网络
简介基于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*16c有效的格子满足(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_WIDTH16是因为十六进制网格固定 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(reverseTrue, keylambda 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, col1)] EMPTY: moves.append(pos_to_idx(row, col1)) # 黑方类似方向相反 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下出一步完全看不懂的臭棋你盯着棋谱复盘半天也找不到原因。原因搜索内部状态是黑匣子光看最终走法很难定位是评估问题还是搜索剪枝问题。解决给搜索加一个调试开关输出每层最佳着法、评估值和剪枝情况。我会在迭代加深的每一层打印一行depth4 score120 movec2c5 time0.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的棋风会逐渐从乱走到有章法。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网