【问题标题】:What are the space complexities of inits and tails?inits 和 tails 的空间复杂度是多少?
【发布时间】:2015-06-06 06:06:49
【问题描述】:

TL;博士

在阅读了 Okasaki 的 Purely Functional Data Structures 中关于 persistence 的文章并查看了他关于单链表的说明性示例(这就是 Haskell 的列表是如何实现的)之后,我想知道Data.Listinitstails 的空间复杂性...

在我看来

  • tails 的空间复杂度在其参数长度上是线性,并且
  • inits 的空间复杂度在其参数长度上是二次

但一个简单的基准表明并非如此。

基本原理

使用tails,可以共享原始列表。计算tails xs 只需沿着列表xs 前进并创建一个指向该列表每个元素的新指针;无需在内存中重新创建 xs 的一部分。

相比之下,因为inits 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的内存数量级相同...嗯...

发生了什么事?

  • 我关于tails(线性)和inits(二次)空间复杂性的结论是否正确?
  • 如果是这样,为什么 GHC 为fInitsfTails 分配大致一样多的内存? list fusion和这个有关系吗?
  • 还是我的基准测试有缺陷?

【问题讨论】:

  • 我唯一的猜测是:中间的 Ints 没有被优化掉,所以 fTails 也为这些分配了 O(n^2) 。必须查看核心来检查(我手头没有 ghc)。
  • 在运行 fInitsfTails 之前,您可能应该强制列表 (print $ sum intRange)。
  • @delnan 谢谢。我还不习惯检查核心,但我会调查一下。
  • @Cirdec 完成。没有变化。
  • 忽略我的 GHC 7.8.3 结果(以及其他任何人)。 GHC7.8.3 has a bug where inits is very slow。它已在 7.8.4 中修复。

标签: haskell profiling singly-linked-list space-complexity


【解决方案1】:

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 探索的最简单的渐近最优实现是基于将take 与连续更大的参数一起应用:

inits xs = [] : go (1 :: Int) xs where
  go !l (_:ls) = take l xs : go (l+1) ls
  go _  []     = []

Felgenhauer 能够使用 take 的私有、非融合版本来获得一些额外的性能,但它仍然没有尽可能快。

在大多数情况下,以下非常简单的实现要快得多:

inits = map reverse . scanl (flip (:)) []

在一些奇怪的极端情况下(如map head . inits),这个简单的实现是渐近非最优的。因此,我使用相同的技术编写了一个版本,但基于 Chris Okasaki 的银行家队列,它既是渐近最优的,也几乎同样快。 Joachim Breitner 进一步优化了它,主要是通过使用严格的scanl' 而不是通常的scanl,这个实现进入了 GHC 7.8.4。 inits 现在可以在 O(n) 时间内产生结果的脊椎;强制整个结果需要 O(n^2) 时间,因为没有一个 conses 可以在不同的初始段之间共享。如果你想要非常快的initstails,你最好的选择是使用Data.Sequence; Louis Wasserman 的实现is magical。另一种可能性是使用Data.Vector——它大概使用切片来处理这些事情。

【讨论】:

  • 非常全面的答案。谢谢。
猜你喜欢
  • 1970-01-01
  • 2014-01-01
  • 2020-03-10
  • 2018-07-23
  • 2011-09-25
  • 2012-04-09
  • 2020-05-03
  • 2022-01-13
  • 1970-01-01
相关资源
最近更新 更多