【问题标题】:Slow tail recursion in F#F# 中的慢尾递归
【发布时间】:2011-03-28 14:20:37
【问题描述】:

我有一个 F# 函数,它以跳过 n、选择 n、跳过 n、选择 n... 的模式返回一个从 0 开始的数字列表,直到一个限制。例如,输入 2 的此函数将返回 [2, 3, 6, 7, 10, 11...]

最初我将其实现为一个非尾递归函数,如下所示:

let rec indicesForStep start blockSize maxSize =
    match start with
    | i when i > maxSize -> []
    | _ -> [for j in start .. ((min (start + blockSize) maxSize) - 1) -> j] @ indicesForStep (start + 2 * blockSize) blockSize maxSize

认为尾递归是可取的,我使用累加器列表重新实现它,如下所示:

let indicesForStepTail start blockSize maxSize =
    let rec indicesForStepInternal istart accumList =
        match istart with
        | i when i > maxSize -> accumList
        | _ -> indicesForStepInternal (istart + 2 * blockSize) (accumList @ [for j in istart .. ((min (istart + blockSize) maxSize) - 1) -> j])
    indicesForStepInternal start []

但是,当我在 Mono 下的 fsi 中使用参数 1、1 和 20,000(即应返回 [1, 3, 5, 7...] 最多 20,000)运行此程序时,尾递归版本明显慢于第一个版本(与第一个版本相比为 12 秒)亚秒)。

为什么尾递归版本更慢?是因为列表连接吗?是编译器优化吗?我真的用尾递归实现了吗?

我也觉得我应该使用高阶函数来做到这一点,但我不确定具体如何去做。

【问题讨论】:

  • 不幸的是,我没有时间提供替代代码,但有一些快速观察:1)它是尾递归的,2)列表追加是 O(n),因此效率低下。我建议反转(逐步降低)您的列表理解,将其转换为 accumList,并在您的第一个模式匹配中返回它之前反转 accumList。
  • 非常感谢大家;这些答案非常有帮助、信息丰富且具有教育意义。

标签: performance f# tail-recursion


【解决方案1】:

正如 dave 指出的那样,问题在于您使用 @ 运算符来附加列表。这是比尾递归更重要的性能问题。事实上,尾递归并没有真正加速程序太多(但它可以在堆栈溢出的大输入上工作)。

您的第二个版本较慢的原因是您将较短的列表(使用[...] 生成的列表)附加到较长的列表(accumList)。这比将较长的列表附加到较短的列表要慢(因为该操作需要复制第一个列表)。

您可以通过以相反的顺序收集累加器中的元素然后在返回结果之前将其反转来修复它:

let indicesForStepTail start blockSize maxSize =
    let rec indicesForStepInternal istart accumList =
        match istart with
        | i when i > maxSize -> accumList |> List.rev
        | _ -> 
           let acc = 
             [for j in ((min (istart + blockSize) maxSize) - 1) .. -1 .. istart -> j] 
             @ accumList
           indicesForStepInternal (istart + 2 * blockSize) acc
    indicesForStepInternal start []

如您所见,这具有较短的列表(使用[...] 生成)作为@ 的第一个参数,在我的机器上,它具有与非尾递归版本相似的性能。请注意,[ ... ] 理解以相反的顺序生成元素 - 以便它们可以在最后反转回来。

您还可以使用 F# seq { .. } 语法更好地编写整个内容。您可以完全避免使用@ 运算符,因为它允许您使用yield 产生单个元素并使用yield! 执行尾递归调用:

let rec indicesForStepSeq start blockSize maxSize = seq {
    match start with
    | i when i > maxSize -> ()
    | _ -> 
      for j in start .. ((min (start + blockSize) maxSize) - 1) do
        yield j
      yield! indicesForStepSeq (start + 2 * blockSize) blockSize maxSize }

我就是这样写的。调用它时,只需添加Seq.toList 即可评估整个惰性序列。此版本的性能与第一个类似。

EDIT经过Daniel的更正,Seq 版本实际上速度稍快!

【讨论】:

  • 我相信它需要是let rec,你的递归调用应该是indicesForStepSeq
  • @Daniel:谢谢!你是对的,这个错误一直发生在我身上:-)。现在,Seq 版本其实快了一点……
  • 出于好奇,我将最后一个函数更改为使用列表表达式而不是序列表达式,它变得非常慢(1 1 20000 为 30 秒,即比 OP 的代码慢得多)。有人知道为什么会这样吗?
  • @wmeyer - 我没有检查反射器,但我想知道它是否在附加。此性能类似于使用附加的 OP 版本。
  • @wmeyer 使用列表推导会更慢,因为您将创建大量临时列表。 yield! 表达式并没有说明它应该只创建对列表的引用(因为它是尾调用),而是迭代创建的列表。列表理解本质上只是seq { .. },在末尾添加了List.ofSeq
【解决方案2】:

在 F# 中,列表类型被实现为单链表。因此,如果 x 和 y 的长度不同,则 x @ y 和 y @ x 的性能会有所不同。这就是您看到性能差异的原因。 (x @ y) 的运行时间为 X.length。

// e.g.
let x = [1;2;3;4]
let y = [5]

如果您执行 x @ y,则 x(4 个元素)将被复制到一个新列表中,并且其内部 next 指针将设置为现有的 y 列表。如果你做了 y @ x 那么 y(1 个元素)将被复制到一个新列表中,并且它的下一个指针将设置为现有列表 x。

我不会使用高阶函数来执行此操作。我会改用列表理解。

let indicesForStepTail start blockSize maxSize =
    [ 
        for block in start .. (blockSize * 2) .. (maxSize - 1) do
            for i in block .. (block + blockSize - 1) do
                yield i
    ]

【讨论】:

    【解决方案3】:

    这看起来像列表追加是问题。 Append 基本上是对第一个参数大小的 O(N) 操作。通过在左边累加,这个操作需要 O(N^2) 时间。

    这通常在函数式代码中完成的方式似乎是以相反的顺序累加列表(通过在右侧累加),然后在最后返回列表的反向。

    您拥有的第一个版本避免了附加问题,但正如您指出的那样,它不是尾递归。

    在 F# 中,解决此问题的最简单方法可能是使用序列。它看起来不是很实用,但是您可以轻松地按照您的模式创建一个无限序列,并使用Seq.take 来获取您感兴趣的项目。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-10-30
      • 1970-01-01
      • 1970-01-01
      • 2011-03-15
      • 1970-01-01
      • 2015-10-01
      相关资源
      最近更新 更多