ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

消灭星星自动求解:搜索剪枝与工程优化实战

消灭星星自动求解:搜索剪枝与工程优化实战 简介一份基于Qt实现的“消灭星星”自动求解程序面向游戏AI学习者和中级Qt开发者提供完整可运行的图形界面与自动决策引擎。程序采用蒙特卡洛单步最优策略通过模拟大量随机消除组合并评估预期得分指导每一步落子实测平均得分稳定在5500以上。压缩包共60个文件其中C源码.h/.cpp完整覆盖界面、线程求解与核心算法dll为Qt运行依赖qm为语言翻译文件jpg/png为界面素材整体仅12.13MB轻量但结构清晰。已有598人学习下载。除可直接运行的exe外资源还包含线程池求解、剪枝优化、Qt事件封装等实践案例有助于理解蒙特卡洛采样与GUI集成的具体写法也方便在此基础上扩展至其他消除类游戏。消灭星星自动求解从“玩到剩残局”到“让程序替我算完所有可能”先交代背景。消灭星星这游戏规则一句话就能说清点击至少两个同色相邻的星星把整个连通块消掉消完后上方的星星下落、空列向左靠拢继续消直到棋盘清空或无处可消。看起来是个休闲游戏但玩过的人都知道后半程棋盘越碎越难经常剩一堆单颗星星卡死。我当初写这个自动求解器起因就是某天玩到第47步明明还剩30多颗星盘面却碎得完全没法收气得直接关掉游戏转头打开编辑器打算让电脑把所有可能走法全算一遍。这一写就是两周。最后做出来的求解器能在单线程下每秒搜索几十万节点普通盘面在5到10秒内给出接近最优的高分走法清空率也能稳定压过手动。这篇文章把这套方案的思路、框架、剪枝细节和踩过的坑完整写一遍适合想给自己的小游戏加“自动解”功能、或者想练搜索剪枝代码的人参考。1. 为什么“消灭星星”能被算死而不是靠运气很多人的第一反应是这游戏不是随机生成盘面吗怎么可能求解对初始盘面是随机的但关键在于游戏开始之后棋盘就变成一个完全确定性的系统。你点击什么位置、消掉哪个连通块后续的塌缩过程完全由当前盘面决定没有任何新星星掉落也没有随机干扰。这跟三消类游戏有本质区别——三消每次消除后会随机补充新方块局面永远不可能被完整枚举而消灭星星的搜索结果是一个有限深度、有限分支的决策树理论上最坏也只需要把所有合法走法都走一遍就能得到最优解。当然理论可行不等于工程可行。10乘10的棋盘、5种颜色初始连通块数量通常在20到35个左右每一步的分支数随着盘面变化可能从几个到二十几个不等。搜索树的深度上限是100步如果完全不剪枝节点数量会膨胀到一个连超级计算机都扛不住的天文数字。所以这里真正的问题不是“能不能搜索”而是“怎么在可接受的时间内搜索得足够深、足够全”。这也是这类自动求解项目的通用套路确定性规则的系统就一定有办法建搜索模型搜索模型爆炸就用启发式剪枝来压。下面整个求解器的设计都是围绕这两句话展开的。2. 求解器的主流程找连通块、消除、塌缩、回溯我先说整体框架。求解器的核心是一个深度优先搜索DFS每个搜索节点对应一个棋盘状态。在某个状态上要做的事情只有四件找出所有可消除的连通块按某种顺序尝试消除每个连通块计算消除后的得分然后进入下一个状态继续递归。等棋盘无法再消除时记录本轮总分回溯返回。为了让这个框架能跑起来有三个基础函数必须先写对分别是查找连通块、执行消除、执行塌缩。查找连通块我用的是显式栈做的Flood Fill没有用递归。原因在后面“工程优化”一节会说这里先看代码。def find_blocks(board): n len(board) visited [[False] * n for _ in range(n)] blocks [] for i in range(n): for j in range(n): if board[i][j] 0 or visited[i][j]: continue color board[i][j] stack [(i, j)] visited[i][j] True cells [] while stack: x, y stack.pop() cells.append((x, y)) for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)): nx, ny x dx, y dy if 0 nx n and 0 ny n and not visited[nx][ny] and board[nx][ny] color: visited[nx][ny] True stack.append((nx, ny)) if len(cells) 2: blocks.append(cells) return blocks这里有一个小细节值得注意连通块的判定只看上下左右四个方向斜对角不算相邻。这也是消灭星星的官方规则写错的话后续塌缩结果和真实游戏会对不上。消除和塌缩是两个独立的步骤。消除就是把块内所有格子置零塌缩分两段先让每列内部下落再把空列整体向左平移。def collapse(board): n len(board) # 第一步列内下落 for col in range(n): rows [board[r][col] for r in range(n) if board[r][col] ! 0] for r in range(n - 1, -1, -1): board[r][col] rows.pop() if rows else 0 # 第二步空列左移 non_empty_cols [] for col in range(n): col_vals [board[r][col] for r in range(n)] if any(col_vals): non_empty_cols.append(col_vals) for col in range(n): if col len(non_empty_cols): for r in range(n): board[r][col] non_empty_cols[col][r] else: for r in range(n): board[r][col] 0塌缩顺序不能搞错。实际游戏里的表现是先做重力下落再做水平靠拢如果你把顺序写反了搜索结果虽然也能“自洽”但每一步的盘面都和真实游戏不一致最终算出来的步骤完全没法用在真实游戏上。这个我在测试阶段踩过坑后面单开一节讲。主搜索的骨架大致是这样的def dfs(board, score, best, history): key encode(board) if history.get(key, -1) score: return history[key] score blocks find_blocks(board) if not blocks: best[0] max(best[0], score) return if score optimistic(board) best[0]: return blocks order_blocks(blocks, board) for cells in blocks[:MAX_BRANCH]: nb [row[:] for row in board] gain len(cells) * (len(cells) - 1) for r, c in cells: nb[r][c] 0 collapse(nb) dfs(nb, score gain, best, history)整体思路不复杂复杂的是其中两个剪枝条件和一个排序函数怎么写这决定了解题速度和最终分数的上限。下面一节逐个拆开讲。3. 三个关键剪枝历史表、乐观上界、分支排序先说历史表。搜索过程中会反复到达同一个棋盘状态原因是消除顺序不同但最终盘面相同。最典型的例子相邻两个同色连通块先消左边再消右边和先消右边再消左边结果可能完全一样。如果不加处理这些重复状态会被当成新分支反复搜索。历史表的思想是用一个字典记录每个状态当前到达时的最高分数如果再次到达这个状态时分数没有更高就直接剪掉。等等这里有个坑。历史表剪枝有一种看起来正确其实会错杀最优解的写法。我当时犯过的错是history[key] score就直接剪枝。咋一看没问题但实际上一旦允许剪枝你会丢掉“同状态但前面路径得分更低剩余路径却更长”的情况。在消灭星星里最终得分由每一步消除得分累加而每一步得分只取决于当前消掉的块大小跟之前怎么走过来的分数高低没有直接依赖。所以理论上两个相同状态到达分高的那一个未来的潜力不一定更大。为了不牺牲正确性我最后用的是上面代码里的history.get(key, -1) score——只有低于等于历史记录才剪完全相等都放行。这个选择让搜索节点数会略微变多但能保住答案质量值得。再说乐观上界。这是整棵搜索树能压住分支数的核心所在。思路也很简单当前分数加上未来“理论上限”都不超过已经找到的最好分数那就直接放弃这条路径。关键在上界怎么算。太紧的上界会错误地剪掉最优路径太松的上界又起不了作用。我试过几种方案最粗糙的版本剩余星星总数。假设剩余星星一次性全部消掉可得S * (S - 1)分S是剩余星星数。这个上界极其宽松剪枝效果聊胜于无。按颜色的上界剩余某色星星全部聚在一起一次消掉加上其他颜色也同样处理。这比总数版本紧一些但还是偏乐观。实际使用版本先用估算函数算一个“较可能达到的高分”再在上界基础上打一个折扣系数比如int(optimistic * 0.85)折扣系数通过离线跑大量棋局调整。折扣系数这个做法在竞赛搜索里常叫“软上界”它不是严格数学上界但工程效果比严格上界好得多因为严格上界太松等于没有。具体系数我调了很多盘最后定在0.85到0.9之间不同难度棋盘会稍微调整。最后是分支排序。DFS搜索的质量高度依赖“先找到好解”的速度——先找到高分答案乐观上界剪枝才有精确的参照物可以剪。所以每次做分支尝试之前要先对所有候选连通块按启发式排序。启发式计算我放在下一节细说这里只说排序对剪枝的影响好的排序能把搜索时间压缩一个数量级以上代价只是每次多算一个排序这笔交易非常划算。还有一个细节是限制分支数量。如果当前盘面可消除的连通块特别多比如20个以上我不会傻傻地全试而是取排序后的前MAX_BRANCH个代码里通常取8到12。这个限制会损失理论最优性但换来的是运行时间的稳定可控。如果你需要“绝对最优解”把它调大或去掉就行代价是等待时间可能指数上升。4. 估值函数写得好不好决定求解器“聪明不聪明”前面提到对候选块排序的启发式这个启发式的质量就是求解器水平的直接体现。好的启发式应该让高分好走的分支排前面糟糕的启发式会让搜索变成“逐个碰运气”。我最初想得很简单以为按连通块大小从大到小排就行结果发现大块虽然积分高但有时消掉一个大块反而把其他大块拆散了整体局面越走越碎反而先消一个中等块能让两个同色大块合并成巨型块后面的得分更高。所以光看块大小是远远不够的。我最后用的评分函数长这个样子def heuristic(block, board): n len(board) size len(block) color board[block[0][0]][block[0][1]] color_remaining sum(1 for r in range(n) for c in range(n) if board[r][c] color) center_dist 0 for r, c in block: center_dist abs(r - 4.5) abs(c - 4.5) center_dist center_dist / size return size * size 0.3 * color_remaining - 0.5 * center_dist这个函数里其实藏着三个维度的信息第一项size * size衡量的是本次消除的直接收益。消灭星星的得分是n*(n-1)是一个二次曲线所以块越大单步收益增长越夸张把size的平方放进去可以拉开大块和小块的差距。第二项color_remaining是当前颜色在全局的剩余数量。这个项的作用是催着算法优先处理“存量还很多”的颜色避免某个颜色被拆得七零八落最后全剩单个。这个思路来自一个朴素观察如果某种颜色最后分散成多个单颗那基本就等于废了。第三项center_dist是块里所有格子的平均曼哈顿距离用来惩罚那些远离中心的边角块。游戏塌缩机制决定了边角的块消掉之后局面变化往往不如中心块剧烈所以边角块优先级通常要低一些。这三个项之间的权重系数也就是0.3和0.5是通过大量对局调出来的不是拍脑袋定的。我调参的方式很简单先跑50盘初始棋局用“只按块大小排序”的版本当基准分然后调整系数看新的评分能否在同样的搜索深度下跑出更高的平均分。试了十几组系数之后上面这个组合最稳。值得说明的是估值函数不影响最终解的理论正确性它只影响搜索效率。即使是最粗糙的“按大小排序”只要不限制分支DFS搜到底还是能找到最优解只是时间慢到不可接受。换句话说估值函数是给搜索当“导航”的不是代替搜索的。5. 从每秒几千节点到几十万节点工程优化实测框架能跑之后接下来就是性能调优。纯Python版本的初始实现搜索速度大概每秒只能处理三千到五千个节点一个普通局面跑满全部分支要几分钟到十几分钟根本没法用。这一节记录几个实际有效的优化按收益从高到低排列。第一个优化是把查找连通块里的扫描顺序改掉。不要在每次find_blocks里都从棋盘的(0,0)开始逐格扫描而是优先从上一轮消除位置附近开始。这个改动基于一个观察消除一个块只会影响棋盘的一部分区域其他区域的连通块不会凭空改变。实测能减少大约三成的重复扫描计算量。当然这个实现起来要额外维护“脏区域”坐标集合代码会复杂一些但收益对得起复杂度。第二个优化是把棋盘编码成整数来当历史表的键。一开始我用元组套元组也就是把每行转成元组再组合速度慢且内存占用高。后来改成将每个格子的颜色用3个bit表示5种颜色最多只需3bit100个格子共300bit用一个Python整数直接当字典键这样编码后既可以用作历史表的key也可以通过位运算直接比较状态。这个改动让字典查找和存储速度至少提升三四倍。第三个优化是上面说的递归转显式栈。Python默认递归深度限制是1000而消灭星星一局搜索深度可能接近100层再加上递归函数内部的调用栈理论上不会爆但每次递归调用都有很大的函数调用开销。我在重构时把递归深度优先搜索改成显式栈模拟核心逻辑不变但每秒节点数从几千涨到了两三万。这是一个纯工程层面的优化不需要改算法结构。第四个优化是多线程并行。DFS本身是深度优先天然不太适合并行但因为搜索的第一步分支数量足够多可以把第一层的每个候选块分给不同线程去搜最后汇总最优结果。我用了四个线程实测在四核机器上能获得2.5到3倍左右的加速。为什么不是四倍因为不同分支的搜索时间差异很大某几个分支很快结束了另外几个分支还在深挖线程之间负载不均衡这是并行搜索的老问题。优化后的实测数据大概是这样配置每秒搜索节点数单局平均耗时中等难度盘面平均得分纯Python嵌套元组做状态键约40008到15分钟偏低整数编码状态键约120003到6分钟接近最优整数编码显式栈脏区域扫描约5000030到60秒接近最优加上四线程并行和软上界剪枝约20万5到10秒接近最优这个表格是从我自己的机型上测出来的硬件是几年前的主流笔记本绝对数字参考价值有限但优化比例和复杂度变化的趋势是有普适性的。如果你的目标是做一个实时的自动点击工具最后一行的性能基本够用。6. 踩坑记录塌缩顺序、历史表误剪、追高分烂尾自动求解写到能跑只是第一步。真正让求解器从“能用”到“可信”靠的是处理各种反直觉的坑。这里挑三个最典型的记录一下每个都是我实际调试过程中花掉大量时间才定位的。第一是塌缩顺序。我一直以为游戏是先水平靠拢再重力下落因为视觉上给人的感觉是列先合并再掉下去。直到第一次把结果接入模拟器自动点击才发现测出来的棋盘和真实游戏差了十万八千里。后来逐帧录像对比确认是先列内下落、再列间左移。这个对求解正确性来说是致命细节步数越靠后误差积累越严重。第二是历史表的误剪。前面提到过用剪枝会错杀最优解。我最初就是这么写的结果发现某个简单的盘面求解器给出的分数反而不如手动玩的高仔细排查后发现就是历史表把某些同状态但“当前分略低、未来潜力更大”的分支提前剪掉了。解决办法见第三节这里不再重复。这个坑很隐蔽因为大部分时候你发现不了只有做结果校验时才会露馅。第三是估值函数引发了“追高分烂尾”现象。这是我调参过程中遇到的最有趣的问题某些参数配比下求解器特别喜欢先把大块都消掉每步得分都很高但走到后半程因为剩余的小块颜色过于分散最后留下大量单颗卡死总得分反而很低。反过来适当调整权重让算法“忍一忍”不要见大块就消反而能促成跨区域的同色块合并最终得分高出很多。这也解释了为什么纯粹的贪心策略在消灭星星里行不通——只看眼前一步永远没法达成最优解。这个项目做到最后最深的体会是所谓自动求解并不是让程序拥有什么超人智力它只是在搜索空间里比较有耐心地多走了几步。人受限于精力和速度只能看两三步程序配上升序剪枝可以稳定地看完整条路径再决定第一步怎么走。而工程上真正值钱的部分恰恰是那些让“看完整条路径”这件事在有限时间内变得可能的小技巧——压缩状态表示、剪掉重复分支、设计一个还算聪明的搜索方向。以后你再看到有人玩这类游戏大概也能想到你看到的是消除动画我看到的是一棵每一步都在被剪枝的搜索树。本文还有配套的精品资源点击获取
返回列表