尾递归与头部经典递归的区别

15
听Scala课程和解释时,我经常听到:“但在真正的代码中,我们不使用递归,而是使用尾递归”。这是否意味着在我的真实代码中,我不应该使用递归,而应该使用类似循环的尾递归,它不需要那句经典的话“为了理解递归,你首先需要理解递归”?实际上,考虑到堆栈,你更可能会使用类似循环的尾递归。我错了吗?那种“经典”的递归仅适用于教育目的,让你的大脑回到大学时代吗?或者,也可以在递归调用深度小于X(其中X是您的堆栈溢出限制)的地方使用它。或者我们可以从经典递归开始编码,然后担心有一天堆栈会失控,应用几个重构来使其类似于尾递归,以使重构领域的应用更强大?
问题:你在真实代码中使用/曾经使用“经典头”递归的一些实例,尚未将其重构为尾递归,也许?

15
给这只猫点赞。问题也很有趣。 - giampaolo
4个回答

8

尾递归==循环

你可以将任何循环表示为尾递归调用。

背景:在纯函数式编程中,每个操作都必须返回某个值。Scala中的while循环不会返回任何表达式,只有副作用(例如更新某个变量)。它仅存在于支持来自命令式背景的程序员。Scala鼓励开发人员重新考虑使用递归替换while循环,因为递归总是会返回某个值。

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

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

回到问题本身,坚持使用“常规”递归没有问题,除非:

  1. 您正在计算大量数字的算法(堆栈溢出)
  2. 尾递归带来明显的性能提升

1
在处理集合时(通常情况下),特别是当您编写某些 API 时(其中您期望其可能的用途范围很大),例如 Scala 中的 map()、flatMap() 方法,那么无论如何都会从头到尾进行重构。即使它不是您开发的 API,也很难猜测用户的堆栈是否会崩溃。同意在最早的阶段实现某些算法时自然地表达您的想法。 - ses

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乘以结果,返回这个乘积。这实际上是头递归(或者如果你愿意的话,是中间递归),因为递归调用不是最后发生的事情更多信息请参见链接

1
请写下自己的内容,而不是复制所有内容,但可以提供参考 https://oldfashionedsoftware.com/2008/09/27/tail-recursion-basics-in-scala/ - Jet
1
@Jet 这个论坛的目的是帮助别人...只要内容有助于帮助某人,那就不应该有问题。我是新手,开始探索Scala,并根据我的理解进行了修改。 - AKs
1
我刚开始学习“尾递归”,这个答案帮助我消除了一些困惑。 - bp4D

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循环编写不会使它更清晰。相反,您可能会在第一次尝试时引入一个错误,因为您忘记将新值存储到其中一个变量 a 或 b 中。
另一方面,看看这两个函数:
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
  }
}

哪一个更容易阅读?它们差不多 - 由于Scala表达式的本质,你获得的所有尾递归函数的语法糖都消失了。
对于递归函数,还有另一件事情要考虑:惰性求值。如果你的代码是惰性求值的,它可以是递归的,但不会发生堆栈溢出。看看这个简单的函数:
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

尝试使用Scala的List进行相同操作会导致程序崩溃。但由于Stream是惰性的,所以这不是问题。在这种情况下,您也可以编写一个尾递归函数,但通常这不容易实现。
许多算法在迭代编写时可能并不清晰-一个例子是深度优先搜索图。你想自己维护一个堆栈来保存需要返回的值吗?不,你不想,因为它容易出错,而且看起来很丑(除了递归的任何定义之外-它也将调用迭代深度优先搜索递归,因为它必须使用堆栈,“正常”的递归也必须使用堆栈-只是隐藏在开发人员后面,由编译器维护)。
回到过早优化的问题,我听说了一个很好的比喻:当你有一个无法用Int解决的问题,因为你的数字将变得非常大,很可能会发生溢出,那么不要转换为Long,因为在这里也很可能会发生溢出。

对于递归来说,这意味着可能存在一些情况下你会耗尽栈空间,但更可能的是,当你切换到仅基于内存的解决方案时,你会遇到内存不足的错误。更好的建议是找到一个不会表现得那么糟糕的不同算法。


总之,尽量选择尾递归而不是循环或普通递归,因为它肯定不会耗尽您的堆栈。但是当您可以做得更好时,请毫不犹豫地改进它。

尾递归对于函数式编程开发者来说比无面循环更加友好。毫无疑问。 - ses

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

一个常见的例子是遍历二叉树数据结构。对于每个节点,您可能需要同时递归左子节点和右子节点。如果您尝试递归地编写这样的函数,首先访问左节点,然后访问右节点,那么您需要维护某种辅助数据结构来跟踪所有剩余的右节点需要被访问。然而,您可以只使用堆栈来实现相同的功能,这将更具可读性。

这种情况的一个例子是 Scala 的 RedBlack 树中的 iterator 方法

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接