【问题标题】:Tail recursion vs head classic recursion尾递归与头经典递归
【发布时间】:2013-11-10 00:48:54
【问题描述】:

听我经常听到的 Scala 课程和解释:“但在实际代码中我们使用的不是递归,而是尾递归”

这是否意味着在我的真实代码中我不应该使用递归,但尾递归非常类似于循环并且不需要史诗般的短语“为了理解递归,您首先需要了解递归”强>。

实际上,考虑到您的堆栈......您更有可能使用类似循环的尾递归。

我错了吗?这种“经典”递归是否仅适用于教育目的,让您的大脑回到大学时代?

或者,尽管如此,我们可以在某个地方使用它。递归调用的深度小于 X(其中 X 是您的堆栈溢出限制)。或者我们可以从经典递归开始编码,然后,害怕有一天你的堆栈被炸毁,应用几次重构,让它像尾巴一样,以便在重构领域更强大?

问题:您将在您的真实代码中使用/已经使用“经典头部”递归的一些真实示例,可能尚未重构为尾部代码?

【问题讨论】:

  • +1 表示猫。问题也很有趣。

标签: scala recursion


【解决方案1】:

尾递归 == 循环

您可以采用任何循环并将其表示为尾递归调用。

背景:在纯 FP 中,一切都必须产生一些价值。 while scala 中的循环不会产生任何表达式,只会产生副作用(例如更新某些变量)。它的存在只是为了支持来自命令式背景的程序员。 Scala 鼓励开发人员重新考虑用递归替换 while 循环,这总是会产生一些价值。

因此,根据 Scala:递归是新的迭代

但是,前面的语句存在一个问题:虽然“常规”递归代码更易于阅读,但它会带来性能损失,并且存在溢出堆栈的固有风险。另一方面,tail-recursive 代码永远不会导致堆栈溢出(至少在 Scala* 中),并且性能将与循环相同(事实上,我确信 Scala 将所有尾递归调用普通的旧迭代)。

回到这个问题,坚持“常规”递归没有错,除非:

  1. 计算大数时使用的算法(堆栈溢出)
  2. 尾递归带来显着的性能提升

【讨论】:

  • 在我看来,每当你处理集合时(你经常这样做),特别是如果你正在编写一些 api (你期望它的可能用途范围很大),示例:map( ), scala 中的 flatMap() 方法,然后无论如何它将从头到尾重构。即使你开发的不是 api,也很难猜测用户的堆栈是否会爆炸。同意在最初阶段实施一些算法时自然地表达你的想法。
【解决方案2】:

开发软件时首先要考虑的是代码的可读性和可维护性。查看性能特征大多是过早的优化。

当递归有助于编写高质量代码时,没有理由不使用它。

尾递归与正常循环的计数相同。看看这个简单的尾递归函数:

def gcd(a: Int, b: Int) = {
  def loop(a: Int, b: Int): Int =
    if (b == 0) a else loop(b, a%b)
  loop(math.abs(a), math.abs(b))
}

它计算两个数的最大公约数。一旦你知道了算法,它是如何工作的就很清楚了——用一个while循环编写它不会让它更清楚。相反,您可能会在第一次尝试时引入错误,因为您忘记将新值存储到变量 ab 中。

另外一边看这两个函数:

def goRec(i: Int): Unit = {
  if (i < 5) {
    println(i)
    goRec(i+1)
  }
}

def goLoop(i: Int): Unit = {
  var j = i
  while (j < 5) {
    println(j)
    j += 1
  }
}

哪一个更容易阅读?它们或多或少是相等的 - 由于基于 Scalas 表达式的性质,您为尾递归函数获得的所有语法糖都消失了。

对于递归函数,还有另一件事可以发挥作用:惰性求值。如果您的代码是惰性评估的,它可以是递归的,但不会发生堆栈溢出。看看这个简单的函数:

def map(f: Int => Int, xs: Stream[Int]): Stream[Int] = f -> xs match {
  case (_, Stream.Empty) => Stream.Empty
  case (f, x #:: xs)     => f(x) #:: map(f, xs)
}

大输入会崩溃吗?我不这么认为:

scala> map(_+1, Stream.from(0).takeWhile(_<=1000000)).last
res6: Int = 1000001

在 Scalas List 上尝试同样的操作会杀死你的程序。但是因为Stream 很懒,所以这不是问题。在这种情况下,您还可以编写一个尾递归函数,但通常这并不容易。

有许多算法在迭代编写时并不清楚——一个例子是图形的depth first search。你想自己维护一个堆栈只是为了保存你需要返回的值吗?不,你不会,因为它容易出错并且看起来很丑(除了递归的任何定义之外 - 它也会调用迭代深度优先搜索递归,因为它必须使用堆栈并且“正常”递归必须使用堆栈以及 - 它只是对开发人员隐藏并由编译器维护)。


回到过早优化这一点,我听过一个很好的类比:当您遇到无法用Int 解决的问题时,因为您的数字会变得很大并且很可能会溢出然后不要切换到Long,因为这里很可能也会溢出。

对于递归,这意味着在某些情况下您可能会炸毁堆栈,但更有可能的是,当您切换到仅基于内存的解决方案时,您会收到内存不足错误。更好的建议是找到一种性能不会那么差的不同算法。


作为结论,尽量选择尾递归而不是循环或正常递归,因为它肯定不会杀死你的堆栈。但是,当您可以做得更好时,请不要犹豫,做得更好。

【讨论】:

  • 对于 FP 开发人员来说,尾递归比无面循环更友好。没有问题。
【解决方案3】:

有两种基本的递归:

  1. 头递归
  2. 尾递归

在头递归中,函数进行递归调用,然后执行更多计算,例如,可能使用递归调用的结果。在尾递归函数中,所有计算都首先发生,递归调用是最后发生的事情。

这种区别的重要性并没有让你突然想到,但它非常重要!想象一个尾递归函数。它运行。它完成了所有的计算。作为它的最后一个动作,它已准备好进行递归调用。在这一点上,堆栈帧的用途是什么?一个都没有。我们不再需要我们的局部变量,因为我们已经完成了所有的计算。我们不需要知道我们在哪个函数中,因为我们只是要重新输入相同的函数。 Scala,在尾递归的情况下,可以省去新栈帧的创建,只重用当前栈帧。无论递归调用多少次,堆栈都不会变得更深。这就是使尾递归在 Scala 中变得特别的巫术。

让我们看看这个例子。

 def factorial1(n:Int):Int =
     if (n == 0) 1 else n * factorial1(n -1)


 def factorial2(n:Int):Int = {
      def loop(acc:Int,n:Int):Int =
           if (n == 0) 1 else loop(acc * n,n -1)

     loop(1,n)  
  } 

顺便说一句,一些语言通过将尾递归转换为迭代而不是通过操作堆栈来实现类似的目的。

这不适用于头递归。你明白为什么吗?想象一个头部递归函数。首先它做了一些工作,然后它进行递归调用,然后它做更多的工作。当我们进行递归调用时,我们不能只重用当前的堆栈帧。在递归调用完成后,我们将需要该堆栈帧信息。它有我们的局部变量,包括递归调用返回的结果(如果有的话)。

这是一个问题。示例函数 factorial1 是头递归还是尾递归?嗯,它有什么作用? (A) 检查其参数是否为 0。 (B) 如果是,则返回 1,因为 0 的阶乘为 1。 (C) 如果不是,则返回 n 乘以递归调用的结果。递归调用是我们在结束函数之前键入的最后一件事。那是尾递归,对吧? 错误。进行递归调用,然后将 n 乘以结果,并返回此乘积。这实际上是头递归(或中间递归,如果您愿意),因为递归调用并不是最后发生的事情

For more info please refer the link

【讨论】:

  • 写你自己的东西而不是复制任何东西,但你可以提供参考oldfashionedsoftware.com/2008/09/27/…
  • @Jet 这个论坛是为了帮助......只要内容正在帮助那些不应该成为问题的人。我是新手,开始探索Scala并根据我的理解进行了修改。
  • 我刚刚开始学习“尾递归”。这个答案是消除一些混乱的事情之一。
【解决方案4】:

如果您不是在处理线性序列,那么尝试编写一个尾递归函数来遍历整个集合是非常困难的。在这种情况下,为了可读性/可维护性,您通常只使用普通递归。

一个常见的例子是遍历二叉树数据结构。对于每个节点,您可能需要在左右子节点上递归。如果您要尝试以递归方式编写这样的函数,首先访问左侧节点,然后访问右侧,您需要维护某种辅助数据结构来跟踪需要访问的所有剩余右侧节点。但是,您可以仅使用堆栈来实现相同的目标,而且它的可读性会更高。

iterator method from Scala's RedBlack tree 就是一个例子:

def iterator: Iterator[(A, B)] =
  left.iterator ++ Iterator.single(Pair(key, value)) ++ right.iterator

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-03-03
    • 1970-01-01
    • 2019-10-30
    • 2016-03-21
    • 2017-11-11
    • 1970-01-01
    • 2018-03-17
    • 1970-01-01
    相关资源
    最近更新 更多