4.3 嵌套组合与扩展

优质
小牛编辑
111浏览
2023-12-01

4.3 嵌套组合与扩展

VimL 虽然只提供了列表与字典两种数据结构,但通过列表与字典的合理组合,几乎能表 达任意复杂的数据结构。这与许多其他流行的脚本语言(如 python)的思想如出一辙。 本节就讨论在 VimL 中如何用列表与字典表示常用数据结构。

堆栈与队列

堆栈是所谓先进后出的结构,队列是先进先出的结构。这可以直接用一个 list 表示, 因为 list 相当于个动态数组,支持随意在两端增删元素。

如果只在列表尾部增删元素,那就实现了堆栈行为。如果尾部增加而在头部删除,就实现 了队列行为,如:

: function Push(stack, item)
:     call add(stack, item)
: endfunction

: function Pop(stack)
:     call remove(stack, -1)
: endfunction

: function Shift(queue)
:     call remove(stack, 0)
: endfunction

在这个示例中,用 Push/Pop 表示堆栈操作,用 Push/Shift 表示队列操作。这只为 简明地说明算法意图,实际应用中最好先检查 stack/queue 是否为 list 类型,以 及检查列表是否为空。

链表

在脚本语言中,其实根本不用实现链表,因为动态数组本身就可用于需要链表的场合。在 VimL 中,就直接用 list 表示线性链就够了。除非你真的需要很频繁地在一个很长的 list 中部增删元素,那么或可用字典来模拟链表的实现。

例如,以下代码构建了一个有 10 个结点的链表,每个结点是个字典,value 键表示存储 内容,next 表示指向下一个结点:

: let head = {}
: for value in range(10)
:   let node = {'value': value, 'next': head}
:   let head = node
: endfor

其实在上面的循环中,临时变量 node 可以省略。head 始终指向链表的起始结点, 可通过 next 键依次访问剩余结点,末尾结点的 next 键值是空字典。

这里的关键是,字典的值,或列表元素的值,不仅可以存储像数字与字串符的简单标量, 还可以存储另一个列表或字典(的引用)。基于这样的嵌套与组合,就可以表达更复杂的 数据结构了。

二维数组(矩阵)

如果列表的每个元素都是另一个列表,那就构成了一个二维数组。例如:

: let matrix = []
: for _ in range(10)
:   let row = range(10)
:   call add(matrix, row)
: endfor

构建了一个 10x10 大小的矩阵,其中每个行向量由 range(10) 生成。这样快速生成 的矩阵每一行都相同,或许不是很有趣,但是可以用以下两层循环重新赋值:

: for i in range(10)
:   for j in range(10)
:     let matrix[i][j] = i * j
:   endfor
: endfor

从数学意义上的矩阵讲,它应是规整的矩形,即每行的长度是一样的。但当在 VimL 中用 列表的列表表示时,其实并不能保证每一行都等长。例如:

: let text = getline(1, '$')
: for i in range(len(text))
:   let line = text[i]
:   let text[i] = split(line, '\s\+')
: endfor

在这里,首先用 getline() 获取当前 buffer 的所有行,保存在 text 这个列表变 量中,其中每个元素表示一行文本字符串。在随后的循环中,又将每行文本分隔成一个个 单词(空格分隔的字符串),将标量字符串元素转化为了另一个列表。因此,text 最 终结果就是列表的列表,即二维数组。而一般情况下,每行的单词数量是不等,所以这个 二维数组不是规整的矩阵。

事实上,这个示例的循环可以直接用 map() 函数代替:

: let text = getline(1, '$')
: call map(text, "split(v:val, '\\s\\+')")

以二叉树为例,也可用一个字典来表示树中某结点,除了需要一个键(如 value)来保 存业务数据,还用一个 left 键表示左孩子结点,right 表示右孩子结点,这两个应 该都是另一个具有相同结构的字典引用,如果缺失某个孩子,则可用空字典表示。

: let node = {}
: let node.value = 0
: let node.left = {}
: let node.right = {}

这样,只要有一个字典变量引用了这样的一个结点(不妨称之为根结点),就相当于引用 着一棵树,沿着结点的 leftright 键就能访问整棵树的所有结点。两个子结点 都是空字典时,该结点就是所谓的叶结点。

不过,由于每个结点含有两个方向的子结点,要遍历树可不是那么直观。有兴趣的读者请 参考相应的树算法。本节内容旨在说明 VimL 的字典用法,展示其表达能力。而算法其实 是与语言无关的。

在上述的树结点字典结构中,只能从一个结点访问其子结点,而无法从子结点访问父结点 。如果有这个需求,只要在每个结点字典中再加一个键引用父结点即可,如:

: let node.parent = {}

每个子结点都有父结点,即 parent 键非空。根结点没有父结点,那 parent 键应该 存个什么值呢?可以就用空字典表示,也可以引用它自身,这都可以将根结点与其他非根 结点区分开来。

我们知道,字典或列表变量都只是某个实体的引用。VimL 的自动垃圾回收机制主要是基 于计数引用的。如果某个字典或列表实体没有被任何变量引用了,即引用计数为 0 时, (在变量离开作用域或显式 :unlet 时会减少引用计数)VimL 就认定该实体无法被访 问了,就会当作垃圾回收其所占用的内存。在大部分简单场合中,这套机制很好用。不过 考虑这里讨论的包含 parentleft right 键的树结点,在父、子结点之间形成 了环引用,它们的引用计数始终不会降到 0 。然而 VimL 另外也有一个算法检测环引用 ,所以也尽可放心使用这个树结构,不必担心内在泄漏。只不过存在环引用时,垃圾回收 的时机可能相对滞后而已。

现在,让我们再考虑一种有任意多个孩子的树(任意叉树)。这种结构在实际应用中是存 在的,比如目录树,每个目录(结点)可以有很多个不确实数量的子目录或文件(叶结点 )。为表示这种结构,我们可以将所有子结点放在一个列表中,然后用一个键引用这个列 表,如下定义每个结点的字典结构:

: let node = {}
: let node.value = 0
: let node.parent = {}
: let node.child = []

与原来的二叉树相比,取消 leftright 键,而以统一的 child 键代替。每当 增加一个子结点时,就添加到 child 列表中,同时维护该子结点的 parent 键。如 果 child 键为空列表,就表示该结点为叶结点。

图是一些顶点与边的集合,常用 G(V, E) 表示,其中 V 是顶点集合,E 是边集合 ,每条边连接着 V 中两个顶点。一般用 |V| 表示顶点的个数,|E| 表示边数。

用程序表示图,有两种常用的方式,邻接矩阵与邻接表。这里讨论一下如何用 VimL 的数 据结构表示图。

邻接矩阵很简单,就是一个 |V| x |V| 大小的矩阵,假设就用变量名 graph 表示这 个矩阵。前面小节已介绍,矩阵在 VimL 中就是列表的列表。如果顶点 ij 之间 有一条边,就 :let graph[i][j] = 1,否则就用一个特殊值来表示这两个顶点之间没 有边,比如在很多情况下用 0 表示无边是可行的,:let grapsh[i][j] = 0。如果是 有权边,则可把边的权重保存在相应的矩阵位置中,如 :let graph[i][j] = w。如果 是无向图,则再对称赋值 :let graph[j][i] = graph[i][j]

由于矩阵元素支持随机访问,用邻接矩阵表示图在某些应用中非常高效简便,尤其中边数 非常稠密的情况下(极限情况是每两个顶点之间都有边的完全连通图)。不过在边数很少 的情况下,这将是个稀疏矩阵,在内存空间使用上比较低效。

邻接表,首先它是包含所有顶点的列表;每个顶点是一个字典结构,它至少有个键 edge 来保存所有与本顶点相关的边,这应是一个边结构的列表;在边结构字典中则保存着权重 weight,以及它所连接的顶点(字典引用)。大致结构如下所示:

: let graph = [] " a list of vertex
: let vertex = {'edge': [], 'id':0, 'data': {}}
: let edge = {'weight':1, 'target': {}, 'source': {}}

如果只要求自上而下访问边结构,那这个字典中可以只保存一个顶点,另一个顶点就是它 被保存的顶点(由它的 edge 键访问到这个边)。这可以减少一些存储空间,不过顶点 也只是字典引用,保存双端点也浪费不了太多空间。

在实际的图应用中,肯定还会有具体的业务数据,这些数据一般是保存顶点结构中。比如 可以给每个顶点给个 id 编号或名字,如果有大量复杂的数据,可单独保存在另一个字 典引用中。

所以,邻接表虽然复杂,但灵活度高,易扩展业务数据。而邻接矩阵在矩阵元素中只能保 存一个值,扩展有些不方便。除非是业务数据是保存在边结构中,那么在矩阵中可以保存 另一个字典引用,而不是简单的权重数值。

JSON

如果你了解 JSON,就会发现 VimL 的列表与字典的语法表示,正好也是符合 JSON 标准 的。一个有效的 JSON 字符串也是也是合适的 VimL 的表达式,可以直接用于 :let 命 令的赋值。

当然这有一个小小的限制,JSON 字符串不能有换行,因为 VimL 语言是按行解析的,且 续行符比较特殊(在下一行开头使用反斜杠)。如果是不太复杂的 JSON,在 Vim 编辑中 可以将普通命令 J 将多行字符串合并为一行,我不认为你会用其他编辑器写 VimL 脚 本。

此外,有个内置函数 jsondecode() 可将一个合法的 JSON 字符串(允许多行)解析为 VimL 值,以及反函数 jsonencode() 将一个 VimL 表达式转换为 JSON 字符串。

总结

在 VimL 中,用列表与字典的组合,可以表达很复杂很精妙的数组结构,几乎只有想不到 没有做不到。其实这也不必奇怪,因为目前大部分作为高级语言的动态脚本,其思想是相 通的。虽然 VimL 似乎只能用于 Vim,但它与其他流行的外部脚本语言,在某种程序上是 极其相似的。