【问题标题】:Do I need to take explicit actions to facilitate sharing with persistent data structures?我是否需要采取明确的措施来促进与持久数据结构的共享?
【发布时间】:2013-08-06 09:53:55
【问题描述】:

我来自命令式背景,我正在尝试实现一个简单的不相交集(“union-find”)数据结构,以练习在 Haskell 中创建和修改(持久)数据结构。目标是有一个简单的实现,但我也关心效率,我的问题与此有关。

首先,我创建了一个不相交集的森林实现,并使用按等级联合,并从定义“点”的数据类型开始:

data Point = Point
  { _value  :: Int
  , _parent :: Maybe Point
  , _rank   :: Int
  } deriving Show

脱节集合林是一个 IntMapInt → Point 映射:

type DSForest = IntMap Point

empty :: DSForest
empty = I.empty

单例集只是从其值 x 到值为 x 的 Point 的映射,没有父级且等级为 1:

makeSet :: DSForest -> Int -> DSForest
makeSet dsf x = I.insert x (Point x Nothing 0) dsf

现在,有趣的部分 - union。此操作将通过将另一个点设置为其父点来修改一个点(并在某些情况下更改其等级)。在Points' 等级不同的情况下,Point 只是简单地“更新”(创建一个新点)使其父点指向另一个点。在它们相等的情况下,将创建一个新的Point,其等级增加一:

union :: DSForest -> Int -> Int -> DSForest
union dsf x y | x == y = dsf 
union dsf x y = 
   if _value x' == _value y'
      then dsf 
      else case compare (_rank x') (_rank y') of
                  GT -> I.insert (_value y') y'{ _parent = Just x' } dsf 
                  LT -> I.insert (_value x') x'{ _parent = Just y' } dsf 
                            -- 1) increase x's rank by one:
                  EQ -> let x''  = x'{ _rank = _rank x' + 1 } 
                            -- 2) update the value for x's rank to point to the new x:
                            dsf' = I.insert (_value x'') x'' dsf 
                            -- 3) then update y to have the new x as its parent:
                         in I.insert (_value y') y'{ _parent = Just x'' } dsf'
  where x' = dsf ! findSet dsf x
        y' = dsf ! findSet dsf y

现在,对于我真正的问题,如果在 EQ 的情况下我做了以下操作:

EQ -> let dsf' = I.insert (_value x') x'{ _rank = _rank x' + 1} dsf 
       in I.insert (_value y') y'{ _parent = Just x'{ _rank = _rank x' + 1 }} dsf'

即首先插入一个新的Point x,其排名增加,然后让y' 的父级成为一个新的Point x,其排名增加,这是否意味着它们不再指向内存中的同一个 Point(这有关系吗?在使用/创建持久数据结构时我应该担心这些事情吗?)

为了完整起见,这里是findSet

findSet :: DSForest -> Int -> Int
findSet dsf' x' = case _parent (dsf' ! x') of
                     Just (Point v _ _)  -> findSet dsf' v
                     Nothing             -> x'

(也欢迎各位大侠了解一下这段代码的效率和设计。)

【问题讨论】:

    标签: haskell data-structures disjoint-sets union-find


    【解决方案1】:

    这是否意味着它们不再指向内存中的同一点?

    我认为您不应该担心这一点,因为这只是不可变值的运行时系统(又名 Haskell 的 RTS)的实现细节。

    就其他建议而言,我会说让函数 findSet 返回 Point 本身而不是键,因为这将消除 union 中的查找。

    findSet :: DSForest -> Int -> Point
    findSet dsf' x' = case _parent pt of
                         Just (Point v _ _)  -> findSet dsf' v
                         Nothing             -> pt
                      where
                          pt = (dsf' ! x')
    

    union 函数中进行适当的更改。

    【讨论】:

      【解决方案2】:

      第一条评论:disjoint-set union-find 数据结构非常非常难以以纯函数的方式做好。如果您只是想练习使用持久性数据结构,我强烈建议您从二叉搜索树等更简单的结构开始。

      现在,要查看一个问题,请考虑您的 findSet 函数。它没有实现路径压缩!也就是说,它不会使通往根的路径上的所有节点都直接指向根。为此,您需要更新 DSForest 中的所有这些点,因此您的函数将返回 (Int, DSForest) 或者可能返回 (Point, DSForest)。在 monad 中执行此操作以处理绕过 DSForest 的所有管道比手动绕过该森林更容易。

      但现在是第二个问题。假设您按照刚才的描述修改了 findSet。它仍然不能完全满足您的要求。特别是,假设您有一个链,其中 2 是 1 的子项,3 是 2 的子项,4 是 3 的子项。现在您在 3 上执行 findSet。这将更新 3 的点,以便其父项是 1 而不是 2。但是 4 的父级仍然是旧的 3 点,其父级是 2。这可能无关紧要,因为看起来你从来没有真正对父点做任何事情,除了拉出它的值(在 findSet 中)。但是,除了提取它的值之外,您从不对父点做任何事情,这一事​​实告诉我,它应该是一个 Maybe Int 而不是一个 Maybe Point。

      让我重复并扩展我在开头所说的话。不相交集是一种特别难以以功能/持久方式处理的数据结构,因此我强烈建议从更简单的树结构开始,例如二叉搜索树或左派堆甚至抽象语法树。这些结构具有所有访问都通过根的属性——也就是说,您总是从根开始,然后沿着树向下工作以到达正确的位置。此属性使作为持久数据结构标志的共享类型变得更加容易。

      不相交集数据结构不具有该属性。不是总是从根开始,一直到感兴趣的节点,而是从任意节点开始,然后一直回到根。当您有这样不受限制的入口点时,通常最简单的处理方法是通过单独的地图(在您的情况下为 DSForest)调解所有共享,但这意味着在任何地方来回传递该地图。

      【讨论】:

      • 谢谢。不实施路径压缩的原因是我看不到它是如何完成的,所以我决定稍后再讨论。阅读您的评论,我现在发现这将非常困难。 (谢谢你的解释!)我认为你是对的,我应该先看看其他结构。我已经收到了一本与你同名的书,正在邮寄途中,它应该很快就会到了。 :-)
      【解决方案3】:

      共享是编译器的事情。当它识别公共子表达式时,编译器可能会选择用内存中的同一个对象来表示它们。但是即使您使用这样的编译器开关(如-fno-cse),也没有义务这样做,并且两者可能(并且通常在没有开关的情况下)由两个不同的表示,尽管相等值,内存中的对象。回复:referential transparency.

      OTOH,当我们命名某个东西并使用该名称两次时,我们(合理地)期望它代表内存中的同一个对象。但是编译器可能会选择复制它并在两个不同的使用站点中使用两个单独的副本,尽管不知道这样做。但它可能。回复:referential transparency.

      另见:


      这里有几个列表生成函数的例子,取自上面的最后一个链接。它们依赖于编译器不复制任何东西,即确实按照 call by need lambda 演算操作语义(如 cmets 中的 nponeccop 所解释的那样)共享任何命名对象,并且不会在其上引入任何额外的共享自己消除常见的子表达式:

      1. 共享定点组合器,创建循环:

        fix f = x where x = f x

      2. 非共享定点组合器,创建伸缩多级链(即常规递归链)

        _Y f = f (_Y f)

      3. 两阶段组合 - 循环和提要

        _2 f = f (fix f)

      【讨论】:

      • 请注意,按需求调用评估策略规定共享某些 redexes。虽然 Haskell 规范相当模糊并且只规定了非严格语义,但按需调用 lambda 演算很好地描述了 Haskell 的 GHC 实现的操作语义,因此您可以相信根据按需调用语义共享的所有内容确实是共享的。
      • @nponeccop 是的,这就是我的意思,感谢您使其更加具体和准确。但是我也看到了一些相反的cmets,w.r.t。并行化例如,说在这种情况下编译器可能选择“拆分”变量。
      • @nponeccop 我已经修改了我的答案以解决您的言论。 :)
      猜你喜欢
      • 2021-11-27
      • 2014-09-29
      • 1970-01-01
      • 2020-05-10
      • 2021-04-26
      • 2017-06-26
      • 2019-11-12
      • 2019-07-11
      相关资源
      最近更新 更多