Persistent Data Structures(持久化数据结构)

1概述

在本篇中,我们介绍持久化数据结构,持久化数据结构就是我们保存过去状态的所有信息的数据结构。他是更大的类别时态数据结构的一部分。另一种时态数据结构,回溯数据结构,我们在第二篇展示。

通常,我们通过改变现有数据结构中的某些东西来处理数据结构的更新:要么是它的数据,要么是组织它的指针。在这个过程中,我们丢失了先前数据结构状态的信息。持久数据结构则不会丢失任何信息。

对于一些数据结构和持久性定义的情况,可以用渐进最小的额外工作或空间开销将普通数据结构转换为持久性结构。

在这一领域反复出现的一个主题是,模型对结果至关重要。

部分持久性和完全持久性对应于一个分支宇宙模型的时间旅行,例如《终结者》和《似曾相识》的第一部和第二部。

2模型和定义

2.1数据结构中的指针机模型

在这个模型中,我们认为数据结构是具有数据项的有界节点的集合。节点中的每一段数据既可以是实际数据,也可以是指向某个节点的指针。

该模型允许的基本类型操作如下:
1.\(x = new Node()\)
2.\(x = y.field\)
3.\(x.field = y\)
4.\(x = y + z, etc(i.e. 数据操作)\)
5.\(destory(x)(如果没有指针指向x)\)
\(x, y , z\)是其中节点或字段名

通过这些形状约束和操作实现的数据结构包括链表和二叉查找树,通常与C语言中的struct或Java中的对象相对应。不属于这一组的数据结构的一个例子是大小可变的结构,如数组。

2.2持久性定义

我们已经模糊地将持久性称为回答关于结构过去状态的查询的能力。这里我们给出了几个关于持久性的定义。

1.部分持久性---在这个持久化模型中,我们可以查询任何以前版本的数据结构,但我们只更新数据结构的最新版本。我们有两个操作\(read(var, version)\)\(newvwesion = write(var, val)\)。这一定义意味着所有数据结构的版本按照线性顺序排列。

2.完全持久化---在这个模型中,数据结构的任何版本都允许更新和查询。我们有两个操作\(read(var, version)\)\(newvwesion = write(var, version, val)\)。这表示数据结构版本之间组织为一棵树。

3.融合持久化---在这个模型中,除了先前的操作之外,我们允许组合操作将多个先前版本的输入组合起来,以输出一个新的单一版本。我们有三个操作\(read(var, version)\), \(newvwesion = write(var, version, val)\), \(newversion = combine(var, var version1, version2)\)。不同于分支树,版本的组合会在版本图上形成DAG (direct acyclic graph)结构。

4.函数持久化---这个模型得名于函数式编程,其中对象是不可变的。这个模型中的节点同样是不可变的:修改不会改变数据结构中的现有节点,而是创建新的节点。Okasaki在他的书中讨论了这些以及其他的函数式数据结构。

函数持久化和其他持久化的区别在于,我们必须保持所有与之前版本相关的结构完整:唯一允许的内部操作是添加新节点。在前三种情况下,只要能够实现接口,我们就可以做任何事情。

上述4个持久化后面的持久化要比前面的更强。函数式包含着融合,融合包含着完全,完全包含着部分。

函数式包含着融合,因为我们只是限制了实现持久化的方式。如果我们限制自己不使用组合器,融合持久化就会变成完全持久化。当我们限制自己只写入最新版本时,完全持久化就变成部分持久化。

3部分持久化

问题:能否高效地实现部分持久化?

答案:是的,假设指针机内存模型和数据节点的入度限制为\(O(1)\)。这个结果是由于Driscoll, Sarnak, Sleator和Tarjan得到。

证明的思路:我们将扩展我们的数据节点以保存修改,也就是mods log。当我们修改了一个节点足够多的时候,我们创建一个新的节点来接受进一步的更新,直到它也被填满。

对于旧数据结构中的每个节点,新数据结构有一个节点的集合:一个是最新版本的当前节点,可能有许多旧节点只用于读取旧版本。每次我们archive一个节点时,我们也会更新所有的(版本化的)指针,以指向最新的节点。

3.1证明

扩展数据节点,包含以下信息:

1.用于数据和指针的只读区域(对应于原始结构中的数据和指针)。

2.(new)反向指针的可写区域。如果节点\(y\)有一个指向节点\(x\)的指针,则节点x有一个指向节点\(y\)的反向指针。这个区域的大小是有限的,因为我们事先知道至多有\(p\)个指向节点的指针。

3.(new)一个可写修改(mods)区域,用于存储表单中的条目(字段、版本、值)。内存区的大小也需要固定,这对写性能也有重要影响。

我们实现读写操作如下:

1.\(read(var, v)\)\(mod\)日志中搜索最大的版本\(w\),使\(w\leq v\),如果值在“旧”节点中,我们就可以通过一个指向旧版本的指针来获取它。

2.\(write(var, val)\)如果\(n\)未满,只需添加到mod log。如果\(n\)没有空间存放更多的mod log,

  • \(n' = new Node()\)
  • 将每个字段(数据和向前指针)的最新版本复制到static field部分。
  • 也复制指向\(n'\)的反向指针
  • 对于每个节点\(x\),使\(n\)指向\(x\),将其后向指针重定向到\(n'\)(使用我们的指针获取它们)(最多\(d\)个)。
  • 对于使\(x\)指向\(n\)的每个节点\(x\),递归调用\(write(x.P, n')\)(最多递归调用\(P\)次)。

对于一些数据结构,如列表或树,我们通常提前知道\(p\)(指向节点的最大指针数)是多少,所以我们可以为这些特定结构实现算法。

3.2分析

  • 空间:

如果我们选择mod log的边界大小为\(2p\),那么节点的大小为\(d + p + 2p\),也是\(O(1)\),因为我们假设只有\(p\leq O(1)\)个指针指向任意节点。选择这种mod日志大小的原因将在下面的成本分析中阐明。

请注意,根节点有一个特殊情况——为了根节点不溢出,请确保每个版本都指向根节点的适当版本。

  • 时间:

读取是廉价的,它需要恒定的时间来检查单个节点的mod log并选择所需的版本。如果mod log中有空间,写入操作也很便宜。否则,一次写入操作的开销会很大。让\(cost(n)\)表示写入节点\(n\)的开销。在最坏的情况下,算法进行递归调用,因此我们得到:

\begin{equation}
const(n) = c + \sum_{x\to n}(const(x))
\end{equation}

其中\(c\)表示确定复制到新节点的最新版本、复制反向指针等的开销\(O(1)\)\(x\to n\)表示\(x\)个点到\(n\)

显然,由于在递归步骤中可能会拆分许多数据节点,因此成本可能会很大。但是,我们知道当一个节点变得满时,那么下一次操作可能会找到一个更空的mod日志。因此,平摊分析比最坏情况分析更适合这种操作。

回想一下势函数法:如果我们知道一个势函数φ,那么平摊成本(n) =成本(n) +∆φ。

考虑以下势函数:
\begin{equation}
\phi = c * \sharp对当前数据节点中的条目进行Mod log
\end{equation}

由于节点已满,现在为空,因此与新节点相关的势能变化为\(−2cp\)。现在我们可以为平摊代价编写一个递归表达式了:

\begin{equation}
amortized_-cost(n) \leq c + c − 2cp + p ∗ amortized_-cost(x)
\end{equation}

对节点\(x\)最坏情况。第二个\(c\)涵盖了我们在mod log中找到空间的情况,只需向其中添加一个条目,从而将势能增加\(c\)

通过将递归展开一次,我们可以看到,在每次展开时,\(−2cp\)抵消了递归的额外成本,只留下\(2c\)成本。因此,平摊成本为\(O(1)\)。尽管图中可能存在循环,但递归过程仍然能够完成,因为分割会减少\(φ\),且\(φ\)是非负的。

Brodal的进一步研究表明,在最坏情况下的实际开销也是O(1)。

4完全持久化

版本表示问题的解决方法是用一棵树来表示版本结构,以及用线性化的方式有效地表示这棵树。

树的线性表示是\((_a (_b (_c)_c)_b (_d)_d)_a\)。你可以把\((_a\)读作开始节点a ,而\()_a\)读作结束节点a 。这种表示对树进行无损编码,我们可以使用这种编码表示直接回答有关树的查询。例如,我们知道\(c\)嵌套在\(b\)中,因为\((b\lt(_c and )_c\lt)_b\)

这种线性化的表示可以使用order maintenance数据结构来实现。现在,只要顺序维护数据结构支持以下两种操作就足够了,而且时间复杂度都是O(1)。

  • 在指定元素之前或之后插入一项。
  • 检查项\(s\)是否在项\(t\)之前。

例如,链表支持\(O(1)\)插入,但优先级测试值为\(O(n)\)。类似地,平衡BST支持这两种操作,但时间为\(O(\log n)\)。Deitz和Sleator展示了两个操作的\(O(1)\)实现,这将在以后介绍。

为了实现版本树查询,例如版本v是否是版本w的祖先,我们可以使用两个比较查询\((_v\lt(_w and)_w \lt)_v\)\(O(1)\)时间内。要实现像将版本v添加为版本w的子版本这样的更新,我们可以在\(O(1)\)内分别在\((w\)之后和\(w)\)之前插入两个元素\((_v和)_v\)

4.2模型构造与算法

数据结构中的节点将保留与部分持久情况相同类型的额外数据。

对于每个节点,我们存储\(d\)个数据条目和\(p\)个返向指针,但现在允许最多\(2(d + p + 1)\)个修改。数据量\(d\)也是每个节点的出指针的一个界限。此外,我们现在也版本反向指针。

1.\(read(n.field, version)\):通过使用顺序维护数据结构,我们可以从mod log中的条目中选择版本的最近祖先,并返回该值。

2.\(write(n.field, value, version)\):如果节点中有空间,只需添加mod条目。其他:

  • \(m = new Node()\) 将节点\(n\)的mod log分成两部分。这里需要划分为子树,而不是任意划分。现在,节点\(m\)拥有内部树的一些mod,而节点\(n\)保留了“旧的”一半更新。

  • 从节点\(n\)的“旧的”mod条目中,计算每个字段的最新值,并将它们写入节点\(m\)的数据和反向指针部分。

  • 递归更新所有d + p + (d + p + 1)邻居的前向和反向指针。

  • 将新版本插入到我们的版本树表示中。

4.3分析

  • 空间---如果我们不拆分或\(d + p + 2(d + p + 1) = 3d + 3p + 2\)当我们拆分一个节点时,两者都是\(O(1)\)

  • 时间---\(read(var, version)\)的实现方式与部分持久化类似。我们使用辅助版本树数据结构,在时间复杂度为O(1)的列表中,从O(1)的元素中找出version的最大祖先。

与部分持久化一样,当节点的mods log中有空间时,写操作的成本很低,而当节点已满时,写操作的成本更高。

考虑\(φ =−c(\sharp empty slots)\),然后当我们拆分时\(∆φ =−2c(d+p+1)\)和当我们不拆分时\(∆φ = c\)。因此,从邻居中选取\(x\)的最坏可能值\(amortized cost(n) (n)≤c+c−2c(d+p+1)+(d+p+(d+p+1))∗ amortized cost(n)\)当我们展开递归一次,我们发现常数约掉了:\(c−2c(d + p + 1) + (2p + 2p + 1)c = 0\)

OPEN Question:完全持久性的摊销

OPEN Question:完全持久性和部分持久性是否都有一个匹配的下界?

5融合持久化

融合持久性提出了新的挑战。首先,我们需要找到版本的新表示。我们的树遍历技术没有扩展到DAGs。而且,这是可能的在\(u\)融合更新后的版本历史中有\(2^u\)路径。例如,通过将字符串与自身重复连接。

串联的Deques(允许堆栈和队列操作的双端队列)可以在固定的时间内完成每次操作(Kaplan, Okasaki和Tarjan)。与字符串一样,我们可以通过递归地将deque与其自身连接起来,在多项式时间内创建隐式指数deque。

由Fiat和Kaplan的一般性转换如下:

  • \(e(v) = 1 + log(\sharp 从根到v的路径)\) 这一措施被称为版本DAG的“有效深度”:如果我们通过DFS(即通过复制每个路径而不重叠)去分解树并重新平衡树,这便是我们所希望达到的最佳效果。

  • \(d(v) =版本DAG中节点v的深度\)

  • 上界:\(\log(\sharp 更新次数)+max_v(e(v))\)

这个结果反映了\(e(v) = 2^u\)时性能较差,其中u是更新次数。这仍然比完整的拷贝要好很多。

下限也由Fait和Kaplan提出\(Ω(e(v))\),如果查询是免费的。它们的构造使得每次更新要进行\(e(v)\)次查询。

OPEN Question:每个操作的空间开销为\(O(1)\)\(O(log (n))\)

Collette、Iacono和Langerman考虑了“不相交操作”的特殊情况:只在没有共享数据节点的版本之间执行合并操作。在这种情况下,\(O(log (n))\)开销是可能的。

如果我们只允许不相交操作,那么每个data节点的版本历史记录就是一棵树。当计算read(node, field, version)时,树的情况:当node在version处被修改时,我们只是读取新版本。在节点沿着路径进行修改之间,我们首先需要找到最后的修改。这个问题可以通过使用“链接切割树”来解决(参见第19讲)。最后,当版本位于叶子节点之下时,问题就更加复杂了。证明使用了如分数级联等技术。

6函数持久化

以下是现有技术的简单示例。

  • 功能均衡的BST---为了在功能上持久化BST,其主要思想(又称“路径复制”)是复制被修改的节点,并通过复制所有祖先节点来传播指针更改。如果没有父指针,则自顶向下工作。假设树是平衡的,这种技术每次操作的开销是\(O(log (n))\)。Demaine, Langerman, Price展示了这一点。

  • Deque---(允许栈和队列操作的双端队列),每次操作(Kaplan、Okasaki和Tarjan)的连接操作都可以在常量时间内完成。此外,Brodal, Makris和ticchlas得到通过拼接可以在常数时间内完成,更新和搜索的时间为\(O(log (n))\)

  • Tries---local navigation,子树复制和删除。Demaine, Langerman, Price展示了如何在最优地保持这种结构。Functional Tries就是融合数据结构的一个例子:可以从旧版本中复制一个子树并插入到新版本中,从而将两个版本合并在一起。

Pippenger得出函数持久化与常规数据结构之间最多\(log (n)\)的代价分割。

OPEN Question:(对于函数和融合)更大的分割?更一般的结构转换?

OPEN Question:列表拆分和连接?一般指针机器?

SOLVED:Blame Trees

OPEN Question:使用剪切和粘贴的数组?特别DAGs?

posted @ 2022-09-06 17:50  lumimusta  阅读(309)  评论(0编辑  收藏  举报