☰
消灭星星自动求解:基于光束搜索与BFS的棋盘建模与游戏AI实现
2026/10/19 19:11:10 网站建设 项目流程

简介:消灭星星自动求解是一份基于QT框架的C++游戏AI项目资源,面向对游戏自动化与智能决策感兴趣的开发者,解决如何在有限步数内通过消除同色方块获取高分的问题。压缩包共60个文件,大小12.13MB,包含6个.h与6个.cpp源码、可执行程序、22个dll动态库、15个qm语言包、图片资源及工程配置,既能在QT环境中重新编译,也能直接启动体验。程序采用单步最优蒙特卡洛方法,逐回合模拟所有可消除组合并评估预期得分,再执行当前最优策略,实测平均得分可稳定超过5500。目前已有605人学习下载,资源完整呈现了游戏状态建模、消除组合生成、蒙特卡洛采样与界面刷新的实现细节,适合希望深入学习随机搜索算法落地、图形界面与计算线程协作的中高级读者,也可作为游戏AI课程设计或二次开发的起点;通过研读源码与算法流程,可掌握启发式评估与单步决策的实用技巧。

1. 消灭星星自动求解:为什么人工拿不到高分,脚本却可以

消灭星星这个游戏看起来无脑:10x10 的棋盘上散着五种颜色的星星,点掉一块同色连通区域,剩下的会掉下来并自动左移,就这样一路点到终局。可分数一上去就会发现,这套规则真正难的是选择,而不是手速。一个连消能拿多少分,取决于你为后面留下的是“整片同色”还是“碎成渣”。所以自动求解在本质上是搜索问题:每一步有几十种合法操作,整棵树指数膨胀,纯贪心必然翻车。这份资源把棋盘建模、连通块识别、光束搜索、终局回放器都打包好了,适合想做游戏 AI 课设、练搜索算法或者研究自动化脚本的开发者直接复现。下面按我实际拆包调试的顺序往下讲,连坐标方向这种小坑都会点出来。

2. 先把棋盘和操作建模:从二维数组到“删除一坨”的模拟

动手写搜索之前,棋盘数据结构必须稳定。很多人的求解器跑着跑着就得出非法步,不是算法问题,是删除模拟和坐标定义对不上。我把这套资源里最核心的三段建模代码拆出来,顺序分别是:棋盘生成、连通块识别、删除后的重力与整列左移。

2.1 棋盘数据结构与五色编码:别用字符串当底层

常见做法是用二维列表存棋盘,颜色用整数 0~4 编码,-1 表示该位置已经消掉。用整数的好处是判断颜色快、内存小,而且后续哈希局面状态也方便。不要用字符串拼棋盘,虽然打印好看,但每次更新要重建字符串,搜索层数一高就难受。

# board 用二维列表,行 r 从 0 到 9,列 c 从 0 到 9 # 颜色编码 0~4,-1 表示空位 import random def random_board(rows=10, cols=10, colors=5): return [[random.randrange(colors) for _ in range(cols)] for _ in range(rows)]

这段代码的默认参数就是 10x10、5 色。我一般会把rows、cols、colors留成参数,因为做小规模调试时,把棋盘缩到 5x5 会比在完整棋盘上跑快很多。注意 0 也是合法颜色,判断空位时一定要用== -1,不要用if not board[r][c]这种写法,否则颜色 0 会被当成空。这个细节在下一段 BFS 里最容易翻车。

2.2 连通块识别:BFS 的 visited 标记要放在入队时

连通块识别是整棵搜索树的地基。搜索每一步都要先找出所有同色且上下左右相连的块,才能生成候选操作。这里用队列实现 BFS,比递归 DFS 稳,因为棋盘虽然只有 100 格,但搜索过程会反复调用,递归深度一多照样把调用栈打爆。

from collections import deque def find_clusters(board, min_size=2): rows, cols = len(board), len(board[0]) visited = [[False] * cols for _ in range(rows)] clusters = [] def bfs(sr, sc): color = board[sr][sc] q = deque([(sr, sc)]) visited[sr][sc] = True cells = [] while q: r, c = q.popleft() cells.append((r, c)) for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nr, nc = r + dr, c + dc if not (0 <= nr < rows and 0 <= nc < cols): continue if visited[nr][nc] or board[nr][nc] != color: continue visited[nr][nc] = True q.append((nr, nc)) return cells for r in range(rows): for c in range(cols): if visited[r][c] or board[r][c] == -1: continue cluster = bfs(r, c) if len(cluster) >= min_size: clusters.append(cluster) return clusters

这里有个很关键的习惯:visited必须在入队那一刻就置位,而不是出队再置位。否则同一个格子可能被入队多次,BFS 返回的连通块里会重复出现坐标,后面算分、回放全乱。min_size=2是按常见规则来的,如果你的版本允许消单个星星,把它改成 1 就行,这个参数后续调参也会用到。

2.3 重力下落与整列左移:模拟顺序错了答案全错

消掉一块后规则分两步走:先让每一列的星星受重力落到最下面,再把整个空列挤掉、右侧列向左补齐。顺序非常重要,先左移再下落会把棋盘变成完全不同的局面。这一段的坐标方向也容易看晕,我习惯统一用“r 从 0 是顶行,c 从 0 是最左列”。

def apply_move(board, cluster): # 先拷贝,避免污染原始棋盘 board = [row[:] for row in board] # 1. 把 cluster 里的格子标记为空 for r, c in cluster: board[r][c] = -1 rows, cols = len(board), len(board[0]) # 2. 重力下落:按列取保留星,从下往上填 for c in range(cols): kept = [board[r][c] for r in range(rows) if board[r][c] != -1] for r in range(rows): board[r][c] = -1 for r in range(rows - len(kept), rows): board[r][c] = kept[r - (rows - len(kept))] # 3. 整列左移:只保留非空列 new_board = [] for c in range(cols): col = [board[r][c] for r in range(rows)] if any(v != -1 for v in col): new_board.append(col) while len(new_board) < cols: new_board.append([-1] * rows) # 4. 从列结构转回行结构 board = [[new_board[c][r] for c in range(cols)] for r in range(rows)] return board

这段代码最容易出错的是第 2 步里的索引换算。kept是按“从顶到底”的顺序取的,填入时也要从底部往上填,所以起始行是rows - len(kept),kept的下标则是r - (rows - len(kept))。如果你把行顺序反过来,星星就会悬浮在中间或者上下颠倒。第 3 步我先收集非空列,再补空列,最后转置回行结构。这个写法牺牲一点性能,但每一步都直观,调试时一眼能看出问题。

3. 自动求解核心:贪心打底,光束搜索提上限

模型建好之后,真正的问题是搜索策略。有人会直接写一个贪心:每步挑得分最高的块消。这样跑起来飞快,但分数经常卡在中等水平。原因很现实:这一步拿的高分,可能把之后的整片同色拆成碎片。所以这份资源的求解核心是光束搜索,它保留了“贪心”的即时收益思路,又加上了评估函数的前瞻能力。

3.1 即时得分与终局得分的反差:贪心为什么翻车

先看得分公式。这份资源默认的计分方式是消除 k 个星星,得k*(k-1)*5,这跟市面上一部分版本的规则一致。如果换成k*10这类线性公式,搜索的排序逻辑也会变化,所以我把计分函数单独抽出来。

def move_score(k): # 默认计分:消除 k 个星星给 k*(k-1)*5 # 你的游戏版本若不同,只改这个函数 return k * (k - 1) * 5

这个公式决定了“消大块”必须优先。举个例子,消一个 5 连给 100 分,而两次 2 连分别只给 20 分,差距明显。可问题在于:把一个大块直接消掉,可能会让上面的星星落下来时自然形成另一个大块;如果把旁边的小块先消掉,反而能让两个区域合并成更大的块,总分更高。贪心只能看到“现在”,看不到“这一步操作对剩余棋盘连通性的影响”。光束搜索就是在这一步折中。

3.2 光束搜索:搜索宽度怎么压下来

朴素 BFS 会同时保存所有中间棋盘,棋盘只有 100 格的时候还能扛,再加 5 种颜色、几十个合法块,内存和时间直接崩。光束搜索的做法是每层只保留得分期望最高的若干个状态,这个数量叫光束宽度。别把它理解成简单的 Top-K 贪心,搜索过程中每个状态都会生成全部合法子节点,只是最后截断。

def beam_search(board, beam_width=20, max_depth=30): # 状态 = (累计得分, 棋盘, 操作序列) beam = [(0, board, [])] best = (0, board, []) for _ in range(max_depth): candidates = [] for total, cur_board, history in beam: clusters = find_clusters(cur_board) if not clusters: # 无法再消,直接作为终局候选 if total > best[0]: best = (total, cur_board, history) continue for cluster in clusters: nxt = apply_move(cur_board, cluster) gain = move_score(len(cluster)) candidates.append((total + gain, nxt, history + [cluster])) if not candidates: break # 排序时同时看累计得分和评估函数 candidates.sort(key=lambda x: eval_board(x[1]) + x[0], reverse=True) beam = candidates[:beam_width] if beam[0][0] > best[0]: best = beam[0] return best

这里两个参数决定搜索行为。beam_width=20表示每层最多保留 20 个候选,调大会更接近穷举但内存随之上涨;max_depth=30表示搜索最多走 30 步,对于 10x10 棋盘,如果每步至少消 2 个星,30 步已经覆盖大部分对局。需要说明的是,beam 排序用的是eval_board + total,total是已经拿到的分数,eval_board是评估函数给出的“未来潜力”,两者量纲要大致匹配,否则会出现一种情况:一个状态总分很高但局面已经死了,另一个状态总分略低但后面还有爆发,排序却把前者留在光束里。

3.3 评估函数:剩余元素、碎片化与颜色惩罚

评估函数是光束搜索的“导向标”。最朴素的评估是看剩余星星数,剩余越少越好,但这个指标太粗:两个棋盘剩余星星一样多,一个全是同色大片,一个全是孤立点,后续得分天差地别。我一般会同时统计最大连通块占比、连通块数量和剩余颜色种类。

def eval_board(board): clusters = find_clusters(board, min_size=1) # 算评估时连 1 星也要看 alive = sum(cell != -1 for row in board for cell in row) if alive == 0: return 1e9 # 清盘,最高分 max_ratio = max(len(cluster) for cluster in clusters) / alive fragmentation = len(clusters) / max(1, alive * 0.1) colors = {cell for row in board for cell in row if cell != -1} # 三个可调权重 return max_ratio * 100 - fragmentation * 5 - len(colors) * 2

这段代码的意图是:大片存在感越强越好,碎片越多越差,颜色种类越少越好。alive * 0.1这个分母是为了做归一化记忆,避免两个不同剩余数的棋盘比较不了。权重 100、5、2 不是玄学,你可以先跑几局,观察失败局面里“碎片化”和“颜色种类”哪个更早导致死局,再上下调。如果追求更稳,也可以把find_clusters(min_size=1)换成只找大块,但那样会丢失对“能不能合并”的刻画。

4. 参数调参与避坑:五个坑让你的求解器越搜越差

光有代码还不够,这套资源我在复现时踩了五个坑,每一个都让搜索结果变得不可信。这一章专门写排障,你按“现象 → 原因 → 解决”的顺序看,能省下大半天。

4.1 五个踩坑记录:现象、原因、解决

第一个坑:删除模拟后棋盘坐标错乱,回放器判定非法。原因是我在做重力下落之前先做了整列左移,或者把行、列下标搞反。解决方法是把apply_move拆成两个独立的子过程:先重力后左移。我还会打印一个随机局面删除前后的完整棋盘,肉眼比对一遍再继续。

第二个坑:用递归 DFS 找连通块,10x10 的棋盘上递归深度一高就抛异常。原因很简单,递归栈被反复调用撑爆。解决方法是全部改成 deque 队列的 BFS,并且visited在入队时置位。从那以后我再也没有因为这个崩过。

第三个坑:beam_width调得越大,结果反而越差。初看很反直觉,后来发现是排序权重问题。eval_board的量纲远大于单步得分,导致光束里全是“评估函数看着好但累计得分低”的状态,搜索目标跑偏。解决办法是调参前先做一次消融测试:只按total排序跑一版,再按eval_board + total跑一版,对比终局分数。

第四个坑:换了一个游戏版本后,脚本跑出的“高分”在真实游戏里不算数。原因是计分公式和是否允许消单星的规则不一致。解决方法是把move_score和min_cluster都暴露成配置项,先用真实手打录像去核对同一个操作序列的得分,确认公式对了再跑搜索。

第五个坑:搜索停在同一个局面反复抖动。原因是多个不同操作序列可能落到同一个棋盘状态,光束里保存了大量重复状态,白白占掉宽度。解决方法是给棋盘做序列化哈希(把每行数字拼成字符串),加入候选前先去重;平局时用固定随机种子决定保留哪个,保证结果可复现。

4.2 参数调试清单:先钉死规则,再动搜索参数

调参不是一上来就改 beam_width。我一般先把规则参数钉死,再把搜索参数从缺省值开始放大。下表是这套资源里最常动的 5 个参数。

参数默认值作用翻车临界点
min_cluster2小于该大小的连通块不参与候选设成 1 会大幅增加候选数,还可能让计分函数出现 0 分操作
move_scorek*(k-1)*5单步得分公式与版本不一致时,搜索树排序全错
beam_width20每层保留候选状态数超过 50 后内存上涨明显,收益边际递减
max_depth30搜索的最大步数太小看不到中局,太大遇到死局会空转
random_seed42平局时决定保留哪个状态不设置时每次结果都不一样,没法复现

调试顺序我会这样走:先在 5x5 的小棋盘上跑,beam_width=5、max_depth=10,确认每一步局面变化合理;再把棋盘换回 10x10,beam_width从 10 逐步加到 30,记录每一步耗时和终局分数。如果耗时增长很快但分数不涨,通常要回头调评估函数,而不是继续加宽度。记住一点:光束搜索的宽度只是兜底,提升上限的是评估函数对“死局”的敏感度。

5. 终局验证与进阶技巧:用回溯器确认“每一步都合法”

搜索算法跑出来的操作序列,不能直接拿去当成标准答案,因为只要apply_move里有一个坐标错位,整条路径都废了。所以这套资源里我最看重的是回放验证器。它把初始棋盘和操作序列一起喂进去,重新执行每一步,逐条检查这一步的连通块在当时的棋盘上是否合法,顺便把累计得分也算出来。这个步骤相当于给求解器加了一层约束,能堵住大部分模拟漏洞。

def replay_validator(initial_board, moves): board = [row[:] for row in initial_board] total = 0 for step, cluster in enumerate(moves, 1): # 检查 cluster 在当前棋盘上是否是一个完整连通块 legal = False for cand in find_clusters(board): # 顺序无关,只比坐标集合 if set(cand) == set(cluster): legal = True break if not legal: return False, step, total, board total += move_score(len(cluster)) board = apply_move(board, cluster) return True, len(moves), total, board

这段代码里有几个细节要留意。比较连通块时不要直接用“坐标列表相等”,因为 BFS 返回的顺序和搜索时记录的顺序可能不同,必须转成集合再比。其次,move_score要在调用apply_move之前算,因为棋盘更新后原来的连通块已经不存在了。最后,返回值里带上每一步之后的棋盘,这样定位非法步时可以直接打印那一层局面。

比回放更进一步的技巧,是给搜索加上“死局早停”。所谓死局,就是棋盘上已经没有合法的可消块,但还剩下一堆星星没消掉。在beam_search里,我可以把评估函数扩展成:遇到死局时,剩余星星越多,惩罚越重。这样一来,光束会把那些“暂时分数高但必然烂尾”的状态提前过滤掉。死局检测本身并不复杂,复用find_clusters即可:

def is_dead(board): # min_size 与搜索时的合法规则保持一致 return len(find_clusters(board, min_size=2)) == 0

把这个函数加进eval_board,比单纯调大beam_width管用得多,因为你在搜索源头就把注定失败的路径排除了。从那以后,我每次改完移动模拟或计分公式,都会强制把最近一次求解的输出保存成 JSON,再跑一遍回放器逐条核;宁可慢十秒,也懒得事后拍大腿。希望这套建模、光束搜索、回放的组合能帮你少踩几个坑。

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

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

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

立即咨询