☰
五子棋人机对弈课设:数据结构与算法实现全解析
2026/10/14 1:49:15 网站建设 项目流程

1. 课程设计选题背后的真实考量

1.1 为什么五子棋是数据结构与算法课设的“黄金标的”

每年到了课程设计选题那会儿,总有一批同学在“图书管理系统”和“学生成绩管理系统”之间反复横跳,最后交上去一个连自己都不想打开第二次的增删改查。我带过几届低年级的课设辅导,也帮不少朋友看过他们的代码,说句实在话,五子棋这个题目被低估了。它看起来像个游戏,但骨子里是一道披着游戏外衣的算法综合题,而且是一道能拉开差距的题。

先把这个题目的核心需求拆开看。一个完整的“五子棋(人机)”课设,至少要覆盖四块内容:棋盘状态的表示与存储、落子合法性的判定、胜负条件的检测、以及电脑一方的走子决策。前两块是数据结构的基本功,第三块是边界条件处理能力的试金石,第四块才是真正决定你这个课设能不能拿高分的地方——因为“人机”两个字,意味着你必须让电脑看起来“会下棋”,而不是随机乱丢。

为什么说它适合作为数据结构与算法的课程设计?因为它天然地把多个知识点串成了一条线。棋盘用二维数组还是稀疏矩阵,关系到空间复杂度的理解;胜负检测用暴力扫描还是方向增量判断,关系到时间复杂度的优化意识;电脑走子用极大极小搜索还是启发式评估,直接把你从“会写代码”拉到“会设计算法”的层面。一个题目能把数组、递归、搜索、评估函数、剪枝这些概念全用上,这在课设题目里并不多见。

我见过太多同学把五子棋做成了“能跑就行”的版本:棋盘画出来,鼠标点一下落个子,电脑随机找个空位下,谁先连成五个谁赢。这种版本拿个及格分没问题,但如果你想让它成为简历上能聊两句的项目,或者想在答辩时被老师多问几个问题然后你从容答上来,那就要在电脑的“脑子”上下功夫。这篇内容就是围绕这个目标来的——不是教你写一个能跑的五子棋,而是教你写一个电脑下得像个正常人、代码结构经得起推敲、算法选择有据可循的五子棋。

1.2 人机对弈的三种实现档次与选型逻辑

在动手之前,你得先想清楚自己要做到哪个档次。我把常见的人机五子棋实现分成三档,你可以对号入座。

第一档是随机落子型。电脑在所有空位里随便挑一个,或者稍微聪明一点,优先挑靠近已有棋子的空位。这种实现的核心代码可能不到二十行,但它的问题在于:玩家下到第三手就能发现电脑是个傻子,体验极差,答辩时老师问一句“你的电脑是怎么决策的”,你只能回答“随机”,场面会比较尴尬。

第二档是启发式评估型。电脑会扫描棋盘上每一个可能的落子点,对每个点计算一个“分数”,分数高的点优先下。分数的计算依据是:如果我在这个点落子,能形成什么样的棋型——活四、冲四、活三、眠三、活二等等。这种实现不需要搜索树,代码量适中,但效果已经相当不错,电脑会堵你的活三,会自己做活四,下起来有来有回。对于大多数课设来说,这一档是性价比最高的选择。

第三档是博弈树搜索型。在启发式评估的基础上,引入极大极小搜索(Minimax),再配合Alpha-Beta剪枝,让电脑能往前看几步。这种实现的效果最好,但代码复杂度和调试难度也最高。如果你时间充裕、对递归理解到位,可以挑战这一档;如果时间紧张,第二档做到扎实,答辩时能把评估函数的每一项讲清楚,也完全够用。

我的建议是:以第二档为基础,在评估函数做扎实之后,如果还有余力,再往上叠加一层浅层的搜索。这样既保证了项目能按时完成,又留出了优化空间。下面我就按照这个思路,把整个实现过程拆开来讲。

2. 棋盘表示与核心数据结构设计

2.1 二维数组还是稀疏结构:一个被低估的选择

棋盘怎么存,这个问题看起来简单,但不同的选择会影响后面所有代码的写法。最直观的方式是开一个二维数组,比如int board[15][15],0表示空,1表示玩家,2表示电脑。这是绝大多数人的第一反应,也是我推荐的做法。原因很简单:五子棋棋盘固定是15×15,总共225个格子,这个规模下二维数组的空间开销完全可以忽略,而它的访问速度是最快的,board[row][col]直接定位,不需要任何额外的查找逻辑。

那什么时候需要考虑稀疏结构?只有当棋盘规模大到比如几百乘几百,而落子又非常稀疏的时候,用哈希表或者坐标列表来存才有意义。五子棋显然不属于这种情况。我见过有同学为了“显得高级”,用map<pair<int,int>, int>来存棋盘,结果每次扫描棋盘都要遍历整个map,代码写起来还别扭,完全是给自己找麻烦。数据结构的选型要服务于操作频率和规模,不要为了炫技而选型。

不过有一个细节值得注意:如果你打算在评估函数里频繁地做“某个点周围有没有棋子”这类判断,可以额外维护一个“有棋子的位置列表”,这样在生成候选落子点时就不需要遍历整个225个格子,只需要遍历这个列表周围的空位即可。这是一个用少量额外空间换取时间的小优化,在搜索层数较深的时候效果明显。

# 棋盘初始化 BOARD_SIZE = 15 EMPTY = 0 PLAYER = 1 COMPUTER = 2 board = [[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)] # 维护一个已落子位置列表,方便生成候选点 occupied_positions = []

2.2 方向数组:把四个方向的变化量提前定义好

五子棋的胜负检测和棋型评估都离不开“沿某个方向数连续棋子”这个操作。四个方向分别是:水平、垂直、主对角线(左上到右下)、副对角线(右上到左下)。与其在代码里写四段几乎一样的循环,不如定义一个方向数组:

DIRECTIONS = [ (0, 1), # 水平 (1, 0), # 垂直 (1, 1), # 主对角线 (1, -1), # 副对角线 ]

这样在检测胜负或者评估棋型时,只需要一个循环遍历四个方向,代码简洁且不容易出错。这个技巧在几乎所有棋盘类游戏里都通用,值得养成习惯。

2.3 胜负检测的两种写法与复杂度对比

胜负检测最朴素的写法是:每次落子后,遍历整个棋盘,对每个有棋子的位置,检查它四个方向上是否连成五个。这种写法的时间复杂度是 O(N²),在15×15的棋盘上大约是225次检查乘以四个方向,实际跑起来也就几千次操作,对于人机对弈来说完全够用。

但更优雅的写法是增量检测:只检查刚刚落下的那颗棋子,看它四个方向上是否形成了五连。因为如果棋盘上出现了五连,那一定是刚刚落子造成的(之前的局面已经检查过了)。这种写法的时间复杂度是 O(1)——固定检查四个方向,每个方向最多往两边各看四格。代码大概长这样:

def check_win(board, row, col, player): for dr, dc in DIRECTIONS: count = 1 # 正方向 for step in range(1, 5): r, c = row + dr * step, col + dc * step if 0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE and board[r][c] == player: count += 1 else: break # 反方向 for step in range(1, 5): r, c = row - dr * step, col - dc * step if 0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE and board[r][c] == player: count += 1 else: break if count >= 5: return True return False

这个函数是整个程序里被调用最频繁的函数之一,写成增量版本不仅快,而且逻辑清晰。注意边界检查一定要放在数组访问之前,否则会越界报错,这是新手最容易踩的坑。

3. 电脑走子决策:从评估函数到搜索

3.1 棋型识别:给每一种棋型定一个“身价”

电脑要会下棋,首先得能“看懂”棋盘上的局势。什么叫看懂?就是给定一个局面,它能判断出哪些棋型是威胁、哪些是机会。五子棋里的基本棋型有这么几种:

棋型名称形态描述威胁等级建议分值
连五五颗连续已获胜100000
活四四颗连续,两端皆空必胜10000
冲四四颗连续,一端被堵极强威胁1000
活三三颗连续,两端皆空强威胁1000
眠三三颗连续,一端被堵中等威胁100
活二两颗连续,两端皆空弱威胁100
眠二两颗连续,一端被堵微弱10

这张表是启发式评估的核心。电脑在决定落子时,会遍历所有候选空位,假设自己在那个位置落子,然后计算这个落子能形成什么棋型,同时也要计算如果对手在那个位置落子会形成什么棋型。前者是“进攻分”,后者是“防守分”,最终的决策分数是两者加权之和。

这里有一个关键的经验:防守分的权重应该略高于进攻分。为什么?因为五子棋先手优势很大,如果电脑只顾自己进攻而不堵对手的活三,很可能自己还没连成四,对手已经连成五了。我一般把防守权重设为进攻权重的1.2到1.5倍,实测下来电脑的“堵人”意识会明显增强。

3.2 评估函数的实现细节与常见陷阱

评估函数的写法直接决定了电脑的棋力。最直接的方式是:对每个候选点,分别计算“我下在这里”和“对手下在这里”的分数,然后相加。但这里有几个容易踩的坑。

第一个坑是只看了落子点本身,没看落子后形成的整体棋型。比如你在一个位置落子,可能同时形成两个活三,这种“双三”的威胁远大于单个活三,但如果你只是简单地把每个方向的分数相加,可能会低估它的价值。解决办法是在评估时对“多方向同时形成威胁”的情况给一个额外加成。

第二个坑是忽略了棋型的“可扩展性”。同样是活二,一个两端都有足够空间扩展成活三甚至活四,另一个虽然两端空但再往外就被堵死了,两者的价值是不一样的。严格的评估需要考虑“这个棋型还有多少发展空间”,但这会让代码复杂度上升不少。对于课设来说,可以先用简化版,把基本棋型识别做准,效果就已经不错了。

第三个坑是评估函数的性能。如果每个候选点都要做一次完整的四方向扫描,而候选点又有几十个,那每走一步可能要计算几百次棋型识别。在Python里这可能会造成明显的卡顿。优化的思路是:只考虑已有棋子周围两格以内的空位作为候选点,这样候选点数量会从上百个降到二三十个,速度提升非常明显。

def evaluate_point(board, row, col, player): """评估在(row, col)落子后,该玩家能获得的分数""" score = 0 for dr, dc in DIRECTIONS: count = 1 blocked_ends = 0 empty_ends = 0 # 正方向扫描 for step in range(1, 5): r, c = row + dr * step, col + dc * step if not (0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE): blocked_ends += 1 break if board[r][c] == player: count += 1 elif board[r][c] == EMPTY: empty_ends += 1 break else: blocked_ends += 1 break # 反方向扫描(同理) # ... 根据count和两端状态返回对应分值 return score

3.3 极大极小搜索与Alpha-Beta剪枝的入门实现

如果你想让电脑再聪明一点,可以在评估函数的基础上加一层搜索。极大极小搜索的思路是:电脑假设自己走一步,然后假设对手走一步,再假设自己走一步……一直往前推N层,然后选择那个在最坏情况下也能获得最好结果的分支。Alpha-Beta剪枝则是在搜索过程中,如果发现某个分支的结果已经不可能影响最终决策,就提前停止对该分支的搜索,从而大幅减少计算量。

对于五子棋课设,我建议搜索深度设为2到4层。深度为2意味着电脑考虑“我走一步,对手走一步”之后的局面;深度为4就是再往前推两步。每增加一层,计算量大约增加一个数量级,所以深度4在Python里可能已经需要几秒钟才能走一步,需要配合剪枝和候选点筛选来控制。

def minimax(board, depth, alpha, beta, is_maximizing): # 检查是否已经分出胜负或达到搜索深度 if depth == 0: return evaluate_board(board) if is_maximizing: max_eval = float('-inf') for row, col in generate_candidates(board): board[row][col] = COMPUTER eval_score = minimax(board, depth - 1, alpha, beta, False) board[row][col] = EMPTY max_eval = max(max_eval, eval_score) alpha = max(alpha, eval_score) if beta <= alpha: break # Alpha-Beta剪枝 return max_eval else: min_eval = float('inf') for row, col in generate_candidates(board): board[row][col] = PLAYER eval_score = minimax(board, depth - 1, alpha, beta, True) board[row][col] = EMPTY min_eval = min(min_eval, eval_score) beta = min(beta, eval_score) if beta <= alpha: break return min_eval

这段代码是极大极小搜索的骨架。注意在递归调用后一定要把棋盘恢复原状,否则整个棋盘的状态会被污染,这是递归搜索里最经典的错误。另外,generate_candidates函数应该只返回有意义的候选点(比如已有棋子附近的空位),而不是所有空位,否则搜索树会大到无法接受。

4. 完整对弈流程与关键环节实现

4.1 从玩家落子到电脑应招的完整链路

一个完整的对弈回合包含以下几个步骤,我按顺序拆开讲。

第一步是玩家落子。玩家通过鼠标点击或者坐标输入选择一个空位,程序需要先检查这个位置是否合法(在棋盘内且为空),如果不合法就提示重新选择。合法的话,把玩家棋子写入棋盘,然后立即调用胜负检测函数判断玩家是否获胜。

第二步是判断游戏是否结束。如果玩家获胜,游戏结束,显示胜利信息;如果棋盘已满(平局),也结束;否则进入电脑回合。

第三步是电脑决策。这是整个程序的核心。电脑需要遍历候选点,对每个点计算决策分数,然后选择分数最高的点落子。如果启用了搜索,则调用极大极小搜索来获取最佳落子点。

第四步是电脑落子后的胜负检测。和玩家落子后的检测逻辑完全一样,如果电脑获胜则结束游戏。

第五步是界面刷新。把更新后的棋盘重新绘制出来,让玩家看到电脑走了哪里。

这个流程看起来简单,但实际写的时候有几个细节容易出问题。比如电脑决策的时间如果太长,界面会卡住,玩家会以为程序死了。解决办法是在电脑思考时显示一个“思考中”的提示,或者把搜索放在单独的线程里执行。对于课设来说,控制搜索深度、优化评估函数性能,让电脑在1秒内完成决策,是比较实际的目标。

4.2 候选点生成策略:别让电脑在无关位置浪费时间

如果让电脑遍历棋盘上所有空位来评估,225个格子减去已落子的数量,可能有将近200个候选点。对每个点做一次评估,再乘以搜索深度,计算量会爆炸。但实际上,五子棋的有意义落子点几乎都在已有棋子的附近。一个空位如果周围两格内没有任何棋子,下在那里几乎没有任何价值。

所以候选点生成的策略是:遍历所有已落子的位置,把它们周围两格内的空位加入候选集合,去重后返回。这样候选点数量通常能控制在20到40个之间,计算量直接降了一个数量级。

def generate_candidates(board): candidates = set() for row in range(BOARD_SIZE): for col in range(BOARD_SIZE): if board[row][col] != EMPTY: for dr in range(-2, 3): for dc in range(-2, 3): r, c = row + dr, col + dc if (0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE and board[r][c] == EMPTY): candidates.add((r, c)) return list(candidates)

这个策略还有一个好处:如果棋盘是空的(电脑先手),候选点集合为空,这时候需要特殊处理,直接下在棋盘正中央即可。这是一个边界情况,但如果不处理,程序会在第一步就崩溃。

4.3 先手与后手的策略差异

电脑执先手和执后手的策略应该有所区别。执先手时,电脑没有需要防守的威胁,应该以进攻为主,优先占据棋盘中央区域,因为中央的棋子有更多的发展方向。执后手时,电脑需要先观察玩家的棋型,如果玩家有活三或冲四,必须优先堵截;如果玩家没有明显威胁,再考虑自己的进攻。

实现上可以通过调整进攻分和防守分的权重来体现。先手时进攻权重设为1.0,防守权重设为0.8;后手时反过来,防守权重设为1.2,进攻权重设为1.0。这个调整不需要改代码逻辑,只需要改两个参数,但效果差异很明显。

5. 常见问题与排查技巧实录

5.1 电脑“看不见”对手的活三怎么办

这是最常见的问题。玩家做了一个活三,电脑却视而不见,继续下自己的棋,然后玩家下一步做成活四,电脑再堵已经来不及了。出现这个问题的原因通常是评估函数里对“对手棋型”的识别有遗漏。

排查思路是这样的:先单独测试评估函数,手动构造一个对手有活三的局面,然后打印出每个候选点的评估分数,看看那个应该被堵的位置分数是不是最高的。如果不是,检查评估函数里对活三的识别逻辑——是不是只检查了当前落子点形成的棋型,而忽略了对手已有的棋型?正确的做法是:对每个候选点,既要计算“我下在这里的进攻分”,也要计算“对手下在这里的防守分”,两者相加才是最终分数。

还有一个细节:活三和眠三的区分。如果对手的三颗棋子两端都是空的,那是活三,必须堵;如果一端被堵,那是眠三,威胁小很多,可以暂时不管。如果你的评估函数把眠三也当成活三来处理,电脑会变得过于保守,到处堵人,反而失去自己的进攻节奏。

5.2 搜索层数增加后程序卡顿的优化方案

把搜索深度从2层加到4层,程序突然变得很慢,每走一步要等好几秒。这是正常的,因为搜索树的大小是指数增长的。优化的方向有几个。

第一个是候选点排序。在搜索之前,先用简单的启发式评估对候选点排序,把分数高的点排在前面。这样Alpha-Beta剪枝能更早地剪掉差的分支,搜索效率会大幅提升。实测下来,好的排序能让搜索速度提升三到五倍。

第二个是限制候选点数量。在每一层搜索时,只保留评估分数最高的前N个候选点(比如前10个),而不是所有候选点。这样搜索树的分支因子从30降到10,深度4的节点数从30^4降到10^4,差距是巨大的。代价是可能错过一些“看起来不好但实际有深远影响”的点,但对于课设来说,这个代价完全可以接受。

第三个是用数组代替集合。在生成候选点时,用列表和布尔标记数组来去重,比用Python的set要快不少。这个优化在候选点数量多的时候效果明显。

5.3 胜负检测的边界条件与测试用例

胜负检测看起来简单,但边界条件很容易出错。我整理了几个必须测试的场景:

测试场景预期结果常见错误
五颗棋子连在棋盘边缘判定获胜边界检查遗漏导致越界
五颗棋子斜向排列判定获胜只检查了水平和垂直
四颗棋子加一个空位不判定获胜误把四颗当成五颗
六颗棋子连成一线判定获胜计数逻辑错误导致漏判
棋盘已满但无人获胜判定平局忘记检查平局条件

测试的时候不要只测正常情况,一定要测边缘和角落。我见过有同学的程序在棋盘中间跑得好好的,一到边缘就崩溃,就是因为边界检查写错了。每次数组访问之前都要检查索引是否在合法范围内,这个习惯能帮你省下大量调试时间。

5.4 电脑“自杀式”落子的原因分析

有时候电脑会下一个看起来毫无意义的棋,既没有进攻也没有防守。这种情况通常是评估函数返回了错误的分值。可能的原因有:棋型识别时方向搞反了、分值表写错了、或者候选点评估时没有正确模拟落子。

排查方法:找一个电脑下出“臭棋”的局面,把这个局面的棋盘状态保存下来,然后单独调用评估函数,打印出每个候选点的分数。看看电脑选择的那个点为什么分数最高。十有八九是因为某个棋型的分值给错了,比如把眠二的分值给得比活三还高,导致电脑优先去连一个没用的二。

还有一种可能是评估函数没有考虑“对手的回应”。比如电脑看到一个点能形成活三,觉得分数很高,但它没意识到对手下一步就能堵死这个活三。这种情况下,单纯提高搜索深度能解决问题,但更根本的办法是在评估函数里加入“对手威胁”的考量。

6. 从课设到可展示项目的扩展思路

6.1 界面美化与交互体验提升

课设的评分不只是看算法,界面和交互也是印象分的重要来源。如果时间允许,可以把控制台版本升级成图形界面。用Python的话,tkinter是标准库自带的,不需要额外安装,画一个15×15的棋盘网格,用圆形表示棋子,鼠标点击落子,实现起来并不复杂。

几个提升体验的细节:鼠标悬停时在对应位置显示一个半透明的预览棋子,让玩家知道会下在哪里;最后落子的位置用一个高亮框标出来,方便玩家看清电脑走了哪里;游戏结束时弹出提示框,显示胜负结果和重新开始的按钮。这些细节花不了太多时间,但能让你的课设在答辩时显得完整度很高。

6.2 棋力调优的参数与实验方法

如果你想让电脑的棋力可调,可以把评估函数里的分值表和权重参数提取成配置项,然后通过实验来调优。比如设置三档难度:简单模式用随机加基础防守,中等模式用完整的启发式评估,困难模式加上搜索。这样在答辩演示时可以现场切换难度,展示你对算法层次的理解。

调参的时候不要凭感觉,要有对比实验。可以写一个自动对弈的脚本,让两个不同参数的电脑互相对战100局,统计胜率。胜率高的参数组合就是更好的。这个方法虽然简单,但比手动试参数要靠谱得多。

6.3 代码结构与答辩准备建议

最后说一个容易被忽视的点:代码结构。很多同学的功能实现了,但所有代码都堆在一个文件里,几百行连在一起,自己过两天都看不懂。建议至少分成三个模块:棋盘逻辑(board.py)、评估与搜索(ai.py)、界面与主循环(main.py)。这样不仅代码清晰,答辩时老师问“你的评估函数在哪里”,你能直接指出来,印象分会高很多。

答辩时最常被问到的几个问题:你的评估函数是怎么设计的?为什么选这些分值?搜索深度是多少?为什么选这个深度?Alpha-Beta剪枝在什么情况下能剪掉分支?这些问题如果你在实现的时候都想过,回答起来会很从容。如果没想过,建议在答辩前把这些问题过一遍,自己给自己讲一遍,比临时抱佛脚强得多。

我在实际做这个课设的时候,最大的体会是:先把评估函数做扎实,再考虑搜索。很多同学一上来就想写极大极小搜索,结果评估函数一堆bug,搜索出来的结果也是错的,调试起来非常痛苦。正确的顺序是:先让电脑能正确识别各种棋型,能堵活三、能自己做活三,这一步做好了,电脑已经像个正常人了;然后再加搜索,让它能往前看两步,这时候提升是锦上添花。如果反过来,地基没打好就盖楼,最后只能推倒重来。

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

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

立即咨询