【Lua 入门系列】Lua数据结构:从 Table 出发,一文串联数组、链表、栈、队列、集合、映射与树的实现原理

🔥 本文专栏: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,遍历结束

这里需要分清两个判断:

  1. 迭代器内部 :检查下一个索引的值是否为 nil,若是则返回 nil。
  2. 泛型 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

这里我们没有移动其他节点的数据,只是调整引用关系。

还要注意两个细节:

  1. 删除头节点时,需要更新 head;删除某个节点时,必须有办法找到它及其前驱节点。
  2. 把节点从链表中摘掉,不代表对象会立即消失;如果仍有其他引用指向它,它仍然可以被访问,垃圾回收器也不会回收它。

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 中通用的存储基础,而数据结构的真正意义,来自我们赋予这份数据的组织方式和操作规则。


相关推荐
漂流瓶jz10 小时前
UVA-1612 猜名次 题解答案代码 算法竞赛入门经典第二版
数据结构·算法·题解·排序·aoapc·算法竞赛入门经典
A boy CDEF girl16 小时前
算法题专栏:优选算法
数据结构·算法·leetcode
图灵-M20 小时前
【数据结构】静态 & 动态顺序表
c语言·数据结构·笔记
careathers20 小时前
【数据结构】基于比较的排序算法 - 选择排序
数据结构·算法·排序算法
XiYang-DING1 天前
P4104.环形链表判断
数据结构·链表
逆向编程1 天前
MATLAB高级数据结构:cell、struct与table深度应用
开发语言·数据结构·matlab
努力努力再努力wz1 天前
【Lua入门系列】从 Table 到元表与元方法:一文理解 Lua 的行为扩展机制
开发语言·junit·lua
m0_639906941 天前
Redis入门|五种数据结构、使用场景、持久化RDB&AOF
数据结构·数据库·redis
2601_970703832 天前
工业除湿机租赁:把一份租金合同拆开看
大数据·数据结构·算法·数据库架构·推荐算法