将元素推到完整二叉树的左侧必然会增加树的最大高度。由于上面的 AVL 树将该信息存储在每个节点中,并且由于沿完整二叉树左脊的每棵树也是完整二叉树,因此将元素推到恰好是完整二叉树的 AVL 双端队列的左侧将需要沿左侧脊柱增加 Ω(lg n) 高度值。
(对此有两个注意事项:(a)您可以在不保留节点高度的情况下存储 AVL 树;相反,您只保留平衡信息(左高、右高,甚至)。这不会改变(b) 在 AVL 树中,您可能不仅需要进行 Ω(lg n) 平衡或高度信息更新,还需要进行 Ω(lg n) 重新平衡操作。我不记得这个细节了,它可能只针对删除,而不是插入。)
-
预测需要重新平衡的地方。如果您使用的树需要 o(lg n) 重新平衡,但您知道需要重新平衡的位置并且您可以足够快地到达那里,您可以在 o(lg n) 中执行双端队列操作时间。使用此策略的双端队列将不仅将两个指针存储到双端队列(左右脊椎的末端,如上所述),而且还会将一些 跳转指针 存储到沿队列更高的位置刺。然后双端队列操作可以在 O(1) 时间内访问跳转指针指向的树的根。如果为需要重新平衡(或更改节点信息)的所有位置维护 o(lg n) 跳转指针,则双端队列操作可能需要 o(lg n) 时间。
(当然,这使得树实际上是一个 dag,因为跳转指针所指向的脊上的树也被脊上的子级所指向。不可变数据结构通常不会与非树相处图表,因为替换一个由多个其他节点指向的节点需要替换指向它的所有其他节点。我已经看到通过消除非跳转指针,将 dag 变回一棵树来解决这个问题。一个可以然后将带有跳转指针的单链表存储为列表的列表。每个从属列表包含该列表的头部与其跳转指针之间的所有节点。这需要小心处理部分重叠的跳转指针,以及一个完整的除了这个,解释可能不合适。)
这是Tsakalidis in his paper "AVL Trees for localized search" 使用的技巧之一,允许在具有宽松平衡条件的 AVL 树上进行 O(1) 双端队列操作。这也是Kaplan and Tarjan in their paper "Purely functional, real-time deques with catenation" 和later refinement of that by Mihaesau and Tarjan 使用的主要思想。 Munro et al.'s "Deterministic Skip Lists" 在这里也值得一提,尽管通过使用树将跳过列表转换为不可变的设置有时会改变允许在末端附近进行如此有效修改的属性。翻译示例见Messeguer's "Skip trees, an alternative data structure to Skip lists in a concurrent approach"、Dean and Jones's "Exploring the duality between skip lists and binary search trees"和Lamoureux and Nickerson's "On the Equivalence of B-trees and deterministic skip lists"。
-
批量完成工作。在上面的完整二叉树示例中,推送时不需要重新平衡,但 Ω(lg n) 节点需要更新其高度或平衡信息。您可以简单地将脊椎末端标记为需要增量,而不是实际进行增量。
理解这个过程的一种方法是类比二进制数。 (2^n)-1 以二进制形式由 n 个 1 的字符串表示。给这个数字加 1 时,你需要把所有的 1 都改成 0,然后在最后加一个 1。下面的 Haskell 将二进制数编码为非空的位串,最低有效位在前。
data Bit = Zero | One
type Binary = (Bit,[Bit])
incr :: Binary -> Binary
incr (Zero,x) = (One,x)
incr (One,[]) = (Zero,[One])
incr (One,(x:xs)) =
let (y,ys) = incr (x,xs)
in (Zero,y:ys)
incr 是一个递归函数,对于 (One,replicate k One) 形式的数字,incr 调用自身 Ω(k) 次。
相反,我们可以仅通过组中的位数来表示相等位的组。如果相邻位或位组相等(在值上,而不是在数量上),则将它们组合为一组。我们可以在 O(1) 时间内递增:
data Bits = Zeros Int | Ones Int
type SegmentedBinary = (Bits,[Bits])
segIncr :: SegmentedBinary -> SegmentedBinary
segIncr (Zeros 1,[]) = (Ones 1,[])
segIncr (Zeros 1,(Ones n:rest)) = (Ones (n+1),rest)
segIncr (Zeros n,rest) = (Ones 1,Zeros (n-1):rest)
segIncr (Ones n,[]) = (Zeros n,[Ones 1])
segIncr (Ones n,(Zeros 1:Ones m:rest)) = (Zeros n,Ones (m+1):rest)
segIncr (Ones n,(Zeros p:rest)) = (Zeros n,Ones 1:Zeros (p-1):rest)
由于 segIncr 不是递归的,并且不会在 Ints 上调用除了 plus 和 minus 之外的任何函数,您可以看到它需要 O(1) 时间。
上面标题为“预测需要重新平衡的地方”部分中提到的一些双端队列实际上使用了一种不同的受数字启发的技术,称为“冗余数字系统”,将重新平衡工作限制在 O(1) 并快速定位。冗余的数字表示很吸引人,但对于这个讨论来说可能太遥远了。 Elmasry et al.'s "Strictly-regular number system and data structures" 是开始阅读该主题的好地方。 Hinze's "Bootstrapping one-sided flexible arrays" 也可能有用。
在"Making data structures persistent",Driscoll 等人。描述 懒惰的重新着色,他们将其归因于 Tsakalidis。他们将其应用于红黑树,可以在插入或删除后通过 O(1) 旋转(但 Ω(lg n) 重新着色)重新平衡(参见Tarjan's "Updataing a balanced tree in O(1) rotations")。这个想法的核心是标记需要重新着色但不旋转的节点的大路径。在 Brown & Tarjan's "A fast merging algorithm" 的旧版本中的 AVL 树上使用了类似的想法。 (同一作品的较新版本使用 2-3 棵树;我没有阅读较新的,也不知道他们是否使用了任何技术,例如惰性重新着色。)
-
随机化。上面提到的 Treap 可以在功能设置中实现,以便它们平均在 O(1) 时间内执行双端队列操作。由于双端队列不需要检查它们的元素,因此该平均值不易受到恶意输入降低性能的影响,这与简单(无重新平衡)二叉搜索树不同,二叉搜索树的平均输入速度很快。 Treaps 使用独立的随机位来源,而不是依赖于数据的随机性。
在持久设置中,treap 可能会因恶意输入而降低性能,攻击者既可以 (a) 使用旧版本的数据结构,也可以 (b) 衡量操作的性能。因为它们没有任何最坏情况下的平衡保证,traps 可能会变得非常不平衡,尽管这种情况很少发生。如果对手等待需要很长时间的双端队列操作,她可以重复启动相同的操作,以测量和利用可能不平衡的树。
如果这不是问题,trap 是一种非常简单的数据结构。它们非常接近上述的 AVL 脊柱树。
上面提到的跳过列表也可能适用于平均时间为 O(1) 的双端队列操作的函数式实现。
用于限制再平衡工作的前两种技术需要对数据结构进行复杂的修改,同时通常可以对双端队列操作的复杂性进行简单分析。随机化以及下一种技术具有更简单的数据结构但更复杂的分析。 The original analysis by Seidel and Aragon is not trivial,并且使用比上面引用的论文更高级的数学对精确概率进行了一些复杂的分析——参见Flajolet et al.'s "Patterns in random binary search trees"。
-
摊销。有几个平衡树,当从根部向上观察时(如上文“反转脊椎”中所述),提供 O(1) 摊销 插入和删除时间。单个操作可能需要 Ω(lg n) 时间,但它们使树处于非常好的状态,以至于昂贵操作之后的大量操作将变得很便宜。
不幸的是,当旧版本的树仍然存在时,这种分析不起作用。用户可以在几乎不平衡的旧树上多次执行操作,而无需任何干预廉价操作。
Chris Okasaki 发明了一种在持久设置中获得摊销界限的方法。解释摊销如何在使用任意旧版本数据结构的能力中幸存下来并不简单,但如果我没记错的话,Okasaki's first (as far as I know) paper on the subject 有一个非常清楚的解释。更全面的解释见his thesis或his book。
据我了解,有两个基本要素。首先,您实际上指定并设置特定昂贵操作之前,而不是仅仅保证在每个昂贵操作之前发生一定数量的便宜操作(通常的摊销方法)执行将为此付出代价的廉价操作。在某些情况下,操作被安排在许多干预廉价步骤之后才开始(和完成)。在其他情况下,该操作实际上仅在未来安排 O(1) 步,但廉价操作可能会执行部分昂贵操作,然后重新安排更多操作以供以后使用。如果一个对手想要一遍又一遍地重复一个昂贵的操作,实际上每次都在重用相同的计划操作。这种分享是第二个要素的来源。
计算是使用惰性设置的。惰性值不会立即计算,但是一旦执行,它的结果就会被保存。客户端第一次需要检查惰性值时,会计算其值。以后的客户端可以直接使用该缓存值,而无需重新计算。
#include <stdlib.h>
struct lazy {
int (*oper)(const char *);
char * arg;
int* ans;
};
typedef struct lazy * lazyop;
lazyop suspend(int (*oper)(const char *), char * arg) {
lazyop ans = (lazyop)malloc(sizeof(struct lazy));
ans->oper = oper;
ans->arg = arg;
return ans;
}
void force(lazyop susp) {
if (0 == susp) return;
if (0 != susp->ans) return;
susp->ans = (int*)malloc(sizeof(int));
*susp->ans = susp->oper(susp->arg);
}
int get(lazyop susp) {
force(susp);
return *susp->ans;
}
惰性结构包含在一些 ML 中,Haskell 默认是惰性的。在引擎盖下,懒惰是一种突变,这导致一些作者称其为“副作用”。如果这种副作用不能很好地与首先选择不可变数据结构的原因一起发挥作用,那可能会被认为是不好的,但是另一方面,将惰性视为副作用允许应用Kaplan, Okasaki, and Tarjan entitled "Simple Confluently Persistent Catenable Lists" 的一篇论文中提到的对持久数据结构的传统摊销分析技术。
再次考虑上面的对手,他试图反复强制计算昂贵的操作。在惰性值的第一个力之后,剩余的每一个力都很便宜。
在他的书中,Okasaki 解释了如何使用每个操作所需的 O(1) 摊销时间来构建双端队列。它本质上是一棵 B+-树,这是一棵树,其中所有元素都存储在叶子上,节点的子节点数量可能会有所不同,并且每个叶子的深度都相同。 Okasaki 使用上面讨论的脊椎反转方法,他将脊椎挂起(即,存储为惰性值)叶元素上方。
Hinze and Paterson called "Finger trees: a simple general-purpose data structure" 的结构介于 Okasaki 设计的双端队列和 "Purely functional representations of catenable sorted lists" of Kaplan and Tarjan 之间。 Hinze 和 Paterson 的结构变得非常流行。
作为摊销分析难以理解的证据,Hinze 和 Paterson 的手指树是 frequently implemented without 惰性,使得时间界限不是 O(1),而是 O(lg n)。似乎使用惰性的一种实现是one in functional-dotnet。该项目还包括一个implementation of lazy values in C#,如果缺少我上面的解释,它可能有助于解释它们。