【问题标题】:Haskell - why would I use infinite data structures?Haskell - 为什么我要使用无限数据结构?
【发布时间】:2020-06-12 17:39:08
【问题描述】:

在 Haskell 中,可以像这样定义无限列表:

[1.. ]

如果找到许多描述如何实现无限列表的文章,我就明白它是如何工作的。 但是,我想不出任何理由使用无限数据结构的概念。

谁能给我一个问题的例子,可以通过 Haskell 中的无限列表更容易(或者可能只)解决?

【问题讨论】:

  • 我相信有很多很好的答案,但为了帮助您入门,无限数据结构通常允许更简单的定义。考虑enumerate,它将列表的每个元素放入带有索引的元组中。这可以使用带有索引的辅助函数递归定义,或者更简单地定义为zipWith [0..]。由于 zip 的性质,写 [0..][0.. length list - 1] 更容易和更通用。
  • 这只是意味着您不必通过数据生成函数传递停止条件,但可以在最后应用它。一个经典的例子是take 50 fibonacci,其中fibonacci 是根据自身定义的。
  • 首先,让我们忽略“数学上无限的事物”(如整数)和“就我的程序而言实际上是无限的事物”(如用户生成的 I/O 流)之间的区别事件。不难看出通用接口在操作两者方面的优势。第一个你可能不会遇到太多,但后者很常见。
  • 根据我的经验,无限数据结构的使用频率并不高。不过,有时它们很方便。例如,如果我们正在编写一个语言解释器,head [var | var <- infiniteListOfVarNames , not (var `freeIn` expression)] 是一个用于生成新名称的短代码,它不会出现在 expression 中。它效率不高,但易于编写和理解。也有类似的情况,需要在满足一定条件的无限多个值中找到“第一个”值,过滤无限列表是非常自然的。 IMO,无限树没那么有用。
  • @cole 我也打算给出zip [0..] 的例子,但坦率地说,这有点肤浅,不仅因为例如Python 的 enumerate 也能完成这项工作,甚至在 Haskell 中也能完成:当使用 Data.Vector 时,你会使用 indexed 而不是 zip。

标签: haskell functional-programming lazy-evaluation


【解决方案1】:

Haskell 中列表的基本优点是它们是一个 control 结构,看起来像一个 data 结构。您可以编写对数据进行增量操作的代码,但它看起来类似于对列表的简单操作。这与其他需要使用显式增量结构的语言形成鲜明对比,例如迭代器(Python 的 itertools)、协程(C# IEnumerable)或范围 (D)。

例如,sort 函数的编写方式可以是在开始产生结果之前对尽可能少的元素进行排序。虽然对 整个 列表进行排序需要 O(n log n) / 线性时间在列表的长度上,minimum xs = head (sort xs) 只需要 O(n) / linear 时间,因为head 将只检查列表的第一个构造函数,例如 x : _,并将 tail 保留为代表排序操作其余部分的未评估 thunk。

这意味着性能是组合的:例如,如果您对一个数据流有很长的操作链,例如sum . map (* 2) . filter (< 5),它看起来像它将首先过滤所有元素,然后在它们上映射一个函数,然后求和,在每一步生成一个完整的中间列表。但是发生的情况是每个元素一次只处理一个:给定[1, 2, 6],这基本上如下进行,所有步骤都以增量方式发生:

  • 总计 = 0
  • 1 < 5 是真的
  • 1 * 2 == 2
  • 总计 = 0 + 2 = 2
  • 2 < 5 是真的
  • 2 * 2 == 4
  • 总计 = 2 + 4 = 6
  • 6 < 5 是假的
  • 结果 = 6

这正是您用命令式语言(伪代码)编写快速循环的方式:

total = 0;
for x in xs {
  if (x < 5) {
    total = total + x * 2;
  }
}

这意味着性能是组合性的:由于懒惰,这段代码在处理列表的过程中一直使用内存。在mapfilter 内部并没有什么特别之处可以实现这一点:它们可以完全独立。

再例如,标准库中的and 计算列表的逻辑与,例如and [a, b, c] == a &amp;&amp; b &amp;&amp; c,它被简单地实现为折叠:and = foldr (&amp;&amp;) True。当它到达输入中的 False 元素时,它会停止评估,这仅仅是因为 &amp;&amp; 在其正确的参数中是惰性的。懒惰让你作曲!

有关这一切的精彩论文,请阅读 John Hughes 着名的 Why Functional Programming Matters,它比我更好地介绍了惰性函数式编程(在 Miranda 中,Haskell 的前身)的优势。

【讨论】:

  • 关于您对minimum 的定义,这不依赖于sort 的实现,它保证最小元素将在线性时间内被识别(例如,插入排序)?
  • @chepner:确实如此——我试图提一下 sort 是为懒惰的消费者编写的一个好的生产者的事实;也就是说,惰性函数自动组合得很好,但并非所有函数都自动足够惰性。
  • @chepner 我认为标准的sort 是一种合并排序,由于惰性,它的行为更像堆排序。如果是这样,它应该保证生成第一个元素的 O(n) 时间。
  • @chepner 您可以在完成中间的所有合并步骤之前提取头部,因为您实际上只需要每个合并的头部。因此,您大致得到: n 次比较以拆分为序列; n/2 次比较以获得第一级合并的头部; n/4次比较得到第二级的heads; n/8 次比较;等等。这是 n+n/2+n/4+n/8+... = 2*n ∈ O(n) 总比较,以获得顶级列表的头部。
  • 这是关于懒惰的一个很好的答案,但它似乎根本没有提到任何无限数据结构。
【解决方案2】:
  • 使用其索引注释列表临时使用无限的索引列表:

    zip [0..] ['a','b','c','d'] = [(0,'a'), (1,'b'), (2,'c'), (3,'d')]
    
  • 在保持纯度的同时记忆函数(在这种情况下,这种转换会导致速度呈指数增长,因为备忘录表是递归使用的):

    fib = (memo !!)
        where
        memo = map fib' [0..]  -- cache of *all* fibonacci numbers (evaluated on demand) 
        fib' 0 = 0
        fib' 1 = 1
        fib' n = fib (n-1) + fib (n-2)
    
  • 带有副作用的纯模拟程序(免费单子)

    data IO a = Return a
              | GetChar (Char -> IO a)
              | PutChar Char (IO a)
    

    可能不终止的程序用无限的 IO 结构表示;例如forever (putChar 'y') = PutChar 'y' (PutChar 'y' (PutChar 'y' ...))

  • 尝试:如果你定义一个大致如下的类型:

    data Trie a = Trie a (Trie a) (Trie a)
    

    它可以表示由自然索引的as 的无限集合。请注意,递归没有基本情况,因此每个Trie 都是无限的。但是索引n 处的元素可以在log(n) 时间内访问。这意味着你可以做这样的事情(使用inttrie 库中的一些函数):

    findIndices :: [Integer] -> Trie [Integer]
    findIndices = foldr (\(i,x) -> modify x (i:)) (pure []) . zip [0..]
    

    这构建了一个高效的“反向查找表”,给定列表中的任何值都可以告诉您它出现在哪些索引处,并且它缓存结果流信息一旦可用:

    -- N.B. findIndices [0, 0,1, 0,1,2, 0,1,2,3, 0,1,2,3,4...]
    > table = findIndices (concat [ [0..n] | n <- [0..] ])
    > table `apply` 0
    [0,1,3,6,10,15,21,28,36,45,55,66,78,91,...
    

    全部来自单行无限折叠。

我相信还有更多的例子,你可以做很多很酷的事情。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-05-18
    • 1970-01-01
    • 2011-12-12
    • 1970-01-01
    • 2020-11-17
    相关资源
    最近更新 更多