【问题标题】:Fibonacci: non-recursive vs memoized recursive puzzling timing results斐波那契:非递归与记忆递归令人费解的计时结果
【发布时间】:2021-09-02 12:15:57
【问题描述】:

在观看了 MIT 关于动态编程的讲座后,我想练习一下斐波那契。我首先编写了朴素的递归实现,然后添加了 memoization。这是记忆的版本:

package main

import (
    "fmt"
)

func fib_memoized(n int, memo map[int]int64) int64 {
    memoized, ok := memo[n]
    if ok {
        return memoized
    }
    if n < 2 {
        return int64(n)
    }
    f := fib_memoized(n-2, memo) + fib_memoized(n-1, memo)
    memo[n] = f
    return f
}

func main() {
    memo := make(map[int]int64)
    for i := 0; i < 10000; i++ {
        fmt.Printf("fib(%d) = %d\n", i, fib_memoized(i, memo))
    }
}

然后我开始编写程序的非递归版本:

package main

import (
    "fmt"
)

func fib(n int) int64 {
    var f1 int64 = 1
    var f2 int64 = 0
    for i := 0; i < n; i++ {
        f1, f2 = f2, f1+f2
    }
    return f2
}

func main() {
    for i := 0; i < 10000; i++ {
        fmt.Printf("fib(%d) = %d\n", i, fib(i))
    }
}

让我感到困惑的是,记忆版本的性能似乎至少与非递归版本一样好,有时甚至胜过它。自然地,我期望记忆化与朴素的递归实现相比会带来很大的改进,但我只是无法弄清楚为什么/如何记忆化的版本可以达到甚至超过其非递归版本。

我确实尝试查看两个版本的程序集输出(使用go tool compile -S 获得),但无济于事。我仍然在记忆版本中看到CALL 指令,并且在我看来,这应该会产生足够的开销来证明它至少比非递归版本略胜一筹。

谁能帮助我了解发生了什么?

附:我知道整数溢出;我用10000只是为了增加负载。

谢谢。

【问题讨论】:

  • 您似乎指的是记忆递归、朴素递归和非递归实现,但您只显示了两个。您正在运行哪些,哪些预计会最快?你能用 Go 基准测试重写这个问题吗?我希望非递归实现甚至比记忆递归实现更快,因为 a)Go 没有很好的尾调用优化,b)你的记忆实现不是尾调用。
  • 我没有包含幼稚版本的原因是因为我认为它不会有利于讨论,因为我的问题实际上是关于记忆递归与非递归(或迭代)版本。很抱歉没有使用 Go 基准测试。无论如何,您似乎已经为此添加了一个响应,我将对其进行研究。谢谢。

标签: algorithm performance go recursion


【解决方案1】:

这不是您问题的答案,但有一种方法可以获取 log(N) 中的第 N 个斐波那契数
您所需要的只是提高矩阵
| 0 1 |
| 1 1 |
使用 binary matrix exponentiation 到 N 次方

资料链接:
https://kukuruku.co/post/the-nth-fibonacci-number-in-olog-n/
https://www.youtube.com/watch?v=eMXNWcbw75E

【讨论】:

  • 感谢您的分享。我不知道这种技术。
【解决方案2】:

我想你是在问为什么记忆递归实现并不比迭代实现快多少。尽管您提到了您没有展示的“朴素递归实现”?

使用基准测试可以看到两者的性能相当,也许迭代更快一点:

package kata

import (
    "fmt"
    "os"
    "testing"
)

func fib_memoized(n int, memo map[int]int64) int64 {
    memoized, ok := memo[n]
    if ok {
        return memoized
    }
    if n < 2 {
        return int64(n)
    }
    f := fib_memoized(n-2, memo) + fib_memoized(n-1, memo)
    memo[n] = f
    return f
}

func fib(n int) int64 {
    var f1 int64 = 1
    var f2 int64 = 0
    for i := 0; i < n; i++ {
        f1, f2 = f2, f1+f2
    }
    return f2
}

func BenchmarkFib(b *testing.B) {
    out, err := os.Create("/dev/null")
    if err != nil {
        b.Fatal("Can't open: ", err)
    }
    b.Run("Recursive Memoized", func(b *testing.B) {
        memo := make(map[int]int64)
        for j := 0; j < b.N; j++ {
            for i := 0; i < 100; i++ {
                fmt.Fprintf(out, "fib(%d) = %d\n", i, fib_memoized(i, memo))
            }
        }
    })
    b.Run("Iterative", func(b *testing.B) {
        for j := 0; j < b.N; j++ {
            for i := 0; i < 100; i++ {
                fmt.Fprintf(out, "fib(%d) = %d\n", i, fib(i))
            }
        }
    })
}
% go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/brackendawson/kata
cpu: Intel(R) Core(TM) i7-8850H CPU @ 2.60GHz
BenchmarkLoop/Recursive_Memoized-12                13424             91082 ns/op
BenchmarkLoop/Iterative-12                         13917             82837 ns/op
PASS
ok      github.com/brackendawson/kata    4.323s

我希望您的记忆递归实现不会快很多,因为:

  1. Go 没有很好的尾调用优化 (TCO)。正如您可能从程序集中看到的那样,仍然存在 CALL,如果可以优化 CALL,递归通常会更快。
  2. 您的记忆递归实现不是尾调用,递归调用必须是函数中使用 TCO 的最后一条语句。

【讨论】:

    【解决方案3】:

    要记住一件非常重要的事情:memo 在测试台的迭代之间保留。因此,记忆化版本在main 中的循环每次迭代最多有两个递归调用。 IE。您允许记忆版本在各个迭代之间保留内存,而迭代版本需要在每次迭代中从头开始计算。

    下一点:
    编写基准测试很棘手。微小的细节会对结果产生重大影响。例如。对printf 的调用很可能需要相当长的时间来执行,但实际上并没有考虑到斐波那契计算的运行时间。我没有任何可用的环境来测试这些 IO 操作对时间的实际影响有多大,但它很可能相当大。尤其是因为您的算法运行了相当小的 10000 次迭代,或者几乎没有 100 微秒,如 @Brackens answer 所示。

    总结一下:
    从基准测试中删除 IO,在每次迭代中从空的 memo 开始,并增加迭代次数以获得更好的时序。

    【讨论】:

    • 就是这样。我没有意识到每次迭代都让记忆版本变得更容易,因为地图上充满了预先计算的结果,这很糟糕。在每次迭代时重置缓存会导致记忆版本比迭代版本慢 10 倍以上。感谢您指出。
    猜你喜欢
    • 2011-12-14
    • 2021-11-17
    • 2010-12-03
    • 2021-12-01
    • 2016-06-27
    • 2020-07-15
    • 2014-04-02
    • 2014-01-16
    相关资源
    最近更新 更多