为什么斐波那契的实施如此之快?

时间:2019-02-23 16:34:45

标签: haskell recursion fibonacci

这种斐波那契的实现很容易理解,但是非常慢:

fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

斐波那契的以下实现很难理解,但速度很快。它可以在我的笔记本电脑上立即计算出第100,000个斐波那契数。

fib = fastFib 1 1

fastFib _ _ 0 = 0
fastFib _ _ 1 = 1
fastFib _ _ 2 = 1
fastFib a b 3 = a + b
fastFib a b c = fastFib (a + b) a (c - 1)

后一种实现在这里发生了什么魔术,它如何工作?

4 个答案:

答案 0 :(得分:4)

为什么第一个实现缓慢?

这很慢,因为每次调用fib可能导致最多两次(平均更像1.6)对fib的调用,因此要计算fib 5,您要调用{{1 }}和fib 4分别调用fib 3fib 3,以及fib 2fib 2,因此我们可以看到每次调用fib 1这样的工作量是调用fib (n+1)的两倍。

我们可能会观察到的一件事是,我们多次尝试完成相同的事情,例如以上我们fib n进行了两次计算。如果您必须解决例如这可能会花费很长时间。 fib 3两次。

如何更快地进行fib?

我认为,比起直接跳入fib 100更好。如果我要求您手动计算第十个斐波那契数,那么我希望您不会通过应用该算法来计算第十三个斐波那契数。您可能会记得到目前为止的情况。实际上,在Haskell中可以做到这一点。只需编写一个程序(懒惰地)生成斐波纳契数字列表并对其进行索引:

fastFib

这要快得多,但是很糟糕,因为它使用大量内存来存储斐波那契数字列表,并且由于必须遵循很多指针,所以找到列表的第n个元素也很慢。 / p>

要从头开始计算单个斐波那契数(即尚未计算任何值)需要二次时间。

您可以手动计算第十个斐波那契数的另一种方法是写下斐波那契数列,直到获得第十个元素。这样,您就无需再过去,也不必记住以前计算过的所有内容,而只需查看前面的两个元素。可以想象有一种必要的算法可以做到这一点

mediumFib = (\n -> seq !! n) where
  seq = 0:1:[mediumFib (i-1) + mediumFib (i-2)| i <- [2..]]

这只是逐步执行递归关系:

fib(n):
  if (n<2) return n
  preprevious = 0
  previous = 1
  i = 2
  while true:
    current = preprevious + previous
    if (i = n) return current
    preprevious, previous = previous, current

实际上我们也可以在Haskell中编写它:

f_n = f_(n-2) + f_(n-1)

现在这非常快,我们也可以将其转换为您拥有的功能。步骤如下:

  1. 交换参数fastFib n | n < 2 = n | otherwise = go 0 1 2 where go pp p i | i = n = pp + p | otherwise = go p (pp + p) (i + 1) (先前)和pp(先前)的参数顺序
  2. p开始向上计数,而不是向上i计数。
  3. n提取到顶级函数中,因为它不再依赖于go

该算法每一步只需要做一个求和,所以它是线性时间,而且非常快。与计算n相比,计算fib (n+1)仅耗费少量时间。与此相比,上面的工作量大约是工作量的1.6倍。

是否有更快的fib n

当然可以。事实证明,有一种表达斐波纳契数列的聪明方法。我们认为转换fib是一系列转换a,b -> a+b,a的特例:

T_pq

特别是T_pq : a -> bq + aq + ap b -> bp + aq p = 0的特殊情况。如果有一种简单的方法可以两次表示应用q = 1,我们现在可以做一些代数运算:

T_pq

所以现在让我们编写一个简单的函数来计算T_pq T_pq : a -> (bp + aq)q + (bq + aq + ap)(q + p) b -> (bp + aq)p + (bq + aq + ap)q = a -> (b + a)(q^2 + 2pq) + a(q^2 + p^2) b -> b(q^2 + p^2) + a(q^2 + 2pq) = T_(q^2 + p^2),(q^2 + 2pq) T_pq^n (a,b)

fib n

现在我们可以使用关系使tPow p q a b n | n = 1 = (b*q + a*q + a*p, b*p + a*q) | otherwise = let (a', b') = tPow p q a b 1 in tPow p q a' b' (n-1) fib 0 = 0 fib 1 = 1 fib n = fst $ tPow 0 1 1 0 (n-1) 更快:

tPow

为什么这样更快?嗯,它之所以更快,是因为计算tPow p q a b n | n = 1 = (b*q + a*q + a*p, b*p + a*q) | odd n = let (a', b') = tPow p q a b 1 in tPow p q a' b' (n-1) | even n = tPow (q*q + p*p) (q*q + 2*p*q) a b (n `div` 2) 比计算fib (2*n)的工作量只是一个常数,而之前它是工作量的两倍,而之前是工作量的四倍,而之前是工作量的平方。实际上,步数类似于二进制fib n的位数加上n二进制表示形式的1的数量。计算n仅需10步,而先前的算法仅需1000步。计算十亿分之一的斐波那契数仅需30步,远少于十亿。

答案 1 :(得分:2)

魔术是递归公式描述的计算过程的反思,证明,解释:

fib 0 = 0    -- NB!
fib 1 = 1
fib n = fib (n-1) + fib (n-2)
      --  n1          n2
      = let {n1 = fib (n-1) ; n2 = fib (n-2)} 
        in n1 + n2
      = let {n1 = fib (n-2) + fib (n-3) ; n2 = fib (n-2)} 
      --            n2          n3
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = fib (n-2) ; n3 = fib (n-3)} 
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = fib (n-3) + fib (n-4) ; n3 = fib (n-3)} 
      --                         n3          n4
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = n3+n4 ; n3 = fib (n-3) ; n4 = fib (n-4)} 
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = n3+n4 ; n3 = n4+n5 ; n4 = fib (n-4) ; n5 = fib (n-5)} 
        in n1 + n2
      = .......

,查看到最终情况,然后翻转时间箭头(或从右向左读取),然后明确编码 作为递归的模拟“调用堆栈” 操作的一部分,在let内部隐式地进行操作。

最重要的是,将等号替换为等号,也就是参照透明性-使用n2代替fib (n-2)每个外观,等等。

答案 2 :(得分:2)

只想弄清楚尾递归与使第二个程序快速运行没有任何关系。下面,我重写您的第一个程序以使用适当的尾部调用,然后将执行时间与第二个程序进行比较。我也改写了那个,因为它可以简化很多-

fib1 n = slow n id
  where
    slow 0 k = k 0
    slow 1 k = k 1
    slow n k = slow (n - 1) (\a ->
               slow (n - 2) (\b ->
               k (a + b)))

fib2 n = fast n 0 1
  where
    fast 0 a _ = a
    fast n a b = fast (n - 1) b (a + b)

对像n = 10这样的小数字的影响可以忽略不计-

fib1 10
-- 55
-- (0.01 secs, 138,264 bytes)

fib2 10
-- 55
-- (0.01 secs, 71,440 bytes)

但是,即使在n = 20左右,我们也注意到fib1的表现有很大的下降-

fib1 20
-- 6765
-- (0.70 secs, 8,787,320 bytes)

fib2 20
-- 6765
-- (0.01 secs, 76,192 bytes)

n = 30,这种影响是可笑的。这两个程序仍然达到相同的结果,所以很好,但是fib1花费了30秒以上。 fib2仍然只需要一秒钟的时间-

fib1 30
-- 832040
-- (32.91 secs, 1,072,371,488 bytes) LOL so bad

fib2 30
-- 832040 (0.09 secs, 80,944 bytes)

这样做的原因是因为第一个程序fib1进行了两个递归调用。随着n的增长,此函数的过程使用指数时间和空间。在n = 30,慢速程序将进行1,073,741,824(2 30 )个递归调用。快速程序只会重复30次。

n = 1000,我们遇到了fib1的一个严重问题。根据{{​​1}}的性能,我们估计完成2 1000 个递归调用将需要fib1 30。同时,1.041082353242204e286毫不费力地处理了1000个递归-

fib2 1000

使用添加的fib2 1000 -- 43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875 -- (0.13 secs, 661,016 bytes) 参数可能很难遵循第一个程序的原始重写。使用k可使我们看到Haskell熟悉的Cont表示法中清晰的步骤顺序-

do

答案 3 :(得分:1)

隐藏输入数字被用作计数器的事实只是一种混淆。我希望,如果您看到类似这样的内容,那么您将理解为什么:

fib2 n = fastFib2 0 1 0 n

fastFib2 current previous count 0 = 0
fastFib2 current previous count 1 = 1
fastFib2 current previous count n
  | count == n = current
  | otherwise  =
     fastFib2 (current + previous) current (count + 1) n

在上面的代码中,我们使计数器明确:当它等于我们的输入n时,我们返回累加器current;否则,我们将跟踪当前和先前数字(“ two preceding ones”)的这种“前向”递归,以构造斐波那契数列所需的所有内容。

您共享的代码执行相同的操作。 (c - 1)使它看起来像是更传统的“向后”递归,实际上它是在第一次调用时从累加器开始,然后添加到累加器中。