从示例逐渐理解Scala尾递归

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)
    }
    
    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  调用次数 69966
1  调用次数 20028
0  调用次数 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. 总结

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容