听我经常听到的Scala课程和解释:“但在实际代码中我们不使用递归,而是尾递归”。
这是否意味着在我的Real代码中我不应该使用递归,但尾递归非常类似于循环并且不需要史诗短语“以便理解递归,您首先需要了解递归”。
实际上,考虑到你的堆栈......你更有可能使用类似循环的尾递归。
我错了吗?这种“经典”递归是否仅适用于教育目的,让您的大脑回到大学过去?
或者,尽管如此,我们还可以使用它..递归调用的深度小于X(其中X是堆栈溢出限制)。或者我们可以从经典递归开始编码,然后,害怕你的堆栈吹了一天,应用几个重构使它像尾巴一样在重构领域使用更强大?
问题:你会使用/在真正的代码中使用“经典头部”递归的一些真实样本,可能还没有重构为尾部代码?
答案 0 :(得分:8)
尾递归==循环
你可以接受任何循环并将其表达为尾递归调用。
背景:在纯FP中,一切都必须产生一些价值。 scala中的while
循环不会产生任何表达式,只会产生副作用(例如更新某些变量)。它的存在只是为了支持来自命令背景的程序员。 Scala鼓励开发人员重新考虑用递归替换while
循环,这总是会产生一些价值。
所以根据Scala:递归是新的迭代。
然而,先前的陈述存在一个问题:虽然“常规”递归代码更容易阅读,但它带来了性能损失并且存在溢出堆栈的固有风险。另一方面, tail-recursive 代码永远不会导致堆栈溢出(至少在Scala *中),并且性能将与循环相同(事实上,我确信Scala会转换所有尾递归调用普通的旧迭代)。
回到这个问题,坚持“常规”递归并没有错,除非:
答案 1 :(得分: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
}
}
哪一个更容易阅读?它们或多或少相等 - 由于基于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
,因为这里也可能会出现溢出。
对于递归,这意味着可能存在您将炸毁堆栈的情况,但更有可能的是,当您切换到仅基于内存的解决方案时,您将获得内存不足错误。一个更好的建议是找到一个不那么糟糕的不同算法。
作为结论,尝试优先选择尾递归而不是循环或正常递归,因为它肯定不会杀死你的堆栈。但是当你做得更好时,不要犹豫,做得更好。
答案 2 :(得分:1)
如果你没有处理线性序列,那么尝试编写尾递归函数来遍历整个集合是非常困难的。在这种情况下,为了便于阅读/维护,通常只使用正常的递归。
一个常见的例子是遍历二叉树数据结构。对于每个节点,您可能需要在左右子节点上重复出现。如果您尝试递归地编写这样的函数,首先访问左边节点然后右边,你需要维护某种辅助数据结构来跟踪需要访问的所有剩余的正确节点。但是,您可以使用堆栈实现相同的功能,并且它将更具可读性。
这方面的一个例子是iterator
method from Scala's RedBlack
tree:
def iterator: Iterator[(A, B)] =
left.iterator ++ Iterator.single(Pair(key, value)) ++ right.iterator
答案 3 :(得分:1)
有两种基本类型的递归:
在头递归中,函数进行递归调用,然后执行一些更多的计算,例如,可能使用递归调用的结果。在尾递归函数中,所有计算首先发生,递归调用是最后发生的事情。
这种区别的重要性并没有突显出来,但它非常重要!想象一下尾递归函数。它运行。它完成了所有的计算。作为最后一个动作,它已准备好进行递归调用。到目前为止,堆栈框架的用途是什么?一个都没有。我们不再需要我们的局部变量,因为我们完成了所有计算。我们不需要知道我们所处的功能,因为我们只是要重新输入相同的功能。 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乘以结果,并返回此产品。这实际上是头部递归(或中间递归,如果你愿意的话)因为递归调用不是最后发生的事情。