【语法算法】
local p = math.floor((i - 1) / 2)——先拆这一行。堆的第 i 个成员,它的父亲就在第 i 除以二再取整的位置——一行取整算出亲缘,数组就这样当树使。最小堆的全部机关就这一行加两条规矩:父永远小于子,出账永远从堆顶走。这篇把最小堆整套写法拆开:配置表、入堆上浮、出堆下沉、看顶、定时器、调试按钮,一段一段照抄能跑。
一、效果演示:上浮下沉的完全二叉树
演示场左半是树、右半是数组——同一份账的两种长相,树给人看亲缘,数组给人看存储,换位的时候两边一起动。点「入堆」:一个随机数从末位进堆,一步步跟自己的父亲比:比父亲小就上浮一格,浮到父亲不再比它大为止,绿光标就是它此刻的位置。点「出堆」:堆顶的最小值离场,末位的数补到顶上,再一路跟子换位沉到底。树在换位,数组在同步换位——两种长相一个账本。演示里试两笔账:连入五个数再连出五次,出堆的次序永远从小到大——堆不管你进多乱,出的时候一准有序,这就是它吃这碗饭的本钱。最小堆教的是规矩:账乱进,账平出——进的时候不挑,出的时候有序,这份从容全靠那两条家规撑着。
flowchart TD
A[新数从末位入堆] --> B{比父亲小}
B -- 是 --> C[与父亲换位 继续上浮]
B -- 否 --> D[停 浮到位]
C --> B
E[出堆 堆顶离场] --> F[末位补到顶]
F --> G{比小的子大}
G -- 是 --> H[与较小的子换位 继续下沉]
G -- 否 --> I[停 沉到位]
H --> G
fx-minheap
二、底层原理:一次上浮加一次下沉
模块的机关是一对搭档。入堆上浮:新数从末位进账,一路跟父亲比——比父亲小就换位继续浮,浮到顶或者父亲不再比自己大为止,上浮保住"父小于子"的家规——家规不破,堆顶永远是全场最小,这就是家规的回报,也是这个数据结构全部的骄傲。出堆下沉:堆顶的最小值离场,账不能空——末位的数补到顶,再一路跟自己较小的子换位沉到底,下沉同样保家规。和无序数组分野在"堆顶永远是最小":无序数组取最小要扫全表,堆取最小只看顶——进账出账频繁、每次只要最值的玩法,堆就是为它长的——每次只要最值的账,排序是杀鸡用牛刀还费柴火。
为什么出堆要末位补顶?顶空了堆就散了架——末位补顶保住了完全二叉的形状,形状一散,下标算亲缘的账就全乱了,形状在,下标算亲缘的那一行才成立;补顶后的下沉是给新顶补课,两步合起来才是一次数的交接——交接不清,家规就破在一处。
三、核心代码:完整模块(上·骨架)
-- @file MinHeap.lua
-- 最小堆 —— 上浮下沉的完全二叉树
local MinHeap = {}
local CONST = {
HEAP_CAP = 64,
AUTOINC_BASE = 1143000,
}
local _heap = {}
local _count = 0
local _compare = nil
local function ShowTip(msg)
if msg and msg ~= "" then SL:ShowSystemTips(msg) end
end
local function ParentOf(i)
return math.floor((i - 1) / 2)
end
local function LeftOf(i)
return 2 * i + 1
end
local function RightOf(i)
return 2 * i + 2
end
local function Less(a, b)
if _compare then
return _compare(_heap[a], _heap[b])
end
return _heap[a] < _heap[b]
end
function MinHeap.Count()
return _count
end
function MinHeap.Peek()
return _heap[0]
end
四、核心代码:完整模块(下·上浮与下沉)
-- 上浮:新数从末位一路跟父亲比
local function Swim(i)
while i > 0 do
local p = ParentOf(i)
if Less(i, p) then
_heap[i], _heap[p] = _heap[p], _heap[i]
i = p
else
break
end
end
end
-- 下沉:补顶的数一路跟较小的子比
local function Sink(i)
while true do
local l = LeftOf(i)
local r = RightOf(i)
local m = i
if l < _count and Less(l, m) then m = l end
if r < _count and Less(r, m) then m = r end
if m == i then break end
_heap[i], _heap[m] = _heap[m], _heap[i]
i = m
end
end
function MinHeap.Push(value)
if _count >= CONST.HEAP_CAP then
ShowTip("堆满了——出几个再入")
return false
end
_heap[_count] = value
Swim(_count)
_count = _count + 1
return true
end
function MinHeap.Pop()
if _count == 0 then return nil end
local top = _heap[0]
_count = _count - 1
_heap[0] = _heap[_count]
_heap[_count] = nil
Sink(0)
return top
end
function MinHeap.BindCompare(func)
_compare = func
end
SL:ScheduleOnce(function()
SL:BindDebugButton("演示入堆", function()
MinHeap.Push(math.random(10, 99))
end)
SL:BindDebugButton("演示出堆", function()
MinHeap.Pop()
end)
ShowTip("技能已加载: 最小堆 (调试按钮触发)")
end, 1.0)
function MinHeap.Unload()
_heap = {}
_count = 0
_compare = nil
end
return MinHeap
五、机制问答
问:为什么数组能当树使?
答:下标算亲缘——第 i 个的父亲是 i 除二取整,左右子是二 i 加一减一,形状是完全二叉,下标永远算得回亲缘。
问:上浮什么时候停?
答:两道停——浮到根没有父亲可比,或者父亲不再比自己大,家规没破就到家了。
问:出堆为什么末位补顶而不是挪整棵子树?
答:保形状——末位补顶只动一条路径,挪子树要动半棵树,补顶下沉是最省的交接。
问:堆和直接排序的区别是什么?
答:动态与静态——排序管一批一次性排好,堆管进进出出随时要最值,账本活着就用堆。
问:堆里的次序稳定吗?
答:不稳定——同值的两个数谁在上谁在下不看先后,在乎先后就往成员里塞一个自增的次序号再比。
六、调参与实战怎么用
第四笔是比较器的挂载:默认比数值、挂了比较器比任意字段——大顶堆就是换一行比较器的事,一套上浮下沉通吃大小顶。第三笔是容量的封顶:六十四格的顶是内存的闸——堆满了入堆返回假不硬塞,调用方自己决定挤出还是丢弃。第二笔是下沉的选子:跟较小的子换位是下沉的铁律——跟大子换位家规当场就破,这一行写错堆就不是堆。第一笔是取顶的零耗:看顶不出账是频繁查最值的福利——出堆再入堆是浪费,看一眼的事就走顶上一格。常见坑三个:下选举子时漏了右子导致家规单边破;容量闸只拦入堆不拦账内换位;比较器换了方向之后忘了同步看顶的语义,顶上站的反而是最大。
实战里最小堆是"随时要最值"的通用答案:技能冷却队列谁先转好、定时器谁先到点、寻路的开放列表谁离目标近,全是堆顶那一格的事——堆顶换人,优先级就换了人。组里的约定是:凡是要"反复取最值"的账,一律上堆不排序——排序是死账,堆是活账。这篇的模块照抄能跑,改的就是 CONST 那张表加比较器。
写完留一句给做优先级系的同学:最小堆卖的是"最值永远在顶上"——那一次一次的上浮下沉和堆顶雷打不动的最小值,是把乱账变成有序出账的一棵完全二叉树。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
【语法算法】 上一版的炮台索敌逻辑有个隐蔽报错:炮台永远打同一个怪——即使那只怪已经死了,索敌的游标也不挪窝。追到底索敌的游…
【语法算法】 先抛一个坑:打不完的火系怪怎么办?火抗怪火打不动,换冰系技能要切装备要换面板——切完黄花菜都凉了。元素转换的答…
【语法算法】 上个月的事故复盘会上有个数字被念了三遍:四成——策划写的是"同伴陪疼四成",代码落下去成了"陪疼四十点",两只…
【语法算法】 单行代码拆解:弹射初速=-420——弹射的全部动力就这一行的负初速。负号朝上、四百二十是弹射的初速大小——踩上…
【语法算法】 上一版的减速类模块全按"乘以零点五"来写,帧率无关的版本照搬了这套写法——结果高帧率机上减速效果好,低帧率机上…
【语法算法】 先抛一个坑:怎么把散在四处的怪聚到一起打?逐个拉是笨办法,一个范围技又只能打一片——引力球的答案是一个会动的吸…