为什么这个迭代的Collat​​z方法比它在Python中的递归版本慢30%?

时间:2014-02-11 07:30:10

标签: python performance recursion iteration

前奏

我有两个特定问题的实现,一个递归和一个迭代,我想知道是什么原因导致迭代解决方案比递归解决方案慢〜30%。

鉴于递归解决方案,我编写了一个迭代解决方案,使堆栈显式化 显然,我只是模仿递归正在做什么,所以当然Python引擎更好地优化来处理簿记。但是我们能编写一个具有类似性能的迭代方法吗?

我的案例研究是关于Project Euler的Problem #14

  

查找最长的Collat​​z链,起始编号低于一百万。

代码

这是一个简约的递归解决方案(由于问题线程中的 veritas 加上来自 jJjjJ 的优化):

def solve_PE14_recursive(ub=10**6):
    def collatz_r(n):
        if not n in table:
            if n % 2 == 0:
                table[n] = collatz_r(n // 2) + 1
            elif n % 4 == 3:
                table[n] = collatz_r((3 * n + 1) // 2) + 2
            else:
                table[n] = collatz_r((3 * n + 1) // 4) + 3
        return table[n]
    table = {1: 1}
    return max(xrange(ub // 2 + 1, ub, 2), key=collatz_r)

这是我的迭代版本:

def solve_PE14_iterative(ub=10**6):
    def collatz_i(n):
        stack = []
        while not n in table:
            if n % 2 == 0:
                x, y = n // 2, 1
            elif n % 4 == 3:
                x, y = (3 * n + 1) // 2, 2
            else:
                x, y = (3 * n + 1) // 4, 3
            stack.append((n, y))
            n = x
        ysum = table[n]
        for x, y in reversed(stack):
            ysum += y
            table[x] = ysum
        return ysum
    table = {1: 1}
    return max(xrange(ub // 2 + 1, ub, 2), key=collatz_i)

使用IPython在我的机器(具有大量内存的i7机器)上的计时:

In [3]: %timeit solve_PE14_recursive()
1 loops, best of 3: 942 ms per loop
In [4]: %timeit solve_PE14_iterative()
1 loops, best of 3: 1.35 s per loop

评论

递归解决方案非常棒:

  • 根据两个最低有效位优化跳过一步或两步 我的原始解决方案没有跳过任何Collat​​z步骤并且花费了大约1.86秒
  • 很难达到Python的默认递归限制1000 collatz_r( 9780657630 )返回1133但需要少于1000次递归调用。
  • 记忆避免回溯
  • collatz_r根据max
  • 按需计算的长度

玩弄它,时间似乎精确到+/- 5毫秒 静态类型如C和Haskell的语言可以获得低于100毫秒的时间 我将memoization table的初始化设计在这个问题的方法中,这样时间就可以反映出"重新发现"每个调用的表值。

collatz_r(2**1002)提出RuntimeError: maximum recursion depth exceeded collatz_i(2**1002)愉快地返回1003

我熟悉生成器,协同程序和装饰器 我使用的是Python 2.7。我也很高兴使用Numpy(我机器上的1.8)。

我在寻找什么

  • 解决性能差距的迭代解决方案
  • 关于Python如何处理递归的讨论
  • 与显式堆栈相关的性能损失的更精细细节

我主要关注第一个,虽然第二个和第三个对这个问题非常重要,但会增加我对Python的理解。

2 个答案:

答案 0 :(得分:10)

这是我在运行一些基准测试之后的(部分)解释,这证实了你的数据。

虽然递归函数调用在CPython中很昂贵,但它们并不像使用列表模拟调用堆栈那样昂贵。递归调用的堆栈是用C实现的紧凑结构(参见Eli Bendersky's explanation和源代码中的文件Python/ceval.c)。

相比之下,您的模拟堆栈是一个Python列表对象,即堆分配的,动态增长的array of pointers到元组对象,后者又指向实际值;再见,参考地点,你好缓存未命中。然后,您使用Python对这些对象进行了非常慢的迭代。使用kernprof进行逐行分析确认迭代和列表处理花费了大量时间:

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                               @profile
    17                                               def collatz_i(n):
    18    750000       339195      0.5      2.4          stack = []
    19   3702825      1996913      0.5     14.2          while not n in table:
    20   2952825      1329819      0.5      9.5              if n % 2 == 0:
    21    864633       416307      0.5      3.0                  x, y = n // 2, 1
    22   2088192       906202      0.4      6.4              elif n % 4 == 3:
    23   1043583       617536      0.6      4.4                  x, y = (3 * n + 1) // 2, 2
    24                                                       else:
    25   1044609       601008      0.6      4.3                  x, y = (3 * n + 1) // 4, 3
    26   2952825      1543300      0.5     11.0              stack.append((n, y))
    27   2952825      1150867      0.4      8.2              n = x
    28    750000       352395      0.5      2.5          ysum = table[n]
    29   3702825      1693252      0.5     12.0          for x, y in reversed(stack):
    30   2952825      1254553      0.4      8.9              ysum += y
    31   2952825      1560177      0.5     11.1              table[x] = ysum
    32    750000       305911      0.4      2.2          return ysum

有趣的是,即使n = x占用总运行时间的8%左右。

(不幸的是,我无法让kernprof为递归版本生成类似的内容。)

答案 1 :(得分:2)

迭代代码有时比递归更快,因为它避免了函数调用开销。但是,stack.append也是一个函数调用(以及顶部的属性查找),并增加了类似的开销。计算append次调用时,迭代版本的函数调用与递归版本一样多。

比较这里的前两个和最后两个时间......

$ python -m timeit pass
10000000 loops, best of 3: 0.0242 usec per loop
$ python -m timeit -s "def f(n): pass" "f(1)"
10000000 loops, best of 3: 0.188 usec per loop
$ python -m timeit -s "def f(n): x=[]" "f(1)"
1000000 loops, best of 3: 0.234 usec per loop
$ python -m timeit -s "def f(n): x=[]; x.append" "f(1)"
1000000 loops, best of 3: 0.336 usec per loop
$ python -m timeit -s "def f(n): x=[]; x.append(1)" "f(1)"
1000000 loops, best of 3: 0.499 usec per loop

...确认append调用排除属性查找与调用最小纯Python函数的时间大致相同,约为170 ns。


从上面我得出结论,迭代版本没有固有的优势。下一个要考虑的问题是哪一个做得更多。为了得到(非常)粗略估计,我们可以查看每个版本中执行的行数。我做了一个快速的实验来发现:

  • collatz_r被称为1234275次,if块的正文执行984275次。
  • collatz_i被称为250000次,而while循环被称为984275次

现在,假设collatz_rif之外有2行,在内部有4行(在最坏的情况下,当else被击中时执行)。这相当于可以执行640万行。 collatz_i的可比数字可能是5和9,总计达到1000万。

即使这只是一个粗略的估计,它也足够符合实际时间。