为什么这个迭代的Collatz方法在Python中比其递归版本慢30%?

16

前言

我有一个特定问题的两个实现,一个是递归方法,另一个是迭代方法。我想知道为什么迭代方法比递归方法慢约30%。

给出递归解法后,我写了一个显示栈状态的迭代解法。
显然,我只是模仿递归的做法,因此Python引擎更好地优化了管理。但我们能否编写性能类似的迭代方法呢?

我的案例研究是在欧拉计划中的第14号问题。

找到起始数小于一百万的最长考拉兹数列。

代码

以下是简洁的递归解法(由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)

在我的电脑上(一台配备大量内存的i7机器)使用IPython,这是时间记录:

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

评论

递归解法真的很棒:

  • 优化可跳过一步或两步,具体取决于最低有效位数。
    我的原始解决方案没有跳过任何 Collatz 步骤,并且需要 ~1.86 秒。
  • 很难达到 Python 默认的递归限制 1000 次。
    collatz_r(9780657630) 返回 1133,但递归调用次数小于 1000。
  • 备忘录避免了重复追踪
  • collatz_r 长度是按需计算的,针对 max

玩弄它时,时间似乎精确到 +/- 5 ms。
像 C 和 Haskell 这样的静态类型语言可以获得低于 100 ms 的时间。
我故意将备忘录 table 的初始化放在方法中,以便此问题反映出在每次调用时 "重新发现" 表值所需的时间。

collatz_r(2**1002) 引发 RuntimeError: maximum recursion depth exceeded
collatz_i(2**1002) 快乐地返回,其结果为 1003

我熟悉生成器、协程和装饰器。
我正在使用 Python 2.7。我也很乐意使用 Numpy(在我的机器上是 1.8)。

我所寻找的

  • 一个迭代解法,能够缩小性能差距
  • 关于 Python 如何处理递归的讨论
  • 显式堆栈涉及的性能惩罚的更细节的信息

我主要寻找第一个,尽管第二个和第三个对于这个问题非常重要,并且将增加我对 Python 的理解。


1
好问题。我在探索节点树时也观察到了相同的行为。 - bgusach
2
我怀疑递归方法中的隐式堆栈(即调用堆栈)使用非常高效的C,并且比使用Python列表的显式堆栈具有更好的性能(后者需要执行比堆栈/帧指针更多的操作)。 - cmh
1
你尝试过使用 deque 来作为你的栈吗? - nmclean
@cmh 不错的提醒,那个注释在我的第一个版本中,但在后来的修改中被遗漏了。 @nmclean 实际上是的。reversed(stack) 提示了我。我不记得结果是什么,但稍后我会回报。 - Prashant Kumar
2个回答

10

在进行了一些基准测试后,我试图对情况进行(部分)解释,这些测试证实了你的数字。

在CPython中,递归函数调用的成本很高,但它们远不及使用列表模拟调用堆栈的成本高。递归调用的堆栈是一个紧凑结构,在C中实现(请参见Eli Bendersky的解释和源代码中的Python/ceval.c文件)。

相比之下,您模拟的堆栈是一个Python列表对象,即一个堆分配、动态增长的指向元组对象的指针数组,而元组对象再指向实际值;再见,局部性,你好,缓存未命中。然后,您使用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 “再见,局部性引用,你好缓存未命中”对我很有启发。我知道Python列表在堆上分配,就像C++中的std::vector一样,但没有明显的联系,这是个问题。如果我有两个列表并推入数字,那么我就无法获得局部性了。我也喜欢数字。在编写时,我确实感到n = x本身需要大量时间,而您对此进行了量化!确实很遗憾我们不能为递归版本使用kernprof - Prashant Kumar

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_r有2行在if之外和4行在其中(在最坏情况下执行,当命中else时)。这加起来总共需要执行640万行。对于collatz_i来说,可比较的数据可能是5和9,这加起来总共需要执行1亿行。

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接