理解Haskell中的结构共享

理解Haskell中的结构共享,haskell,lazy-evaluation,Haskell,Lazy Evaluation,在Liu和Hudak的论文“用箭头堵住空间泄漏”中,声称这会导致O(n^2)运行时行为(用于计算第n项): ,而这给了我们线性时间: successors n = let ns = n : map (+1) ns in ns 。这句话肯定是正确的,因为我可以很容易地用GHCi进行验证。然而,我似乎无法确切理解为什么,以及在这种情况下结构共享是如何帮助的。我甚至试着写出计算第三项的两个展开式 以下是我对第一种变体的尝试: successors 1 !! 2 (1 :

在Liu和Hudak的论文“用箭头堵住空间泄漏”中,声称这会导致O(n^2)运行时行为(用于计算第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

正如你所看到的,我的展开式看起来几乎完全相同,并且似乎暗示了两者的二次行为。不知何故,结构共享在后一个定义中出现并重用了早期的结果,但它看起来很神奇。有人能详细说明一下吗?

不严格地说:在
ns
的定义中,你可以假装
ns
已经被完全评估过了。所以我们实际上得到的是,基本上

successors n = let ns = n : map (+1) [n,n+1,n+2,n+3,n+4,...]
您只需计算这张地图的成本

让我们从操作角度来考虑这个问题

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
的第二个元素(现在已知)的指针。因此,在我们继续的过程中,我们建立了一个列表,它的最后一部分总是一个小的循环位。

为了理解这一点,我们需要定义
map

map _ []     = []
map f (x:xs) = f x : map f xs
我们将计算
0
,假装结果列表的脊椎在计算时被强制。我们首先将
n
绑定到
0

successors 0 = let ns = 0 : map (+1) ns 
               in ns
无论在什么地方,我们都会保留计算结果——在构造函数的(非严格)字段中,或者在
let
绑定的
中,我们实际上是在存储一个thunk,它将在计算thunk时接受计算结果的值。我们可以通过引入一个新的变量名在代码中表示这个占位符。对于将
map(+1)ns
放置在
构造函数尾部的最终结果:
我们将引入一个名为
ns0
的新变量

successors 0 = let ns = 0 : ns0 where ns0 = map (+1) ns 
               in ns
map (+1) (n1 : ns1) = n1 + 1 : map (+1) ns1
第一次扩展 现在让我们扩展一下

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
successors 0 = 0 : n1 : ns1
                   where
                       n1  = 0 + 1
                       ns1 = n1 + 1 : map (+1) ns1
我们将为计算
0+1
map(+1)ns0
引入新的变量名
n1
ns1
,最右边的
构造函数的参数

successors 0 = 0 : ns0
                   where
                       ns0 = n1    : ns1
                       n1  = 0 + 1
                       ns1 =         map (+1) ns0
successors 0 = 0 : n1 : ns1
                   where
                       n1  = 0 + 1
                       ns1 = n2     : ns2
                       n2  = n1 + 1
                       ns2 =          map (+1) ns1
第二次扩张 我们展开
map(+1)ns0

successors 0 = let ns = 0 : ns0 where ns0 = map (+1) ns 
               in ns
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 : ns0 where ns0 = 0 + 1 : map (+1) ns0
successors 0 = 0 : n1 : ns1
                   where
                       n1  = 0 + 1
                       ns1 = n1 + 1 : map (+1) ns1
我们将为计算
n1+1
map(+1)ns1
引入新的变量名
n2
ns2
,最右边的
构造函数的参数

successors 0 = 0 : ns0
                   where
                       ns0 = n1    : ns1
                       n1  = 0 + 1
                       ns1 =         map (+1) ns0
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
这显然是在列表的脊椎中线性增长,在thunks中线性增长,以计算列表中的值。正如dfeuer所描述的,我们只处理列表末尾的“小循环位”

如果我们强制列表中的任何值,那么引用它的所有剩余thunk现在将引用已经计算的值。例如,如果我们强制
n2=n1+1
它将强制
n1=0+1=1
,并且
n2=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的加法。我们可以(免费)用刚刚计算的值替换所有的
n1
s和
n2
s,并且忘记这些变量名

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

当强制执行
n3
时,它将使用已知的
n2
结果(
2
),并且前两个加法将不再执行。

您必须对图形进行操作才能理解共享。在线性公式上这样做会让人困惑。谢谢!我想我现在明白了!谢谢你的回答!这对我帮助很大。很难选择接受哪一个。