尾递归 vs 头递归

12 浏览
0 Comments

尾递归 vs 头递归

在听Scala课程和解释时,我经常听到:“但在真实的代码中,我们不使用递归,而是使用尾递归”。这是不是意味着在我的真实代码中,我不应该使用递归,而是应该使用类似循环的尾递归,它不需要那句经典的话“为了理解递归,你首先需要理解递归”。实际上,考虑到你的堆栈,你更有可能使用类似循环的尾递归。我错了吗?那种“经典”的递归只适用于教育目的,让你的大脑回到大学时代吗?或者,我们可以在递归调用的深度小于X(其中X是你的堆栈溢出限制)的情况下使用它。或者,我们可以从经典递归开始编码,然后担心有一天堆栈会溢出,进行一些重构,使它类似尾递归,以在重构领域更加强大使用?

问题:您在您的真实代码中使用/曾经使用过“经典头”递归的一些实际示例,尚未重构为尾递归的呢?

0
0 Comments

首先,开发软件时应着重关注代码的可读性和可维护性。关注性能特征大多是过早优化。

如果递归有助于编写高质量的代码,那就没有理由不使用它。

对于尾递归和普通循环也是如此。看看这个简单的尾递归函数:

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,因为很可能在这里也会溢出。

对于递归来说,这意味着可能有些情况下你会耗尽堆栈,但更有可能的是,当你切换到基于内存的解决方案时,会出现内存不足的错误。更好的建议是找到一个性能不那么差的不同算法。

总之,尽量使用尾递归而不是循环或普通递归,因为它肯定不会耗尽你的堆栈。但是如果你能做得更好,就不要犹豫,尽量做得更好。

对于函数式编程开发者来说,尾递归是比无面循环更友好的朋友。毫无疑问。

0
0 Comments

在递归中,有两种基本类型:头递归和尾递归。头递归是指函数在进行递归调用之后执行一些额外的计算,可能使用递归调用的结果进行计算。而尾递归则是先进行所有计算,然后进行递归调用。

这种区别的重要性可能不是很明显,但却非常重要!想象一个尾递归函数。它运行,完成所有计算。在最后一个动作中,它准备进行递归调用。此时,堆栈帧还有什么用处呢?没有用处。我们不再需要局部变量,因为我们已经完成了所有计算。我们也不需要知道我们在哪个函数中,因为我们只是要重新进入同一个函数。在尾递归的情况下,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与结果相乘,并返回这个乘积。这实际上是头递归(或者是中间递归,如果你喜欢),因为递归调用不是发生在最后。

更多信息请参考链接:https://oldfashionedsoftware.com/2008/09/27/tail-recursion-basics-in-scala/

这个问题是关于尾递归和头递归的区别以及解决方法的。尾递归是在所有计算完成后进行递归调用,而头递归是在进行递归调用之前进行一些额外的计算。在尾递归中,Scala可以消除创建新的堆栈帧,而是重用当前的堆栈帧,从而避免堆栈溢出的问题。然而,头递归无法重用当前的堆栈帧,因为在递归调用之后仍然需要堆栈帧信息。

以上是关于尾递归和头递归的基本概念和区别的讲解。通过理解它们的不同之处,我们可以在编写递归函数时选择合适的方式,从而提高代码的效率和性能。

0
0 Comments

尾递归与经典递归之间的区别

在函数式编程中,一切都必须产生某个值。Scala中的while循环并不产生任何表达式,只有副作用(例如更新某个变量)。它的存在只是为了支持那些来自命令式编程背景的程序员。Scala鼓励开发者重新考虑将while循环替换为递归,因为递归总是会产生某个值。因此,根据Scala的说法:“递归是新的迭代”。

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

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

1. 在计算大数时会导致堆栈溢出。

2. 尾递归可以带来明显的性能提升。

在我看来,无论何时处理集合(通常是这样),尤其是当你编写一些API时(你希望该API可能有广泛的用途范围),例如Scala中的map()、flatMap()方法,它们最终都会从头到尾进行重构。即使不是你开发的API,很难猜测用户的堆栈是否会溢出。在实现某个算法的早期阶段,用自然的方式表达你的思想是有道理的。

所以,尾递归是解决这个问题的方法。尾递归可以将原本使用循环的算法转换为递归调用。通过使用尾递归,代码将不会导致堆栈溢出,并且性能与循环相同。在处理大量数据时,尾递归可以带来明显的性能提升。

总结起来,尾递归是一种更好的替代传统递归的方法,它可以避免堆栈溢出并提高性能。无论是处理大量数据还是编写可重用的API,尾递归都是一个值得考虑的选择。

0