从示例逐渐理解Scala尾递归

Posted wangzaixiang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从示例逐渐理解Scala尾递归相关的知识,希望对你有一定的参考价值。

本文转自团队小伙伴的技术博客,是团队人员在学习Scala及函数式编程时的一个技术总结,本文针对函数式编程中最为经典的“尾递归”模式进行了总结,完整的介绍了概念、使用方法,以及如何从传统过程式编程的for/while过度到Pure Function的转换模式,非常值得函数式编程的初学者了解、学习。


1.递归与尾递归

1.1 递归

1.1.1 递归定义

递归大家都不陌生,一个函数直接或间接的调用它自己本身,就是递归。它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的代码就可以执行多次重复的计算。

1.1.2 递归的条件

一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。

以递归方式实现阶乘函数的实现:

代码清单1-1

def factorial(n:Int): Long ={    
 if(n <= 0) 1    else n * factorial(n-1) }

代码清单中,if(n <= 0) 1是递归返回段,else后面部分是递归前进段。

1.1.3 递归的缺点:
  • 需要保持调用堆栈,如代码清单1-1,每一次递归都要保存n*factorial(n-1)栈帧信息。如果调用次数太多,可能会导致栈溢出

  • 效率会比较低,递归就是不断的调用自己本身,如果方法本身比较复杂,每次调用自己效率会较低。

1.2 尾递归

1.2.1 定义

尾递归的定义比较简单,即函数在函数体最后调用它本身,就被称为尾递归。

我们可以这样理解尾递归

  • 所有递归形式的调用都出现在函数的末尾

  • 递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分

1.2.2 例子程序

下面我们使用尾递归的模式实现上面的阶乘

代码清单1-2

def factorial(n:Int):Long = {
   @tailrec    def factorial(main:Int,aggr:Int): Long ={
           if(main <= 0) aggr
           else factorial(main-1,main*aggr)    }       factorial(n,1) }

我们可以比较代码清单1-1和1-2
1-1中,每次的递归调用都要本身时依赖n这个变量,所以,它只能是个不同的递归。

1-2中,函数factorial每次返回的都是它自己本身,没有依赖任何值。它做的是将main每次减1,将aggr每次乘main,然后将这两个结果作为下一次递归调用的参数,进行调用。

尾递归的核心思想是通过参数来传递每一次的调用结果,达到不压栈。它维护着一个迭代器和一个累加器。

1.3 循环

循环能够解决大多数的累计问题,循环可以完成累加和迭代,处理问题比较简单,思想也比较符合,容易理解

n的阶乘循环的写法

代码清单1-3

def fibfor(n:Int): Int ={
    var m = 1
    for (i <- 1 to n) {
        m = m * i
    }
    m
}

循环版本,会有var的可变变量,我们知道,函数式编程就应该更函数范,我们尽可能的要用vals去替换可变的vars
所以我们可以使用递归的方式来消除掉vars


2.改写 (循环,递归 TO 尾递归)

事实上,scala都是将尾递归直接编译成循环模式的。所以我们可以大胆的说,所有的循环模式都能改写为尾递归的写法模式

尾递归会维护一个或多个累计值(aggregate)参数和一个迭代参数。我们具体分析

2.1迭代器和累计器

  • 累计值参数aggregate将每次循环产生的结果进行累计,然后传到下一次的调用中。

  • 迭代器,和普通递归或循环一样,每次递归或循环后,改变一次。(如for(i=0;i<1-;i++)里面的i)

2.2 普通递归转换为尾递归

并不是所有的递归都能改写为尾递归,那些比较复杂的递归调用时无法优化为尾递归的。但是大部分还是能够进行优化的。

代码清单1-1 和代码清单 1-2 是求n阶阶乘的普通递归与尾递归的写法,前者没有进行优化,每次调用都会压栈。
后者,通过定义一个aggregate(累计)参数,对每一次调用后的结果做一次累计,而另一个参数main称为迭代器,每一次调用都会-1,当达到符合返回的条件时,将累计值返回。

2.3 循环(while loop)转为尾递归(tail recursion)

正如上文循环例子所述,存在var,函数式编程就应该有函数范,我们尽量使用val来代替,所以接下来来看,怎么将循环转换为尾递归

2.3.1 循环和尾递归

正如上文所说的迭代器和累计器,循环和尾递归都有这两个概念

迭代器和累计器

尾递归每一次的调用自身都会有一次累加(或者累积,累减等),然后会有一个迭代器进行迭代,一个累加器进行累加迭代的结果,然后作为参数,再去调用自身。

2.3.2 如上面求n阶乘的尾递归例子:
  • 1.循环的例子中存在一个var,它在每次循环中充当一个累加器的角色,累加每一次的迭代结果,而每次迭代过程就是m*i的一个过程。

  • 2.尾递归也是一样的思想,以main作为迭代器,每次递减1,类似循环里的i,以aggr作为累加器,每次累计迭代的结果,类似循环的m

  • 3.相对于普通的递归,这里尾递归多的一个参数就是累加器aggr,用于累计每一次递归迭代的结果。这样做的目的就是每一次调用的结果可以作为下一次函数调用的参数。

3.具体示例-加深理解

3.1 例子1 - 求斐波拉契数列

  • 普通递归写法(性能较低)

def fibonacci(n:Int): Long ={
    n match {
     case 1 | 2 => 1      case _ => fibonacci(n-1) + fibonacci(n-2)    } }
  • 循环的写法(循环写法)

def fibonacciFor(n:Int): Int = {
    var current = 1
    var next = 1
    if(n <=2) 1
    else {
       for(i <- 2 until n) {            var aggr = current + next            current = next            next = aggr        }          next    } }

可以看到,aggr是累加器,然后将累加的值赋值给下一个next,而current等于next,每一次循环,都有给current和next赋予新值,当累加完成后,返回next的值。

  • 尾递归写法

如何对其进行优化?

仔细分析,上面的普通循环,每一轮两个值都在改变,然后又一个累加器aggr,对这两个值进行累加,并赋值给更大的next,然后进入下一次循环。

尾递归,我们也是同样的做法,定义两个接受值,当前的,和下一个,然后需要一个累加值。

这里普通方法的递归调用是两个原函数相加,涉及到的变量有 n , n-1 , n-2

因此,我们在考虑使用尾递归时,可能也需要使用到三个参数,初略涉及,尾递归函数需要使用三个参数,于是改写如下:

def fibonacci(n: Int): Long = {
   @tailrec    def fibonacciTail(main: Int, current: Int, next: Int): Long = {      main match {
           case 1 | 2 => next
           case _ => fibonacciByTail(main - 1, next, current+next)      }    }        fibonacciTail(n,1,1) }

使用尾递归和模式匹配。每一次调用自身,将next赋值给current,然后累加current和next的值赋值给新的next值,call下一轮。思想上和上面循环很像。但是更函数范,消除掉了var。


3.2 例子2 - loadBalance算法

需求,设计一个程序,传入一个比例数组,比如Array(1,3,6,一直调用该函数,返回的3个节点的比例也应该如传入的1:3:6的比例一样。

  • 我最开始使用for循环return实现了这个需求,代码如下:

def loadBalance(arr:Array[Int]): Int ={
     //根据传入的数组使用scan高级函数进行变化,具体算法例子:      //eg (1,3,6) ->  (1,4,10)      //这样的目的是,随机出来的值为0-1时,选择第一个节点,为1-4时选择第二节点,依次类推      val segment:Array[Int] = arr.scan(0)(_ + _).drop(1)      //随机数的范围,根据传入的数组的数据之和来,例如上的便是 10 ,产生的随机数介于0 - 9 之间      val weightSum:Int  = arr.sum      val random = new Random().nextInt(weightSum)            for(i <- 0 until segment.size ){
             if(random < segment(i)){
                       return i        }      }    
     0

}

我通过测试程序调用1万次该方法,返回的随机节点的比例是符合传入的比例的。

思考:

虽然这样可以达到目的,但是代码写的既不优雅,在scala函数式编程中最好是不能使用return来强行打断函数执行的,并且在最后,我还需要去写一个0来作为默认返回。

尾递归优化

大部分或者几乎所有的for循环都能使用尾递归进行优化,那上面这个代码如何进行优化呢?

思路:上文的for循环,每次增加的是segment的下标,每循环一次 +1,因此,我们在设计尾递归时,可以使用一个参数来实现相同的功能,而另一个参数应该就是产生的随机数。
ok,我们来进行实现

def loadBalance(arr:Array[Int]): Int ={
     //根据传入的数组使用scan高级函数进行变化,具体算法例子:      //eg (1,3,6) ->  (1,4,10)      //这样的目的是,随机出来的值为0-1时,选择第一个节点,为1-4时选择第二节点,依次类推      val segment:Array[Int] = arr.scan(0)(_ + _).drop(1)      //随机数的范围,根据传入的数组的数据之和来,例如上的便是 10 ,产生的随机数介于0 - 9 之间      val weightSum:Int  = arr.sum      val random = new Random().nextInt(weightSum)      //写一个内部方法      def loadUtil(rand:Int,index:Int) {
       //assert,保证程序健壮        assert(index < arr.length && arr(index) >= 0)                  if(rand < segment(index)) index
       else loadUtil(rand,index+1)      }
     loadUtil(random,0)

}

我们可以看到,使用尾递归的做法,代码会非常的优雅,现在写一个测试类进行测试!

def main(args: Array[String]): Unit = {
    val arr = Array(1,2,7)
    val countMap = collection.mutable.Map[Int,Int]()
   for(_ <- 1 until 100000) {      val res = loadBalance(arr)      countMap.get(res) match {
           case Some(x) => countMap += (res -> (x+1))
           case None => countMap +=(res -> 1)      }    }    countMap.foreach(x => {      println(s"${x._1}  调用次数 ${x._2}")    })      }
 //测试10000次,返回结果如下:2  调用次数 699661  调用次数 200280  调用次数 10005

如上,测试是通过的!是不是很优雅,感受到了尾递归的魅力?


4. scala编译器对尾递归的优化

Scala 对形式上严格的尾递归进行了优化,对于严格的尾递归,不需要注解

@tailrec 可以让编译器去检查该函数到底是不是尾递归,如果不是会报错

具体以上面那个计算斐波拉契数列的例子进行性能分析
def time[T](t: =>T): T  = {
    val b = System.nanoTime()
    val x = t
    val e = System.nanoTime();
    println("time: " + (e-b)/1000 + "us");
    x
    
}

var count: Long = 0
  // @tailrec
  def fib2(n: Long): Long = {
    count += 1
    n match {
     case 1 | 2 => 1      case _ =>        fib2(n-1) + fib2(n-2)    }  }

通过上面时间和调用次数的测试,可以得出尾递归的性能消耗很低,速度很快。

4.1 编译器对尾递归的优化

当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。

scala编译器会察觉到尾递归,并对其进行优化,将它编译成循环的模式。

4.2 Scala尾递归的限制

  • 尾递归有严格的要求,就是最后一个语句是递归调用,因此写法比较严格。

  • 尾递归最后调用的必须是它本身,间接的赋值它本身的函数也无法进行优化。

5. 总结

循环调用都是一个累计器和一个迭代器的作用,同理,尾递归也是如此,它也是通过累加和迭代将结果赋值给新一轮的调用,通过这个思路,我们可以轻松的将循环转换为尾递归的形式。

[本文完,欢迎转载,转载请注明出处]


以上是关于从示例逐渐理解Scala尾递归的主要内容,如果未能解决你的问题,请参考以下文章

Scala尾递归优化

Python进阶 —— 尾递归

如何使涉及期货尾递归的函数?

[Notes] 简单的 Scala 尾递归函数

scala实战学习-尾递归函数

如何在 Scala 中使用 Stream.cons 编写不泄漏的尾递归函数?