【问题标题】:Is Tail Call optimization all that explains this performance difference尾调用优化是否可以解释这种性能差异
【发布时间】:2023-03-16 07:05:01
【问题描述】:

我看到了三种不同的方式来编写斐波那契函数的递归形式:使用数学内联、使用数学内联和结果缓存以及一种使用尾递归。我知道在缓存答案后,使用 memoization 会将 O(N) 算法转换为 O(1)。但我不明白尾调用优化如何能有这么大的帮助。我的印象是它可能会阻止一些副本或类似的东西。它几乎与 O(1) 一样快。 Ruby 做了什么让这个速度如此之快?

这是内联数学的缓慢幼稚实现。这显然是运行最慢的 O(N) 时间,然后在 O(N^2) 时间内循环显示。

puts Benchmark.measure {
  # Calculate the nth Fibonacci number, f(n).
  def fibo (n)
    if n <= 1
      return n
    else
      value = fibo(n-1) + fibo(n-2)
      return value
    end
  end

  # Display the Fibonacci sequence.
  (1..40).each do |number|
    puts "fibo(#{number}) = #{fibo(number)}"
  end
}

时代 Ruby 1.9.3:55.989000 0.000000 55.989000 ( 55.990000)
时代 JRuby 1.7.9:51.629000 0.000000 51.629000 (51.629000)
来源(http://rayhightower.com/blog/2014/04/12/recursion-and-memoization/?utm_source=rubyweekly)

这是记忆答案的版本,很清楚为什么这对我来说很快。一旦它完成了数学运算,任何后续请求都会在 O(1) 时间内运行,因此当它包含在循环中时,在最坏的情况下它仍会在 O(N) 时间内运行:

puts Benchmark.measure {
  # Fibonacci numbers WITH memoization.

  # Initialize the memoization array.
  @scratchpad = []
  @max_fibo_size = 50
  (1..@max_fibo_size).each do |i|
    @scratchpad[i] = :notcalculated
  end

  # Calculate the nth Fibonacci number, f(n).
  def fibo (n)
    if n > @max_fibo_size
      return "n must be #{@max_fibo_size} or less."
    elsif n <= 1
      return n
    elsif @scratchpad[n] != :notcalculated
      return @scratchpad[n]
    else
      @scratchpad[n] = fibo(n-1) + fibo(n-2)
      return @scratchpad[n]
    end
  end

  # Display the Fibonacci sequence.
  (1..40).each { |number|
    puts "fibo(#{number}) = #{fibo(number)}"
  }
}

时代 Ruby 1.9.3:0.000000 0.000000 0.000000 ( 0.025000)
次 JRuby 1.7.9:0.027000 0.000000 0.027000 (0.028000)
来源(http://rayhightower.com/blog/2014/04/12/recursion-and-memoization/?utm_source=rubyweekly)

这个版本的尾调用递归版本几乎可以立即运行:

puts Benchmark.measure {
  # Calculate the nth Fibonacci number, f(n). Using invariants
  def fibo_tr(n, acc1, acc2)
    if n == 0
      0
    elsif n < 2
      acc2
    else
      return fibo_tr(n - 1, acc2, acc2 + acc1)
    end
  end

  def fibo (n)
    fibo_tr(n, 0, 1)
  end 

  # Display the Fibonacci sequence.
  (1..50).each do |number|
    puts "fibo(#{number}) = #{fibo(number)}"
  end
}

时代 Ruby 1.9.3:0.000000 0.000000 0.000000 ( 0.021000)
次 JRuby 1.7.9:0.041000 0.000000 0.041000 ( 0.041000)
来源(https://gist.github.com/mvidaurre/11006570

【问题讨论】:

    标签: ruby recursion jruby tail-recursion


    【解决方案1】:

    尾递归不是这里的区别。事实上,Ruby 并没有做任何事情来优化尾调用。

    不同之处在于,朴素算法每次调用时都会递归调用自身两次,从而提供 O(2n) 性能,这意味着运行时间随着 N 的增加呈指数增长。尾调用版本以线性时间运行。

    【讨论】:

    • 我不敢相信我错过了!我读了它,我根本没想到它是这样做的。
    • “Ruby 没有做任何事情来优化尾调用” – 然而,它也没有做任何事情来优化尾调用。 (与明确禁止 TCO 的 Python 不同。)Ruby 实现执行 TCO 是完全合法的,而且有些实现。 YARV 可以执行 TCO,并且在执行 TCO(如 IBM J9)的 JVM 上运行 JRuby 可能会也可能不会导致某些方法获得 TCO。
    【解决方案2】:

    TL; DR: 正如 Chuck 已经提到的,Ruby 没有 TCO。但是,进行一次递归而不是两次递归对您使用多少堆栈以及完成多少次迭代有很大影响。有了这个答案,我只想指出,有时记忆版本比迭代版本更好。注意:我不是红宝石程序员。它可能不是惯用代码。

    测试表明迭代方法非常快,它可以从头开始生成 1..50 的 fib,就像您的记忆版本在上述 3 的每个方法调用中重用计算一样快。

    我认为 1..50 完成得如此之快,以至于查看迭代实际上是否更快并不是一个非常可靠的方法。我将 memopization 版本更改为:

    # Initialize the memoization array.
    @scratchpad = []
    
    # Calculate the nth Fibonacci number, f(n).
    def fibo (n)
      if n <= 1 
        return n
      end
      if @scratchpad[n].nil?
        @scratchpad[n] = fibo(n-1) + fibo(n-2)
      end
      return @scratchpad[n]
    end
    

    然后我将循环更改为:

    (1..5000).each { |number|
      fibo(number) # no need to time character output
    }
    

    这是我电脑上的结果:

    Iteration:   6.260000   0.010000   6.270000 (  6.273362)
    Memoization: 0.000000   0.000000   0.000000 (  0.006943)
    

    我用过:

    ruby -v
    ruby 1.9.3p194 (2012-04-20 revision 35410) [x86_64-linux]
    

    将 memoization 版本增加到 1..50000 仍然比迭代版本快很多。原因是每次迭代都从头开始,而记忆化版本的算法更无效,但记忆化使得每个数字最多只能递归两次,因为我们有 fib(n-1)fib(n-2) in the array when calculatingfib(n)`。

    最慢的当然是O(fib(n))。迭代有O(n)。通过记忆,fib(n-2) 在计算fib(n-1) 时是免费的,所以我们回到O(n),但在您的测试中,您在下一个之前计算前一个斐波那契数,因此实际上从1..x 的每个单独迭代都是O(1)。如果您从最大的数字开始,第一次迭代将是 O(n),接下来的每个迭代都是 O(1)

    【讨论】:

    • 我没有写任何代码。它们都来自同一篇文章及其 cmets。我认为您的版本将为零?更好,更惯用。扩展基准以更深入地研究两个更优化的版本是个好主意
    猜你喜欢
    • 2014-06-09
    • 1970-01-01
    • 1970-01-01
    • 2021-10-26
    • 2011-09-25
    • 2018-08-30
    • 2021-05-27
    • 1970-01-01
    • 2010-10-26
    相关资源
    最近更新 更多