haskell – inits and tails的空间复杂性是什么?

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 – 它可能使用切片来处理这类事情.

点赞