TL; DR
在阅读了关于Okasaki的纯函数数据结构中的持久性的文章并阅读了关于单链表(这是Haskell的列表是如何实现的)的说明性示例之后,我不禁想到Data.List的内部和尾部的空间复杂性……
在我看来,这
>尾部的空间复杂度在其参数的长度上是线性的,并且
> inits的空间复杂度在其参数的长度上是二次的,
但一个简单的基准指示不然.
合理
使用tails,可以共享原始列表.计算尾部xs简单地包括沿着列表xs行走并创建指向该列表的每个元素的新指针;无需在内存中重新创建部分xs.
相反,因为ins xs的每个元素“以不同的方式结束”,所以不存在这样的共享,并且xs的所有可能的前缀必须在内存中从头开始重新创建.
基准
下面的简单基准测试显示两个函数之间的内存分配没有太大差异:
-- Main.hs
import Data.List (inits, tails)
main = do
let intRange = [1 .. 10 ^ 4] :: [Int]
print $sum intRange
print $fInits intRange
print $fTails intRange
fInits :: [Int] -> Int
fInits = sum . map sum . inits
fTails :: [Int] -> Int
fTails = sum . map sum . tails
用我的编译我的Main.hs文件后
ghc -prof -fprof-auto -O2 -rtsopts Main.hs
并运行
./Main +RTS -p
Main.prof文件报告以下内容:
COST CENTRE MODULE %time %alloc
fInits Main 60.1 64.9
fTails Main 39.9 35.0
为fInits分配的内存和为fTails分配的内存具有相同的数量级… Hum …
到底是怎么回事?
>我对尾部(线性)和内部(二次)的空间复杂性的结论是否正确?
>如果是这样,为什么GHC为fInits和fTails分配大致相同的内存?列表融合是否与此有关?
>或者我的基准有缺陷?
最佳答案 Haskell报告中的inits实现与基本4.7.0.1(GHC 7.8.3)的实现相同或几乎完全相同,速度非常慢.特别是,fmap应用程序递归堆叠,因此强制结果的连续元素变得越来越慢.
inits [1,2,3,4] = [] : fmap (1:) (inits [2,3,4])
= [] : fmap (1:) ([] : fmap (2:) (inits [3,4]))
= [] : [1] : fmap (1:) (fmap (2:) ([] : fmap (3:) (inits [4])))
....
Bertram Felgenhauer探索的最简单的渐近最优实现是基于应用带有相继更大的参数:
inits xs = [] : go (1 :: Int) xs where
go !l (_:ls) = take l xs : go (l+1) ls
go _ [] = []
Felgenhauer能够使用私有的非融合版本来获得一些额外的性能,但它仍然没有尽可能快.
在大多数情况下,以下非常简单的实现速度要快得多:
inits = map reverse . scanl (flip (:)) []
在一些奇怪的角落情况下(如map head.inits),这个简单的实现是渐近非最优的.因此,我使用相同的技术编写了一个版本,但基于Chris Okasaki的Banker队列,这是渐近最优且几乎同样快. Joachim Breitner进一步优化了它,主要是通过使用严格的scanl’而不是通常的scanl,这个实现进入GHC 7.8.4. inits现在可以在O(n)时间内产生结果的脊柱;强制整个结果需要O(n ^ 2)时间,因为在不同的初始段之间不能共享任何一个.如果你想要真正荒谬的快速inits和tail,你最好的选择是使用Data.Sequence; Louis Wasserman的实现is magical.另一种可能性是使用Data.Vector – 它可能使用切片来处理这类事情.