table 是 Lua 中唯一的数据结构,其他语言所提供的其他数据结构比如:arrays、records、lists、queues、sets 等,Lua 都是通过 table 来实现,并且在 lua 中 table 很好的实现了这些数据结构。
在传统的 C 语言或者 Pascal 语言中我们经常使用 arrays 和 lists(record+pointer)来实现大部分的数据结构,在 Lua 中不仅可以用 table 完成同样的功能,而且 table 的功能更加强大。通过使用 table 很多算法的实现都简化了,比如你在 lua 中很少需要自己去实现一个搜索算法,因为 table 本身就提供了这样的功能。
我们需要花一些时间去学习如何有效的使用 table,下面我们通过一些例子来看看如果通过 table 来实现一些常用的数据结构。首先,我们从 arrays 和 lists 开始,不仅因为它是其他数据结构的基础,而且是我们所熟悉的。在第一部分语言的介绍中,我们已经接触到了一些相关的内容,在这一章我们将再来完整的学习他。
在 lua 中通过整数下标访问表中的元素即可简单的实现数组。并且数组不必事先指定大小,大小可以随需要动态的增长。
通常我们初始化数组的时候就间接的定义了数组的大小,比如下面的代码:
a = {} -- new array
for i=1, 1000 do
a[i] = 0
end
通过初始化,数组 a 的大小已经确定为 1000,企图访问 1-1000 以外的下标对应的值将返回 nil。你可以根据需要定义数组的下标从 0,1 或者任意其他的数值开始,比如:
-- creates an array with indices from -5 to 5
a = {}
for i=-5, 5 do
a[i] = 0
end
然而在 Lua 中习惯上数组的下表从 1 开始,Lua 的标准库与此习惯保持一致,因此如果你的数组下标也是从 1 开始你就可以直接使用标准库的函数,否则就无法直接使用。
我们可以用构造器在创建数组的同时并初始化数组:
squares = {1, 4, 9, 16, 25, 36, 49, 64, 81}
这样的语句中数组的大小可以任意的大,甚至几百万。
Lua 中主要有两种表示矩阵的方法,第一种是用数组的数组表示。也就是说一个表的元素是另一个表。例如,可以使用下面代码创建一个 n
行 m 列的矩阵:
mt = {} -- create the matrix
for i=1,N do
mt[i] = {} -- create a new row
for j=1,M do
mt[i][j] = 0
end
end
由于 Lua 中 table 是个对象,所以对于每一行我们必须显式的创建一个 table,这看起来比起 c 或者 pascal 显得冗余,另一方面它也提供了更多的灵活性,例如可以修改前面的例子来创建一个三角矩阵:
for j=1,M do
改成
for j=1,i do
这样实现的三角矩阵比起整个矩阵,仅仅使用一半的内存空间。第二中表示矩阵的方法是将行和列组合起来,如果索引下标都是整数,通过第一个索引乘于一个常量(列)再加上第二个索引,看下面的例子实现创建 n 行 m 列的矩阵:
mt = {} -- create the matrix
for i=1,N do
for j=1,M do
mt[i*M + j] = 0
end
end
如果索引都是字符串的话,可以用一个单字符将两个字符串索引连接起来构成一个单一的索引下标,例如一个矩阵 m,索引下标为 s 和 t,假定 s 和 t 都不包含冒号,代码为:m[s…’:’…t],如果 s 或者 t 包含冒号将导致混淆,比如(“a:”, “b”) 和(“a”, “:b”),当对这种情况有疑问的时候可以使用控制字符来连接两个索引字符串,比如’\0’。实际应用中常常使用稀疏矩阵,稀疏矩阵指矩阵的大部分元素都为空或者 0 的矩阵。
例如,我们通过图的邻接矩阵来存储图,也就是说:当 m,n 两个节点有连接时,矩阵的m,n 值为对应的 x,否则为 nil。如果一个图有 10000 个节点,平均每个节点大约有 5 条边,为了存储这个图需要一个行列分别为 10000 的矩阵,总计 10000*10000 个元素,实际上大约只有 50000 个元素非空(每行有五列非空,与每个节点有五条边对应)。很多数据结构的书上讨论采用何种方式才能节省空间,但是在 Lua 中你不需要这些技术,因为用 table 实现的数据本身天生的就具有稀疏的特性。如果用我们上面说的第一种多维数组来表示,需要 10000 个table,每个 table 大约需要五个元素(table);如果用第二种表示方法来表示,只需要一张大约 50000 个元素的表,不管用那种方式,你只需要存储那些非 nil 的元素。
Lua 中用 tables 很容易实现链表,每一个节点是一个 table,指针是这个表的一个域,并且指向另一个节点(table)。例如,要实现一个只有两个域:值和指针的基本链表,代码如下:根节点:
list = nil
在链表开头插入一个值为 v 的节点:
list = {next = list, value = v}
要遍历这个链表只需要:
local l = list
while l do
print(l.value)
l = l.next
end
其他类型的链表,像双向链表和循环链表类似的也是很容易实现的。然后在 Lua 中在很少情况下才需要这些数据结构,因为通常情况下有更简单的方式来替换链表。比如,我们可以用一个非常大的数组来表示栈,其中一个域 n 指向栈顶。
虽然可以使用 Lua 的 table 库提供的 insert 和 remove 操作来实现队列,但这种方式实现的队列针对大数据量时效率太低,有效的方式是使用两个索引下标,一个表示第一个元素,另一个表示最后一个元素。
function ListNew ()
return {first = 0, last = -1}
end
为了避免污染全局命名空间,我们重写上面的代码,将其放在一个名为 list 的 table中:
List = {}
function List.new ()
return {first = 0, last = -1}
end
下面,我们可以在常量时间内,完成在队列的两端进行插入和删除操作了。
function List.pushleft (list, value)
local first = list.first - 1
list.first = first
list[first] = value
end
function List.pushright (list, value)
local last = list.last + 1
list.last = last
list[last] = value
end
function List.popleft (list)
local first = list.first
if first > list.last then error("list is empty") end
local value = list[first]
list[first] = nil -- to allow garbage collection
list.first = first + 1
return value
end
function List.popright (list)
local last = list.last
if list.first > last then error("list is empty") end
local value = list[last]
list[last] = nil -- to allow garbage collection
list.last = last - 1
return value
end
对严格意义上的队列来讲,我们只能调用 pushright 和 popleft,这样以来,first 和 last的索引值都随之增加,幸运的是我们使用的是 Lua 的 table 实现的,你可以访问数组的元素,通过使用下标从 1 到 20,也可以 16,777,216 到 16,777,236。另外,Lua 使用双精度表示数字,假定你每秒钟执行 100 万次插入操作,在数值溢出以前你的程序可以运行 200年。
假定你想列出在一段源代码中出现的所有标示符,某种程度上,你需要过滤掉那些语言本身的保留字。一些 C 程序员喜欢用一个字符串数组来表示,将所有的保留字放在数组中,对每一个标示符到这个数组中查找看是否为保留字,有时候为了提高查询效率,对数组存储的时候使用二分查找或者 hash 算法。
Lua 中表示这个集合有一个简单有效的方法,将所有集合中的元素作为下标存放在一个 table 里,下面不需要查找 table,只需要测试看对于给定的元素,表的对应下标的元素值是否为 nil。比如:
reserved = {
["while"] = true, ["end"] = true,
["function"] = true, ["local"] = true,
}
for w in allwords() do
if reserved[w] then
-- `w' is a reserved word
...
还可以使用辅助函数更加清晰的构造集合:
function Set (list)
local set = {}
for _, l in ipairs(list) do set[l] = true end
return set
end
reserved = Set{"while", "end", "function", "local", }
假定你要拼接很多个小的字符串为一个大的字符串,比如,从一个文件中逐行读入字符串。你可能写出下面这样的代码:
-- WARNING: bad code ahead!!
local buff = ""
for line in io.lines() do
buff = buff .. line .. "\n"
end
尽管这段代码看上去很正常,但在 Lua 中他的效率极低,在处理大文件的时候,你会明显看到很慢,例如,需要花大概 1 分钟读取 350KB 的文件。(这就是为什么 Lua 专门提供了 io.read(*all)选项,她读取同样的文件只需要 0.02s)为什么这样呢?Lua 使用真正的垃圾收集算法,但他发现程序使用太多的内存他就会遍历他所有的数据结构去释放垃圾数据,一般情况下,这个算法有很好的性能(Lua的快并非偶然的),但是上面那段代码 loop 使得算法的效率极其低下。
为了理解现象的本质,假定我们身在 loop 中间,buff 已经是一个 50KB 的字符串,每一行的大小为 20bytes,当 Lua 执行buff…line…"\n"时,她创建了一个新的字符串大小为50,020 bytes,并且从 buff 中将 50KB 的字符串拷贝到新串中。也就是说,对于每一行,都要移动 50KB 的内存,并且越来越多。读取 100 行的时候(仅仅 2KB),Lua 已经移动了 5MB 的内存,使情况变遭的是下面的赋值语句:
buff = buff … line … “\n”
老的字符串变成了垃圾数据,两轮循环之后,将有两个老串包含超过 100KB 的垃圾数据。这个时候 Lua 会做出正确的决定,进行他的垃圾收集并释放 100KB 的内存。问题在于每两次循环 Lua 就要进行一次垃圾收集,读取整个文件需要进行 200 次垃圾收集。并且它的内存使用是整个文件大小的三倍。这个问题并不是 Lua 特有的:其它的采用垃圾收集算法的并且字符串不可变的语言也都存在这个问题。Java 是最著名的例子,Java 专门提供 StringBuffer 来改善这种情况。
在继续进行之前,我们应该做个注释的是,在一般情况下,这个问题并不存在。对于小字符串,上面的那个循环没有任何问题。为了读取整个文件我们可以使用io.read(*all),可以很快的将这个文件读入内存。但是在某些时候,没有解决问题的简单的办法,所以下面我们将介绍更加高效的算法来解决这个问题。
我们最初的算法通过将循环每一行的字符串连接到老串上来解决问题,新的算法避免如此:它连接两个小串成为一个稍微大的串,然后连接稍微大的串成更大的串。。。算法的核心是:用一个栈,在栈的底部用来保存已经生成的大的字符串,而小的串从栈定入栈。栈的状态变化和经典的汉诺塔问题类似:位于栈下面的串肯定比上面的长,只要一个较长的串入栈后比它下面的串长,就将两个串合并成一个新的更大的串,新生成的串继续与相邻的串比较如果长于底部的将继续进行合并,循环进行到没有串可以合并或者到达栈底。
function newStack ()
return {""} -- starts with an empty string
end
function addString (stack, s)
table.insert(stack, s) -- push 's' into the the stack
for i=table.getn(stack)-1, 1, -1 do
if string.len(stack[i]) > string.len(stack[i+1]) then
break
end
stack[i] = stack[i] .. table.remove(stack)
end
end
要想获取最终的字符串,我们只需要从上向下一次合并所有的字符串即可。table.concat 函数可以将一个列表的所有串合并。使用这个新的数据结构,我们重写我们的代码:
local s = newStack()
for line in io.lines() do
addString(s, line .. "\n")
end
s = toString(s)
最终的程序读取 350 KB 的文件只需要 0.5s,当然调用 io.read("*all")仍然是最快的只需要 0.02s。
实际上,我们调用 io.read("*all")的时候,io.read 就是使用我们上面的数据结构,只不过是用 C 实现的,在 Lua 标准库中,有些其他函数也是用 C 实现的,比如 table.concat,使用 table.concat 我们可以很容易的将一个 table 的中的字符串连接起来,因为它使用 C实现的,所以即使字符串很大它处理起来速度还是很快的。
Concat 接受第二个可选的参数,代表插入的字符串之间的分隔符。通过使用这个参数,我们不需要在每一行之后插入一个新行:
local t = {}
for line in io.lines() do
table.insert(t, line)
end
s = table.concat(t, "\n") .. "\n"
io.lines 迭代子返回不带换行符的一行,concat 在字符串之间插入分隔符,但是最后一字符串之后不会插入分隔符,因此我们需要在最后加上一个分隔符。最后一个连接操作复制了整个字符串,这个时候整个字符串可能是很大的。我们可以使用一点小技巧,插入一个空串:
table.insert(t, "")
s = table.concat(t, "\n")
table的字段:name(结点的名称),adj(与该结点邻接的结点集合)
function name2node(graph, name) --将名称对应到结点
if not graph[name] then --结点不存在,创建一个新的
graph[name] = {name = name, adj = {}}
end
return graph[name]
end
function readgraph() --构造一个图
local graph = {}
for line in io.lines() do
local namefrom, nameto = string.match(line,"(%S+)%s+(%S+)") --切分行中的两个名称--查找相应的结点
local from = name2node(graph,namefrom)
local to = name2node(graph,nameto)
from.adj[to]=true --将to添加到from的邻接集合
end
return graph
end
function findpath(curr, to, path, visited) --采用深度优先遍历
path=path or {}
visited = visited or {}
if visited[curr] then --结点是否已访问过
return nil --这里没有路径
end
visited[curr] = true --将结点标记为已访问过
path[#path+1] = curr --将其加到路径中
if curr==to then --最后的结点吗?
return path
end
for node in pairs(curr.adj) do
local p = findpath(node, to, path, visited)
if p then return p end
path[#path] = nill --从路径中删除结点
end
end
function printpath(path) --打印路径
for i = 1,#path do
print(path[i].name)
end
end
g= readgrath()
a=name2node(g,"a")
b=name2node(g,"b")
p = findpath(a,b)
if p then printpath(p) end