【问题标题】:Implement an immutable deque as a balanced binary tree?将不可变双端队列实现为平衡二叉树?
【发布时间】:2011-03-17 07:43:33
【问题描述】:

我一直在思考如何将双端队列(即双端队列)实现为不可变数据结构。

似乎有不同的方法可以做到这一点。 AFAIK,immutable data structures are generally hierarchical,以便在修改诸如插入或删除项目之类的操作后可以重用它的主要部分。

Eric Lippert 在他的博客上有关于此主题的 twoarticles,以及 C# 中的示例实现。

我觉得他的两个实现都比实际需要的更复杂。不能将双端队列简单地实现为二叉树,其中元素只能在“左侧”(front)和非常“右侧”(back) 树的?

                               o
                              / \
                             …   …
                            /     \
                           …       …
                          / \     / \
              front -->  L   …   …   R  <-- back

此外,树将通过旋转保持合理的平衡:

  • 在前面插入或从后面取出时向右旋转,并且
  • 从前面移除或从后面插入时向左旋转。

在我看来,Eric Lippert 是一个非常聪明的人,我非常尊敬他,但他显然没有考虑过这种方法。因此,我想知道,这是有充分理由的吗?我建议的实现双端队列的方法是否幼稚?

【问题讨论】:

  • 我写了一个前端插入和删除的基本示例实现:C# source code
  • 既然双端队列是不可变的,为什么不使用数组呢?
  • @Dave:因为理想情况下,我希望在操作操作时在新的(修改的)双端队列中尽可能多地重用初始(未修改的)双端队列中的项目。使用数组似乎几乎不可能。

标签: data-structures functional-programming deque immutability


【解决方案1】:

正如 Daniel 所指出的,使用众所周知的平衡搜索树(如 AVL 或红黑树)实现不可变双端队列会给出 Θ(lg n) 最坏情况复杂度。 Lippert 讨论的一些实现乍一看可能看起来很复杂,但是有许多不可变的双端队列具有 o(lg n) 最差或平均或摊销复杂性,它们是由平衡树以及两个简单的想法构建的:

  1. 扭转脊椎

    要在传统的平衡搜索树上执行双端队列操作,我们需要访问末端,但我们只能访问中心。为了到达左端,我们必须导航左子指针,直到最终到达死胡同。最好有一个指向左端和右端的指针,而不需要所有的导航工作。事实上,我们真的不需要经常访问根节点。让我们存储一个平衡的搜索树,以便访问末端是 O(1)。

    下面是一个 C 语言示例,说明您通常如何存储 AVL 树:

    struct AVLTree {
      const char * value;
      int height;
      struct AVLTree * leftChild;
      struct AVLTree * rightChild;
    };
    

    为了设置树,以便我们可以从边缘开始向根移动,我们更改树并将沿从根到最左边和最右边子节点的路径的所有指针存储在 reverse。 (这些路径分别称为左和右脊柱)。就像反转单链表一样,最后一个元素成为第一个元素,因此现在可以轻松访问最左边的子元素。

    这有点难以理解。为了帮助解释,假设您只对左脊椎执行此操作:

    struct LeftSpine {
      const char * value;
      int height;
      struct AVLTree * rightChild;
      struct LeftSpine * parent;
    };
    

    在某种意义上,最左边的孩子现在是树的“根”。如果你这样画树,看起来会很奇怪,但是如果你只是简单地把树的正常绘制和左脊椎上的所有箭头反转,LeftSpine 结构的含义应该会变得更清楚。现在可以立即访问树的左侧。右侧脊柱也可以这样做:

    struct RightSpine {
      double value;
      int height;
      struct AVLTree * leftChild;
      struct RightSpine * parent;
    };
    

    如果您同时存储左右脊椎以及中心元素,则可以立即访问两端。插入和删除可能仍然是 Ω(lg n),因为重新平衡操作可能需要遍历整个左侧或右侧脊椎,但现在简单地查看左侧和最右侧的元素是 O(1)。

    此策略的示例is used to make purely functional treaps with implementations in SML and Java (more documentation)。这也是其他几个具有 o(lg n) 性能的不可变双端队列的关键思想。

  2. 约束Rabalancing工作

    如上所述,在 AVL 树的左端或最右端插入可能需要 Ω(lg n) 时间来遍历脊柱。下面是一个演示这一点的 AVL 树示例:

    一棵完整的二叉树通过归纳定义为:

    • 高度为 0 的完整二叉树是一个空节点。
    • 高度为 n+1 的完整二叉树有一个根节点,其中高度为 n 的完整二叉树作为子节点。

    将元素推到完整二叉树的左侧必然会增加树的最大高度。由于上面的 AVL 树将该信息存储在每个节点中,并且由于沿完整二叉树左脊的每棵树也是完整二叉树,因此将元素推到恰好是完整二叉树的 AVL 双端队列的左侧将需要沿左侧脊柱增加 Ω(lg n) 高度值。

    (对此有两个注意事项:(a)您可以在不保留节点高度的情况下存储 AVL 树;相反,您只保留平衡信息(左高、右高,甚至)。这不会改变(b) 在 AVL 树中,您可能不仅需要进行 Ω(lg n) 平衡或高度信息更新,还需要进行 Ω(lg n) 重新平衡操作。我不记得这个细节了,它可能只针对删除,而不是插入。)

    为了实现o(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#,如果缺少我上面的解释,它可能有助于解释它们。

双端队列可以实现为二叉树吗?是的,如果持续使用,它们的最坏情况复杂性不会比 Eric Lippert 提出的更差。但是,Eric 的树实际上不够复杂,无法在持久设置中获得 O(1) 的双端队列操作,但如果您愿意接受摊销的性能,则只有很小的复杂度余量(使中心变得懒惰) .假设对手不太狡猾,一个不同但也很简单的陷阱视图可以在功能设置中获得 O(1) 的预期性能。在函数设置中使用树状结构获得 O(1) 最坏情况的双端队列操作需要比 Eric 的实现更复杂一些。


最后两点(虽然这是一个非常有趣的话题,我保留稍后添加更多内容的权利):-)

  1. 上面提到的几乎所有双端队列也是手指搜索树。在功能设置中,这意味着它们可以在 O(lg(min(i,ni))) 时间内在第 i 个元素处拆分,并且两个大小为 n 和 m 的树可以在 O(lg(min(n,m) )) 时间。

  2. 我知道实现不使用树的双端队列的两种方法。冈崎在他的书和论文以及我上面链接的论文中提出了一个。另一个使用一种称为“全局重建”的技术,并在Chuang and Goldberg's "Real-time deques, multihead Turing machines, and purely functional programming" 中介绍。

【讨论】:

  • 哇,我……很困惑。我要花很多时间来吸收你提供的所有材料,但既然这是我要求的,那很好。 :) 非常感谢您花时间发布如此详尽的答案!
  • 我不明白 Okasaki 的双端队列在什么意义上不使用树,它们在我看来就像非常复杂的树,而 Kaplan 和 Tarjan 用二叉树来描述它们的(通常相似的)结构。
  • Okasaki 描述了几种实现双端队列的方法。我认为不太像​​树的那些是在他的论文第 52 页上标注的那些——银行家的双端队列和实时双端队列。
【解决方案2】:

不能简单地实现双端队列 作为二叉树,其中元素可以 只能插入或删除 非常“左”(前面)和 树的非常“正确”(背面)?

当然。高度平衡树的修改版本,特别是 AVL 树,将非常容易实现。然而,这意味着用n 元素填充基于树的队列需要O(n lg n) 时间——您应该争取与可变对应物具有相似性能特征的双端队列。

您可以使用两个堆栈(左堆栈和右堆栈)为所有主要操作创建一个简单的不可变双端队列,并为所有主要操作提供分摊的常数时间操作。 PushLeft 和 PushRight 分别对应左右栈的压栈值。您可以从 LeftStack.Hd 和 LeftStack.Tl 中获取 Deque.Hd 和 Deque.Tl;如果您的 LeftStack 为空,请设置 LeftStack = RightStack.Reverse 和 RightStack = 空堆栈。

type 'a deque = Node of 'a list * 'a list    // '

let peekFront = function
    | Node([], []) -> failwith "Empty queue"
    | Node(x::xs, ys) -> x
    | Node([], ys) -> ys |> List.rev |> List.head
let peekRear = function
    | Node([], []) -> failwith "Empty queue"
    | Node(xs, y::ys) -> y
    | Node(xs, []) -> xs |> List.rev |> List.head
let pushFront v = function
    | Node(xs, ys) -> Node(v::xs, ys)
let pushRear v = function
    | Node(xs, ys) -> Node(xs, v::ys)
let tl = function
    | Node([], []) -> failwith "Empty queue"
    | Node([], ys) -> Node(ys |> List.rev |> List.tail, [])
    | Node(x::xs, ys) -> Node(xs, ys)

这是一种非常常见的实现方式,并且很容易优化以获得更好的性能。

【讨论】:

  • 原始问题中链接的第一篇 Lippert 博客文章中讨论了此实现。不幸的是,如果用户在左侧为空时将一个元素从左侧弹出,然后在长时间的反向后从右侧弹出,它可以在一个大而完整的左侧和一个空的右侧以及一个大而完整的右侧和空的左侧之间来回摆动操作等等。这意味着操作实际上不是恒定的摊销时间。但是,在您上面发布的代码中,您只允许从前面弹出(不允许 PeekRight 或 DequeRight)。这有时被称为 stack+queue 的“steque”。
【解决方案3】:

其他答案都很棒。我将向他们补充一点,我选择双端队列的手指树实现是因为它对泛型类型系统进行了不寻常且有趣的使用。大多数数据结构在它们的结构中都是递归的,但这种技术也将递归置于我以前从未见过的类型系统中;我认为这可能会引起普遍的兴趣。

【讨论】:

  • 这项技术(有时称为数据结构引导)的一个有趣之处在于,当幼稚地实现时,它需要 多态递归,而这在 C++ 等语言中是不可能的处理模板的方式,但在 C# 中显然 是 可能的。这种技术的另一个有趣之处在于,您甚至可以使用它来确保更复杂的不变量,例如 AVL 或红黑平衡条件。这个边距太小,无法包含更多细节,但我会很乐意随时更详细地讨论这个问题。
【解决方案4】:

如果使用平衡二叉树,则两端的插入和删除都是 O(lg N)(平均和最坏情况)。 Eric Lippert 的实现中使用的方法更有效,在平均情况下以恒定时间运行(最坏的情况仍然是 O(lg N))。

请记住,修改不可变树涉及重写您正在修改的节点的所有父节点。所以对于双端队列,你不希望树是平衡的;相反,您希望 L 和 R 节点尽可能靠近根,而树中间的节点可以更远。

【讨论】:

  • +1 非常有见地,尤其是。你答案的第二段。我会花一些时间来考虑这件事的影响。谢谢!
猜你喜欢
  • 1970-01-01
  • 2016-09-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-02-08
  • 2016-06-05
相关资源
最近更新 更多