【语法算法】
上个月一场爆栈事故差点把新写的副本逻辑送走:千层迷宫的探索用了递归版的深度优先,层数一深,调用栈直接爆了,整个脚本环境当场挂起——挂得毫无征兆,日志停在第一百多层。复盘的结论很干脆:递归版写法漂亮但深度不可控,改显式栈的迭代版——栈自己管,多深都撑得住。这篇把整改后的整套写法拆开:配置表、标记访问、四向试探、回溯出栈、定时器、调试按钮,一段一段照抄能跑。
一、效果演示:标记回溯的迷宫探索
演示场一张十五乘九的迷宫。点「单步」:橙色的探索者往前走一格——走过的格子染上淡淡的绿,这是"来过"的标记;走进死胡同,探索者原地退一步——回溯,格子染灰,栈退一格;退到岔口再走没走过的方向。一路试探回溯,直到撞见右下角的出口——整条从起点到出口的栈染成金色,那条金路就是答案,弯弯曲曲也是路。点「重生成」换一张新迷宫再走。演示里试两笔账:金路的长度和它拐的弯数——深度优先找的是"一条能走的路",不是"最短的路",认准这一点再用它。深度优先教的是回溯:走不通就退,退到有岔口的地方再试——退不是认输,是把没走的路记在账上。
flowchart TD
A[栈顶出当前格] --> B{四向有没走过的格}
B -- 有 --> C[标记入栈 前进一格]
C --> D{是出口}
D -- 是 --> E[栈即通路 结束]
D -- 否 --> A
B -- 无 --> F[出栈回溯一格]
F --> A
fx-dfsmaze
二、底层原理:一次标记前进加一次出栈回溯
模块的机关是一对搭档。标记前进:每到一个格子先盖"来过"的章,再从四个方向里挑没去过的走——走一步入一层栈,栈里存的就是脚下这条路。出栈回溯:四向全是死路的那一拍出栈退一格——退回岔口换一条没走过的方向接着探,格子上的灰印就是回溯的尸检报告——灰印越多的迷宫,死胡同越多。和递归版的分野在"栈自己管深度":递归版的栈是调用栈,深度超限就爆;显式栈是自己建的表,一百层一千层都只是表长——那次爆栈事故换来的就是这一版。
栈为什么恰好就是通路?栈里每一层都是"从起点走到这里的一步"——回溯只出栈不改栈里剩下的层,探到出口那一刻,从栈底到栈顶读出来就是一条完整的路。这不是巧合,是深度优先的定义本身——栈有多深,路就有多长,栈空了,路就断在半途。
三、核心代码:完整模块(上·骨架)
-- @file DfsMaze.lua
-- 深度优先 —— 标记回溯的迷宫探索
local DfsMaze = {}
local CONST = {
GRID_W = 15,
GRID_H = 9,
STEP_MAX = 400,
AUTOINC_BASE = 1144000,
}
local _walls = {}
local _visited = {}
local _stack = {}
local _done = false
local _steps = 0
local _exitC = 0
local _exitR = 0
local function KeyOf(c, r)
return c .. "," .. r
end
local function ShowTip(msg)
if msg and msg ~= "" then SL:ShowSystemTips(msg) end
end
function DfsMaze.SetMaze(wallTable, exitC, exitR)
_walls = wallTable or {}
_exitC = exitC
_exitR = exitR
end
function DfsMaze.Reset(startC, startR)
_visited = {}
_stack = {}
_done = false
_steps = 0
_visited[KeyOf(startC, startR)] = true
_stack[#_stack + 1] = { startC, startR }
end
四、核心代码:完整模块(下·试探回溯与栈即通路)
-- 试探回溯:单步推进,撞墙出栈
local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
function DfsMaze.Step()
if _done or _steps >= CONST.STEP_MAX then
return false
end
local cur = _stack[#_stack]
if not cur then
_done = true
ShowTip("走投无路——回溯到头也没出口")
return false
end
local c, r = cur[1], cur[2]
if c == _exitC and r == _exitR then
_done = true
ShowTip("到出口了——栈就是走通的路")
return true
end
_steps = _steps + 1
for _, dir in ipairs(DIRS) do
local nc = c + dir[1]
local nr = r + dir[2]
if nc >= 0 and nr >= 0
and nc < CONST.GRID_W and nr < CONST.GRID_H
and not _walls[KeyOf(nc, nr)]
and not _visited[KeyOf(nc, nr)] then
_visited[KeyOf(nc, nr)] = true
_stack[#_stack + 1] = { nc, nr }
return true
end
end
_stack[#_stack] = nil
return true
end
function DfsMaze.Path()
return _stack
end
SL:ScheduleOnce(function()
SL:BindDebugButton("走一步", function()
DfsMaze.Step()
end)
ShowTip("技能已加载: 深度优先 (调试按钮触发)")
end, 1.0)
function DfsMaze.Unload()
_walls = {}
_visited = {}
_stack = {}
_done = false
end
return DfsMaze
五、机制问答
问:栈版为什么不会爆栈?
答:栈是自己建的表——递归版的深度压在调用栈上,超限即挂;显式栈只是表长,千层迷宫也只是千个元素。
问:栈里的内容为什么恰好是通路?
答:回溯只出栈不重排——留下的每一层都是起点到脚下的必经步,探到出口从栈底读到栈顶就是答案。
问:灰色的回溯格还能再走吗?
答:能——回溯格盖着"来过"的章,不重复试探,但换一条路经过它时照样踩过去记进栈。
问:深度优先找到的是最短路吗?
答:不是——它找的是"第一条走通的路",要最短得换广度优先,两个朋友的分工从来不同。
问:最坏要走多少步?
答:格数封顶——每格至多进栈出栈一回,步数封顶的保险再兜一层,最坏也就是全迷宫刷一遍色。
六、调参与实战怎么用
第四笔是爆栈的教训:千层递归改显式栈是那场事故的整改——递归写法留着看,上线的版本一律用栈版,这条是组里的军规。第三笔是邻居的次序:四向的试探次序决定路的形状——先右后下找出的路贴边走,先下后右找出的路贴底走,次序一变路就变,但没有对错。第二笔是步数的刹车:四百步封顶是死循环的闸——标记漏打的迷宫能让探索者永远打转,刹车让最坏情况也只是提前收摊。第一笔是标记的口径:来过就盖章不看出栈与否——回溯过的格子保留绿印,这是防止转圈的根,也是灰印能留作尸检报告的前提。常见坑三个:递归版深层的爆栈事故;标记漏打导致原地转圈;出口判定漏了起点即终点的情况。
实战里深度优先不止走迷宫:任务的依赖试探、表格的连通检查、界面的焦点链遍历,全是"一条道走到黑再回头"的同一套骨架。组里的约定是:探路类逻辑先问深度上不封顶吗——上不封顶就显式栈,封了顶才配递归。这篇的模块照抄能跑,改的就是迷宫的墙表和出口。
写完留一句给做探索系的同学:深度优先卖的是"一条道走到黑的底气"——那一格格染绿又染灰的试探和最后一条金色的栈路,是把"走不通就回头"做成账本的一根探条。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
【语法算法】 上一版的炮台索敌逻辑有个隐蔽报错:炮台永远打同一个怪——即使那只怪已经死了,索敌的游标也不挪窝。追到底索敌的游…
【语法算法】 先抛一个坑:打不完的火系怪怎么办?火抗怪火打不动,换冰系技能要切装备要换面板——切完黄花菜都凉了。元素转换的答…
【语法算法】 上个月的事故复盘会上有个数字被念了三遍:四成——策划写的是"同伴陪疼四成",代码落下去成了"陪疼四十点",两只…
【语法算法】 单行代码拆解:弹射初速=-420——弹射的全部动力就这一行的负初速。负号朝上、四百二十是弹射的初速大小——踩上…
【语法算法】 上一版的减速类模块全按"乘以零点五"来写,帧率无关的版本照搬了这套写法——结果高帧率机上减速效果好,低帧率机上…
【语法算法】 先抛一个坑:怎么把散在四处的怪聚到一起打?逐个拉是笨办法,一个范围技又只能打一片——引力球的答案是一个会动的吸…