简体   繁体   English

尾递归函数的性能

[英]Performance of the tail recursive functions

The variety of books, articles, blog posts suggests that rewriting recursive function into tail recursive function makes it faster.各种书籍、文章、博客文章表明,将递归函数重写为尾递归函数可以使其更快。 No doubts it is faster for trivial cases like generating Fibonacci numbers or calculating factorial.毫无疑问,对于生成斐波那契数或计算阶乘等琐碎情况,它会更快。 In such cases there is a typical approach to rewrite - by using "helper function" and additional parameter for intermediate results.在这种情况下,有一种典型的重写方法 - 通过使用“辅助函数”和中间结果的附加参数。

TAIL RECURSION is the great description of the differences between tail recursive and not tail recursive functions and the possible way how to turn the recursive function into a tail recursive one. TAIL RECURSION很好地描述了尾递归函数和非尾递归函数之间的区别,以及如何将递归函数变成尾递归函数的可能方法。 What is important for such rewriting - the number of function calls is the same (before/after rewriting), the difference comes from the way how those calls are optimized for tail recursion.这种重写的重要之处在于——函数调用的数量是相同的(重写之前/之后),不同之处在于这些调用是如何针对尾递归进行优化的。

Nevertheless, it is not always possible to convert the function into tail recursive one with such an easy trick.然而,并不总是可以通过这样一个简单的技巧将函数转换为尾递归函数。 I would categorize such cases as below我会将此类情况分类如下

  1. Function still can be rewritten into tail recursive but that might require additional data structures and more substantial changes in the implementation函数仍然可以重写为尾递归,但这可能需要额外的数据结构和实现中的更多实质性更改
  2. Function cannot be rewritten into tail recursive with any means but recursion still can be avoided by using loops and imitating stack ( I'm not 100% sure that tail recursion is impossible in some cases and I cannot describe how identify such cases, so if there is any academical research on this subject - the link would be highly appreciated )函数不能以任何方式重写为尾递归,但仍然可以通过使用循环和模仿堆栈来避免递归(我不是 100% 确定在某些情况下尾递归是不可能的,我无法描述如何识别这种情况,所以如果有是否有关于这个主题的学术研究 - 非常感谢链接

Now let me consider specific example when function can be rewritten into tail recursive by using additional structures and changing the way algorithm works.现在让我考虑可以通过使用附加结构和改变算法工作方式将函数重写为尾递归的具体示例。

Sample task: Print all sequences of length n containing 1 and 0 and which do not have adjacent 1s.示例任务:打印所有长度为 n 且包含 1 和 0 且没有相邻 1 的序列。

Obvious implementation which comes to mind first is below (on each step, if current value is 0 then we generate two sequences with length n-1 otherwise we generate only sequence with length n-1 which starts from 0)首先想到的明显实现如下(在每一步,如果当前值为 0,那么我们生成两个长度为 n-1 的序列,否则我们只生成长度为 n-1 的序列,从 0 开始)

  def gen001(lvl: Int, result: List[Int]):Unit = {
    //println("gen001")
    if (lvl > 0) {
      if (result.headOption.getOrElse(0) == 0) {
        gen001(lvl - 1, 0 :: result)
        gen001(lvl - 1, 1 :: result)
      } else gen001(lvl - 1, 0 :: result)
    } else {
      println(result.mkString(""))
    }
  }

  gen001(5, List())

It's not that straightforward to avoid two function calls when current element is 0 but that can be done if we generate children for each value in the intermediate sequences starting from the sequence '01' on the level 1. After having hierarchy of auxiliary sequences for level 1..n we can reconstruct the result ( printResult ) starting from leaf nodes (or sequence from the last iteration in other words).当当前元素为 0 时,避免两个函数调用并不是那么简单,但是如果我们为中间序列中的每个值生成子级(从级别 1 的序列“01”开始),则可以做到这一点。 在级别具有辅助序列的层次结构之后1..n 我们可以从叶节点(或者换句话说,从最后一次迭代开始的序列)重建结果( printResult )。

  @tailrec
  def g001(lvl: Int, current: List[(Int, Int)], result: List[List[(Int, Int)]]):List[List[(Int, Int)]] = {
    //println("g001")
    if (lvl > 1) {
      val tmp = current.map(_._1).zipWithIndex
      val next = tmp.flatMap(x => x._1 match {case 0 => List((0, x._2), (1, x._2)) case 1 => List((0, x._2))})
      g001(lvl - 1, next, next :: result)
    } else result
  }

  def printResult(p: List[List[(Int, Int)]]) = {
    p.head.zipWithIndex.foreach(x => 
      println(p.scanLeft((-1, x._2))((r1, r2) => (r2(r1._2)._1, r2(r1._2)._2)).tail.map(_._1).mkString("")))
  }

  val r = g001(5, List(0,1).zipWithIndex, List(List(0,1).zipWithIndex))

  println(r)

  printResult(r)

Output输出

List(List((0,0), (1,0), (0,1), (0,2), (1,2), (0,3), (1,3), (0,4), (0,5), (1,5), (0,6), (0,7), (1,7)), List((0,0), (1,0), (0,1), (0,2), (1,2), (0,3), (1,3), (0,4)), List((0,0), (1,0), (0,1), (0,2), (1,2)), List((0,0), (1,0), (0,1)), List((0,0), (1,1)))
00000
10000
01000
00100
10100
00010
10010
01010
00001
10001
01001
00101
10101

So now, if we compare two approaches, the first one requires much more recursive calls however on the other hand it's much more efficient in terms of memory because no additional data structures of intermediate results are required.所以现在,如果我们比较两种方法,第一种方法需要更多的递归调用,但另一方面它在内存方面效率更高,因为不需要额外的中间结果数据结构。

Finally, the questions are最后,问题是

  1. Is there a class of recursive functions which cannot be implemented as tail recursive?是否有一类递归函数不能实现为尾递归? If so how to identify them?如果是,如何识别它们?
  2. Is there a class of recursive functions such as their tail recursive implementation cannot be as efficient as non tail recursive one (for example in terms of memory usage).是否有一类递归函数,例如它们的尾递归实现不能像非尾递归那样有效(例如在内存使用方面)。 If so how to identify them.如果是,如何识别它们。 (Function from above example seems to be in this category) (上面例子中的函数似乎在这个类别中)

Links to academical research papers are highly appreciated.学术研究论文的链接受到高度赞赏。

PS.附注。 Thera are a number of related questions already asked and below links may be quite useful已经提出了许多相关问题,以下链接可能非常有用

Rewrite linear recursive function as tail-recursive function 将线性递归函数重写为尾递归函数

https://cs.stackexchange.com/questions/56867/why-are-loops-faster-than-recursion https://cs.stackexchange.com/questions/56867/why-are-loops-faster-than-recursion

UPDATE更新

Quick performance test快速性能测试

Tail recursive function can be simplified to store only auxiliary sequence on each step.尾递归函数可以简化为每一步只存储辅助序列。 That would be enough to print result.这足以打印结果。 Let me take out of the picture function which prints result and provide just modified version of the tail recursive approach.让我去掉打印结果的图片函数,并提供尾递归方法的修改版本。

  def time[R](block: => R): R = {
    val t0 = System.nanoTime()
    val result = block    // call-by-name
    val t1 = System.nanoTime()
    println("Elapsed time: " + (t1 - t0)/1e9 + "s")
    result
  }

  @tailrec
  def gg001(lvl: Int, current: List[Int], result: List[List[Int]]):List[List[Int]] = {
    //println("g001")
    if (lvl > 1) {
      val next = current.flatMap(x => x match {case 0 => List(0, 1) case 1 => List(0)})
      gg001(lvl - 1, next, next :: result)
    } else result
  }

  time{gen001(30, List())}
  time{gg001(30, List(0,1), List(List(0,1)))}

  time{gen001(31, List())}
  time{gg001(31, List(0,1), List(List(0,1)))}

  time{gen001(32, List())}
  time{gg001(32, List(0,1), List(List(0,1)))}

Output输出

Elapsed time: 2.2105142s
Elapsed time: 1.2582993s
Elapsed time: 3.7674929s
Elapsed time: 2.4024759s
Elapsed time: 6.4951573s
Elapsed time: 8.6575108s

For some N tail recursive approach starts taking more time than the original one and if we keep increasing N further it will start failing with java.lang.OutOfMemoryError: GC overhead limit exceeded对于某些 N 尾递归方法开始花费比原始方法更多的时间,如果我们继续增加 N 它将开始失败java.lang.OutOfMemoryError: GC overhead limit exceeded

Which makes me think that overhead to manage auxiliary data structures for tail recursive approach outweighs performance gains due to less number of recursive calls as well as their optimization.这让我认为管理尾递归方法的辅助数据结构的开销超过了由于递归调用次数减少及其优化而带来的性能提升。

I might have chosen not the most optimal implementation and/or data structures and also it may be due to language specific challenges but tail recursive approach does not look as absolute best solution (in terms of execution time/resources) even for this specific task.我可能没有选择最佳的实现和/或数据结构,也可能是由于语言特定的挑战,但即使对于这个特定任务,尾递归方法看起来也不是绝对的最佳解决方案(就执行时间/资源而言)。

The class of functions in 1 is empty: any computable function written in a recursive style has a tail-recursive equivalent (at the limit, since there's a tail-recursive implementation of a Turing Machine, you can translate any computable function into a Turing Machine definition and then the tail recursive version of that function is running that definition through the tail-recursive implementation of a Turing Machine). 1 中的函数类是空的:任何以递归风格编写的可计算函数都有一个尾递归等价物(在极限情况下,由于存在图灵机的尾递归实现,您可以将任何可计算函数转换为图灵机定义,然后该函数的尾递归版本通过图灵机的尾递归实现运行该定义)。

There are likewise no functions for which tail recursion is intrinsically less efficient than non-tail recursion.同样,没有尾递归本质上比非尾递归效率低的函数。 In your example, for instance, it's simply not correct that "it's much more efficient in terms of memory because no additional data structures of intermediate results are required."例如,在您的示例中,“它在内存方面效率更高,因为不需要中间结果的额外数据结构”,这是不正确的。 The required additional structure of intermediate results is implicit in the call-stack (which goes away in the tail recursive version).中间结果所需的附加结构隐含在调用堆栈中(在尾递归版本中消失)。 While the call stack is likely an array (more space efficient than a linked-list) it also, because of its generality, stores more data than is required.虽然调用堆栈可能是一个数组(比链表更节省空间),但由于其通用性,它也存储了比所需更多的数据。

Let me try to answer my own question.让我试着回答我自己的问题。

Recursive function can be easily rewritten into a tail recursive one if it is never called more than once from the function body.如果从函数体中多次调用递归函数,则可以轻松地将其重写为尾递归函数。 Alternatively, any function can be rewritten into a loop and loop can be rewritten into tail recursive function but this would require using some auxiliary data structures [at least for] for stack.或者,任何函数都可以重写为循环,循环可以重写为尾递归函数,但这需要使用一些辅助数据结构 [至少对于] 堆栈。 Such implementation can be slightly faster than the original approach (I believe this difference may vary from one language to another) but on the other hand it will be more cumbersome and less maintainable.这种实现可能比原始方法稍快(我相信这种差异可能因一种语言而异),但另一方面它会更麻烦且更不易维护。

So practically, tail recursive implementation makes sense when it does not require efforts to implement stack and the only additional hassle is storing intermediate results.所以实际上,当不需要努力实现堆栈并且唯一额外的麻烦是存储中间结果时,尾递归实现是有意义的。

I would say that one clear example of a recursive function which cannot be implemented with tail-recursion is a function is one in which the last statement calls itself twice.我想说一个不能用尾递归实现的递归函数的一个明显例子是一个函数,其中最后一条语句调用了自己两次。 One example would be a common recursive algorithm for finding the maximum depth of a binary tree, where the last line will look something like max(max_depth(left), max_depth(right)) .一个例子是用于查找二叉树最大深度的常见递归算法,其中最后一行看起来类似于max(max_depth(left), max_depth(right))

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM