递归算法的优化思路和CPS

原创 2016年08月29日 21:28:38

递归算法的本质是定义一个规则, 让程序根据规则去帮你完成一件事。然而递归被吐槽的最多的事它感人的性能和爆栈的可能性,有必要整理一下如何对递归程序做优化。


这里先以Fibonacci为例。

Scala代码:

def fib1(n: BigInt): BigInt = {
    if(n == 0) 0
    else if(n == 1) 1
    else fib1(n - 1) + fib1(n - 2)
  }

以上是Fibonacci的一般递归算法, 几乎就是把定义抄了上去, 然后程序在层层递归调用中不知不觉把结果算了出来。当n的数值过大时, 由于递归过深,临时变量塞满了栈空间导致stackoverflow. 有两种比较直观的解决方案: 改写成迭代或者尾递归。

Scala代码

  def fib4(n: BigInt): BigInt = {
    var n0 = 0
    var n1 = 1
    
    var i = 1
    val l = while(i <= n) {
      var tmp = n1
      n1 = n0 + n1
      n0 = tmp
      i = i + 1
    }
    n0
  }
以上是用迭代的方式实现Fibonacci。迭代是一种递推的方法, 核心思想是自下而上进行计算, 并将中间结果保存下来, 以避免递归算法对同一个值重复计算的复杂性。 直观的认识是, 我们可以建立一个大小为n的数组, 把f(1) ...f(n - 1)的结果都保存下来。然而经过观察我们发现, 在求f(n)的时候, 我们只需要用到f(n - 1)和f(n - 2), 之前的结果完全可以抛弃。因此这里定义了n1, n2来保存中间结果, 每次循环重新计算n2的值, 并将老的n2赋值给n1。经过这种优化以后, 空间复杂度从o(n)降到了o(1)。 但这只是个特例, 递归改写成循环并不一定能减少空间复杂度。

这里唯一要注意的是边界值。 当循环以 <= n作为条件时, n = 0时不需要进入循环, n = 1时需要计算一次, 所以此处i = 0, 末尾返回较小的值

通过上面的分析, 我们发现通过循环可以将空间复杂度减少为o(1)。这意味着这个递归能在不构造栈的情况下被改写成尾递归。

 def fib3(n: BigInt): BigInt = {
    def fibInner(n: BigInt, acc1: BigInt, acc2: BigInt): BigInt = {
      if(n == 0) acc1
      else {
        fibInner(n - 1, acc2, acc1 + acc2)
      }
    }
    fibInner(n, 0, 1)
  }
上面是尾递归算法。 可以看出尾递归的思路其实是迭代的思路, acc1和acc2代表迭代算法中的n0和n1, 每次迭代将acc1赋值为acc2, 将acc2赋值为acc1 + acc2, 和迭代算法如出一辙, 最后返回acc1,对应n0。

由上面分析可以得出一个简单结论:尾递归是迭代算法的一种变相实现, 如果我们推导不出迭代算法, 那么尾递归也同样推导不出。 同时形如f(n) = f(n - x) .... f(n - y)的单向递归都可以套用以上模式改写成循环或尾递归, acc(临时变量)的个数取决于x和y之间的跨度。

此外, 我们还可以使用CPS(Continuation-Passing Style)的方式来改写函数使其成为尾递归。

def fibCont(n: BigInt, continuation: BigInt => BigInt = (x => x)): BigInt = {
    if (n < 2) continuation(n)
    else {
      fibCont(n - 1,
        r1 => fibCont(n - 2, r2 => continuation(r1 + r2)));
    }
  }
Currying之后的函数如下
def fibCont(n: BigInt)(continuation: BigInt => BigInt = (x => x)): BigInt = {
    if (n < 2) continuation(n)
    else {
      fibCont(n - 1)(r1 => fibCont(n - 2)(r2 => continuation(r1 + r2)));
    }
  }
Fibonacci的CPS函数可以理解为:要计算f(n), 就要先计算f(n - 1), 再将f(n - 1)的结果放到continuation函数中做接下来的事。而接下来的事无非就是计算f(n - 2), 再将两个数相加的结果作为下一层continuation的参数。

参考了很多大神的博客和资料以及自己的亲身实践以后, 得出如下结论: CPS的方法可以用上述模式把任何递归改写成使用continuation函数的方法, 但:1不能减少程序的复杂度, 2这种形式的尾递归不一定能被编译器优化(如Scala, 在上述函数前加上@tailrec无法通过编译)。CPS体现的是函数式编程的一种思想, 即把变量的值变换替换成函数的定义变换。换句话说, 上述的continuation在层层递归中函数体越拼越长, 到了递归出口才传入真正的参数来计算。 至于为什么空间复杂度并没有减小,是因为这种方式节省了临时变量压栈的空间,是以加长匿名函数体为代价的。 当函数体越来越长时, 该爆栈还是得爆栈。

版权声明:本文为博主原创文章,未经博主允许不得转载。

相关文章推荐

优化递归的效率

函数递归调用是很常见的做法,但是它往往是低效的,本文探讨优化递归效率的思路。 1.尾递归转换成迭代 尾递归是一种简单的递归,它可以用迭代来代替 比如 求阶乘函数的递归表达 int f(int...

递归优化之尾递归

采用递归算法和尾递归算法解决斐波那契问题,并分析造成两者计算时间差距之大的原因。最后,以一个例子,说明函数式语言的尾递归优化

尾递归、CPS等几种求阶乘的算法

好久没写手快生了。好久没来也已是物是人非了。发个阶乘的尾递归、CPS等几种写法吧。 #include "stdafx.h" // 阶乘 #include #include using ...

关于C语言优化

前几天看网上新闻,有人建议把编程纳入高考,且不论是否靠谱,却至少说明一件事:会写点程序不再有什么可炫耀的,将来更有可能成为全民普及技能。其实即使现在,很多人写程序的兴奋感还没消退,就悲哀地发现自己程序...
  • ipmux
  • ipmux
  • 2014-01-04 14:21
  • 746

C优化篇之减少运算量

程序优化的另一个出发点是减少运行过程中的运算量,有两个大的思路:     1)把部分计算量转移到离线,或者说把一部分工作挪到程序之外,人为处理,以减轻程序本身压力。比如查表、浮点转定点以及其他数学算...
  • ipmux
  • ipmux
  • 2014-02-16 22:47
  • 1259

补遗篇之volatile

C中volatile关键字在程序操作变量时,强制读写变量所在内存,以阻止编译器对某些特殊变量的错误优化。反过来,只有靠程序员用volatile过滤一些特殊情况后,编译器才能大胆优化。volatile作...
  • ipmux
  • ipmux
  • 2013-12-29 14:36
  • 580

web.config文件中连接字符串说明

对于SQLServerExpress数据库连接配置,以前看到过几次,都是匆匆扫一眼,今天上午在一开源软件中又看到了它,感觉有必要对它有一个清楚的认识,示例如下:"Data Source=./SQLEx...

for循环和递归算法的运行效率比较(c语言)

实验目的 在编程语言中,对比不同编程风格的代码写法,或者通过使用不同的编译器和编译优化参数,通过编译器生成汇编代码,静态分析所生成汇编代码的运行效率。    实验平台、工具 在window 7...

[Java 8] (8) Lambda表达式对递归的优化(上) - 使用尾递归

递归优化 很多算法都依赖于递归,典型的比如分治法(Divide-and-Conquer)。但是普通的递归算法在处理规模较大的问题时,常常会出现StackOverflowError。处理这个问题,我们...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:深度学习:神经网络中的前向传播和反向传播算法推导
举报原因:
原因补充:

(最多只允许输入30个字)