利用缓存提高递归效率

先来看一个奥数题:有一数列,第一个数为6,第二个为3,除第一个数外,每个数是比其前后两个数之和少5,求此数列前200个数之和。
按题意,列个递推公式:
f n = f n − 1 + f n + 1 − 5 f_n = f_{n-1} + f_{n+1} - 5 fn=fn1+fn+15
移一下项,就和兔子数列类似了:
f n + 1 = f n − f n − 1 + 5 f_{n+1} = f_n - f_{n-1} + 5 fn+1=fnfn1+5
按这个公式写程序就很简单了,下面是javascript写的:

function f (n) {
 if (n === 0) {
   return 6
 } else if (n === 1) {
   return 3
 } else {
   return f(n - 1) - f(n - 2) + 5
 }
}

这个递归程序的问题是效率很差,因为f(n)需要的时间是f(n-1)和f(n-2)的时间之和。所以随着n的增长,时间会按兔子数列的方式增长。所以当n还没到100,计算就需要几十秒的时间了。效率低的一个原因在于有些计算是重复的,比如计算f(4)就需要计算f(3)和f(2),计算f(3),要算f(2)和f(1),而计算f(2)要算f(1)和f(0)……这样f(2)在算f(4)和f(3)时都要算,而f(1)在算f(3)和f(2)时都要算……而且n越大,重复计算就越多。
对于这种简单的递归问题,把程序该成循环就能简单解决,但有些递归不是这么好改的。对于这类存在大量重复计算的递归,有一个通用的简单方法,就是利用缓存,将结果保持起来,如果发现需要再次计算,返回缓存的结果。

const cached = {}
function f (n) {
 if (cached[n]) {
   return cached[n]
 }
 if (n === 0) {
   return 6
 } else if (n === 1) {
   return 3
 } else {
   const x = f(n - 1) + 5 - f(n - 2)
   cached[n] = x
   return x
 }
}

相对于原来的程序没有大的改动,但这个程序就算n到1000,也能在1秒以内算完。

最后,可以利用缓存把程序稍微写短一点。

const cached = {}
cached[0] = 6
cached[1] = 3
function f (n) {
  if (cached[n]) {
   return cached[n]
  } else {
   const x = f(n - 1) + 5 - f(n - 2)
   cached[n] = x
   return x
  }
}

再看一个稍微复杂一点的例子:汉诺塔。
这个例子要改成循环不容易。当然要改用缓存优化也不容易。因为它的“输出”是一系列操作,而操作是无法直接缓存的。好在可以把每个操作编码一下就可以把一系列操作变成一个数组。编码很简单,因为汉诺塔有3个,所以可以用两位3进制数表示从一个塔x移动到另一个塔yx*3 + y
所以这个递归函数就从输出一个移动序列变成了返回一个整数数组。

function move (x, y) {
  return x*3 + y
}
function hanoi (n, a, b, c) {
  if (n === 1) {
    return [move(a, c)]
  } else {
    return hanoi(n - 1, a, c, b)
      .concat([move(a, c)])
      .concat(hanoi(n - 1, b, a, c))
  }
}

在汉诺塔程序中会反复用到将n个盘子从塔a移动到塔c这样的计算。所以可以把这个移动序列缓存起来。就可以提高效率。

const cached = {}
function hanoi (n, a, b, c) {
  const m = move(a, c)
  const state = n + '' + m
  if (cached[state]) {
    return cached[state]
  }
  else if (n === 1) {
    return [m]
  } else {
    const s = hanoi(n - 1, a, c, b)
      .concat([move(a, c)])
      .concat(hanoi(n - 1, b, a, c))
    cached[state] = s
    return s
  }
}

试了一下,22层的汉诺塔可以从2秒提高到0.2秒。效果还是比较明显的。
当然,缓存其实是一个空间换时间策略。并非只有在递归函数中有用。只要是会出现反复计算同样的参数的情况下就都适用。

再回到开始的那个奥数题,就算有这种优化,计算200项,对人来说计算量还是太大。那该怎么算呢?
其实不用算多少,手工算十来项就会发现这是个循环数列。然后就是一个乘法。
其实这个数列是循环数列是可以证明的。
f n = f n − 1 − f n − 2 + 5 f n + 1 = f n − f n − 1 + 5 f_n = f_{n-1} - f_{n-2} + 5\\ f_{n+1} = f_{n} - f_{n-1} + 5 fn=fn1fn2+5fn+1=fnfn1+5
将上面两式相加:
f n + 1 + f n = f n − 1 − f n − 2 + 5 + f n − f n − 1 + 5    ⟹    f n + 1 = − f n − 2 + 5 f_{n+1} + f_{n} = f_{n-1} - f_{n-2} + 5 + f_{n} - f_{n-1} + 5 \\ \implies f_{n+1} = -f_{n-2} + 5 fn+1+fn=fn1fn2+5+fnfn1+5fn+1=fn2+5

f n − 2 = − f n − 5 + 5 f_{n-2} = -f_{n-5}+5 fn2=fn5+5
所以
f n + 1 = − f n − 5 + 5 = − ( − f n − 5 + 5 ) + 5 = f n − 5 f_{n+1} = -f_{n-5} + 5 = -(-f_{n-5}+5)+5 = f_{n-5} fn+1=fn5+5=(fn5+5)+5=fn5
所以每隔6个数就会重复。其实真正要计算的就6个数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值