了解 Haskell 中的结构共享

Understanding Structure Sharing in Haskell

在 Liu 和 Hudak 的论文 "Plugging a Space Leak with an Arrow" 中,声称这会导致 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

如您所见,我的展开看起来几乎完全相同,并且似乎暗示两者的二次行为。以某种方式在后一个定义中设置了结构共享并重用了早期的结果,但它看起来很神奇。谁能详细说说?

松散地说:在 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,但是访问的部分已经计算好了。不需要重新计算。此强制的效果是用 n+1:map (+1) ns' 替换 map (+1) ns,其中 ns' 是指向 ns 的(现在已知的)第二个元素的指针。因此,在我们继续的过程中,我们构建了一个列表,其最后一块总是有点循环。

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

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

我们将计算 successors 0,假装结果列表的脊柱在我们计算时被强制。我们将从绑定 n0.

开始
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

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

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

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

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 处处理 "little circular bit"。

如果我们强制保留列表中的任何值,所有引用它的剩余 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 的加法。我们可以(免费)将 n1n2all 替换为刚刚计算的值,而忘记那些变量名。

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

n3 被强制使用时,它将使用已知的 n2 结果 (2),而前两个加法将永远不会再进行。