4.表与数据结构取舍

4.表与数据结构取舍


4.1 知识点

表充当多种数据结构

前几篇一直在聊表的引用、拷贝。
这篇回到 table 本身。

Lua 里常用容器基本都绕不开 table
它可以当数组、字典、集合、队列来写。虽然可以充当成各种数据结构,但如果用错场景,成本会差很多。

比如战斗里一帧插删几百次,和配置表里按 id 查一行数据,肯定不是一套写法。
所以真正要先想清楚的是:这张表到底打算当什么用。

连续列表当数组,id 映射当字典,判重当集合,队列的话维护 head/tail 出队,别 table.remove(t, 1) 删头,这样移动太多元素。

数组语义

key 从 1 开始连续往上长,中间没洞,就按数组来用。

local skills = { 1001, 1002, 1003 }

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

待释放技能 id、Buff 列表、本帧要 tick 的表现 id,项目里最常见的就是这种。

这种表适合用:

  • list[i]
  • #list
  • ipairs
  • 两参数 table.insert(list, value) 尾插

但前提是它真的是连续数组。

一旦中间有洞,或者业务上已经混了字符串 key,就别再把它当“纯数组”看。

字典语义

按 id 查配置行、按名字查对象、运行时做映射表,走的是字典语义。

key 不要求连续,甚至可以根本不是数字。

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

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

skillMap[1001] 查一次就能拿到配置行。
配置表、对象缓存、id 到实例的映射,一般都是查一次就行。

字典别拿 # 当长度,也别指望 ipairs 扫全表——空洞、pairs 顺序那套,语法篇 10.表 已经讲过,这里不赘述。
pairs 能遍历,但顺序不能当 UI 排序、帧同步 Tick 顺序。这种时候单独挂一张顺序表。

顺序表

字典负责查,顺序表负责「按什么先后走」。
两张表分工:map 存数据,order 存 id 列表;遍历 order,再去 map 里取。

帧同步 Tick Entity 就是典型场景。实体按 id 查,entityMap 存运行时数据;每帧谁先谁后,各端必须一致,不能直接使用 pairs(entityMap) 不可靠顺序进行遍历:

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

-- 每帧 Tick 顺序,各端共用同一份
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

entityMap 只管 lookup,Tick 先后交给 entityOrder
新 Entity 入战,map 里塞数据,order 尾插 id 就行。

直接赋值和插入对比

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,2 号位插入 15,原 20 被挤到后面

所以这两句实际效果不一样——承接上面 { 10, 20, 30, 40 } 那张表:

t[2] = 15              -- 只覆盖 2 号槽,表长还是 4
table.insert(t, 2, 15) -- 2 号位挤进新元素,后面整体后移,表长变 5

前者只改一个槽。
后者是在 2 号位插入,原来的 2 号位和后面元素一起往后搬。

table.insert 尾插

两个参数的 table.insert 是尾插。

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

table.insert(pending, "C")

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

待处理事件、日志行、临时列表,一般是字节往后加的话,那使用尾插就够了。
没有明确需求,最好不要往中间插。因为会涉及一些搬家开销。

table.insert 中间插入

三个参数的 table.insert 会在 pos 处插入,并把 pos 及之后的元素往后搬。

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

插得越靠前,要搬的元素越多。
插到 1 号位,基本就是整段平移。

中间插入不能说完全禁止,但是最好别在热路径里频繁调用。
例如在战斗 Tick、红点刷新、表现队列这类地方,如果大表频繁中间插入,Profiler 里很容易看到这块开销,影响性能。

table.remove 头删的问题

table.remove(t, 1) 会删掉第一个元素,然后把后面的元素整体前移。

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

table.remove(tasks, 1)

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

demo 里三行代码看不出问题。
但战斗帧里如果每帧对几百上千条指令 remove(1),这块开销会叠起来。

热路径 FIFO 队列别这么写。而是最好用 head / tail 更,见后文。

table 作为集合

判重、判存活、判已访问,用 key 占位就够了。

local visited = {}

visited[1001] = true
visited[1002] = true

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

visited[1001] = nil

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

值写 true 就行。当然用 1 也行,一般只是个占位。

真要附带访问时间、来源节点、权重,再把 value 写成一张小表:

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

普通判重集合别一上来就套这种结构。

同时得注意,别为了一个集合,又维护一份数组来回扫,比如下面这种写法就别用,判 alive 得把整个数组从 1 扫到尾,性能很差:

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,2001 不在列表里,但得先扫完整张表才知道

逻辑上看起来能跑,但 Entity 一多、每帧判很多次,就是 O(n)。上面 visited[1001] ~= nil 查一次 key 就搞定了,是 O(1)。

table 作为队列

入队往 tail 写,出队只动 head,避开 table.remove(t, 1)

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

local function Enqueue(v)
    tail = tail + 1
    q[tail] = v -- 入队:往 tail 写,不碰 head
end

local function Dequeue()
    if head > tail then
        return nil -- 队列为空
    end

    local v = q[head]
    q[head] = nil -- 出队后断掉旧引用,方便 GC
    head = head + 1 -- 只挪队头,不像 remove(1) 那样搬后面一截

    return v
end

Enqueue("A")
Enqueue("B")

print(Dequeue()) -- 输出:A,先进先出
print(Dequeue()) -- 输出:B
print(Dequeue()) -- 输出:nil,队列已空

项目很多地方使用队列,比如待执行指令、网络消息、异步回调排队。
注意q[head] = nil 也不能省略,出队后要旧引用断掉,方便 GC 回收,避免内存泄漏。

head 走太远以后,表会变得稀疏——前面大量 key 已经是 nil,但 slot 还占着:

for i = 1, 5 do
    Enqueue("cmd")
end

for i = 1, 4 do
    Dequeue()
end

print(head)    -- 输出:5,队头已经挪到 5
print(q[1])    -- 输出:nil,1~4 号位是空槽,但表还在占坑
print(q[tail]) -- 输出:cmd,有效数据只剩 tail 这一格

队列是长期对象的话,空闲时可以压缩成新表,把有效段挪回 1 号位;或者队列已空,直接 q, head, tail = {}, 1, 0 换一张干净的:

-- 把 [head, tail] 里的有效元素,紧凑拷到新表
local function CompactQueue(q, head, tail)
    local newQ = {}
    local newTail = 0

    for i = head, tail do
        newTail = newTail + 1
        newQ[newTail] = q[i] -- 从 1 号位重新排,丢掉前面的空槽
    end

    return newQ, 1, newTail -- head 回到 1,tail 变成有效长度
end

q, head, tail = CompactQueue(q, head, tail)

print(head) -- 输出:1,队头重新贴到数组段开头
print(q[1]) -- 输出:cmd,前面 1~4 的空槽不再占坑

但我觉得最重要的还是先把热路径里的 remove(1) 干掉。

数组段和哈希段

前面聊的尾插顺手、中间 insert/remove 要搬元素、混 key 时 #/ipairs ,粗略讲都可以归结成:table 里有一块数组段放连续 1..n,另有一块哈希段放其余 key。

混用两种 key 的表要格外注意:

local mixed = {
    "A",
    "B",
    role = "cache"
}

print(mixed[1])   -- 输出:A,1、2 在数组段
print(mixed.role) -- 输出:cache,role 在哈希段

for i, v in ipairs(mixed) do
    print(i, v) -- 输出:1 A / 2 B,ipairs 只走数组段,扫不到 role
end

数组段怎么切、什么时候 rehash,跟 Lua 5.x、LuaJIT、项目魔改运行时都有关。真想抠实现,再去翻 ltable.c 也不迟。

总结

同一张 table,当数组、字典、集合、队列写,写法不一样,成本也不一样。

连续 1..n 当数组。
id 映射当字典,顺序走 order 表。
判重用集合。
FIFO 用 head / tail。

t[k] = v 是写槽,而不是插入。
table.insert(t, pos, v)table.remove(t, 1) 都会涉及搬家,热路径里要谨慎。


4.2 知识点代码

Lesson4_表与数据结构取舍.lua

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

print("**********知识点一 表充当多种数据结构************")

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

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


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

local skills = { 1001, 1002, 1003 }

print("skills[2]:", skills[2]) -- 输出:1002,数组按下标取值
print("#skills:", #skills) -- 输出:3,连续 1..n 时 # 才靠谱

for i, skillId in ipairs(skills) do
    print("技能下标和技能 id:", i, skillId) -- 输出:1 1001 / 2 1002 / 3 1003
end


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

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

print("skillMap[1001].name:", skillMap[1001].name) -- 输出:FireBall
print("skillMap[1002]:", skillMap[1002]) -- 输出:nil,查不到就是 nil


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("Tick:", entityId, entity.hp) -- 输出:101 99 / 102 79 / 103 119,各端顺序一致
end

print("**********知识点五 直接赋值和插入对比************")

local direct = { 10, 20, 30 }

direct[2] = 99

print("direct[2]:", direct[2]) -- 输出:99,只覆盖 2 号位
print("direct[3]:", direct[3]) -- 输出:30,后面的元素不会被搬动
print("#direct:", #direct) -- 输出:3

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

table.insert(insertDemo, 2, 15)

print("insertDemo[2]:", insertDemo[2]) -- 输出:15,2 号位插入新元素
print("insertDemo[3]:", insertDemo[3]) -- 输出:20,原 20 被挤到后面
print("#insertDemo:", #insertDemo) -- 输出:5,和 t[2]=15 完全不是一回事


print("**********知识点六 table.insert 尾插************")

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

table.insert(tailList, "C")

print("#tailList:", #tailList) -- 输出:3
print("tailList[3]:", tailList[3]) -- 输出:C,两参数 insert 是尾插


print("**********知识点七 table.insert 中间插入************")

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

table.insert(midList, 2, 15)

print("midList[1]:", midList[1]) -- 输出:10
print("midList[2]:", midList[2]) -- 输出:15,新元素插进 2 号位
print("midList[3]:", midList[3]) -- 输出:20,原 20 被往后挤
print("#midList:", #midList) -- 输出:5


print("**********知识点八 table.remove 头删的问题************")

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

local removedTask = table.remove(headRemove, 1)

print("removedTask:", removedTask) -- 输出:move,被删掉的元素会返回
print("headRemove[1]:", headRemove[1]) -- 输出:attack,头删后后面整体前移
print("headRemove[2]:", headRemove[2]) -- 输出:idle
print("#headRemove:", #headRemove) -- 输出:2


print("**********知识点九 table 作为集合************")

local aliveSet = {}

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

print("1001 在集合里:", aliveSet[1001] ~= nil) -- 输出:true
print("2001 在集合里:", aliveSet[2001] ~= nil) -- 输出:false

aliveSet[1001] = nil

print("移除后 1001:", aliveSet[1001] ~= nil) -- 输出:false,赋 nil 等于从集合里删掉

local visited = {}

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

print("visited 附带信息:", 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 2001:", IsAlive(2001)) -- 输出:false,能跑但每次 O(n) 扫整张表,别这么写


print("**********知识点十 table 作为队列************")

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 -- 出队后断掉旧引用
    head = head + 1 -- 只挪队头,不像 remove(1) 那样搬元素

    return v
end

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

print("Dequeue:", Dequeue()) -- 输出:cmd_move,先进先出
print("Dequeue:", Dequeue()) -- 输出:cmd_attack
print("Dequeue:", Dequeue()) -- 输出:nil,队列已空

for i = 1, 5 do
    Enqueue("cmd")
end

for i = 1, 4 do
    Dequeue()
end

print("head:", head) -- 输出:5,队头走远,前面 1~4 号是空槽
print("q[1]:", q[1]) -- 输出:nil

local function CompactQueue(q, head, tail)
    local newQ = {}
    local newTail = 0

    for i = head, tail do
        newTail = newTail + 1
        newQ[newTail] = q[i]
    end

    return newQ, 1, newTail
end

q, head, tail = CompactQueue(q, head, tail)

print("压缩后 head:", head) -- 输出:1
print("压缩后 q[1]:", q[1]) -- 输出:cmd,有效数据贴回数组段开头


print("**********知识点十一 数组段和哈希段************")

local mixed = {
    "A",
    "B",
    role = "cache"
}

print("mixed[1]:", mixed[1]) -- 输出:A,1、2 在数组段
print("mixed.role:", mixed.role) -- 输出:cache,role 在哈希段

for i, v in ipairs(mixed) do
    print("ipairs mixed:", i, v) -- 输出:1 A / 2 B,ipairs 只走数组段
end


print("**********知识点十二 总结************")

print("连续 1..n 当数组,id 映射当字典") -- 输出:连续 1..n 当数组,id 映射当字典
print("业务顺序用 order 表,别赌 pairs 顺序") -- 输出:业务顺序用 order 表,别赌 pairs 顺序
print("集合查 key,别数组 for 扫") -- 输出:集合查 key,别数组 for 扫
print("队列用 head/tail,别 remove(1) 删头") -- 输出:队列用 head/tail,别 remove(1) 删头
print("t[k]=v 是写槽,insert/remove 会搬元素") -- 输出:t[k]=v 是写槽,insert/remove 会搬元素


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

×

喜欢就点赞,疼爱就打赏