递归算法的优化思路和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在层层递归中函数体越拼越长, 到了递归出口才传入真正的参数来计算。 至于为什么空间复杂度并没有减小,是因为这种方式节省了临时变量压栈的空间,是以加长匿名函数体为代价的。 当函数体越来越长时, 该爆栈还是得爆栈。

递归算法的优化思路和CPS

递归算法的本质是定义一个规则, 让程序根据规则去帮你完成一件事。然而递归被吐槽的最多的事它感人的性能和爆栈的可能性,有必要整理一下如何对递归程序做优化。 这里先以Fibonacci为例。 ...
  • fanwu72
  • fanwu72
  • 2016年08月29日 21:28
  • 1099

图 深度优先遍历 广度优先遍历 非递归遍历 图解算法过程

图 深度优先遍历 广度优先遍历 图解算法过程
  • collonn
  • collonn
  • 2014年01月06日 18:12
  • 14930

每天刷个算法题20160523:骑士巡游的递归转非递归解法

为了防止思维僵化,每天刷个算法题。这里是骑士巡游的递归转非递归解法。...
  • u012077163
  • u012077163
  • 2016年05月28日 14:17
  • 3146

程序员面试内容和技巧,详细介绍了求职过程、解答思路、链表、树和图、数组、递归算法等等。

  • 2009年01月09日 13:44
  • 14.27MB
  • 下载

菜鸟更要独立思考之一 从思路到实现,菜鸟也能掌握递归算法

本文章重递归算法的思路,而非纯粹的代码说明,请寻找递归算法优质代码的读者左转(返回按钮),适合能够平心静气的人,坐下来,好好看看作者的视角,如果有好的想法,请不吝交流。...
  • wmg_csdn
  • wmg_csdn
  • 2016年05月12日 10:48
  • 209

汉诺塔递归算法 (思路+python实现)

python实现# -*- coding:utf-8 -*-def print_path(head, end): #输出路径 print("#",head,"-->",end)...
  • H_earbeats
  • H_earbeats
  • 2017年08月09日 16:14
  • 262

.net递归算法优化源码案例

  • 2013年08月19日 11:14
  • 65KB
  • 下载

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

好久没写手快生了。好久没来也已是物是人非了。发个阶乘的尾递归、CPS等几种写法吧。 #include "stdafx.h" // 阶乘 #include #include using ...
  • hikaliv
  • hikaliv
  • 2013年10月20日 15:10
  • 2985

面试中遇到递归算法题别慌--常见递归算法题的解题思路

前几天在博客园看到有人面试时,遇到递归算法题,一时手痒就解了一个。顺便网上又找来几个,也实现了。给大家分享一下,开阔一下思路,没准你明天面试就能用上。1、编写一个方法用于验证指定的字符串是否为反转字符...
  • yzx226
  • yzx226
  • 2011年02月20日 11:20
  • 8268

递归算法(阶乘)

  • 2013年06月16日 17:28
  • 13KB
  • 下载
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:递归算法的优化思路和CPS
举报原因:
原因补充:

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