理解Haskell中的结构共享

时间:2015-03-23 16:14:32

标签: haskell lazy-evaluation

在Liu和Hudak撰写的“用箭头堵塞空间泄漏”一文中,声称这会导致O(n ^ 2)运行时行为(用于计算第n项):

 successors n = n : map (+1) (successors n)

,虽然这给了我们线性时间:

successors n = let ns = n : map (+1) ns
               in ns

。这句话肯定是正确的,因为我可以使用GHCi轻松验证。但是,我似乎无法理解为什么,以及在这种情况下结构共享如何帮助。我甚至试图写出计算第三学期的两个扩展。

以下是我对第一个变体的尝试:

successors 1 !! 2
(1 : (map (+1) (successors 1))) !! 2
     (map (+1) (successors 1)) !! 1
     (map (+1) (1 : map (+1) (successors 1))) !! 1
     2 : (map (+1) (map (+1) (successors 1))) !! 1
         (map (+1) (map (+1) (successors 1))) !! 0
         (map (+1) (map (+1) (1 : map (+1) (successors 1)))) !! 0
         (map (+1) (2 : map (+1) (map (+1) (successors 1)))) !! 0
         3 : map (+1) (map (+1) (map (+1) (successors 1))) !! 0
         3

和第二个:

successors 1 !! 2
(let ns = 1 : map (+1) ns in ns) !! 2
(1 : map (+1) ns) !! 2
     map (+1) ns !! 1
     map (+1) (1 : map (+1) ns) !! 1
     2 : map (+1) (map (+1) ns) !! 1
         map (+1) (map (+1) ns) !! 0
         map (+1) (map (+1) (1 : map (+1) ns)) !! 0
         map (+1) (2 : map (+1) (map (+1) ns)) !! 0
         3 : map (+1) (map (+1) (map (+1) ns)) !! 0
         3

如你所见,我的扩展看起来几乎完全相同,似乎暗示了两者的二次行为。不知何故,结构共享在后一个定义中设置并重用早期的结果,但它看起来很神奇。任何人都可以详细说明吗?

2 个答案:

答案 0 :(得分:13)

松散地说:在ns的定义中,您可以假装ns已被完全评估。所以我们实际得到的基本上是

successors n = let ns = n : map (+1) [n,n+1,n+2,n+3,n+4,...]

您只需计算此map的费用。

让我们从操作上考虑一下。

ns = n : map (+1) ns

做什么?好吧,它会分配一些内存来保存ns,并在其中存储一个(:)构造函数,它指向值n和一个代表map (+1) ns的“thunk”。但是那个thunk代表ns作为指向那个持有ns的内存的指针!所以我们实际上在内存中有一个循环结构。当我们要求ns的第二个元素时,那个thunk就被强制了。这涉及访问ns,但已经计算了访问的部分。它不需要再次计算。此强制的效果是将map (+1) ns替换为n+1:map (+1) ns',其中ns'是指向ns的(现在已知的)第二个元素的指针。因此,当我们继续时,我们建立一个列表,其最后一块总是一个小圆点。

答案 1 :(得分:10)

要理解这一点,我们需要map

的定义
map _ []     = []
map f (x:xs) = f x : map f xs

我们将计算successors 0,假装结果列表的主干在我们计算时被强制执行。我们首先将n绑定到0

successors 0 = let ns = 0 : map (+1) ns 
               in ns

我们坚持计算的结果 - 在构造函数的一个(非严格)字段或letwhere绑定中,我们实际上存储了一个将要使用的thunk计算thunk时的计算结果的值。我们可以通过引入一个新的变量名来代表这个占位符。对于map (+1) ns放置在:构造函数尾部的最终结果,我们将引入一个名为ns0的新变量。

successors 0 = let ns = 0 : ns0 where ns0 = map (+1) ns 
               in ns

第一次扩展

现在让我们展开

map (+1) ns

使用map的定义。我们从let绑定我们刚刚写道:

ns = 0 : ns0 where ns0 = map (+1) ns

因此

map (+1) (0 : ns0) = 0 + 1 : map (+1) ns0

当强制第二项时,我们有:

successors 0 = let ns = 0 : ns0 where ns0 = 0 + 1 : map (+1) ns0
               in ns

我们不再需要ns变量,因此我们会将其删除以进行清理。

successors 0 = 0 : ns0 where ns0 = 0 + 1 : map (+1) ns0

我们将为计算n1ns1引入新变量名0 + 1map (+1) ns0,这是最右边:构造函数的参数。

successors 0 = 0 : ns0
                   where
                       ns0 = n1    : ns1
                       n1  = 0 + 1
                       ns1 =         map (+1) ns0

第二次扩展

我们展开map (+1) ns0

map (+1) (n1 : ns1) = n1 + 1 : map (+1) ns1

在列表的第三个项目(但尚未达到它的价值)被迫之后,我们有:

successors 0 = 0 : ns0
                   where
                       ns0 = n1    : ns1
                       n1  = 0 + 1
                       ns1 =         n1 + 1 : map (+1) ns1

我们不再需要ns0变量,因此我们会将其删除以进行清理。

successors 0 = 0 : n1 : ns1
                   where
                       n1  = 0 + 1
                       ns1 = n1 + 1 : map (+1) ns1

我们将为计算n2ns2引入新变量名n1 + 1map (+1) ns1,这是最右边:构造函数的参数。

successors 0 = 0 : n1 : ns1
                   where
                       n1  = 0 + 1
                       ns1 = n2     : ns2
                       n2  = n1 + 1
                       ns2 =          map (+1) ns1

第三次扩张

如果我们再次重复上一节中的步骤,我们有

successors 0 = 0 : n1 : n2 : ns2
                   where
                       n1  = 0 + 1
                       n2  = n1 + 1
                       ns2 = n3     : ns3
                       n3  = n2 + 1
                       ns3 =          map (+1) ns2

这显然在列表的主干中线性增长,并且在thunk中线性增长以计算列表中保存的值。正如dfeuer所描述的那样,我们只会处理"小圆形位"在列表的 end

如果我们强制列表中保留的任何值,则引用它的所有剩余thunk现在将引用已经计算的值。例如,如果我们强制n2 = n1 + 1,则会强制n1 = 0 + 1 = 1n2 = 1 + 1 = 2。该列表看起来像

successors 0 = 0 : n1 : n2 : ns2
                   where
                       n1  = 1           -- just forced
                       n2  = 2           -- just forced
                       ns2 = n3     : ns3
                       n3  = n2 + 1
                       ns3 =          map (+1) ns2

我们只做了两次补充。由于计算结果是共享的,因此永远不会再次计数最多2的加法。我们可以(免费)用刚刚计算的值替换n1n2所有,并忘记这些变量名称。

successors 0 = 0 : 1 : 2 : ns2
                   where
                       ns2 = n3   : ns3
                       n3  = 2 + 1       -- n3 will reuse n2
                       ns3 =        map (+1) ns2

n3被强制使用时,它将使用已知的n22)的结果,并且前两次添加将永远不会再次完成。