【问题标题】:Purely functional concurrent skip list纯功能并发跳过列表
【发布时间】:2010-08-15 22:35:03
【问题描述】:

Skip lists (Pugh, 1990) 为排序字典提供对数时间操作,如搜索树,但skip lists are much more amenable to concurrent updates

是否可以创建一个高效的纯功能并发跳过列表?如果没有,是否有可能创建任何一种高效的纯功能并发排序字典?

【问题讨论】:

  • 我很困惑。在没有破坏性更新的情况下,并发将不是问题,不是吗?
  • @sepp2k:如果问题需要并发,那么纯函数式解决方案(或最接近的解决方案)可能是什么样的?
  • 好问题!我和一个朋友最近玩弄了一个跳过列表的实现,并想知道同样的事情:因为单链表的功能和持久性都是微不足道的,并且可以将跳过列表视为由单链表网络构建而成( “向上”和“跨越”),你也可以简单地制作一个功能性和持久性的跳过列表吗?
  • 我认为你需要更好地量化“效率”。纯函数排序的平衡二叉树相对简单,运算时间为 O(log n),但称结果为高效是否合理尚有争议。

标签: scala haskell f# clojure purely-functional


【解决方案1】:

跳过列表的特性使它们有利于并发更新(即大多数加减法都是局部的)也使它们不利于不变性(即列表中的许多早期项目最终指向后面的项目,并且必须改变)。

具体来说,跳过列表由如下结构组成:

NODE1 ---------------------> NODE2 ---------...
  |                           |
  V                           V
NODE1a --> NODE1b ---------> NODE2a --> NODE2b --> NODE2c --- ...

现在,如果您有更新,例如删除 NODE2bNODE1b,您可以在本地处理它:您只需将 2a 指向 2c1a 指向 2a分别,你就完成了。不幸的是,因为叶节点都指向另一个,所以对于功能性(不可变)更新来说,这不是一个好的结构。

因此,树结构更适合不可变性(因为损坏总是局部受限的——只有您关心的节点及其直接父节点通过树的根)。

并发更新不适用于不可变数据结构。如果您考虑一下,任何功能解决方案都会将A 更新为f(A)。如果您想要两个更新,一个由f 提供,一个由g 提供,您几乎必须执行f(g(A))g(f(A)),或者您必须拦截请求并创建一个新操作h = f,g您可以一次性申请所有内容(或者您必须做其他各种非常聪明的事情)。

但是,并发读取对于不可变数据结构的效果非常好,因为您可以保证不会更改状态。如果您不认为可以在任何其他写入中断之前解决读/写循环,那么您永远不必锁定读取。

因此,写入繁重的数据结构可能更好地实现可变(并且使用类似跳过列表的东西,您只需要在本地锁定),而读取繁重的数据结构可能更好地实现不可变(树是一个更自然数据结构)。

【讨论】:

  • 当然!我怎么能错过这个?跳过列表和普通列表的根本区别在于后者只允许consing(即在前面插入),而前者的整个点是它允许在任何地方插入。我同意:在树上复制路径看起来要便宜得多。
【解决方案2】:

Andrew McKinlay 的解决方案是这里真正的跳过列表的真正“真正”功能解决方案,但它有一个缺点。您花费对数时间来访问一个元素,但现在超出头部元素的突变变得毫无希望。你想要的答案被埋没在无数的路径副本中!

我们可以做得更好吗?

部分问题在于从 -infinity 到您的项目有多个路径。

但是,如果您仔细考虑搜索跳过列表的算法,您永远不会使用这个事实。

我们可以认为树中的每个节点都有一个首选链接,从左边到它的最上面的链接,在某种意义上可以被认为是“拥有”那个条目。

现在我们可以将“手指”的概念考虑到数据结构中,这是一种功能性技术,使您能够专注于一个特定元素,并提供返回根的路径。

现在我们可以从一个简单的跳过列表开始

-inf-------------------> 16
-inf ------> 8 --------> 16
-inf -> 4 -> 8 -> 12 --> 16

逐级展开:

-inf-------------------> 16
  |                       |
  v                       v
-inf ------> 8 --------> 16
  |          |            |
  v          v            v
-inf -> 4 -> 8 -> 12 --> 16

删除除首选指针之外的所有指针:

-inf-------------------> 16
  |                       |
  v                       v
-inf ------> 8           16
  |          |            |
  v          v            v
-inf -> 4    8 -> 12     16

然后,您可以通过跟踪您必须翻转才能到达那里的所有指针,将“手指”移动到位置 8。

-inf ------------------> 16
   ^                      |
   |                      v
-inf <------ 8           16
   |         |            |
   v         v            v
-inf -> 4    8 -> 12     16

从那里可以删除 8,将手指推到其他地方,然后您可以继续用手指在结构中导航。

这样看,我们可以看到跳过列表中的特权路径形成了一个生成树!

如果您在树中只有特权指针并使用像这样的“瘦节点”,则用手指移动 1 步是 O(1) 操作。如果你使用胖节点,那么手指向左/向右移动可能会更昂贵。

所有操作都保持 O(log n),您可以像往常一样使用随机跳过列表结构或确定性结构。

也就是说,当我们将跳过列表分解为首选路径的概念时,我们会发现跳过列表只是一棵树,其中包含一些我们不需要插入/搜索/删除的冗余非首选链接,这样从右上角开始的每条路径的长度都是 O(log n)

即使没有手指,您也可以使用这种形式在树中保持每次插入/删除/更新的 O(log n) 预期时间。

现在,您问题中没有意义的关键词是“并发”。纯粹的函数式数据结构没有就地突变的概念。你总是会产生新的东西。从某种意义上说,并发功能更新很容易。每个人都有自己的答案!他们只是看不到对方。

【讨论】:

  • 我记得多父问题也使得无锁并发相当棘手(或者如果不修改甚至不可能)。
【解决方案3】:

不是跳过列表,但似乎与问题描述相符:Clojure 的持久性红黑树(请参阅PersistentTreeMap.java)。来源包含此通知:

/**
 * Persistent Red Black Tree
 * Note that instances of this class are constant values
 * i.e. add/remove etc return new values
 * <p/>
 * See Okasaki, Kahrs, Larsen et al
 */

这些树维护元素的顺序,并且在 Rich Hickey 使用该词的意义上是“持久的”(不可变并且能够在构建更新版本时保持其性能保证)。

如果您想玩弄它们,可以使用函数sorted-map 在 Clojure 代码中构造实例。

【讨论】:

  • 它们是不可变的,所以你根本无法修改它们,无论是否涉及并发。然而,它们确实具有有助于在涉及并发的程序中成功使用的特性:作为它们不变性的直接结果,一次从多个线程读取它们没有问题,并且由于它们的“持久性”性质,构建新的、略有不同的实例从预先存在的实例(例如,使用新键构建扩展地图时)非常有效。
【解决方案4】:

如果您只需要在跳过列表的前面进行 cons,那么应该可以制作一个持久不可变的版本。

这种跳过列表的优点是“随机”访问。例如您可以比在常规单链表中更快地访问第 n 个元素。

【讨论】:

    猜你喜欢
    • 2019-12-06
    • 2019-12-05
    • 2013-04-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-08-21
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多