4.表与数据结构取舍

4.表与数据结构取舍


4.1 知识点

表和数据结构

Lua 里表可以当数组、字典、集合、队列,但用法不同,成本差很多

使用表当数据结构前,先想清楚几个问题:

  • 按下标顺序访问还是按 id 查?
  • 顺序会不会影响结果?
  • 只判存在还是要完整数据?
  • FIFO 会不会在热路径里频繁出入队?

数组语义

从 1 开始连续增长,中间不留洞,就按数组来写。

local skills = { 1001, 1002, 1003 }

print(skills[1]) -- 输出:1001
print(#skills)   -- 输出:3

数组语义的前提是“连续”。只要中间可能有 nil,或者混了其他的业务 key,就不能把 # 当稳定获取长度的API。

数组常见操作:

  • list[i] 按下标读写。
  • #list 只用于连续列表。
  • ipairs(list) 只用于连续列表。
  • table.insert(list, value) 用于尾插。

热路径里,尾插通常没问题。
但是如果频繁中间插入、头部删除,就得用别的方式处理了。

字典语义

按 id 查配置行、按名字查对象、按实例 id 找运行时对象,走字典语义。

local skillMap = {
    [1001] = { name = "FireBall" },
    [1005] = { name = "IceBolt" },
    ["passive_1"] = { name = "Shield" }
}

print(skillMap[1001].name) -- 输出:FireBall
print(skillMap[1002])      -- 输出:nil

字典的重点是通过key得到value。遍历顺序是不可靠的。
不能拿 # 当数量。
业务需要数量,需要自己维护 count;如果业务需要顺序,单独维护顺序表。

顺序表

字典负责查,顺序表负责“按什么顺序处理”。

有时候一些场景下,必须得按一定顺序遍历处理。比如,在帧同步 Tick 里这个边界很重要,因为要保证一致性。
实体按 id 查用 entityMap,Tick 顺序用 entityOrder。不要把 pairs(entityMap) 的遍历结果当业务顺序。

local entityMap = {
    [101] = { hp = 100 },
    [102] = { hp = 80 },
    [103] = { hp = 120 }
}

local entityOrder = { 101, 102, 103 }

for _, entityId in ipairs(entityOrder) do
    local entity = entityMap[entityId]
    entity.hp = entity.hp - 1
    print(entityId, entity.hp)
    -- 输出:101 99 / 102 79 / 103 119,各端按同一份顺序 Tick
end

新实体入战时,entityMap[id] = entity,再把 id 尾插进 entityOrder
删除时看项目需求:可以从顺序表移除,也可以标记失效后帧末批量整理。

写槽和插入

t[k] = v 是写某个槽。
如果这个 key 原来有值,就是覆盖;不会把后面的元素往后挪。

local t = { 10, 20, 30 }

t[2] = 99

print(t[1], t[2], t[3]) -- 输出:10  99  30
print(#t)               -- 输出:3

table.insert(t, pos, value) 是插入。
pos 开始,后面的数组元素会整体后移。

local t = { 10, 20, 30, 40 }

table.insert(t, 2, 15)

print(t[1], t[2], t[3], t[4], t[5]) -- 输出:10  15  20  30  40
print(#t)                           -- 输出:5

这两个操作不能混为一谈:

t[2] = 15              -- 覆盖 2 号位
table.insert(t, 2, 15) -- 在 2 号位插入,后面元素整体后移

中间插入和头删

尾插通常干净,基本不会有什么影响:

local pending = { "A", "B" }

table.insert(pending, "C")

print(#pending)   -- 输出:3
print(pending[3]) -- 输出:C

中间插入会让后面的元素“搬家”:

local nums = { 10, 20, 30, 40 }

table.insert(nums, 2, 15)

print(nums[1], nums[2], nums[3], nums[4], nums[5])
-- 输出:10  15  20  30  40

头删也会“搬家”,往前搬:

local tasks = { "move", "attack", "idle" }

local removedTask = table.remove(tasks, 1)

print(removedTask)      -- 输出:move
print(tasks[1], tasks[2]) -- 输出:attack  idle

demo 里数据不多的话,看起来没什么问题。
但是如果一个很多元素的表频繁这么干,很吃性能。尤其是每一帧频繁调用时。

但是中间插入和头删也不是完全禁用。
冷路径、短列表、编辑器工具里一般无所谓。

怎么避免搬家

队列:维护头尾双指针

FIFO 队列别用 table.remove(q, 1) 做出队。前面也提过,这样会搬家。
通用的写法是维护 head / tail。可以理解为 Leetcode 学的双指针(手动狗头)。

local q = {}
local head, tail = 1, 0 -- head 队头,tail 队尾;head > tail 表示空队列

local function Enqueue(v)
    tail = tail + 1
    q[tail] = v -- 入队只写 tail
end

local function Dequeue()
    if head > tail then
        return nil
    end

    local v = q[head]
    q[head] = nil -- 断掉旧引用,方便 GC
    head = head + 1

    return v
end

Enqueue("cmd_move")
Enqueue("cmd_attack")

print(Dequeue()) -- 输出:cmd_move
print(Dequeue()) -- 输出:cmd_attack
print(Dequeue()) -- 输出:nil

q[head] = nil 一般不能省略。
尤其 value 里可能挂着对象、回调、资源句柄时,旧引用断掉才不影响 GC。

长期来看,这样实现的队列还有一个小问题是 head 会越走越远。
可以加个实现,如果队列变空,可以直接重置:

if head > tail then
    q, head, tail = {}, 1, 0
end

也可以在适当时机考虑压缩。
大多数项目代码里,把热路径的 remove(1) 改掉是合理优化。

中间删除:不要求顺序,就用末尾元素补位

有些列表只是为了快速遍历,不要求顺序稳定。
比如子弹列表、临时命中列表、对象池里的空闲对象,这种就没必要每次删除都让后面的元素往前搬。

可以用最后一个元素补到被删除的位置:

local function RemoveFast(list, index)
    local lastIndex = #list

    if index < 1 or index > lastIndex then
        return
    end

    list[index] = list[lastIndex] -- 用最后一个元素补当前位置
    list[lastIndex] = nil         -- 清掉最后一个槽位,断掉旧引用
end

local list = { "A", "B", "C", "D" }

RemoveFast(list, 2)

print(list[1], list[2], list[3], list[4])
-- 输出:A  D  C  nil

这样删除很快,但是顺序会变。
所以它适合不关心顺序的运行时列表,不适合 UI 列表、排行榜、任务展示顺序这种业务。

按 id 删除:字典 + 数组 + indexMap

如果既想用数组遍历,又想按 id 快速删除,可以多维护一张 indexMap
数组负责遍历,字典负责按 id 找位置。
用空间换时间。数组段方便遍历,哈希段方便 O(1) 查位置。

local entityList = {}
local entityIndexMap = {} -- key 是实体 id,value 是实体在 entityList 里的下标

local function AddEntity(entity)
    table.insert(entityList, entity)

    -- 新实体一定插在末尾,所以它的下标就是当前长度
    entityIndexMap[entity.id] = #entityList
end

local function RemoveEntity(id)
    -- 先通过 id 找到实体在数组里的位置,避免遍历 entityList
    local index = entityIndexMap[id]
    if index == nil then
        return
    end

    local lastIndex = #entityList
    local removedEntity = entityList[index]
    local lastEntity = entityList[lastIndex]

    -- 用最后一个实体补到被删除的位置,避免 table.remove 造成整体前移
    entityList[index] = lastEntity
    entityList[lastIndex] = nil -- 清掉最后一个槽位,断掉旧引用

    -- 被删除的实体已经不在列表里了,下标表也要同步清掉
    entityIndexMap[removedEntity.id] = nil

    -- 如果删除的不是最后一个实体,补位实体的下标也要更新
    if lastEntity ~= removedEntity then
        entityIndexMap[lastEntity.id] = index
    end
end

AddEntity({ id = 101, name = "RoleA" })
AddEntity({ id = 102, name = "RoleB" })
AddEntity({ id = 103, name = "RoleC" })

RemoveEntity(102)

print(entityList[1].id, entityList[2].id, entityList[3])
-- 输出:101  103  nil

print(entityIndexMap[101], entityIndexMap[102], entityIndexMap[103])
-- 输出:1  nil  2

这种写法项目里挺常见。有些项目组会封装常用 Lua 数据结构,底层都是多个表模拟对应数据结构。
注意点是只要移动了数组里的元素,就要同步维护 indexMap
否则下次按 id 删除,拿到的下标就是旧的。

必须保顺序:先标记,帧末统一整理

如果顺序必须保留,那就不能用末尾补位。
UI 列表、任务顺序、帧同步 Tick 里,顺序乱了会直接变成业务 bug。

这种场景可以先标记删除,等这一轮逻辑结束后统一整理。

本质思想也是我们刷Leetcode题的时候,一个字段标记有效位置,一个字段遍历。
然后不停的把遍历到的覆盖进有效位置,然后有效位置+1。
这样遍历完后,数组前面都是有效的,有效位置后的有效的元素都丢到前面来了,有效位置后的就可以干掉了。
相当于压缩。

local entities = {
    { id = 101, dead = false },
    { id = 102, dead = true },
    { id = 103, dead = false },
    { id = 104, dead = true },
}

local function Compact(list)
    -- writeIndex 表示下一个“存活对象”应该写入的位置
    -- readIndex 负责从前往后扫描原数组
    local writeIndex = 1

    for readIndex = 1, #list do
        local entity = list[readIndex]

        -- 只保留没有死亡的对象
        -- 在原数组上往前覆盖,不另建新表
        if not entity.dead then
            list[writeIndex] = entity
            writeIndex = writeIndex + 1
        end
    end

    -- writeIndex 后面的元素,已经不属于压缩后的有效数组
    -- 这些位置可能还引用着旧对象,如果不清掉,会让旧对象继续被 table 持有
    for i = writeIndex, #list do
        list[i] = nil
    end
end

Compact(entities)

for i = 1, #entities do
    print(entities[i].id) -- 依次输出:101 / 103
end

这种做法是把很多次零散删除,变成一次统一整理。
这一帧先标记,安全时机再清理。比如帧末。

中间插入:能延后就延后

中间插入也一样:热路径里频繁插才麻烦,偶尔插一次无所谓。
能延后就先丢临时表,安全时机再合并。

local entityOrder = { 101, 102, 103 }
local pendingAddList = {}

local function AddEntityLater(id)
    -- 不直接修改 entityOrder
    -- 避免在遍历主顺序表时,边遍历边插入导致顺序和循环范围变复杂
    table.insert(pendingAddList, id)
end

local function FlushPendingAdd()
    -- 当前一轮逻辑结束后,再把待添加的实体统一追加到主顺序表末尾
    for i = 1, #pendingAddList do
        table.insert(entityOrder, pendingAddList[i])
    end

    -- 这一批待添加数据已经处理完,清空临时列表
    -- 这里直接换一张新表,适合没有其他地方持有 pendingAddList 引用的情况
    pendingAddList = {}
end

AddEntityLater(104)
AddEntityLater(105)

FlushPendingAdd()

print(entityOrder[1], entityOrder[2], entityOrder[3], entityOrder[4], entityOrder[5])
-- 输出:101  102  103  104  105

如果业务必须插到指定位置,就要看频率。
低频逻辑直接 table.insert 没问题;高频逻辑最好重新设计结构。

集合

只做判重、判存在,一般用 key 做占位。

local aliveSet = {}

aliveSet[1001] = true
aliveSet[1002] = true

print(aliveSet[1001] ~= nil) -- 输出:true
print(aliveSet[2001] ~= nil) -- 输出:false

aliveSet[1001] = nil

print(aliveSet[1001] ~= nil) -- 输出:false

值一般写 true 就可以,写1也无所谓。
如果还要记录访问时间、来源节点、权重,再把 value 换成小表。没有特殊需求一般没啥必要。

local visited = {}

visited[1001] = {
    visitTime = 12345,
    fromNode = "A"
}

print(visited[1001].visitTime) -- 输出:12345

不要为了判一个元素存在,每次都遍历数组,以下是bad case:

local aliveList = { 1001, 1002, 1003 }

local function IsAlive(id)
    for i = 1, #aliveList do
        if aliveList[i] == id then
            return true
        end
    end

    return false
end

print(IsAlive(1001)) -- 输出:true
print(IsAlive(2001)) -- 输出:false

逻辑虽然没有问题跑,但元素一多,每帧查很多次,就会变成无意义的扫描,很吃性能的。

总结

连续下标访问,用数组。
按 id 查数据,用字典。
只判断存在,用集合。
既要查得快,又要顺序稳定,就字典配顺序表。

t[k] = v 是写槽。
table.insert(t, pos, v) 会搬后面的元素。
table.remove(t, pos) 会把后面的元素往前补。

热路径里别频繁让大数组搬家。
FIFO 用 head / tail;不要求顺序就末尾补位;要求顺序就先标记,帧末统一整理。


4.2 知识点代码

Lesson4_表与数据结构取舍.lua

print("**********表与数据结构取舍************")

print("**********知识点一 表先定用途************")

local arrayList = { 1001, 1002, 1003 }
local skillMap = {
    [1001] = "FireBall",
    [1002] = "IceBolt"
}

print(arrayList[1])  -- 输出:1001,数组按下标取
print(skillMap[1001]) -- 输出:FireBall,字典按 key 查


print("**********知识点二 数组语义************")

local skills = { 1001, 1002, 1003 }

print(skills[2]) -- 输出:1002
print(#skills)   -- 输出:3

table.insert(skills, 1004)

print(skills[4]) -- 输出:1004,连续列表尾插
print(#skills)   -- 输出:4


print("**********知识点三 字典语义************")

local configMap = {
    [1001] = { name = "FireBall" },
    [1005] = { name = "IceBolt" },
    ["passive_1"] = { name = "Shield" }
}

print(configMap[1001].name) -- 输出:FireBall
print(configMap[1002])      -- 输出:nil

local playerMap = {}
local playerCount = 0

local function AddPlayer(id, data)
    if playerMap[id] == nil then
        playerCount = playerCount + 1
    end

    playerMap[id] = data
end

AddPlayer(1001, { name = "PlayerA" })
AddPlayer(2008, { name = "PlayerB" })

print(playerCount)          -- 输出:2,字典数量自己维护
print(playerMap[2008].name) -- 输出:PlayerB


print("**********知识点四 顺序表************")

local entityMap = {
    [101] = { hp = 100 },
    [102] = { hp = 80 },
    [103] = { hp = 120 }
}

local entityOrder = { 101, 102, 103 }

for _, entityId in ipairs(entityOrder) do
    local entity = entityMap[entityId]
    entity.hp = entity.hp - 1
    print(entityId .. ":" .. entity.hp)
    -- 依次输出:101:99 / 102:79 / 103:119
end


print("**********知识点五 写槽和插入************")

local direct = { 10, 20, 30 }

direct[2] = 99

print(direct[1], direct[2], direct[3]) -- 输出:10  99  30
print(#direct)                         -- 输出:3

local insertDemo = { 10, 20, 30, 40 }

table.insert(insertDemo, 2, 15)

print(insertDemo[1], insertDemo[2], insertDemo[3], insertDemo[4], insertDemo[5])
-- 输出:10  15  20  30  40
print(#insertDemo) -- 输出:5


print("**********知识点六 中间插入和头删************")

local pending = { "A", "B" }

table.insert(pending, "C")

print(#pending)   -- 输出:3
print(pending[3]) -- 输出:C

local nums = { 10, 20, 30, 40 }

table.insert(nums, 2, 15)

print(nums[2], nums[3]) -- 输出:15  20
print(#nums)            -- 输出:5

local tasks = { "move", "attack", "idle" }
local removedTask = table.remove(tasks, 1)

print(removedTask)      -- 输出:move
print(tasks[1], tasks[2]) -- 输出:attack  idle


print("**********知识点七 怎么避免搬家************")

print("----------队列:维护 head / tail----------")

local q = {}
local head, tail = 1, 0 -- head 队头,tail 队尾;head > tail 表示空队列

local function Enqueue(v)
    tail = tail + 1
    q[tail] = v -- 入队只写 tail,不挪动已有元素
end

local function Dequeue()
    if head > tail then
        return nil
    end

    local v = q[head]
    q[head] = nil -- 出队后断掉旧引用,避免旧对象继续被 table 持有
    head = head + 1

    return v
end

Enqueue("cmd_move")
Enqueue("cmd_attack")

print(Dequeue()) -- 输出:cmd_move
print(Dequeue()) -- 输出:cmd_attack
print(Dequeue()) -- 输出:nil

if head > tail then
    q, head, tail = {}, 1, 0 -- 队列空了以后重置,避免 head 一直变大
end

print(head, tail) -- 输出:1  0


print("----------中间删除:末尾元素补位----------")

local function RemoveFast(list, index)
    local lastIndex = #list

    if index < 1 or index > lastIndex then
        return
    end

    list[index] = list[lastIndex] -- 用最后一个元素补当前位置,避免后面元素整体前移
    list[lastIndex] = nil         -- 清掉最后一个槽位,断掉旧引用
end

local bulletList = { "BulletA", "BulletB", "BulletC", "BulletD" }

RemoveFast(bulletList, 2)

print(bulletList[1], bulletList[2], bulletList[3], bulletList[4])
-- 输出:BulletA  BulletD  BulletC  nil,删除很快,但顺序会变


print("----------按 id 删除:数组 + indexMap----------")

local entityList = {}
local entityIndexMap = {} -- key 是实体 id,value 是实体在 entityList 里的下标

local function AddEntity(entity)
    table.insert(entityList, entity)

    -- 新实体插在末尾,所以它的下标就是当前长度
    entityIndexMap[entity.id] = #entityList
end

local function RemoveEntity(id)
    -- 先通过 id 找到数组下标,避免每次遍历 entityList
    local index = entityIndexMap[id]
    if index == nil then
        return
    end

    local lastIndex = #entityList
    local removedEntity = entityList[index]
    local lastEntity = entityList[lastIndex]

    -- 用最后一个实体补到被删除的位置,避免 table.remove 造成整体前移
    entityList[index] = lastEntity
    entityList[lastIndex] = nil -- 清掉最后一个槽位,断掉旧引用

    -- 被删除的实体已经不在列表里了,下标表也要同步清掉
    entityIndexMap[removedEntity.id] = nil

    -- 如果删除的不是最后一个实体,补位实体的下标也要更新
    if lastEntity ~= removedEntity then
        entityIndexMap[lastEntity.id] = index
    end
end

AddEntity({ id = 101, name = "RoleA" })
AddEntity({ id = 102, name = "RoleB" })
AddEntity({ id = 103, name = "RoleC" })

RemoveEntity(102)

print(entityList[1].id, entityList[2].id, entityList[3])
-- 输出:101  103  nil

print(entityIndexMap[101], entityIndexMap[102], entityIndexMap[103])
-- 输出:1  nil  2


print("----------必须保顺序:标记后统一整理----------")

local roleList = {
    { id = 101, dead = false },
    { id = 102, dead = true },
    { id = 103, dead = false },
    { id = 104, dead = true },
}

local function Compact(list)
    local writeIndex = 1 -- 下一个有效对象应该写入的位置

    for readIndex = 1, #list do
        local role = list[readIndex]

        -- 不新建数组,直接把有效对象往前覆盖
        if not role.dead then
            list[writeIndex] = role
            writeIndex = writeIndex + 1
        end
    end

    -- writeIndex 后面的槽位已经不是有效数据了,需要清掉旧引用
    for i = writeIndex, #list do
        list[i] = nil
    end
end

Compact(roleList)

for i = 1, #roleList do
    print(roleList[i].id)
    -- 依次输出:101 / 103
end


print("----------中间插入:能延后就延后----------")

local entityOrder = { 101, 102, 103 }
local pendingAddList = {}

local function AddEntityLater(id)
    -- 不在遍历主顺序表时直接插入,先丢进待添加列表
    table.insert(pendingAddList, id)
end

local function FlushPendingAdd()
    -- 当前一轮逻辑结束后,再统一合并到主顺序表
    for i = 1, #pendingAddList do
        table.insert(entityOrder, pendingAddList[i])
    end

    pendingAddList = {} -- 这一批已经处理完,直接换一张空表
end

AddEntityLater(104)
AddEntityLater(105)

FlushPendingAdd()

print(entityOrder[1], entityOrder[2], entityOrder[3], entityOrder[4], entityOrder[5])
-- 输出:101  102  103  104  105


print("**********知识点八 集合************")

local aliveSet = {}

aliveSet[1001] = true
aliveSet[1002] = true

print(aliveSet[1001] ~= nil) -- 输出:true,1001 存在
print(aliveSet[2001] ~= nil) -- 输出:false,2001 不存在

aliveSet[1001] = nil

print(aliveSet[1001] ~= nil) -- 输出:false,1001 已经移除

local visited = {}

visited[1001] = {
    visitTime = 12345,
    fromNode = "A"
}

print(visited[1001].visitTime) -- 输出:12345,集合的 value 也可以换成业务数据

local aliveList = { 1001, 1002, 1003 }

local function IsAlive(id)
    for i = 1, #aliveList do
        if aliveList[i] == id then
            return true
        end
    end

    return false
end

print(IsAlive(1001)) -- 输出:true,但这是遍历数组查存在
print(IsAlive(2001)) -- 输出:false,数据多了以后这种写法不适合热路径


print("**********知识点九 总结************")

print("table 先看用途,再选写法")
print("连续下标访问,用数组")
print("按 id 查数据,用字典")
print("只判断存在,用集合")
print("既要查得快,又要顺序稳定,就字典配顺序表")
print("FIFO 队列用 head/tail,不要在热路径 remove(1)")
print("不要求顺序的删除,可以用末尾元素补位")
print("要求顺序的删除,可以先标记,帧末统一整理")
print("中间插入能延后就延后,能批量就批量")


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏