🔥 本文专栏:Lua
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
不必因为今天尚未抵达理想的远方,就否定一路走来的自己。
思维导图:先建立统一的心智模型
text
各种数据结构的抽象性质(与具体语言无关)
↓
不同语言如何提供这些能力?
├── C++:语言能力 + 标准库容器
└── Lua:Table + 标准库函数 + 自己定义操作规则
↓
先复习 TValue 与 Table 的关系
├── TValue:类型标记 + 值(Table 引用)
└── Table:数组部分 + 哈希部分
↓
按「组织方式」和「操作规则」理解具体数据结构
├── 数组:整数索引;空洞、#、ipairs、多维数组
├── 链表:节点 Table;next / prev 引用
├── 栈:约定只在数组末尾操作,后进先出
├── 队列:队头 / 队尾,先进先出
│ └── 双索引 → 固定容量循环队列
├── 集合:将元素作为 Table 的键,关注是否存在
├── 映射:通过键定位实际业务数据
└── 树:通过子节点引用,或利用完全二叉树的数组索引关系
↓
最后形成统一认识:
Table 是基础;具体的数据结构语义来自我们如何组织和操作数据。

一、为什么 Lua 的数据结构学习可以从 Table 出发?
1. 数据结构的性质不会因为编程语言不同而改变
我们在学习数据结构的时候,已经认识了数组、链表、栈、队列、集合、映射和树。
其实这些概念并不是某一种编程语言专属的。例如,链表中的节点通过引用关系相互连接,栈遵循后进先出,队列遵循先进先出,这些性质并不会因为换成 Lua 就发生变化。
这里真正发生变化的是:不同编程语言提供了哪些现成的能力,让我们能够把这些抽象结构实现出来。
比如在 C++ 中,我们可以使用标准库提供的容器和容器适配器:
cpp
std::vector<int> arr; // 动态数组
std::list<int> linkedList; // 双向链表
std::stack<int> stack; // 栈
std::queue<int> queue; // 队列
这里要注意:vector、list、stack、queue 是 C++ 标准库提供的,而不是全部属于 C++ 语言自身的内置类型。我们可以直接使用已经封装好的接口,不需要每次都重新实现对应的数据结构。
而 Lua 的设计相对简洁。它没有在标准库中直接提供一整套与上述容器一一对应的专用类型,却提供了一种非常灵活的基础结构:Table。
因此,Lua 中很多数据结构的实现,并不是从内存分配开始手动造一遍底层轮子,而是直接复用 Table 的存储、键值访问和引用机制,再根据需要规定操作方式。
2. 回顾 TValue:Table 变量保存的是什么?
在此前学习 Lua 基本数据类型时,我们已经建立了 TValue 的内部表示模型。
可以把 Lua 运行时的值理解为:
text
TValue
├── 类型标记
└── 值
├── number 等类型:保存相应数值表示
└── table 类型:保存对 Table 对象的引用
需要强调的是,Lua 语言层面的变量并不是一个与 TValue 永远一一对应的固定结构体。TValue 是我们理解 Lua 内部值表示的一种模型,具体对象还可能涉及栈槽、寄存器等实现细节。
但对于 Table 值,有一点非常重要:TValue 不会把整张 Table 的内容复制进自己内部,而是保存对 Table 对象的引用。
因此:
lua
local a = {value = 10}
local b = a
b.value = 100
print(a.value) -- 100
a 与 b 保存的是同一张 Table 的引用,修改其中一个引用所指向的对象,另一个引用访问到的自然也是修改之后的结果。
这个机制将贯穿后面的二维数组、链表和树。
3. Table 的数组部分与哈希部分
从典型的 Lua 实现来看,Table 内部通常同时管理数组部分和哈希部分。
lua
local t = {
10, 20, 30,
name = "战士",
level = 10
}
从逻辑上看,可以理解为:
text
t
├── 整数键
│ ├── [1] = 10
│ ├── [2] = 20
│ └── [3] = 30
└── 字符串键
├── ["name"] = "战士"
└── ["level"] = 10
这里需要区分两个层面:
- 程序设计层面:我们通过整数键或者字符串键访问对应的值。
- Table 内部实现层面:Lua 决定哪些键放在数组部分,哪些键放在哈希部分。
特别注意:并不是所有整数键一定都被放在数组部分,也不是数组部分的每个槽位都必须具有非 nil 的值。
因此,我们后面说"利用 Table 的整数索引实现数组",指的是逻辑上的索引使用方式,而不是说程序能够直接指定某个键必须放入内部数组区域。
二、数组:直接复用 Table 的整数索引
1. 为什么 Lua 不需要我们重新实现数组?
对于数组,我们希望能够通过整数索引访问元素。
而 Table 本身就已经支持这一点:
lua
local arr = {10, 20, 30, 40}
print(arr[1]) -- 10
print(arr[3]) -- 30
arr[2] = 200 -- 修改第二个元素
print(arr[2]) -- 200
这里的 arr 就是一张普通的 Table。只不过我们规定它主要通过整数键来组织数组元素。
因此,一维数组并没有引入新的基础类型。我们直接利用已有 Table 的能力即可。
2. 插入和删除:为什么尽量保持索引连续?
Lua 标准库已经提供了一些非常方便的函数:
lua
local arr = {10, 20, 30}
table.insert(arr, 40) -- 在末尾插入 40
table.insert(arr, 2, 100) -- 在索引 2 插入 100,后面元素后移
local removed = table.remove(arr, 3) -- 删除索引 3,后面元素前移
print(removed) -- 20
执行完成之后,arr 为:
text
索引: 1 2 3 4
数据: 10 100 30 40
这里 table.insert 与 table.remove 的一个重要作用就是:针对正常的数组序列,插入或删除中间元素时维护后续元素的位置关系。
那么为什么我们此前一直强调,不要随意把数组中间某个元素直接赋值为 nil 呢?
原因在于 # 运算符的边界规则。
3. nil 空洞与 # 的边界问题
首先来看一个正常序列:
lua
local arr = {10, 20, 30, 40}
print(#arr) -- 4
现在直接修改:
lua
arr[2] = nil
此时:
text
索引: 1 2 3 4
数据: 10 nil 30 40

我们需要回顾此前对 # 的认识:对于 Table,# 返回的是符合规则的边界(border) ,并不等于"统计有多少个非 nil 元素"。
对于上面的 arr,索引 1 和 4 都可能构成边界,因此 #arr 不保证 返回固定的、符合直觉的元素个数。不能简单认定此时它一定等于 3,也不能假设它一定等于 4。
而 table.insert()、table.remove() 的默认位置以及移动范围都会涉及数组长度规则。如果数组中已经存在空洞,就可能偏离我们期望的顺序操作语义。
所以:
对于希望保持连续索引的普通数组,在中间删除元素时通常使用
table.remove();而不是直接给中间位置赋值nil。
当然,并不是说所有 nil 赋值都有问题。例如对于原本连续的数组,arr[#arr] = nil 可以直接移除末尾元素,不会在中间制造空洞。
真正重要的是:你当前使用的数据结构,是否要求维护连续的有效索引。
4. 数组遍历:ipairs() 与泛型 for 的职责分工
我们此前已经学习过 Lua 的泛型 for 和迭代器,因此这里可以直接复用已有知识。
lua
local arr = {10, 20, 30}
for i, value in ipairs(arr) do
print(i, value)
end
对于 ipairs(arr),我们可以把它返回的核心三元组理解为:
text
迭代器函数 不变状态 初始控制变量
iterator arr 0
Lua 5.4 的泛型 for 机制还支持可选的第四个"关闭值",但它不影响这里关于 ipairs 的核心理解。
ipairs() 使用的是无状态迭代器。
所谓无状态,指的是迭代器函数本身不需要通过闭包保存"已经遍历到哪个位置";当前控制变量由外部的泛型 for 保存并更新,每次调用时作为参数传入。
我们甚至可以自己写一个简化版本:
lua
local function iterator(t, index)
local nextIndex = index + 1
local value = t[nextIndex]
if value == nil then
return nil
end
return nextIndex, value
end
local function myIpairs(t)
return iterator, t, 0
end
local arr = {10, 20, 30}
for i, value in myIpairs(arr) do
print(i, value)
end
执行流程可以理解为:
text
泛型 for 保存:iterator、arr、0
↓
调用 iterator(arr, 0) → 1, 10
↓
第一个返回值 1 不为 nil,执行循环体
↓
更新控制变量为 1
↓
调用 iterator(arr, 1) → 2, 20
↓
继续循环......
↓
调用 iterator(arr, 3) → nil
↓
第一个返回值为 nil,遍历结束
这里需要分清两个判断:
- 迭代器内部 :检查下一个索引的值是否为
nil,若是则返回nil。 - 泛型
for:检查迭代器的第一个返回值 是否为nil,若是则终止循环。
所以 ipairs() 不是先计算 #arr,再遍历到该长度;它从索引 1 开始逐次访问,遇到第一个 nil 就停止。
对于有状态迭代器,则可以通过闭包捕获并维护 index:
lua
local function createIterator(t)
local index = 0
return function()
index = index + 1
if t[index] == nil then
return nil
end
return index, t[index]
end
end
for i, value in createIterator({10, 20, 30}) do
print(i, value)
end
这时遍历位置保存在闭包捕获的 index 中,迭代器每被调用一次就主动更新它。对比前面的 ipairs(),两者可以使用同样的泛型 for 语法,但遍历进度究竟由哪里维护并不一样。不要把两种机制混为一谈。
5. 二维数组:Table 嵌套 Table
理解了一维数组之后,二维数组实际上非常自然。
我们可以联想到 C++ 的:
cpp
std::vector<std::vector<int>> matrix;
外层 vector 的每个元素都是一个 vector<int>;Lua 中则可以让外层 Table 的每个元素引用另一张 Table:
lua
local matrix = {
{10, 20, 30},
{40, 50, 60},
{70, 80, 90}
}
print(matrix[2][3]) -- 60

理解 matrix[2][3] 时,不需要把它想得很神秘:
lua
local row = matrix[2] -- 先取第二行 Table 的引用
local value = row[3] -- 再访问这张子 Table 的索引 3
print(value) -- 60
所以,二维数组本质上就是:最外层 Table 保存子 Table 的引用,而每个子 Table 再通过整数键保存该行的数据。
6. 动态创建一个指定行列数的二维数组
假设我们需要动态创建 3 行 4 列,元素初始值全部为 0:
lua
local rows = 3
local cols = 4
local matrix = {}
for i = 1, rows do
matrix[i] = {} -- 每轮创建一张新的子 Table
for j = 1, cols do
matrix[i][j] = 0
end
end
这里的职责分工非常清晰:
- 外层循环:依次创建每一行对应的 Table。
- 内层循环:向当前行 Table 中写入各列的键值对。
注意:每次循环都要重新执行 matrix[i] = {},避免多行指向同一张 Table。
下面这种写法就有引用共享问题:
lua
local row = {0, 0, 0}
local matrix = {row, row, row}
matrix[1][1] = 100
print(matrix[2][1]) -- 100,三行引用的是同一张 Table
这也再次说明,Table 保存的是引用,我们需要根据业务需求决定"共享同一个对象"还是"创建独立对象"。
7. 二维数组的增删:区分"行"和"列"
对于二维数组,操作其实有两个不同层次。
第一类:操作外层 Table,也就是增删一整行。
lua
table.insert(matrix, {1, 2, 3, 4}) -- 插入一整行
table.remove(matrix, 2) -- 删除第二行
第二类:操作某一行 Table,也就是增删该行内部的元素。
lua
table.insert(matrix[1], 2, 99) -- 第一行索引 2 插入 99
table.remove(matrix[1], 3) -- 第一行删除索引 3
这里还需要区分:只给某一行插入一个元素,不等于向整个二维数组插入一列。
如果希望向所有行的第二列都插入一个值,需要遍历所有行:
lua
for _, row in ipairs(matrix) do
table.insert(row, 2, 99)
end
在上述代码中,我们假设 matrix 是外层连续、各行也都是正常序列的二维数组。
还有一点:Lua 不强制每一行具有相同长度。
lua
local jagged = {
{1, 2},
{3, 4, 5},
{6}
}
这种不规则二维数组也是合法的。因此,如果业务需要严格的矩阵,就需要自己维护各行列数一致的约束。
二维、三维乃至更高维数组的机制没有本质区别:继续增加 Table 嵌套层级即可。在日常学习中,理解一维与二维就足以掌握核心思想。
三、链表:从"索引访问"过渡到"节点引用"
1. 链表在 Lua 中如何表示?
对于数组,我们通常使用整数索引来组织数据;而对于链表,节点在逻辑上通过引用相连,不要求在内存中连续排列。
既然 Table 的值可以保存另一张 Table 的引用,那么我们就可以让一张 Table 表示一个链表节点:
lua
local node1 = {value = 10}
local node2 = {value = 20}
local node3 = {value = 30}
node1.next = node2
node2.next = node3
local head = node1

对于一个单向链表节点,我们通常在逻辑上约定两个字段:
text
node
├── value:保存节点数据
└── next :保存下一个节点的引用
需要注意:next 只是我们自定义的字段名,不是 Lua 的特殊关键字。末尾节点的 next 为 nil 时,Lua 也不会实际保留一组 next = nil 的键值对;它只是表达"没有下一个节点"。
2. 链表遍历:while 最直观,但并不是只能使用 while
对于链表,我们最容易想到的是沿着 next 一直走:
lua
local current = head
while current ~= nil do
print(current.value)
current = current.next
end
执行逻辑很简单:
text
current 保存当前节点引用
↓
访问 current.value
↓
current = current.next
↓
直到 current 为 nil
但这里不能认为"链表节点不连续,所以不能使用泛型 for"。
因为我们此前已经认识到:泛型 for 不要求数据物理连续,它只要求迭代器按照约定返回下一个控制变量。
例如,我们也可以写一个链表迭代器:
lua
local function nextNode(firstNode, current)
if current == nil then
return firstNode
end
return current.next
end
for node in nextNode, head, nil do
print(node.value)
end
这个例子同样采用了泛型 for 的迭代器协议。
因此,链表用 while 是因为写法直观 ,而不是因为 for 在语义上无法支持它。当然,我们不能直接把这种节点式链表交给 ipairs(),因为它不是从 1 开始的整数索引序列。
3. 插入、删除:本质是修改引用关系
假设当前链表为:
text
node1(10) → node2(20) → node3(30) → nil
现在希望在 node1 后面插入新节点 100:
lua
local newNode = {value = 100}
newNode.next = node1.next
node1.next = newNode
为什么先修改 newNode.next?
因为我们先要让新节点保存原本后继节点的引用,然后再将前一个节点指向新节点。这样就不容易丢失原来的链路。
最终得到:
text
node1(10) → newNode(100) → node2(20) → node3(30) → nil
若要删除中间节点 node2,并且已经知道它的前驱节点,则可以让前驱节点直接跳过它:
lua
-- 假设 node1.next 此时仍然直接指向 node2
node1.next = node2.next
这里我们没有移动其他节点的数据,只是调整引用关系。
还要注意两个细节:
- 删除头节点时,需要更新
head;删除某个节点时,必须有办法找到它及其前驱节点。 - 把节点从链表中摘掉,不代表对象会立即消失;如果仍有其他引用指向它,它仍然可以被访问,垃圾回收器也不会回收它。
4. 双向链表与复杂度
双向链表只是在节点中增加一个指向前驱的引用:
lua
local a = {value = 10}
local b = {value = 20}
a.next = b
b.prev = a
对于链表来说:
- 已知前驱节点时,插入、删除可以通过常数次引用修改完成。
- 如果需要从头开始寻找目标节点,则查找本身可能需要
O(N)。 - 在 Lua 中,每个节点使用一张独立 Table 还会带来额外的对象分配与垃圾回收开销。
所以链表并不是在所有情况下都优于数组;它只是更适合某些需要频繁调整节点连接关系的场景。
四、栈与队列:同样是 Table,不同的是操作规则
前面学习数组和链表时,我们更多关注的是数据如何组织。
而栈与队列更侧重于数据按照什么顺序插入与取出。
这也是为什么栈、队列可以采用数组实现,也可以采用链表实现。我们没有必要把它们理解为与底层存储方式完全绑定的结构。
1. 栈:约定数组末尾就是栈顶
栈遵循后进先出(LIFO)。对于 Lua,直接使用 Table 数组的尾部操作即可:
lua
local stack = {}
-- 入栈
table.insert(stack, 10)
table.insert(stack, 20)
table.insert(stack, 30)
-- 查看栈顶
print(stack[#stack]) -- 30
-- 出栈
print(table.remove(stack)) -- 30
这里其实没有什么新的复杂机制:
text
入栈:在 Table 数组末尾插入
出栈:从 Table 数组末尾删除
只要我们约定最后进入的元素位于栈顶,就自然满足后进先出。
对于正常序列,尾部操作通常不需要整体搬移已有元素,因此很适合用 Table 数组实现栈。
2. 队列:为什么不能简单照搬栈的操作?
队列遵循先进先出(FIFO)。最简单的写法是:
lua
local queue = {}
table.insert(queue, 10)
table.insert(queue, 20)
table.insert(queue, 30)
print(table.remove(queue, 1)) -- 10
语义没有问题,但如果队列有大量元素,table.remove(queue, 1) 删除第一个元素时,需要把后面的元素向前移动。
假设当前有 N 个元素,那么队头删除通常需要 O(N) 的移动成本。
这里自然会产生一个问题:
我们只是想让旧的队头失效,为什么必须把后面的所有数据整体向前移动?
答案是:不需要。
3. 双索引队列:只维护有效区间,不再依赖 #
我们可以把队列设计成:
lua
local queue = {
data = {},
first = 1,
last = 0
}
这里:
first指向当前队头所在的整数索引。last指向当前队尾所在的整数索引。first > last表示队列为空。
入队:
lua
local function enqueue(value)
assert(value ~= nil, "queue 不接受 nil")
queue.last = queue.last + 1
queue.data[queue.last] = value
end
出队:
lua
local function dequeue()
if queue.first > queue.last then
return nil
end
local value = queue.data[queue.first]
queue.data[queue.first] = nil
queue.first = queue.first + 1
-- 队列重新变空时,重置索引
if queue.first > queue.last then
queue.first = 1
queue.last = 0
end
return value
end
例如,队列在出队前:
text
索引: 1 2 3 4
数据: 10 20 30 40
↑ ↑
first last
删除 10 后:
text
索引: 1 2 3 4
数据: nil 20 30 40
↑ ↑
first last

这里我们并没有把 20、30、40 移动到前面,只是让 first 指向索引 2。
这时虽然索引 1 出现了 nil,但并不会妨碍我们实现队列,因为:
队列的有效区间由 first 与 last 决定,而不是由 #queue.data 决定。
队列长度可以计算为:
lua
local size = queue.last - queue.first + 1
所以,关键并不是强行保证 Table 从索引 1 开始全部连续,而是保证当前有效区间中的索引有意义,并由我们自己维护这个区间。
在通常的均摊成本分析下,这种队列的入队与出队都可以做到近似 O(1),避免头部删除导致的 O(N) 元素移动。
4. 循环队列:固定容量 + 索引取模
不过,普通双索引队列还有一个问题:如果长期不断入队、出队,两个索引可能持续增大。
既然前面已经有被移除元素留下的空位,为什么不能把它们重新利用起来?
这就是循环队列的思路。
循环队列本质上就是固定容量的数组,加上能够循环移动的队头、队尾索引。
但循环队列除了取模,还需要解决一个经典问题:如何区分队列为空和队列已满?
一个非常直观的办法是增加 size:
lua
local queue = {
data = {},
capacity = 5,
first = 1, -- 下一个出队位置
rear = 1, -- 下一个入队位置
size = 0
}
local function nextIndex(i)
return i % queue.capacity + 1
end
local function enqueue(value)
assert(value ~= nil, "queue 不接受 nil")
if queue.size == queue.capacity then
return false -- 队满
end
queue.data[queue.rear] = value
queue.rear = nextIndex(queue.rear)
queue.size = queue.size + 1
return true
end
local function dequeue()
if queue.size == 0 then
return nil -- 队空
end
local value = queue.data[queue.first]
queue.data[queue.first] = nil
queue.first = nextIndex(queue.first)
queue.size = queue.size - 1
return value
end
这里还要注意一个命名约定:本例中的 rear 指向的是"下一次入队的位置",不是最后一个有效元素的位置 。这与前面双索引队列的 last 有区别,因此不能机械套用同一个判断公式。
除此之外,还有"牺牲一个槽位"来区分空和满的写法;但对理解 Lua 数据结构来说,掌握上面的思路已经足够。
最后形成这样的逻辑:
text
栈:只操作末尾 → Table 数组直接复用
队列:尾进头出 → 若每次 remove(1),会移动元素
↓
维护 first / last
↓
只移动索引,不移动有效元素
↓
进一步:固定容量 + 取模 = 循环队列
五、集合与映射:回到 Table 的键值关系
数组、链表、栈和队列已经说明了一点:Table 可以通过不同方式组织元素。
接下来研究集合和映射,我们又可以回到 Table 最直接的能力------通过键访问值。
1. 集合:只关心某个元素是否存在
集合(Set)主要研究的是成员关系:某个元素是否属于这个集合。
对于 Lua,我们可以直接让集合元素成为 Table 的键:
lua
local unlockedSkills = {
fireball = true,
heal = true,
shield = true
}
添加、删除和查询都非常直接:
lua
unlockedSkills.lightning = true -- 添加
print(unlockedSkills.fireball ~= nil) -- true
unlockedSkills.heal = nil -- 删除
这里用 true 只是一种约定,意思是"这个键存在"。核心并不是值有什么特别的意义,而是用键来表达集合元素。
如果我们原本就有一张 Table,且只想知道某个键存不存在,那么根本不需要额外创建一个集合。
例如:
lua
local player = {
name = "战士",
hp = 100,
online = false
}
print(player.hp ~= nil) -- true
print(player.online ~= nil) -- true
注意:不能直接用 if player.online then 判断是否存在,因为 false 是一个合法的值,但在条件判断中为假。
还有元表的影响。如果我们此前为 Table 配置过 __index,普通访问一个不存在的键也可能返回默认值:
lua
local player = {name = "战士"}
setmetatable(player, {
__index = function(t, key)
if key == "hp" then return 100 end
end
})
print(player.hp) -- 100
print(rawget(player, "hp")) -- nil
这里普通读取体现的是元表参与之后的访问结果,而 rawget() 查询的是这张 Table 自身实际保存的键值。
对应地,__newindex 可能接管对缺失键的普通赋值。因此讨论"读键""写键"时,需要说明有没有相关元表干预。
顺便联系 C++:std::unordered_map 的 operator[] 在键不存在时会插入默认初始化的值,但 find()、contains() 等查找方式不会。不能笼统地说 C++ 哈希表"只要读取就会新增键"。
2. 集合运算:不必使用嵌套循环
这是我们讨论中另一个值得串联的知识点。
假设:
lua
local A = {apple = true, banana = true, orange = true}
local B = {banana = true, grape = true}
若要计算交集,直觉上可能想到遍历 A,再逐一遍历 B,比较元素是否相等。
但这样就失去了 Table 哈希查找的优势。
实际上,我们只需要遍历 A,并直接通过 B[key] 判断对应元素是否存在:
交集 A ∩ B:
lua
local intersection = {}
for key in pairs(A) do
if B[key] ~= nil then
intersection[key] = true
end
end
并集 A ∪ B:
lua
local union = {}
for key in pairs(A) do
union[key] = true
end
for key in pairs(B) do
union[key] = true
end
差集 A − B:
lua
local difference = {}
for key in pairs(A) do
if B[key] == nil then
difference[key] = true
end
end
假设集合 A 有 N 个元素,集合 B 有 M 个元素,并且普通键查找在平均情况下可以视为 O(1),那么:
| 运算 | 实现方式 | 平均时间复杂度 |
|---|---|---|
| 交集 A ∩ B | 遍历 A,按键查找 B | O(N) |
| 并集 A ∪ B | 分别遍历 A 和 B | O(N + M) |
| 差集 A − B | 遍历 A,按键查找 B | O(N) |
如果交集改为遍历较小的集合,平均查找次数还可以进一步减少。这里的复杂度没有计入元表自定义行为等特殊情况。
所以关键不是"集合操作一定要两层循环",而是:既然 Table 本来就能通过键直接定位元素,就没有必要反复扫描另一个集合。
3. 映射:除了键是否存在,更关心键对应的值
集合更关注"有没有这个元素",而映射(Map)更关注"这个键对应什么数据"。
例如在游戏服务端中,我们需要通过玩家 ID 查找玩家信息:
lua
local players = {
[1001] = {name = "战士", level = 10},
[1002] = {name = "法师", level = 20},
[1003] = {name = "射手", level = 15}
}
这里:
text
玩家 ID(Key) → 玩家信息(Value)
1001 → Table:战士、等级 10
1002 → Table:法师、等级 20
1003 → Table:射手、等级 15
对应的增删改查完全是此前学过的 Table 键值操作:
lua
-- 新增
players[1004] = {name = "刺客", level = 5}
-- 查找
local player = players[1002]
if player ~= nil then
print(player.name)
end
-- 修改
players[1002].level = 30
-- 删除
players[1003] = nil
-- 遍历
for playerId, info in pairs(players) do
print(playerId, info.name, info.level)
end
这里我们通常采用 pairs() 而不是 ipairs(),因为映射的键不一定是从 1 开始连续的整数。pairs() 的遍历顺序也不保证固定。
所以对于集合与映射,我们最终可以理解为:
text
Table 的键值映射
├── 集合:键表示元素;值通常统一为 true
└── 映射:键用于定位;值保存真正的业务数据
它们不需要为"集合"和"映射"各发明一套全新的底层存储结构。
六、树:利用 Table 引用关系组织层次结构
对于树,其实我们已经不需要重新学习二叉树的基本定义了。
因为有了链表的知识,树在 Lua 中的实现也就非常自然:链表节点保存下一个节点的引用;二叉树节点保存左右子节点的引用。
1. 普通二叉树:每个节点用一张 Table
lua
local root = {value = 10}
root.left = {value = 20}
root.right = {value = 30}
root.left.left = {value = 40}
root.left.right = {value = 50}
我们可以把节点理解为:
text
node
├── value:当前节点的数据
├── left :左子节点 Table 的引用(或 nil)
└── right:右子节点 Table 的引用(或 nil)
这里 left、right 与链表中的 next 并没有本质区别,都是我们定义的字段,用来表达对象之间的引用关系。
由于它们本身也是 Table,我们可以很方便地递归访问:
lua
local function preorder(node)
if node == nil then
return
end
print(node.value)
preorder(node.left)
preorder(node.right)
end
preorder(root)
-- 10 20 40 50 30
这实际上就是把我们已经学过的函数递归 + Table 引用组合起来。
2. 完全二叉树:还能通过数组索引表达父子关系
对于完全二叉树,特别是堆这类结构,我们可以直接采用数组的层序表示。
lua
local tree = {10, 20, 30, 40, 50}

由于 Lua 数组通常从索引 1 开始,因此对于索引为 i 的节点:
text
左子节点:2 * i
右子节点:2 * i + 1
父节点: math.floor(i / 2) (i > 1)
如果计算出的子节点索引已经超出了有效数组范围,就表示没有对应子节点。
这里需要理解两种方式的区别:
- 普通二叉树 :通过独立 Table 节点之间的
left、right引用组织关系,更灵活。 - 完全二叉树:可以把父子关系隐含在连续的数组索引中,更适合数组表达。
同一棵树的数据结构性质没有变化,变化的是节点关系的具体表示方式。
七、最后统一梳理:Table 如何复用成不同的数据结构?
回顾整篇文章,我们会发现 Lua 的常见数据结构虽然名字很多,但真正用到的基础能力并不复杂。
| 数据结构 | Lua 中最常见的表示方式 | 需要重点维护的规则 |
|---|---|---|
| 一维数组 | 一张 Table,使用整数索引 | 索引连续性、# 与空洞 |
| 二维数组 | 外层 Table 保存子 Table 引用 | 行列关系与每行独立对象 |
| 单向/双向链表 | 每个节点一张 Table | next / prev 引用关系 |
| 栈 | Table 数组 | 只从末尾入栈、出栈 |
| 队列 | Table + 队头/队尾索引 | 先进先出、有效区间 |
| 循环队列 | 固定容量 Table + 循环索引 | 取模、判空、判满 |
| 集合 | Table 的键表示集合元素 | 成员唯一性、存在性判断 |
| 映射 | Table 的键对应业务数据 | 键值查找和更新 |
| 二叉树 | Table 节点引用或数组层序表示 | 父子关系与树的结构约束 |
1. 哪些结构可以直接用标准库,哪些要自己维护?
数组与栈 通常可以直接利用 Table 数组,再结合 table.insert() / table.remove() 实现常见操作。
队列虽然也可以用这两个函数写出一个基础版本,但在频繁出队的场景中,更适合自己维护头尾索引,避免整体移动元素。
链表与引用式树需要我们自己组织节点之间的连接关系,因为 Lua 没有直接提供专门的链表、二叉树容器。
集合与映射几乎可以直接复用 Table 的键值能力;两者的区别主要来自业务上如何理解 Key 和 Value。
2. 不要把"逻辑结构"与"内部存储实现"混为一谈
这是本文最值得反复强调的一点。
例如:
- "Lua 数组索引是连续的",说的是我们在程序中维护的逻辑索引关系,并不等于 Table 一定把所有元素放在一块连续物理内存里。
- "链表节点可以离散",说的是链表不依赖数组式的连续位置关系,而是通过引用连接节点。
- "栈与队列",本质上描述的是插入和删除顺序,具体底层既可以用数组,也可以用链表。
- "集合与映射",都可以基于 Table 的键值操作构建,区别是我们希望表达什么业务语义。
3. 复杂度还要结合具体实现分析
不能仅凭"它叫链表"或"它叫队列",就直接判断某次操作一定是 O(1)。
例如:
- 链表已知前驱节点时插入是常数次引用修改;但如果需要先寻找位置,查找仍可能为
O(N)。 - 栈的尾部操作通常不需要搬移大量元素;中间位置插入或删除数组元素则可能需要
O(N)移动。 - 队列采用
table.remove(queue, 1),队头删除通常是O(N);采用头尾索引后,通常可以达到均摊O(1)。 - 集合的交集不必嵌套遍历,利用另一个 Table 的平均常数时间键查找,可以使一次单向扫描达到
O(N)。
特别地,Lua 的 Table 长度运算、扩容和重哈希都存在具体实现成本。因此,上面的复杂度适合用来分析主要操作与平均性能 ,不能机械理解成 Lua 标准库已经对全部操作承诺了严格最坏情况 O(1)。
八、总结:真正需要掌握的不是更多名称,而是组合已有机制的能力
从一开始,我们便认识到:数组、链表、栈、队列、集合、映射和树并不是 Lua 独有的概念。它们的抽象性质不会随着编程语言发生变化。
Lua 的特殊之处在于,它没有为每一种结构分别提供一个专用的内置容器,而是把非常多的组织能力集中在 Table 这一通用类型上。
因此,我们在学习这些数据结构时,实际上一直在复用此前的知识:
text
TValue 的值表示
↓
Table 对象及其引用
↓
整数键与键值映射
↓
标准库函数、while、泛型 for 与迭代器
↓
按不同规则组织、操作 Table
↓
数组 / 链表 / 栈 / 队列 / 集合 / 映射 / 树
对于我们后面阅读游戏服务端的 Lua 业务代码来说,这一点也很重要。看到一张 Table,不应该只把它机械地理解为"一个表",而是要继续判断:
这张 Table 究竟是在维护数组、集合、映射、队列,还是在作为某个节点保存其他对象的引用?它的操作规则是什么?
只要理解了它如何组织数据、如何访问元素、如何保持约束,以及相关操作的性能特点,就能够更容易读懂这些业务代码背后的数据结构设计。
最终结论:Table 是 Lua 中通用的存储基础,而数据结构的真正意义,来自我们赋予这份数据的组织方式和操作规则。
