news 2026/10/27 18:10:35

中国象棋人机对弈源码解析:从棋盘表示到搜索剪枝的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中国象棋人机对弈源码解析:从棋盘表示到搜索剪枝的完整实现

简介:这是一份面向棋类爱好者与程序开发者的中国象棋人机对弈源代码,基于MFC框架实现,适合想研究棋类AI算法、图形界面与用户交互的中级开发者参考学习。压缩包共80个文件,约145KB,以h头文件与cpp源文件为主,另有ico、bmp、cur等界面图标与位图资源,以及rc、dsp、dsw等工程配置文件和可执行程序,结构完整便于直接编译运行。资源核心在于人机对弈引擎,涵盖Minimax、Alpha-Beta剪枝、NegaScout、置换表、历史启发、迭代加深与渴望搜索等多种搜索策略,并支持搜索层数调节与悔棋功能,玩家可依据不同深度体验难度变化。代码中还包括走法生成、局面评估、将军判断与残局棋谱等模块,配合ReadMe与调试说明,能帮助读者理解AI决策流程、状态回溯存储及棋盘绘制逻辑。目前已有1269人学习下载,是研究棋类AI与游戏开发的实用素材。

1. 从一份象棋程序源码说起:人机对弈到底难在哪

很多人第一次拿到「中国象棋人机对弈源代码」这类压缩包时,心态是兴奋的——解压、打开 IDE、点运行,以为马上就能跟电脑杀一盘。结果往往是:棋盘画出来了,棋子能走,但电脑要么乱走,要么卡死,要么第一步就送车。问题不在代码写得烂,而在于「人机对弈」这四个字背后藏着三层完全不同的工程:棋盘表示与走法生成、局面评估、搜索与剪枝。任何一层没打通,程序就只是个会摆棋子的画板。

这份源码标题里的关键词——chess、中国象棋、人机对弈、象棋程序——其实已经点明了它的定位:一个用搜索算法驱动电脑走子的完整对弈程序。它要解决的核心问题是:给定当前局面,电脑如何在有限时间内选出一个「不太蠢」的走法。适合谁看?适合想搞懂博弈树搜索怎么落地、想自己写一个能跑起来的象棋 AI、或者想拿现成源码改评估函数和搜索深度的开发者。接下来我不讲空泛的 AI 原理,而是顺着这份源码的骨架,把棋盘怎么存、走法怎么生成、评估函数怎么写、搜索怎么剪枝、参数怎么调,一层层拆开讲清楚。

2. 棋盘表示与走法生成:人机对弈的地基

2.1 为什么用一维数组而不是二维数组

中国象棋棋盘是 9 列 × 10 行,共 90 个交叉点。新手最容易想到的是board[10][9]二维数组,直观好理解。但真正跑搜索的时候,二维数组的坐标转换会拖慢速度——每生成一步棋都要做行列计算,搜索几百万个节点时这点开销会被放大。常见做法是用一维数组board[90],把行列压成一个索引:index = row * 9 + col。这样走法生成时只需要对索引做加减,配合预计算的偏移量表,速度能快不少。

# 棋盘用一维数组表示,0 表示空,正数表示红方,负数表示黑方 # 索引 = row * 9 + col,row 0~9,col 0~8 BOARD_SIZE = 90 board = [0] * BOARD_SIZE # 棋子编码:用整数区分颜色和兵种 # 红方:1=帅 2=仕 3=相 4=马 5=车 6=炮 7=兵 # 黑方:-1=将 -2=士 -3=象 -4=马 -5=车 -6=炮 -7=卒 PIECE_KING = 1 PIECE_ADVISOR = 2 PIECE_ELEPHANT = 3 PIECE_HORSE = 4 PIECE_ROOK = 5 PIECE_CANNON = 6 PIECE_PAWN = 7 def pos_to_index(row, col): """把行列坐标转成一维索引""" return row * 9 + col def index_to_pos(index): """把一维索引还原成行列坐标""" return index // 9, index % 9

这段代码的关键在于棋子编码用了正负号区分红黑,兵种用绝对值区分。这样判断「这个位置是不是对方棋子」只需要看符号,判断「是不是同种棋子」只需要看绝对值,省掉大量条件分支。参数上,BOARD_SIZE = 90是固定的,不要改成 91 或 89,否则偏移量表会全部错位。

2.2 走法生成:每个兵种一套规则

走法生成是象棋程序里最容易写错的部分。帅、仕、相、马、车、炮、兵,每个兵种的移动规则都不一样,还有「蹩马腿」「塞象眼」「炮翻山」这些特殊限制。常见做法是为每个兵种写一个生成函数,输入当前索引,输出所有合法目标索引。

def generate_rook_moves(board, index, side): """生成车的所有走法,side=1 表示红方,-1 表示黑方""" moves = [] row, col = index_to_pos(index) # 四个方向:上下左右 directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] for dr, dc in directions: r, c = row + dr, col + dc while 0 <= r <= 9 and 0 <= c <= 8: target = pos_to_index(r, c) if board[target] == 0: moves.append((index, target)) elif board[target] * side < 0: # 遇到对方棋子,可以吃,但不能继续前进 moves.append((index, target)) break else: # 遇到己方棋子,不能走 break r += dr c += dc return moves

车的走法逻辑是沿四个方向一直走,遇到空位就记录,遇到对方棋子记录后停止,遇到己方棋子直接停止。这里board[target] * side < 0是判断对方棋子的技巧:如果目标格子的符号和己方相反,乘积就是负数。参数side必须传 1 或 -1,传 0 会导致所有判断失效。

马的走法要额外检查「蹩马腿」:马走日字,但如果马腿位置有棋子,这个方向就不能走。炮的走法更特殊:移动时和车一样,但吃子时必须隔一个棋子(炮架)。这些规则如果写错,程序会出现「马穿墙」「炮隔两个子吃」的玄学走法,调试起来非常痛苦。

提示:走法生成写完后,一定要写一个测试函数,把每个兵种在空棋盘和满棋盘上的走法数量打印出来,和标准象棋规则对照。这一步偷懒,后面搜索再快也没用。

3. 局面评估函数:电脑怎么判断「这步棋好不好」

3.1 子力价值表:不是简单的车马炮

搜索算法本身不知道什么是「好棋」,它只负责在候选走法里找分数最高的。分数从哪来?从评估函数来。评估函数输入一个局面,输出一个分数,正数表示红方优势,负数表示黑方优势。最基础的部分是子力价值:车比马贵,马比兵贵。但中国象棋的子力价值不是固定的,过河兵比未过河兵值钱,炮在开局和中局价值不同。

# 基础子力价值表,单位可以理解为「分」 PIECE_VALUE = { PIECE_KING: 10000, PIECE_ROOK: 900, PIECE_CANNON: 450, PIECE_HORSE: 400, PIECE_ADVISOR: 200, PIECE_ELEPHANT: 200, PIECE_PAWN: 100, } # 兵的位置加成:过河后价值提升 def pawn_position_bonus(row, side): """根据兵的行号给额外加分,side=1 红方,-1 黑方""" if side == 1: # 红兵从下往上走,row 越小越靠近对方 if row <= 4: return 100 # 过河兵 return 0 else: if row >= 5: return 100 return 0

这段代码里,帅的价值给了 10000,因为帅被吃游戏就结束了,必须给一个极大的值让搜索算法避免丢帅。车的 900 和马 400 的差距不是随便定的,是经过大量对局统计出来的经验值。兵过河加 100 分,是为了让电脑愿意把兵往前拱,而不是缩在家里。

3.2 位置价值表:让棋子往好位置走

光有子力价值还不够,电脑会变成「只会吃子不会布局」的莽夫。常见做法是给每个兵种加一张位置价值表,比如马在中间比在边线好,炮在中路比在底线好。位置表通常是一个 90 个元素的数组,和棋盘索引一一对应。

# 马的位置价值表(简化版),索引对应棋盘位置 HORSE_POS_VALUE = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 4, 4, 4, 2, 0, 0, 0, 0, 4, 8, 10, 8, 4, 0, 0, 0, 0, 6, 10, 12, 10, 6, 0, 0, 0, 0, 6, 10, 12, 10, 6, 0, 0, 0, 0, 4, 8, 10, 8, 4, 0, 0, 0, 0, 2, 4, 4, 4, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ] def evaluate(board): """评估整个局面,返回红方视角的分数""" score = 0 for index in range(90): piece = board[index] if piece == 0: continue side = 1 if piece > 0 else -1 kind = abs(piece) value = PIECE_VALUE[kind] if kind == PIECE_HORSE: value += HORSE_POS_VALUE[index] score += value * side return score

评估函数的逻辑是遍历整个棋盘,把每个棋子的子力价值和位置价值加起来,红方加正分,黑方加负分。参数上,位置价值表的数值需要反复调整——调大了电脑会只顾占位不吃子,调小了位置表形同虚设。我一般会先用一套网上流传的经典表,然后根据实际对局效果微调。

注意:评估函数里不要做太复杂的计算,它会被调用几十万次。每多一个分支,搜索速度就掉一截。位置表用查表代替实时计算,是性价比最高的优化。

4. 搜索与剪枝:让电脑在有限时间里算得更深

4.1 极小极大搜索:博弈树的基本框架

有了走法生成和评估函数,接下来就是搜索。中国象棋是零和博弈,红方想最大化分数,黑方想最小化分数。极小极大搜索(Minimax)的思路是:红方走棋时选分数最高的分支,黑方走棋时选分数最低的分支,交替递归下去。

def minimax(board, depth, side, alpha, beta): """带 Alpha-Beta 剪枝的极小极大搜索 depth: 剩余搜索深度 side: 当前走棋方,1 红方,-1 黑方 alpha: 当前最大下界 beta: 当前最小上界 """ if depth == 0: return evaluate(board) moves = generate_all_moves(board, side) if not moves: # 无棋可走,判负 return -10000 * side if side == 1: max_score = -float('inf') for src, dst in moves: # 执行走法 captured = board[dst] board[dst] = board[src] board[src] = 0 score = minimax(board, depth - 1, -side, alpha, beta) # 撤销走法 board[src] = board[dst] board[dst] = captured max_score = max(max_score, score) alpha = max(alpha, score) if alpha >= beta: break # Beta 剪枝 return max_score else: min_score = float('inf') for src, dst in moves: captured = board[dst] board[dst] = board[src] board[src] = 0 score = minimax(board, depth - 1, -side, alpha, beta) board[src] = board[dst] board[dst] = captured min_score = min(min_score, score) beta = min(beta, score) if alpha >= beta: break # Alpha 剪枝 return min_score

这段代码的核心是 Alpha-Beta 剪枝:alpha记录红方已经能找到的最好分数,beta记录黑方已经能找到的最差分数。当alpha >= beta时,说明当前分支不可能影响最终结果,直接跳过。参数depth控制搜索深度,深度每加 1,节点数大约翻 5 到 10 倍。alpha初始传-inf,beta初始传+inf。

4.2 走法排序:剪枝效率的关键

Alpha-Beta 剪枝的效果极度依赖走法顺序。如果先搜好棋,剪枝会大量发生;如果先搜废棋,剪枝几乎不起作用。常见做法是给走法排序:吃子的走法优先,尤其是吃大子的走法。

def score_move(board, src, dst): """给走法打分,用于排序""" captured = board[dst] if captured == 0: return 0 # 吃子走法按被吃棋子的价值排序 return PIECE_VALUE[abs(captured)] def generate_all_moves(board, side): """生成所有走法并按吃子价值排序""" moves = [] for index in range(90): piece = board[index] if piece * side <= 0: continue kind = abs(piece) if kind == PIECE_ROOK: moves.extend(generate_rook_moves(board, index, side)) # 其他兵种的生成函数省略 # 按吃子价值从高到低排序 moves.sort(key=lambda m: score_move(board, m[0], m[1]), reverse=True) return moves

走法排序的收益非常明显:在同样深度下,排好序的搜索节点数可能只有乱序的十分之一。参数上,score_move只考虑被吃棋子的价值,更精细的做法还会减去移动棋子的价值(避免用大子换小子),但那样排序本身的开销也会增加,需要权衡。

提示:如果搜索速度还是慢,优先检查走法生成有没有重复生成、评估函数有没有重复计算。这两个地方是性能黑匣子,用 profiler 跑一下就能定位。

5. 人机对弈程序的避坑与排查

5.1 电脑走子卡死或超时

现象:点击「电脑走棋」后程序无响应,或者等十几秒才走一步。

原因:搜索深度设得太大,或者 Alpha-Beta 剪枝没生效。常见情况是走法排序没做,导致剪枝率极低;也可能是评估函数里做了字符串操作或对象创建,拖慢了整体速度。

解决:先把搜索深度降到 3 层,确认能快速走子后再逐步加。检查alpha >= beta的判断有没有写反,剪枝条件写反会导致搜索退化成全量遍历。评估函数里避免动态分配内存,位置表提前算好存成常量。

5.2 电脑走出非法棋

现象:马走田字、炮隔两个子吃、帅出九宫。

原因:走法生成函数里边界检查或特殊规则漏写。最常见的是马的「蹩马腿」没判断,炮的「炮架」计数错误,帅的九宫范围限制写错行列。

解决:为每个兵种写单元测试,在空棋盘上生成走法,和标准规则逐条对照。特别检查边界条件:马在边线时某些方向应该不可走,炮在底线时吃子逻辑是否正常。测试用例要覆盖棋盘四个角和九宫边缘。

5.3 评估函数导致电脑「送子」

现象:电脑明明能吃到对方的车,却选择走一步无关紧要的棋,甚至主动把子送到对方嘴里。

原因:评估函数的分数尺度不对。如果子力价值差距太小,搜索算法分不清吃车和吃兵的区别;如果位置价值加得太多,电脑会为了占一个好位置而放弃吃子。

解决:检查PIECE_VALUE表,车的价值应该明显高于马和炮,马炮明显高于兵。位置价值的加成不要超过对应兵种子力价值的 30%。可以打印几个典型局面的评估分数,手动验证是否符合直觉。

5.4 搜索深度增加后反而变弱

现象:深度从 3 调到 5,电脑反而走出更差的棋。

原因:这是典型的「地平线效应」——搜索到深度边界时,评估函数看到一个暂时占优的局面,但实际上再走一步就会被反杀。深度越深,这种边界误判的影响可能越大。

解决:在评估函数里加入简单的威胁检测,比如被将军时给一个负分。更彻底的做法是加静止搜索(Quiescence Search),在深度耗尽后继续搜索吃子走法,直到局面稳定。静止搜索能显著缓解地平线效应,但会增加节点数,需要控制递归层数。

5.5 红黑双方评估不对称

现象:同样的局面,红方走棋时评估分数和黑方走棋时不一致,导致电脑执红和执黑表现差距很大。

原因:评估函数里位置价值表只写了一套,没有根据红黑方向做镜像。红方的兵过河是往上走,黑方的兵过河是往下走,如果共用一张表,必然有一方判断错误。

解决:为红黑双方分别准备位置价值表,或者写一个镜像函数,把黑方的位置索引翻转后再查表。翻转规则是:mirror_index = (9 - row) * 9 + col。所有位置相关的评估都要做这个处理。

6. 进阶技巧:用置换表和迭代加深把棋力再提一档

前面五章把一份象棋程序的基本骨架讲完了。如果你已经能跑通搜索、电脑能正常走子,接下来最值得投入的优化是置换表和迭代加深。这两个技巧不需要改动评估函数,但对棋力提升非常直接。

置换表(Transposition Table)解决的是「同一个局面被重复搜索」的问题。象棋里不同走法顺序可能到达同一个局面,比如先走马再走炮,和先走炮再走马,结果一样。没有置换表时,这个局面会被搜两遍。加一张哈希表,把局面的 Zobrist 哈希值和搜索结果存起来,下次遇到直接查表。

# Zobrist 哈希:为每个棋子的每个位置预生成随机数 import random zobrist_table = [[[random.getrandbits(64) for _ in range(90)] for _ in range(8)] for _ in range(2)] def compute_hash(board): """计算局面的 Zobrist 哈希值""" h = 0 for index in range(90): piece = board[index] if piece == 0: continue side = 0 if piece > 0 else 1 kind = abs(piece) h ^= zobrist_table[side][kind][index] return h # 置换表用字典实现,key 是哈希值,value 是 (深度, 分数, 标志) transposition_table = {} def search_with_tt(board, depth, side, alpha, beta): """带置换表的搜索""" h = compute_hash(board) if h in transposition_table: tt_depth, tt_score, tt_flag = transposition_table[h] if tt_depth >= depth: if tt_flag == 'exact': return tt_score elif tt_flag == 'lower' and tt_score >= beta: return tt_score elif tt_flag == 'upper' and tt_score <= alpha: return tt_score # 省略搜索逻辑,搜索完成后把结果存入置换表 # transposition_table[h] = (depth, score, flag) return 0

Zobrist 哈希的核心是异或运算:每个棋子的每个位置对应一个随机数,局面的哈希值就是所有棋子随机数的异或。走一步棋时,只需要异或掉旧位置的随机数、异或上新位置的随机数,不需要重新计算整个棋盘。参数上,随机数用 64 位整数,碰撞概率极低。置换表的标志位分三种:exact表示精确分数,lower表示分数下界,upper表示分数上界,查表时根据标志决定能否直接返回。

迭代加深(Iterative Deepening)是另一个实用技巧:先搜 1 层,再搜 2 层,再搜 3 层,直到时间用完。这样做的目的是用浅层搜索的结果给深层搜索做走法排序,让 Alpha-Beta 剪枝效率更高。而且时间控制更灵活——比赛或对弈时通常有时间限制,迭代加深可以保证在时间耗尽前总能返回一个可用的走法。

import time def iterative_deepening(board, side, time_limit=3.0): """迭代加深搜索,时间限制 3 秒""" start_time = time.time() best_move = None for depth in range(1, 20): if time.time() - start_time > time_limit: break # 用上一层的走法排序结果初始化本层 move = search_root(board, depth, side) if move: best_move = move return best_move

迭代加深的参数time_limit根据实际需求设,一般 1 到 5 秒比较合适。太短了搜索深度不够,太长了用户等得着急。我一般会在界面上加一个「思考时间」选项,让用户自己选。

最后说一个我踩过的坑:置换表在搜索过程中会不断增长,如果不加清理,内存会越吃越多。常见做法是给置换表设一个最大条目数,超过后清空或者替换旧条目。另外,置换表里存的分数是和搜索深度绑定的,浅层搜索的分数不能直接用在深层搜索里,查表时必须比较深度。

这套东西调下来,一个能跑、能下、棋力还过得去的中国象棋人机对弈程序就成型了。从棋盘表示到搜索剪枝,每一步都有坑,但每一步的优化都能在棋力上看到反馈。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 18:09:40

ESP32浏览器改WiFi密码原理与NVS安全操作指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:09:32

大功率开关电源定制如何避免功率虚标:从参数陷阱到验证实操

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:09:30

工业通信光模块选型指南:1X9与SFP核心差异与决策框架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:07:36

树莓派4B嵌入式人脸识别门禁系统实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:06:47

嵌入式I2C调试实战:从协议原理到波形排查核心思路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:06:15

Matlab生成GNSS PRN码:Gold码、二级代码与频谱分析全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华