结合记忆化和尾调用优化

6

最近我学到了使用装饰器的一种强大方式,可以用来实现记忆化递归函数。“嘿,这很棒,让我们试试!”

class memoize:
    """Speeds up a recursive function"""
    def __init__(self, function):
        self.function = function
        self.memoized = {}

    def __call__(self, *args):
        try:
            return self.memoized[args]
        except KeyError:
            self.memoized[args] = self.function(*args)
            return self.memoized[args]
#fibmemo
@memoize
def fibm(n, current=0, next=1):
    if n == 0:
        return current
    else:
        return fibm(n - 1, next, current+next)

timeit表明这确实加速了算法:

fibmemo     0.000868436280412
fibnorm     0.0244713692225

"哇,这可能非常有用!我想知道我能推到多远?"
当我开始使用高于140的输入时,我很快就遇到了RuntimeError: maximum recursion depth exceeded. "啊,太糟糕了.."

经过一番搜索,我找到了一个技巧,似乎解决了这个问题。
"这看起来也很不错!让我们试试它"

class TailRecurseException:
    def __init__(self, args, kwargs):
        self.args = args
        self.kwargs = kwargs

def tail_call_optimized(g):
    """
    This function decorates a function with tail call optimization. It does this by throwing an exception
    if it is it's own grandparent, and catching such exceptions to fake the tail call optimization.

    This function fails if the decorated function recurses in a non-tail context.
    """
    def func(*args, **kwargs):
        f = sys._getframe()
        if f.f_back and f.f_back.f_back and f.f_back.f_back.f_code == f.f_code:
            raise TailRecurseException(args, kwargs)
        else:
            while 1:
                try:
                    return g(*args, **kwargs)
                except TailRecurseException, e:
                    args = e.args
                    kwargs = e.kwargs
    func.__doc__ = g.__doc__
    return func

#fibtail
@tail_call_optimized
def fibt(n, current=0, next=1):
    if n == 0:
        return current
    else:
        return fibt(n - 1, next, current+next)

好的,我有一种方法可以使用记忆化来加速我的斐波那契函数。我也有一种方法来推动递归限制。但是我无法想出如何同时实现这两个功能。

#fibboth
@memoize
@tail_call_optimized
def fibb(n, current=0, next=1):
    if n == 0:
        return current
    else:
        return fibb(n - 1, next, current+next)

fibboth     0.00103717311766 
fibtail     0.274269805675
fibmemo     0.000844891605448
fibnorm     0.0242854266612

我尝试将装饰器组合起来,对于小于140的输入看起来可以工作,但一旦超过该范围,就会出现“RuntimeError: maximum recursion depth exceeded”的错误。这就像是@tail_call_optimized失败了。 "什么情况?"


问题:

  1. 有没有办法将这些装饰器组合起来?如果有,如何组合?
  2. 为什么当组合时,似乎装饰器对较小的输入起作用?

你提供的例子并没有真正进行尾调用优化。尾调用优化是指在不增加堆栈的情况下进行函数调用。但是你正在增加堆栈,然后立即引发异常,这也是很耗费资源的。这更像是一种跳板技术(请参见wheaties的答案),而不是尾调用优化。 - Claudiu
4个回答

4
这里有两个问题:第一个问题是,正如@badcook所指出的那样,memoize装饰器在技术上将您的函数转换为非尾递归函数。然而,tail_call_optimized装饰器并不关心这一点。
第二个问题,也是它无法正常工作的原因,是memoize装饰器每次调用fibb时都会向堆栈添加一个额外的帧。因此,它不是自己的爷爷,而更像是自己的曾祖父。您可以修复检查,但请注意,memoize装饰器将被有效地绕过。
因此,故事的寓意是尾调用优化和记忆化不能混合使用。
当然,对于这个特定的问题,有一种方法可以在对数步骤中解决问题(有关更多详细信息,请参见SICP习题1.19,链接为http://mitpress.mit.edu/sicp/full-text/book/book-Z-H-11.html#%_sec_1.2.4),使得这个问题在这种情况下变得不太重要。但这不是这个问题的重点。

这很有道理。是否可能将两者合并为一个装饰器,从而同时获得两种装饰器的好处呢?我承认,如果可能的话,我不知道那会是什么样子。 - Marcel Wilson
1
并不完全是这样。主要问题在于备忘录函数本质上不是尾递归的。如果你仔细想一下,如果某些特定参数没有“备忘录”,那么备忘录代码必须调用底层函数,然后保存结果。因此,函数执行的最后一个操作不再是对自身的调用,而是结果的记忆化。对于已经被记忆化的参数,根本不需要调用底层函数。因此,实际上没有任何尾调用需要“优化”。 - Nathan Davis

3
@NathanDavis在他的回答中说得很好-你应该接受它。 tail_call_optimized() 是一段令人讨厌的代码,它依赖于两件事情:
  1. 它知道调用栈长成什么样子;
  2. 如果它破坏了调用栈的一部分,那也没关系。

如果你将其应用于一个真正的尾递归函数上,那么这些问题就不存在了。但是如果将它与另一个装饰器组合使用,问题#1就不再成立了。你可以试着“修复”它,例如:

def tail_call_optimized(g):
    def func(*args, **kwargs):
        f = sys._getframe()
        code = f.f_code
        fcount = 0
        while f:
            if f.f_code is code:
                fcount += 1
                if fcount > 1:
                    raise TailRecurseException(args, kwargs)
            f = f.f_back
        while 1:
            try:
                return g(*args, **kwargs)
            except TailRecurseException, e:
                args = e.args
                kwargs = e.kwargs
    func.__doc__ = g.__doc__
    return func

现在它会回溯调用栈任意数量的帧以再次“找到自己”,这确实消除了递归限制异常。但是,正如Nathan所暗示的那样,当它引发TailRecurseException时,也会消除对记忆化装饰器的正在进行的调用。最终,在调用(比如)fibb(5000)之后,只有参数5000会在备忘录中。
你可以再次使它变得复杂,重新连接调用堆栈以仅丢弃对tail_call_optimized装饰器的正在进行的调用,然后备忘录将再次正确工作。但是-惊喜!;-)-然后调用堆栈仍将包含所有级别的备忘录装饰器的正在进行的调用,并且您将再次达到最大递归限制。由于备忘函数本身不以调用结束(即,抛弃与备忘函数调用相对应的堆栈帧从来都不正确),因此没有简单的方法可以解决这个问题。

1
你遇到的是 Python 中的堆栈限制。如果你真的想这么做,你需要开始使用一些被称为 Trampoline 的东西。这实际上是将堆栈空间换成堆空间。
有一篇很好的文章介绍了如何思考这些问题在 javascript 中,并且还有一个更具体的针对 Python 的文章。从那篇文章中,你所需要的是:
def trampoline(func):
  def decorated(*args):
    f = func(*args)
    while callable(f):
        f = f()
    return f
  return decorated

为了避免堆栈溢出,让你能够无限制地进行操作,请仔细阅读。
编辑:
我还想补充说明,这是一个Trampoline的幼稚实现。有更好的版本的库,这也是我链接js文章的原因。你可以在其中看到一个更强大的版本,它可以处理许多类型的相关计算,同时保留尾调用优化的思想。

尾调用优化并不是为了将堆栈空间换成堆空间,而是完全消除了需要空间的需求,除了一些常量数量。此外,tail_call_optimized 装饰器的整个重点是为自递归函数提供一个跳板 -- 它的方法与传统的跳板有些不同,但我认为可以合理地认为它是一个跳板。它可以自行工作。问题是为什么它不能很好地与 memoize 装饰器组合使用。 - Nathan Davis
@NathanDavis 你完全正确关于尾调用优化。仅因为一个装饰器以某种方式工作并不意味着让事情正常工作的答案会涉及它。我从未建议使用 tail_call_optimized 装饰器,也没有提到 memoize 装饰器。我指向了一个不同的解决方案来探索。 - wheaties
我知道你没有说要使用tail_call_optimized装饰器,也没有提到memoize装饰器。我的观点是,这两者的结合对于这个问题是必不可少的。 - Nathan Davis
我认为上面的蹦床实际上并不起作用:你会在网上看到这些东西很多,但是因为 func 实际上将变成 decorated,堆栈帧会增长并最终导致堆栈溢出。尝试使用一个简单的尾递归阶乘来验证,这应该是显而易见的。 - maxcountryman

1

根据简短的浏览(现在需要离开),我猜测你的记忆化装饰器破坏了尾调用(即你的函数不再处于尾部位置),因此实际上该函数不再是尾调用可优化的。


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