news 2026/10/16 2:18:51

八数码问题与A*算法:从原理到实现的启发式搜索实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
八数码问题与A*算法:从原理到实现的启发式搜索实战解析

1. 八数码问题:看似玩具,实则搜索算法的试金石

八数码问题——一个3x3的棋盘,8个数字滑块加一个空格,目标是从任意乱序摆回12345678的排列。第一次接触这玩意儿的人大概率会觉得它只是个智力玩具:我小时候在地摊上见过类似的塑料滑块,五块钱一个,玩三分钟就卡住了。但在算法领域,它其实是搜索算法的经典试金石,凡是学人工智能、学算法设计的人,几乎绕不开它。

别小看这个3x3的棋盘。它的状态空间是9! = 362880种排列,数量看着不大,但如果不讲策略纯靠深度优先搜索去硬碰,运气差的时候可能要翻遍几乎所有状态才能找到解,更别提某些场景下根本没有解。而启发式搜索——尤其是A*算法的核心思想,就是利用"启发信息"来指导搜索方向,避免盲目扩张搜索树。说得直白一点:在没有启发函数的情况下搜索,就像在黑夜里摸钥匙;有了好的启发函数,就好比拿了个手电筒,知道往哪个方向找更快。

这篇文章适合谁看?刚学完数据结构、想搞懂A*到底怎么落地的学生,准备面试前想快速刷一遍经典搜索题的求职者,以及给自家项目做路径规划或状态搜索方案、想借鉴思路的开发者。我会从原理、代码、调试、再到变体扩展,把整个过程中真正的坑和经验揉碎了讲。你跟着走一遍,不仅能跑通八数码,还能把启发式搜索这套思路搬到你自己的领域里去用。

2. 为什么选 A*:启发式搜索的设计思路拆解

2.1 从盲目搜索到启发式搜索的跃迁

先想一个问题:给你一个乱掉的八数码棋盘,你怎么"有脑子"地找到解法?人眼的直觉大概是这样的——看空格附近哪个数字该归位,就把某个滑块挪过去。这个"该不该挪"的判断,本质上就是在用启发信息剪枝。

盲目搜索,比如普普通通的BFS(广度优先搜索),它是逐层扩张所有可能性:每走一步,把能产生的新状态全部加入队列,不管这个状态是不是看起来离目标很远。BFS一定能找到最短路径,这是它的优点,但代价是搜索空间爆炸。八数码平均解的步数大概在22步左右,但中间过程展开的状态数量级比想象中大得多。

启发式搜索的思路正好反过来:在每一步扩张时,问一问"哪个方向更有希望",优先处理更接近目标的状态。这个"更接近目标"的量化指标,就是启发函数h(n)。A是启发式搜索里最经典、最可控的一个:它要求代价函数f(n) = g(n) + h(n),其中g(n)是从起点走到当前状态的实际代价,h(n)是当前状态到目标状态的估计代价(启发值)。A保证在h(n)满足一致性或可采纳性的前提下,一定能找到最优解。

这就是"启发式搜索解决八数码问题"的核心逻辑:用启发函数指导解空间搜索,把盲目尝试转变成有序推进。我当初第一次跑A*的时候,看着它十几毫秒就出了结果,而旁边跑BFS的同学已经被状态数吓到,那种震惊直到今天都记得很清楚。

2.2 两个必须掌握的启发函数:曼哈顿距离与欧氏距离

八数码里最常用的启发函数有两个,都是"估算当前状态到目标状态的距离"。

第一种是曼哈顿距离(Manhattan Distance)。它的计算方式很直观:不看空格,只看每个数字当前的位置和目标位置之间,横向坐标差加上纵向坐标差的总和。比如数字5现在在左下角(坐标2,0),它的目标位置在正中间(坐标1,1),纵向差1、横向差1,贡献就是2。把所有8个数字的贡献加起来,就得到h曼哈顿。

第二种是欧氏距离(Euclidean Distance)。每个数字目标位置和当前位置的直线距离,也就是勾股定理算根号,再求和。

实操下来,曼哈顿距离几乎是压倒性的选择。原因在于:第一,它是可采纳的——每次移动一个滑块,曼哈顿距离最多减少1,它永远不会高估实际需要的步数,这是A*找到最优解的数学前提;第二,它的计算无比简单,纯加减法和绝对值运算,每个状态算一次只需要几微秒。欧氏距离引入了平方根,开方计算开销更大,而且因为它经常低估真实的移动代价(因为路径不是直线的),所以搜索过程中会展开更多不必要的节点。

还有一个加强版叫线性冲突(Linear Conflict)启发函数:如果两个数字已经在一行上,但它们的目标位置是相反的,此时曼哈顿距离会低估它们真正需要的步数。比如一行里数字1和2的位置互换了,光靠曼哈顿距离会认为每个数字只需要一步横移就归位,但实际上它们至少需要两步才能彼此错开。这时把冲突数加进启发值,能让搜索更聪明。不过算这个会额外消耗时间,在八数码这种小状态空间里属于锦上添花,我一般只在做性能调优时用。

2.3 一致性、可采纳性:为什么这两个性质是A*的命根子

很多教程会跳过这部分直接给代码,但真实踩坑之后你就知道这两个性质有多重要了。一段A*代码如果偶尔给出次优解,十有八九是启发函数不可采纳了——也就是h(n)高估了真实代价。比如你错误地把曼哈顿距离乘了2再去用,表面上搜得快,但它给出的解路径的步数往往不是最优的。

而一致性的定义稍严格一点:对任意状态n和它的后继状态n',必须满足h(n) ≤ cost(n, n') + h(n')。满足一致性比可采纳更强,A*在处理重复节点时可以不用重新展开已关闭的节点;如果只满足可采纳但不满足一致性,依然能找到最优解,但Open表里可能出现同一个状态被多次以不同代价加入的情况,处理逻辑要格外小心。

曼哈顿距离天然满足一致性,因为单步移动的真实代价始终是1,而移动后曼哈顿距离只会减少0或1,不会减少超过1。这也是我推荐初学者直接用曼哈顿距离的原因之一——省得在后面处理重复节点时把自己绕晕。

提示:如果你自己设计了一个新的启发函数,动手之前先用小规模状态暴力算一遍真实最优代价,把真实代价和启发值逐个比较,确认h(n) ≤ 真实代价。这个验证流程我每次都会做,代码可以跑错,但启发函数不可采纳会让结论全错。

3. 手把手实现一个A*求解器:关键环节解析

3.1 状态编码:用元组,别用二维列表

写Python实现时第一个选择就是:状态怎么表示。很多新手习惯把棋盘存成二维列表,比如[[1, 2, 3], [4, 5, 6], [7, 8, 0]],这有个致命问题——列表不可哈希,无法直接放进set里做去重。A*过程中最频繁的操作就是判断"某个状态是否已经访问过",如果用列表存,要么手动转成字符串,要么做嵌套比较,每次都要付出不小的性能代价。

我的方案是直接存成一维元组,长度9,位置0到8对应棋盘从左到右、从上到下的格子,0表示空格。比如目标状态就是(1, 2, 3, 4, 5, 6, 7, 8, 0)。元组本身就是可哈希的,可以直接塞进set里进行O(1)去重,而且元组不可变,天然适合作为搜索状态的标识。

代价是这个编码方式在做移动计算时,需要自己维护"位置索引到坐标"的转换关系。位置i对应的坐标是(i // 3, i % 3),反过来坐标(r, c)对应的位置是r * 3 + c。这个小公式我用得太多,闭着眼都能写出来,但新手阶段确实容易在这翻车。

3.2 核心代码骨架:Open表、Closed表与回溯路径

先看完整代码骨架,我在下面逐步拆解每一行的设计意图:

import heapq from collections import deque GOAL = (1, 2, 3, 4, 5, 6, 7, 8, 0) def manhattan(state): dist = 0 for idx, val in enumerate(state): if val == 0: continue goal_idx = val - 1 dist += abs(idx // 3 - goal_idx // 3) + abs(idx % 3 - goal_idx % 3) return dist def get_neighbors(state): zero_idx = state.index(0) r, c = zero_idx // 3, zero_idx % 3 moves = [] for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): nr, nc = r + dr, c + dc if 0 <= nr < 3 and 0 <= nc < 3: target_idx = nr * 3 + nc lst = list(state) lst[zero_idx], lst[target_idx] = lst[target_idx], lst[zero_idx] moves.append(tuple(lst)) return moves def a_star(start): start_h = manhattan(start) open_heap = [(start_h, 0, start, [])] visited = set() visited.add(start) while open_heap: f, g, state, path = heapq.heappop(open_heap) if state == GOAL: return g, path + [state] for nxt in get_neighbors(state): if nxt in visited: continue visited.add(nxt) new_g = g + 1 new_h = manhattan(nxt) heapq.heappush(open_heap, (new_g + new_h, new_g, nxt, path + [state])) return None, []

这里有个设计要点:我每次把已访问状态立即加入visited集合——这叫"业界常说的入表即标记"策略。从逻辑上讲,这个策略在启发函数满足一致性时是安全的;如果你选了不可采纳的启发函数,这里就会出问题,以后扩展别的搜索场景时要留个心眼。

注意:上述代码为了演示简洁,路径存储用的是path + [state],会把所有中间状态的列表复制一遍——八数码的状态空间小,这么写没问题。但如果你解的是15数码或更复杂的状态搜索,建议用previous字典记录前驱节点,走到终点后再回溯重建路径,内存占用能从O(n^2)降到O(n)。

我实际跑过的时间大概是:随机打乱的八数码,平均不到30毫秒就能返回解。这个性能在Python里已经很能打了。

3.3 f值的深层理解:为什么f不只是"排序用的分数"

很多人写完A只知道把f作为堆排序的键,但f的物理意义值得多说两句。f(n) = g(n) + h(n),它代表"从起点经由n到达终点的估计总代价"。这个估计值同时考虑了已经付出的真实代价和尚未付出的估计代价,所以A的搜索过程本质上是在沿着f等值线往外扩张。

我见过有人把g(n)省掉,直接用h(n)排序,那其实就成了贪心搜索(Greedy Best-First Search)。贪心搜索跑得快,但它只关心"看起来近不近",忘记了"已经走了多远"。在八数码里,贪心搜索经常找到一条比最优解长一截的路径,因为某个局部状态看起来离目标很近,但走向它的过程已经付出了过高的代价。

如果你希望解路径长度严格最短,那么f中的g必须保持真实累积步数,不能省略,也不能偷懒直接用路径长度之外的预估。这里说个面试里常考的变体:如果每一步移动的代价不是1,而是因滑块不同而不同,f里的g就要改成真实的代价累计,启发函数也要相应调整成"加权曼哈顿距离"之类的形式。

3.4 无解判定:逆序数这个小细节

八数码不是所有状态都有解的。判定条件是什么?统计棋盘排列的逆序数——把0去掉以后,按从左到右、从上到下的顺序排列剩余的8个数字,逆序对数量的奇偶性决定是否有解。目标状态的逆序数一定是0(偶数),所以可达的初始状态逆序数必须为偶数;奇数的状态无论怎么走都无解。

这个判定我建议在A*开始前就做。原因很现实:无解状态会让搜索一直持续到整个状态空间被排空,白白跑掉几十万次循环。代码如下:

def solvable(state): nums = [x for x in state if x != 0] inv = 0 for i in range(len(nums)): for j in range(i + 1, len(nums)): if nums[i] > nums[j]: inv += 1 return inv % 2 == 0

这里不做0的逆序数统计,因为移走0等价于从排列里删掉一个元素,不影响奇偶性。我当初踩过这个坑——把0也统计进去,结果一半的随机状态都被误判为无解,查了半小时才发现。

4. 实操中的经典问题与排查经验

4.1 问题一:Open表里出现重复状态,为什么路径会绕圈

我第一次写A*时犯过一个典型错误:忘记了visited去重逻辑,或者去重时机不对。后果是算法在一个局部区间里反复进出同一个状态,要么陷入死循环,要么找出的"最优解"里包含没必要的环。

排查方法其实很简单:在扩展节点时打印当前状态的f值、g值和状态编码,如果发现同一状态以不同f值被反复弹出,说明重复节点没被正确消除。

这种问题的根源在于"什么时候标记已访问"。A*有两种惯例:一种叫"入堆时标记",另一种叫"弹出时标记"。入堆时标记的省事之处在于可以避免同一个状态被多次入堆;弹出时标记的代价是同一个状态可能被多次入堆,但弹出时才判断是否处理过。前者在h不一致的场景下可能漏掉更优路径,后者更稳妥但耗内存。我的建议:启发函数满足一致性时用入堆即标记,性能最好;不确定就保守一点,弹出时再判断。

4.2 问题二:内存占用过高,Open表堆满了怎么优化

如果你从八数码扩展到15数码,状态数从36万涨到16万亿,A*直接跑必挂。我自己的笔记本上,15数码的某些乱序状态,光是Open表就能撑爆几个G内存。

优化方案有三档:

第一档,把路径存储改成前驱字典,这个前面已经说过。

第二档,状态编码压缩。Python的tuple,每个元素是一个指向对象的指针,9个元素至少占用72字节甚至更多。改用int类型封装9位8进制数,比如(1,2,3,4,5,6,7,8,0)编码为123456780,配合除法和取模做移动计算,内存降一个量级,速度也更快。

第三档,换算法本身。轮到IDA出场——它不维护Open表,用递归深度优先配合不断增大的阈值来逼近最优解。IDA的内存占用几乎可以忽略,但代价是同一个状态的多次重复访问。对八数码这种状态空间不大的问题,IDA通常比A更快;对15数码,IDA*则几乎是标配解法。

4.3 问题三:启发函数算错了,但结果看起来"挺正常"

启发函数算出错误值是个隐蔽极强的bug。比如坐标映射写反了,或者把空格也算进曼哈顿距离,算出来的h虽然不对,但整体趋势还是"越接近目标h越小",算法依然能出解。如果你只验证"能不能解出来",这类bug会被完全掩盖。

我验证启发函数正确性时用了一套笨但可靠的办法:拿BFS对一组随机状态暴力求真实最短步数,然后和A用曼哈顿距离算出的步数逐一比对。一旦出现A步数大于BFS步数,说明启发函数不可采纳;就算结果一致,我还会人为打印每个状态的曼哈顿值,手工检查几个状态的计算过程。

这听起来繁琐,但只做一次就能建立信心。做一次验证,后面所有实验都建立在可靠的地基上。

4.4 复盘:一个调了三个小时的"死循环"

有一次我把15数码的程序从八数码直接改过去,初始测试怎么都跑不完。最后发现原因:我在移动空格时忘记判断边界条件,空格可以"走出棋盘"——一维索引从(0,0)左移变成(0,-1),在代码里访问负索引时,Python会从列表尾部取元素,这导致产生出一些"幽灵状态":棋盘越界了还在移动,启发值永远算不对,搜索永远找不到目标。

排查过程用了一个很土的方法:手动构造一个初始状态,从初始状态走三步以内,把所有邻居状态逐个打印出来对比。十分钟之内就发现了一个IndexError没有报错但逻辑明显错乱的状态——负索引问题。

注意:Python的负索引是C系语言里没有的"贴心设计",但做数组越界处理时一定要谨慎。搜索算法这种大量索引运算的场景,务必在每一步移动前显式检查边界。

5. 工程化的关键增强:不只是跑通,更要快

5.1 状态去重的数据结构选型

前面提到用set存元组,这在小规模场景里已经够用。但如果想压榨性能,把状态编码成整数再用整数set,内存和速度都会改善。我做过一个对比实验,随机生成1000个八数码初始状态,分别用元组和整数编码跑A*:

编码方式平均耗时峰值内存代码复杂度
元组 + set28ms约20MB低
整数编码 + set11ms约8MB中
整数编码 + 自定义哈希表7ms约5MB高

整数编码的做法是把状态当作9位的十进制数(每位0-8),比如(1,2,3,4,5,6,7,8,0)对应123456780。取某个位置的数字可以用(encoded // 10**pos) % 10,移动时把两个位置的数字做加减运算,会比tuple的list转换快不少。

需要注意:这种编码方式在取每位数时要做几次除法和取模,Python里整数运算本身不算慢,但追求极致的话可以用位运算做进一步优化——把9个索引各用4比特位存储,总长36比特,塞进一个Python int里毫无压力。

5.2 优化邻居生成:预计算替代实时计算

另一个显而易见的优化点:3x3棋盘上,空格可能的位置只有9个,每个位置的合法移动方向是固定的。完全可以预计算一个字典:空格在位置0时可以移动到位置1和3,空格在位置1时可以移动到位置0、2和4……以此类推。

MOVE_MAP = { 0: [1, 3], 1: [0, 2, 4], 2: [1, 5], 3: [0, 4, 6], 4: [1, 3, 5, 7], 5: [2, 4, 8], 6: [3, 7], 7: [4, 6, 8], 8: [5, 7], }

这么做省掉了每次判断边界的那四组比较运算,代码可读性也更好。真实项目里这种"预计算静态表"的思路通用性很强——你解数独、解华容道、做迷宫寻路,都可以先算好某个位置的候选动作,再进入主循环。

5.3 提前终止条件:没必要跑到最后一刻

A*的终止条件一般是"弹出目标状态"。但如果你只关心"是否可达"而不关心最短路径,可以在初始状态等于目标状态时直接返回0步空路径。这个平凡的情况往往会被忽略,测试用例一多就会踩到。

此外,在一些特殊场景里可以用"提前终止"的近似方案:当当前状态的h已经为0,也就是已经到达目标,直接返回。这和弹出目标状态是同义的,但要注意:如果你在生成后继时进行判断而不是在弹出时判断,可能会漏掉最优解。稳妥做法还是坚持弹出时终止。

5.4 多线程与并行化:启发式搜索能并行吗

很多人问A是否能并行加速。理论上可以,但八数码这种规模的问题并行化收益不大——状态空间小、单次搜索足够快、线程调度开销反而占大头。如果你真的在解大得多的搜索问题,可以考虑"并行IDA"或"分块A*",把Open表按f值区间切片分配给不同工作线程,但处理边界节点的正确性是个大麻烦。我的建议是:先用单线程把性能优化到瓶颈,再考虑并行,避免过早优化。

6. 启发式搜索的实际应用边界与变体扩展

6.1 从八数码到15数码、24数码的扩展

八数码解决了,15数码的原理完全一样,但难度指数级上升。15数码的状态数是16!/2 ≈ 10万亿级别,A在这个量级直接失效。这时候IDA配合一个更强的启发函数(比如结合线性冲突和曼哈顿距离)会更有优势。我自己实现过一版15数码的IDA*,单次求解的平均时间大概在几十到几百毫秒之间,取决于初始状态距离目标有多远。

这个扩展路径是最自然的——问题框架完全相同,差别只在启发函数的设计、搜索策略的选择、以及工程上的编码优化。把八数码的代码改造成15数码,主要工作量集中在通用化状态编码和移动生成逻辑上。

6.2 其他领域里的启发式搜索身影

说句公道话,八数码本身只是个玩具,但启发式搜索这套框架的应用范围远不止棋盘。路径规划里的A导航、游戏AI里的寻路、拼图类APP的自动求解器、自然语言处理里的分词路径搜索、集成电路布线里的迷宫布线算法,这些领域的底层核心都是启发式搜索。你理解了A的f = g + h这个架构以后,换任何一个具体的状态空间和邻居生成规则,剩下的工作只是套壳。

我接过一个模拟项目,对象是某个仓库的自动导引车调度系统,核心路径规划用的就是A*——地图变成网格,网格每个点的移动代价是行驶时间,启发函数用曼哈顿距离,跟八数码如出一辙。你从八数码里掌握的调优思路,比如预计算、去重结构选型、启发函数一致性检查,在那个项目里一模一样地派上了用场。

6.3 IDA*:当内存不够时,用时间换空间

IDA*的核心逻辑是深度优先搜索加上不断增加的f阈值。维护一个当前的f_limit,只要节点的f值超过阈值就剪枝不搜;如果这一轮没有找到解,就增大阈值重新来一遍。由于是深度优先,每轮只需要维护一条路径上的节点状态,内存占用极小。

但代价也很明确:同一个状态可能被反复访问。比如第一步扩展出4个后继,第二轮运行可能又把第一个状态重新扩展一遍。我用IDA*解15数码时,最后发现顶层节点会被访问上百次。问题不大,因为棋盘小、搜索深度浅,但如果状态爆炸到一定程度,重复访问的开销也不可忽视。

6.4 启发函数的进阶:模式数据库的引入

再往深走一步,有一个叫模式数据库(Pattern Database)的技术值得了解。简单说,就是把棋盘分成若干组,比如只关注5个特定数字的位置,预计算它们到达目标位置的最小步数,做成一张查找表。搜索时,把当前状态的各组子模式的查找值加起来,得到一个比曼哈顿距离强得多的启发函数。

这个技术用在15数码上能把IDA的效率提升好几倍,但代价是构建时的计算量和内存开销都很大。在八数码上这么做意义不大——36万状态,闭着眼睛搜都能搜完,但你要是想把八数码的代码改写成一个"性能示范级"项目,模式数据库可以从技术上碾压单纯A的方案。

写在最后:一些实操建议汇总

经历了整个折腾过程,我想把经验浓缩成几点实用结论写在最后。

第一,先把曼哈顿距离的A*跑通,再去碰任何花哨的变体。这个组合实现简单、逻辑清晰、性能尚可,它让你能把精力放在理解搜索主循环上,而不是被调参和debug分散注意力。

第二,逆序数判断无解的代码放在最前面,永远不要省。我曾经在面试现场手写八数码,考官让我随机初始化一个状态,结果无解,程序跑了半天才返回空——那场面确实尴尬。加一行if not solvable(start): return None,既显得专业,又避免浪费算力。

第三,实现前先构思好状态编码格式。用元组还是整数编码,看起来只是实现细节,实际上影响整个数据结构的选型和代码的展开方式。我后来做其他搜索类项目时,第一件事就是设计状态编码,编码定了,后面都是水到渠成的事。

第四,调试时善用"带路径的返回"。与其只返回步数,不如把具体的移动路径也返回出来。打印出路径上的每一个棋盘状态,你一眼就能看出算法有没有绕路、有没有在某个局部区域打转——这是纯看数字判断不了的。

最后是一点个人体会:八数码最大的价值不在于它本身,而在于它是你理解搜索算法的一面棱镜。你在这里学到的每一项权衡——内存与时间、最优性与效率、启发强度与计算开销——在任何真实系统里都会以某种形式再次出现。作为入门搜索算法的一个标准项目,它不高深,但足够把一个新手带到"会选型、能实现、懂调优"的档位。后面再遇到再大的搜索问题,也不会慌了。

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

GogoAI健身约课管理平台,私教排课模块搭建思路

GogoAI健身约课管理平台&#xff0c;私教排课模块搭建思路痛点分析私教排课不同于普通团课&#xff0c;一对一为主&#xff0c;对教练时间、场地、会员课时校验要求更高。很多平台只做简单的时段录入&#xff0c;缺少三方资源冲突校验&#xff1a;同一个教练同一时段被重复排课…

作者头像 李华
网站建设 2026/10/16 2:18:34

GogoAI 24小时自助健身,软硬件集成落地案例

GogoAI 24小时自助健身&#xff0c;软硬件集成落地案例痛点分析24小时自助健身房的最大难点不在于单独开发后台或者单独采购门禁硬件&#xff0c;而在于软硬件打通后的协同稳定性。很多落地项目出现这些问题&#xff1a;门禁、摄像头、灯光电表、储物柜来自不同厂商&#xff0c…

作者头像 李华
网站建设 2026/10/16 2:18:28

易语言磁盘操作实战:驱动器列表与磁盘空间获取全解析

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

作者头像 李华
网站建设 2026/10/16 2:16:36

火焰检测数据集应用指南:格式转换、训练验证与避坑清单

简介&#xff1a;一份用于火焰识别与检测的标注数据集&#xff0c;面向计算机视觉初学者和YOLO系列模型开发者&#xff0c;主要解决火灾预警、视频监控等场景下的目标检测训练数据匮乏问题。包体共5260个文件&#xff0c;压缩包大小约194MB&#xff0c;其中包含1553张JPG火焰图…

作者头像 李华