关闭

递归算法的优化思路和CPS

725人阅读 评论(0) 收藏 举报

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


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

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:34531次
    • 积分:1004
    • 等级:
    • 排名:千里之外
    • 原创:65篇
    • 转载:22篇
    • 译文:0篇
    • 评论:0条
    文章分类