持久化数据结构学习笔记——序列

持久化数据结构学习笔记——序列

前言

本学期正在修读一门数据结构课程,内容是持久化数据结构(Persistent Data Structures)。我感到这门课的内容十分有趣,希望可以写一些笔记记录一下学习过程。笔记的内容将会围绕课程展开,但是其中主要是我自己的一些思考得出的“私货”。

我们日常接触到的数据结构多是可变的(mutable),这意味着对于一个数据结构的更新将会破坏其过去的版本。与之相对应的,持久化数据结构在更新时会创建一个“新”的数据结构,从而与此同时保证对旧有版本的访问与修改。持久化数据结构可以带来许多好处,比如异常安全(Exception Safety)和并发性(Concurrency)。但是,这并不意味着我们必须为此付出很大的代价。实际上,许多持久化数据结构在实现上会更加自然直观,也可以保持很高的效率。

持久化数据结构与不可变性(Immutability)有一些相关性。我们往往会利用不可变性来实现高效率的持久化数据结构。不可变性保证了同一数据结构的不同版本可以共享相同的部分,从而节省达到节省空间的目的。

让我们从最简单的例子开始。

序列

序列(Sequence)是一种抽象数据类型(Abstract Data Type)。它由四个函数定义:

  1. emtpy 返回一个空序列
  2. first 作用在一个序列上,返回其第一个元素
  3. rest 作用在一个序列上,返回除去其首元素以后的新序列
  4. cons 作用在一个元素和一个序列上,返回一个新的序列,使得 first (cons h t) = hrest (cons h t) = t 成立

在OCaml中,上述定义表达如下

module type sequence = sig
    type 'a sequence
    val empty : 'a sequence
    val first : 'a sequence -> 'a option
    val rest : 'a sequence -> 'a sequence option
    val cons : 'a -> 'a sequence -> 'a sequence 
end

在函数式语言中常见的列表(List)就是一个符合本定义的数据结构。值得注意的是列表就是一个持久化数据结构的简单例子。列表是非常高效的,上述函数的时间复杂度均为O(1) ,同时其不可变性保证了高效的内存利用与持久性。

但是,如果我们并不需要频繁使用以上操作,而希望我们的序列有相对高效的随机访问呢?我们在之前的序列定义中添加一个新的接口 index : int -> 'a sequence -> 'a option ,作用在一个整数和一个序列上,返回其对应位置的元素。符合我们要求的新的实现可以有相对较慢的 restcons ,但是需要有比O(n) 更快的 index 。一个自然的想法是使用树状的结构代替线性结构提高访问效率。

第一个尝试

我们选择使用二叉树来存储(编码?encode?)一个序列结构,这要求我们在序列的位置和二叉树的中存储元素的位置之间建立一个双射。一个比较显而易见的做法是选取一棵深度为k 的满二叉树,用他的所有叶子结点来存储数据。叶子结点从左向右分别标注为 02^{k-1}-1 。这样我们建立了这棵树的叶子结点和序列中的位置的一一对应。并不令人意外的是,这种对应关系实际上就是 02^n-1 的自然数的 n 位二进制(高位补全 0 )表示。这一关系给出了一个查找我们的二叉树中的元素的方法:将序列中的位置转换成二进制表示,补齐高位的零,然后从二叉树的根节点开始,按照二进制表示的从高位到低位,逢0向左逢1向右,直至达到叶子结点。

以上思路给出了一个看似可能的实现。但是,这一次尝试中我们只使用了二叉树的叶子结点,造成了空间的浪费。这一点并没有真的影响到我们的实现的空间复杂度。但是,它暴露出了一件更重要的事情:我们的思路引入了不必要的信息,而这是不自然的。

解决方法

让我们试着分析一下这件事的原因是什么。我们为了让自然数和它的二进制表示是一一对应,必须限定二进制表示的位数,同时对位数不足的二进制表示补全高位的0。这种关系是我们人为限定的,而我们引入的冗余信息就是对二进制表示的位数的限定,与之相对应的,就是我们只使用了二叉树的叶子结点,而空置了其余节点。那么,解决这个问题的思路似乎明朗了。我们需要找寻一种自然数的表示,它必须是base-2的(只使用两种符号,与二叉树的结构对应),使得他是一种同构计数(Bijective Numeration)。换句话说,我们需要的表示是一个由两种字符 \{a,b\} 构成的任意长度的字符串。我们需要做的就是赋予这些字符串组成的集合 \{a,b\}^* 一个到自然数集的双射。

我们选取 12 来组成这个字符串。我们用空字符串 \varepsilon 来表示 0 ,用 a_n…a_1a_0 表示\sum_{i=0}^n a_i2^i其中a_i\in\{1,2\}. 例如:4被表示为127被表示为111。容易证明这种表示是一个双射。这是一种常用的计数方法,被称为bijective base-2 numeration。

由此,我们可以得到一个更紧凑的在二叉树中存储序列的方法。将表示序列位置的自然数展开成它的bijective base-2表示(展开方法类似二进制数),然后从树的根节点、二进制表示的最高位开始逢1向左逢2向右直至走完,停止的位置就是在二叉树中应该存放的位置。直观上看,二叉树中的节点被我们从上到下从左到右依次标注。

如果我们选用这样的结构,firstindex 的实现是显然的。随着元素增多,这棵树的形态始终是接近满的,这保证了index 一定是 O(\log n) 的。但是,似乎并没有一个直观的方法提示我们应该如何高效的实现 consrest。究其原因,每当我们插入新元素或者去除旧元素时,树的结构会被破坏,每一个其中的元素的位置都会受到影响。同时,甚至是 index 的实现都是不甚自然的,因为我们需要首先展开一个自然数,完整存储这个结果,再从高到低使用。那么,如果我们使用逆序的bijective base-2 numeration呢?

新的结构 Braun tree

考虑一个序列  \varepsilon,1,2,11,12,21,22,… 如果把它们当作逆序以后的表示,即 a_0a_1…a_n 表示 \sum_{i=0}^n a_i2^i ,那么这个序列将对应 0,1,2,3,5,4,6,… 用这样的表示来编码二叉树中的位置,我们会得到结构如下的树:

这样的树被称为Braun tree。这个改变看似只是方便了我们实现index,但它却具有更大的意义。Braun tree 具有如下的性质:

  1. Braun tree的左支和右支都是Braun tree
  2. Braun tree的左支的元素个数只可能和右支相等或多1

第二条性质保证了Braun tree总是平衡的的,我们可以高效的访问它的节点。第一条性质保证了我们可以方便地递归实现对于Braun tree的一些操作。同时,我们发现对于任意的i,序数是2i+1的元素和2i+2的元素总是在同一父节点的左右子树中处在同一个位置。这个性质和我们选取的自然数的逆序同构表示是直接相关的。这一性质为我们提供了高效实现 consrest 的思路。

当我们想要在Braun tree的根节点增加新元素时,我们新增元素替换了旧的根节点元素。由于上述性质,新的二叉树的右子树将是当前的左子树,新的左子树将是旧的二叉树的右子树增加了旧根节点元素以后的结果。我们可以递归地将根节点替换,将旧元素插入右子树,然后交换左右子树。

OCaml 实现

我们按照上述讨论在OCaml中定义Braun tree,并声明它是序列的一种实现:

type 'a brt = Empty | Node of 'a * 'a brt * 'a brt
type 'a sequence = 'a brt

emptyfirst 的实现非常显然,在此不做赘述。cons 应当对它作用的的Braun tree进行判断,如果为空则返回只有一个节点的树,否则按照我们的讨论返回一个新的树,其根节点是新的元素。cons 实现如下:

let rec cons x = function
    Empty -> Node (x, Empty, Empty)
  | Node (x', lt, rt) -> Node (x, cons x' rt, lt)

要实现 rest ,更容易入手的方法是实现cons 的逆运算unconscons 作用在一个元素和一棵树上,返回一棵新树。uncons 则应当作用在一棵树上,“拆解”出它的根节点,并返回根节点元素和一棵由剩余元素组成的树。考虑到cons的值域中没有Emptyuncons 的返回值应当是一个 option 类型。因此,uncons 的类型应当为 'a sequence -> ('a * 'a sequence) option

由于unconscons的逆运算,它的实现可以直接得到。对于空的树,我们应当返回None。而对于非空的树,我们返回的“拆解”下的元素则为当前的根节点。同时我们应当对这棵树的左子树(由旧的树的右子树cons旧的根节点得到)递归地进行uncons,如果返回结果是一个元素和一棵树构成的二元组,那么我们返回的树的根节点为递归返回的元素,左右子树分别是当前的右子树和递归返回的树。如果递归返回的结果是None,返回的树应当为空。在OCaml中实现如下:

let rec uncons = function
    Empty -> None
  | Node (x, lt, rt) ->
     match uncons lt with
       None -> Some (x, Empty)
     | Some (x', lt') -> Some (x, Node (x', rt, lt'))

let rest t = match uncons t with
    None -> None
  | Some (_, t') -> Some t'

index 我们已经进行了详细的讨论,它的实现在此按下不表。

分析总结

上述的实现保证了持久性,同时有相对较高的效率。cons rest index 的时间复杂度均为O(\log n),同时由于我们的实现是不可变的,新旧版本的树可以共享数据和节点。值得注意的是想要用n个元素创建一棵Braun tree可以在O(n)的时间完成,具体的实现参见文末Okasaki的文章。

Braun tree的序列实现并不是非常的高效,但至少是可用的。同时Braun tree本身是一种在持久化数据结构中时常出现的数据结构,它被用来实现很多复杂的数据结构。除此以外,实际应用中也有和传统的序列(如C++ vector)一样高效的持久化实现,比如Clojure的persistent vector。

参考资料

Three Algorithms on Braun Trees by Chris Okasaki

文章被以下专栏收录