理论上可以优化Ackermann函数吗?

42

我想知道是否存在比标准变体更好的时间复杂度的Ackermann函数版本。

这不是一项作业,我只是好奇。我知道Ackermann函数除了作为性能基准之外没有任何实际用途,因为它具有深度递归。我知道这些数字增长得非常快,我对计算它不感兴趣。

虽然我使用的是Python 3,并且整数不会溢出,但我确实有限的时间。我根据在Wikipedia上找到的定义自己实现了一个版本,并计算了极小值的输出,只是为了确保输出正确。

enter image description here

def A(m, n):
    if not m:
        return n + 1
    return A(m - 1, A(m, n - 1)) if n else A(m - 1, 1)

上述代码是对图像的直接翻译,非常慢,我不知道如何进行优化,难道无法优化吗?
我能想到的一种方法是使用记忆化,但递归是向后运行的,每次函数被递归调用时,参数之前都没有遇到过,每个连续的函数调用参数都会减少而不是增加,因此需要计算函数的每个返回值,当你第一次使用不同的参数调用函数时,记忆化是无效的。
只有在再次使用相同参数调用函数时,记忆化才能起作用,它不会重新计算结果,而是检索缓存的结果,但如果你使用任何(m, n) >= (4, 2)的输入调用该函数,它将导致解释器崩溃。
我还根据这个答案实现了另一个版本。
def ack(x, y):
    for i in range(x, 0, -1):
        y = ack(i, y - 1) if y else 1
    return y + 1

但实际上它更慢:
In [2]: %timeit A(3, 4)
1.3 ms ± 9.75 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

In [3]: %timeit ack(3, 4)
2 ms ± 59.9 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

理论上,阿克曼函数能被优化吗?如果不能,能否明确证明它的时间复杂度无法降低?

我刚刚测试了A(3, 9)A(4, 1)会导致解释器崩溃,以及对于A(3, 8)这两个函数的性能:

In [2]: %timeit A(3, 8)
432 ms ± 4.63 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [3]: %timeit ack(3, 8)
588 ms ± 10.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

我做了一些更多的实验:
from collections import Counter
from functools import cache

c = Counter()
def A1(m, n):
    c[(m, n)] += 1
    if not m:
        return n + 1
    return A(m - 1, A(m, n - 1)) if n else A(m - 1, 1)

def test(m, n):
    c.clear()
    A1(m, n)
    return c

这些论点确实重复出现。
但令人惊讶的是,缓存根本没有起到任何帮助作用。
In [9]: %timeit Ackermann = cache(A); Ackermann(3, 4)
1.3 ms ± 10.1 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

只有在使用相同的参数再次调用函数时,缓存才会起作用,如下所述:
In [14]: %timeit Ackermann(3, 2)
101 ns ± 0.47 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

我已经多次使用不同的参数进行测试,结果总是没有任何效率提升。


16
这个问题可能更适合在https://cs.stackexchange.com/或https://cstheory.stackexchange.com/上提问。根据快速的Google Scholar搜索,Ackermann函数的实现可以进行优化,例如https://www.sciencedirect.com/science/article/pii/0304397588900461(1988)或https://dl.acm.org/doi/abs/10.1145/966049.777398(2003)。 - wp78de
18
这个问题可能更适合在https://cs.stackexchange.com/或https://cstheory.stackexchange.com/上提问。根据快速的Google Scholar搜索,Ackermann函数的实现可以进行优化,例如https://www.sciencedirect.com/science/article/pii/0304397588900461(1988年)或https://dl.acm.org/doi/abs/10.1145/966049.777398(2003年)。 - wp78de
7
备忘录法绝对有助于时间复杂度。只需在开头加上一个简单的print(m, n),然后调用A(3, 2)。你会看到重复的值一次又一次地出现。例如,A(1, 5)被调用了16次。缓存肯定会使它更快。 - user2390182
7
备忘录法绝对有助于时间复杂度。只需在开头加入简单的print(m, n)语句并调用A(3, 2),你会看到重复的值一次又一次地出现。例如,A(1, 5)被调用了16次。缓存绝对会使其更快。 - user2390182
3
这个早期的问题探讨了一种快速迭代算法来计算阿克曼函数的值。我没有进行基准测试,但也许它适合你的需求?(它的渐近复杂度也比朴素递归更快。) - templatetypedef
显示剩余14条评论
7个回答

27

解决方案

最近我根据templatetypedef提到的同一篇论文写了一系列的解决方案。其中许多方案使用生成器,每个m值对应一个生成器,产生n=0、n=1、n=2等数值。以下是我最喜欢的一个方案:

def A_Stefan_generator_stack3(m, n):
    def a(m):
        if not m:
            yield from count(1)
        x = 1
        for i, ai in enumerate(a(m-1)):
            if i == x:
                x = ai
                yield x
    return next(islice(a(m), n, None))

解释

考虑生成器 a(m)。它产生 A(m,0), A(m,1), A(m,2) 等等。A(m,n) 的定义使用了 A(m-1, A(m, n-1))。因此,a(m) 在索引 n 处产生的值是 A(m,n),计算方式如下:

  • a(m) 生成器本身在索引 n-1 处产生了 A(m,n-1)。这个值就是该生成器之前产生的值(x)。
  • a(m-1) 生成器在索引 x 处产生了 A(m-1, A(m, n-1)) = A(m-1, x)。因此,a(m) 生成器迭代 a(m-1) 生成器,并获取索引 i == x 处的值。

基准测试

以下是计算所有 A(m,n)(其中 m≤3 且 n≤17)所需的时间,还包括 templatetypedef 的解决方案:

 1325 ms  A_Stefan_row_class
 1228 ms  A_Stefan_row_lists
  544 ms  A_Stefan_generators
 1363 ms  A_Stefan_paper
  459 ms  A_Stefan_generators_2
  866 ms  A_Stefan_m_recursion
  704 ms  A_Stefan_function_stack
  468 ms  A_Stefan_generator_stack
  945 ms  A_Stefan_generator_stack2
  582 ms  A_Stefan_generator_stack3
  467 ms  A_Stefan_generator_stack4
 1652 ms  A_templatetypedef

注意:使用数学洞察力/公式可以找到更快(非常快)的解决方案,请参见我的评论pts的答案。我故意没有这样做,因为我对编码技术感兴趣,避免深度递归和重新计算。我得到的印象是这也是问题/OP想要的,他们确认了这一点(在一个已删除的答案下可见,如果您有足够的声望)。
def A_Stefan_row_class(m, n):
    class A0:
        def __getitem__(self, n):
            return n + 1
    class A:
        def __init__(self, a):
            self.a = a
            self.n = 0
            self.value = a[1]
        def __getitem__(self, n):
            while self.n < n:
                self.value = self.a[self.value]
                self.n += 1
            return self.value
    a = A0()
    for _ in range(m):
        a = A(a)
    return a[n]


from collections import defaultdict

def A_Stefan_row_lists(m, n):
    memo = defaultdict(list)
    def a(m, n):
        if not m:
            return n + 1
        if m not in memo:
            memo[m] = [a(m-1, 1)]
        Am = memo[m]
        while len(Am) <= n:
            Am.append(a(m-1, Am[-1]))
        return Am[n]
    return a(m, n)


from itertools import count

def A_Stefan_generators(m, n):
    a = count(1)
    def up(a, x=1):
        for i, ai in enumerate(a):
            if i == x:
                x = ai
                yield x
    for _ in range(m):
        a = up(a)
    return next(up(a, n))


def A_Stefan_paper(m, n):
    next = [0] * (m + 1)
    goal = [1] * m + [-1]
    while True:
        value = next[0] + 1
        transferring = True
        i = 0
        while transferring:
            if next[i] == goal[i]:
                goal[i] = value
            else:
                transferring = False
            next[i] += 1
            i += 1
        if next[m] == n + 1:
            return value


def A_Stefan_generators_2(m, n):
    def a0():
        n = yield
        while True:
            n = yield n + 1
    def up(a):
        next(a)
        a = a.send
        i, x = -1, 1
        n = yield
        while True:
            while i < n:
                x = a(x)
                i += 1
            n = yield x
    a = a0()
    for _ in range(m):
        a = up(a)
    next(a)
    return a.send(n)


def A_Stefan_m_recursion(m, n):
    ix = [None] + [(-1, 1)] * m
    def a(m, n):
        if not m:
            return n + 1
        i, x = ix[m]
        while i < n:
            x = a(m-1, x)
            i += 1
        ix[m] = i, x
        return x
    return a(m, n)


def A_Stefan_function_stack(m, n):
    def a(n):
        return n + 1
    for _ in range(m):
        def a(n, a=a, ix=[-1, 1]):
            i, x = ix
            while i < n:
                x = a(x)
                i += 1
            ix[:] = i, x
            return x
    return a(n)


from itertools import count, islice

def A_Stefan_generator_stack(m, n):
    a = count(1)
    for _ in range(m):
        a = (
            x
            for a, x in [(a, 1)]
            for i, ai in enumerate(a)
            if i == x
            for x in [ai]
        )
    return next(islice(a, n, None))


from itertools import count, islice

def A_Stefan_generator_stack2(m, n):
    a = count(1)
    def up(a):
        i, x = 0, 1
        while True:
            i, x = x+1, next(islice(a, x-i, None))
            yield x
    for _ in range(m):
        a = up(a)
    return next(islice(a, n, None))


def A_Stefan_generator_stack3(m, n):
    def a(m):
        if not m:
            yield from count(1)
        x = 1
        for i, ai in enumerate(a(m-1)):
            if i == x:
                x = ai
                yield x
    return next(islice(a(m), n, None))


def A_Stefan_generator_stack4(m, n):
    def a(m):
        if not m:
            return count(1)
        return (
            x
            for x in [1]
            for i, ai in enumerate(a(m-1))
            if i == x
            for x in [ai]
        )
    return next(islice(a(m), n, None))


def A_templatetypedef(i, n):
    positions = [-1] * (i + 1)
    values = [0] + [1] * i
    
    while positions[i] != n:       
        values[0]    += 1
        positions[0] += 1
            
        j = 1
        while j <= i and positions[j - 1] == values[j]:
            values[j] = values[j - 1]
            positions[j] += 1
            j += 1

    return values[i]


funcs = [
    A_Stefan_row_class,
    A_Stefan_row_lists,
    A_Stefan_generators,
    A_Stefan_paper,
    A_Stefan_generators_2,
    A_Stefan_m_recursion,
    A_Stefan_function_stack,
    A_Stefan_generator_stack,
    A_Stefan_generator_stack2,
    A_Stefan_generator_stack3,
    A_Stefan_generator_stack4,
    A_templatetypedef,
]

N = 18
args = (
    [(0, n) for n in range(N)] +
    [(1, n) for n in range(N)] +
    [(2, n) for n in range(N)] +
    [(3, n) for n in range(N)]
)

from time import time

def print(*args, print=print, file=open('out.txt', 'w')):
    print(*args)
    print(*args, file=file, flush=True)
    
expect = none = object()
for _ in range(3):
  for f in funcs:
    t = time()
    result = [f(m, n) for m, n in args]
    # print(f'{(time()-t) * 1e3 :5.1f} ms ', f.__name__)
    print(f'{(time()-t) * 1e3 :5.0f} ms ', f.__name__)
    if expect is none:
        expect = result
    elif result != expect:
        raise Exception(f'{f.__name__} failed')
    del result
  print()

2
关于如何改进这个问题的想法:我们知道对于所有的n,A(1, n) = 2n + 3。也许你可以通过直接返回2n+3来消除递归的最后两层,每当需要计算A(1, n)时。 - templatetypedef
2
改进的想法是:我们知道对于所有 n,A(1, n) = 2n + 3。也许在请求 A(1, n) 时,你可以直接返回 2n+3,以消除递归的最后两个层级? - templatetypedef
2
对于如何改进这个问题的思考:我们知道对于任意的n,A(1, n) = 2n + 3。也许你可以通过直接返回2n+3来消除递归的最后两层,每当需要计算A(1, n)时。 - undefined
1
@templatetypedef 嗯,是的,对于m=2和m=3也有类似的公式,而且对于m=4还有一个简单快速的计算方法。所以我们可以利用这些来进一步优化几个层次。我个人对那种“数学”解决方案不感兴趣。实际上,我正在准备一个关于这个问题的问答,问题中排除了这种数学解决方案。它将涉及到“编码”技术,避免深度递归和重复计算。通过数学洞察力跳过某些值将被排除在外。不知怎么的,我以为这个问题也想要那样的答案,但现在我看到我误读了。嗯,算了 :-) - Stefan Pochmann
3
谢谢分析。我的编程背景是C++,在那里使用紧密循环来处理数组通常是最快的方法。我还在适应“Python的编程方式”! - templatetypedef
显示剩余20条评论

19
您在文本中提出了这两个问题:

从理论上讲,阿克曼函数可以优化吗?

当然可以,您可以先简单地实现它,然后通过技术手段进行优化(例如记忆化,存储中间值等)。但我认为这并不是您真正想问的问题。

如果不能,是否可以明确证明其时间复杂度无法降低?

优化与时间复杂度无关。"O"符号表示摒弃了常数乘法因子。您可以有两种算法,其中一种计算阿克曼函数所需的时间或空间是另一种的百万分之一,但它们的O复杂度仍然相同。

引用自维基百科

阿克曼函数以威廉·阿克曼命名,是最简单1且最早发现的非原始递归的可计算函数示例之一。

这个函数不是原始递归的,而且它增长速度比任何原始递归函数都要快。
"原始递归"意味着你可以用循环实现算法,其中边界在之前就已知;也就是说,你不需要可能无限重复的while循环。这是一个有点抽象的概念,再次引用维基百科的话:
"原始递归函数的重要性在于大多数可计算函数,在数论(以及更一般地在数学中)研究的函数都是原始递归的。"
是的,已经证明了Ackermann函数不是原始递归的。发现这一点可能不会给你带来任何金钱,但肯定会让你在理论计算机科学界声名显赫。
你不能通过优化来消除这种复杂性 - 将你的程序视为一种不同格式的证明阿克曼函数确实是原始递归的方式;你只需要将其转换回数学/逻辑术语,然后就完成了。多年来没有发生这种情况,再加上存在像链接中的“确凿证据”这样的事实,告诉你它确实按照所宣传的方式运行。
顺便提一下:所有这些也必须从阿克曼函数很可能被“设计”成具有这种属性的角度来看待 - 正如维基百科页面所提到的,在它被发现或创建之前,有些人认为“所有”可计算函数都是原始递归的。虽然我不知道是什么驱使阿克曼先生在20世纪20年代做出这个决定,但阿克曼函数现在确实是理论计算机科学中复杂性理论的基石;一个非常迷人的故事。

2
原始递归属性意味着如果你需要一个循环,你能够事先知道需要多少次迭代。一般来说,这是不可能的,退出循环的条件只能在执行循环时计算出来。 - mvw
4
一个天真的递归斐波那契算法的时间复杂度为O(Fib(n))。一个简单的迭代斐波那契算法的时间复杂度为O(n),同样适用于记忆化递归斐波那契算法。通过算法优化或者重复计算子问题的记忆化,可以在复杂度类中实现巨大的改进。当然,对于阿克曼函数来说,由于设计上的限制,最好的时间复杂度不可能那么快,但它可以是与最天真的实现不同的O(f(n))复杂度类。 - Peter Cordes
4
一个天真的递归斐波那契算法的时间复杂度为O(Fib(n))。一个简单的迭代斐波那契算法的时间复杂度为O(n),同样适用于记忆化递归斐波那契算法。通过算法优化或者重复计算子问题的记忆化,可以在复杂度类中实现巨大的改进。当然,对于阿克曼函数,你所能做的最好的性能确实是由设计可证明不会那么快,但它可能属于与最天真的实现不同的O(f(n))复杂度类。 - Peter Cordes
4
一个天真的递归斐波那契算法的时间复杂度为O(Fib(n))。一个简单的迭代斐波那契算法的时间复杂度为O(n),同样适用于记忆化递归斐波那契算法。通过算法优化或者重复计算子问题的记忆化,可以在复杂度类中实现巨大的改进。当然,对于阿克曼函数来说,由于设计上的限制,最快的实现显然无法达到那么快的速度,但它可以属于不同的O(f(n))复杂度类别,而不是最天真的实现。 - undefined
2
“优化与时间复杂度无关” - 这完全是错误的,正如@PeterCordes已经解释过的那样。即使你将“优化”限定为仅仅像记忆化这样的内容,也就是忽略通过切换到完全不同的算法来优化程序的方式。 - Kelly Bundy
显示剩余15条评论

15

v0基本上就是你的代码:

def ack(m, n):
  if m == 0: return n + 1
  return ack(m - 1, 1) if n == 0 else ack(m - 1, ack(m, n - 1))

这个程序在2.49秒内计算出了ack(3, 9)。ack()函数被调用了11164370次。我们可以将已经计算过的值缓存起来,以避免大量重复调用该函数。
v1版本使用字典作为缓存,只有在缓存中不存在结果时才进行计算。
c = {}

def ack(m, n):
  global c

  if "{}-{}".format(m, n) in c: return c["{}-{}".format(m, n)]
  else:
    if m == 0: ret = n + 1
    else: ret = ack(m - 1, 1) if n == 0 else ack(m - 1, ack(m, n - 1))

    c["{}-{}".format(m, n)] = ret
    return ret

这个计算在0.03秒内完成了ack(3, 9),因此ack(3, 9)不再适合用于测量执行时间。这次ack()被调用了12294次,节省了大量时间。但我们可以做得更好。从现在开始,我们使用ack(3, 13),目前运行时间为0.23秒。

v2只关心m > 0的缓存情况,因为m == 0的情况是微不足道的。通过这样做,空间复杂度有所降低:

c = {}

def ack(m, n):
  global c

  if m == 0: return n + 1
  else:
    if "{}-{}".format(m, n) in c: return c["{}-{}".format(m, n)]
    else: ret = ack(m - 1, 1) if n == 0 else ack(m - 1, ack(m, n - 1))

    c["{}-{}".format(m, n)] = ret
    return ret

这个操作在0.18秒内完成了ack(3, 13)。但我们可以做得更好。
v3通过每次迭代只计算一次缓存中的键来节省一些时间。
c = {}

def ack(m, n):
  global c

  if m == 0: return n + 1
  else:
    key = "{}-{}".format(m, n)
    if key in c: return c[key]
    else: ret = ack(m - 1, 1) if n == 0 else ack(m - 1, ack(m, n - 1))

    c[key] = ret
    return ret

这次运行的时间是0.14秒。我肯定在午夜附近不能再做更多工作,但我会再考虑一下。对于那些知道这意味着什么的人来说,这真是一项有趣的工作,纯属男人干活。

5
你可以使用元组作为字典的键。 - MatBailie
5
你可以使用元组作为字典的键。 - MatBailie
5
你可以使用元组作为字典的键。 - undefined
1
当然,我并不是真正用Python进行编程,只是为了像这样的DIY目的。我相信专业人士可以更进一步。明天我可能会再看一下。问题是运行它会让我的笔记本电脑崩溃。即使将最大递归设置为200000,ack(3, 14)仍然会在0.14秒内完成:D - Gambanishu Habbeba
1
没问题,我并不是真正用Python编程,只是为了像这样的DIY目的。我相信专业人士可以更进一步。明天我可能会再看一下。问题是运行它会让我的笔记本电脑崩溃。即使将最大递归设置为200000,ack(3, 14)仍然在0.14秒内完成:D - Gambanishu Habbeba
1
没问题,我并不是真正用Python编程,只是为了像这样的自己动手做一些东西。我相信专业人士可以更进一步。明天我可能会再看一下。问题是运行它会让我的笔记本电脑崩溃。即使将最大递归设置为200000,ack(3, 14)仍然会在0.14秒内完成:D - undefined

12
这是我对Grossman-Zeitman算法的Python实现的尝试,它以O(A(i, n))的时间迭代计算A(i, n)。有关该算法的工作原理,请查看链接的问题描述。
def ackermann(i, n):
    positions = [-1] * (i + 1)
    values = [0] + [1] * i
    
    while positions[i] != n:       
        values[0]    += 1
        positions[0] += 1
            
        j = 1
        while j <= i and positions[j - 1] == values[j]:
            values[j] = values[j - 1]
            positions[j] += 1
            j += 1

    return values[i]

鉴于内部循环非常紧凑且没有递归,我怀疑这个实现可能会比你最初发布的基本递归解决方案更高效。然而,我还没有对这个实现进行正确性或时间测试;它是基于我在相关问题中编写的Java代码。

在具有O(1)操作成本的机器上,O(A(i, n))不仅适用于操作数是输入(n)的固定倍数,还适用于输出(A(i, n))。不是 RAM - greybeard
我怀疑(但还没有完全推导出论证)即使在RAM机器上,这实际上是O(A(i, n))的复杂度。即使所涉及的整数使用了大量位数,每个增量操作的摊销成本仍然是O(1),而且通过仅存储指向相关数字的指针,可以减少复制大量数字的成本。不过,也许有一个我忽略的小技术细节? - templatetypedef
我预计比较 positions[j - 1] == values[j] 的时间大约为 ~ log(A(i-1, n))。如果相等的情况下,那么不相等呢?我猜是摊销常数。 - greybeard

11

But surprisingly caching doesn't help at all:

In [9]: %timeit Ackermann = cache(A); Ackermann(3, 4)
1.3 ms ± 10.1 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
那是因为你做错了。只有你的 Ackermann 被记忆化了,而不是你的 A。而且递归调用都指向了 A
m,n = 3,8 的时间,包括一个正确记忆化的版本 B
440.30 ms  A(m, n)
431.11 ms  Ackermann = cache(A); Ackermann(m, n)
  1.74 ms  B.cache_clear(); B(m, n)

关于 B
在之后打印 B.cache_info() 来查看缓存的效果:CacheInfo(hits=1029, misses=5119, maxsize=None, currsize=5119)。所以 B 有 5,119 次“真实”调用(需要执行工作),以及 1,029 次从缓存中获取答案的调用。如果没有记忆化,它将被调用 2,785,999 次。
对于 m,n = 3,12,大约需要 32 毫秒。
对于 m,n = 3,13,由于递归过深而导致崩溃。
代码:
from timeit import repeat
import sys

sys.setrecursionlimit(999999)

setup = '''
from functools import cache

def A(m, n):
    if not m:
        return n + 1
    return A(m - 1, A(m, n - 1)) if n else A(m - 1, 1)

@cache
def B(m, n):
    if not m:
        return n + 1
    return B(m - 1, B(m, n - 1)) if n else B(m - 1, 1)

m, n = 3, 8
'''

codes = [
    'A(m, n)',
    'Ackermann = cache(A); Ackermann(m, n)',
    'B.cache_clear(); B(m, n)',
]

for code in codes:
    t = min(repeat(code, setup, number=1))
    print(f'{t*1e3:6.2f} ms ', code)

9
TL;DR Ackermann函数增长得非常迅猛,以至于对于(m >= 4和n >= 3),结果无法适应RAM。对于较小的m或n值,可以直接计算值(无需递归)并快速获得结果。
我知道这个答案对于优化递归函数调用没有帮助,但它提供了一个在现实的、当代的计算机上计算Ackermann函数值的快速解决方案(附有分析),并且直接回答了问题的第一段。
我们希望将结果存储在计算机上的无符号二进制大整数变量中。为了存储值2 ** b,我们需要(b + 1)位数据,以及(c1 + c2 * ceil(log2(b)))位头部来存储长度b本身。(c1是非负整数,c2是正整数。)因此,我们需要超过b位的RAM来存储2 ** b。
拥有大量RAM的计算机:
2023年,消费级个人电脑的RAM容量可达128 GiB,而商用服务器的RAM容量可达2 TiB(参考链接1)。
2020年,已经建造了一台单机架服务器,其RAM容量为6 TiB(参考链接2)。
2017年,曾经构建过一台RAM容量为160 TiB的大型服务器(参考链接3)。
因此,我们可以说在2023年,构建一台RAM容量为1 PiB(等于1024 TiB)的计算机是不可行的,而1 EiB(等于1024 PiB,等于1048576 TiB,等于2的63次方比特)的存储在2023年及近期内是完全不可能的。
目前对宇宙中原子数量的估计是10的82次方(即10乘以10的81次方),小于16乘以2的270次方,也小于2的274次方。
让我们想象一下最大的计算机。即使有更多的原子存在,假设有2的300次方个原子,并且我们能够将所有这些原子都用于一台计算机,并且每个原子都能够存储1 EiB的数据,那么我们仍然只有2的363次方比特的内存空间;这仍然太小,无法存储值为2的2的363次方的Big值。

让我们看看Knuth的上箭头符号(https://en.wikipedia.org/wiki/Knuth%27s_up-arrow_notation)中哪些值小于Big

  • 2 ^ b == 2 ** b:对于 1 ≤ b ≤ (2 ** 363 - 1),该等式成立。

    2 ^ (2 ** 363) == 2 ** (2 ** 363) == 大于或等于 Big。

  • 2 ^^ b:对于 1 ≤ b ≤ 5,该运算有效。

    2 ^^ 1 == 2;

    2 ^^ 2 == 2 ** 2 == 4;

    2 ^^ 3 == 2 ** (2 ** 2) == 16;

    2 ^^ 4 == 2 ** (2 ** (2 ** 2)) == 65536;

    2 ^^ 5 == 2 ** (2 ** (2 ** (2 ** 2))) == 2 ** 65536 == 2 ** (2 ** 16) 小于 Big;

    2 ^^ 6 == 2 ** (2 ** (2 ** (2 ** (2 ** 2)))) == 2 ** (2 ** 65536) 大于或等于 Big。

  • 2 ^^^ b:对于 1 ≤ b ≤ 3,该运算有效。

    2 ^^^ 1 == 2;

    2 ^^^ 2 == 2 ^^ 2 == 4;

    2 ^^^ 3 == 2 ^^ (2 ^^ 2) == 65536;

    2 ^^^ 4 == 2 ^^ (2 ^^ (2 ^^ 2)) == 2 ^^ 65536 大于或等于 2 ^^ 6 大于或等于 Big。

  • 2 ^^^^ b:对于 1 ≤ b ≤ 2,该运算有效。

    2 ^^^^ 1 == 2;

    2 ^^^^ 2 == 2 ^^^ 2 == 4;

    2 ^^^^ 3 == 2 ^^^ (2 ^^^ 2) == 2 ^^^ 4 == 2 ^^ 65536 大于或等于 2 ^^ 6 大于或等于 Big。

  • 2 ^^^^^ b 和更多箭头:对于 1 ≤ b ≤ 2,该运算有效。其值至少为 2 ^^^^ b,请参见那里。

因此,以下是如何在Python中计算可行值的方法:
def up_arrow(a, b):
  if b <= 2:
    if b < 0:
      raise ValueError
    return (1, 2, 4)[b]
  elif a == 1:
    if b >> 363:
      raise ValueError
    return 1 << b  # This may run out of memory for large b.
  elif a == 2:
    if b > 5:
      raise ValueError
    if b == 5:
      return 1 << 65536
    return (16, 65536)[b - 3]
  elif a == 3:
    if b > 3:
      raise ValueError
    return 65536
  else:
    raise ValueError

鉴于对于 m >= 3, ack(m, n) == up_arrow(m - 2, n + 3) - 3(见https://en.wikipedia.org/wiki/Ackermann_function#Table_of_values), 我们可以计算出Ackermann函数的可行值:
def ack(m, n):
  if n < 0:
    raise ValueError
  if m in (0, 1):
    return n + (m + 1)  # This may run out of memory for large n.
  elif m == 2:
    return (n << 1) + 3  # This may run out of memory for large n.
  elif m == 3:
    if n >> 360:
      raise ValueError
    return (1 << (n + 3)) - 3  # This may run out of memory for large n.
  elif m == 4:
    if n > 2:
      raise ValueError
    if n == 2:
      return (1 << 65536) - 3
    return (13, 65533)[n]
  elif m == 5:
    if n > 0:
      raise ValueError
    return 65533
  else:
    raise ValueError

print([ack(m, 0) for m in range(6)])
print([ack(m, 1) for m in range(5)])
print([ack(m, 2) for m in range(5)])
print([ack(m, 3) for m in range(4)])
print([ack(m, 4) for m in range(4)])

7
问题标记为Python,但使用ctypes调用C++代码也是Python的一个特性。它包含3个版本(我测量了result = [ackermann(m,n) for m<=3, n <= 17]的时间成本,就像在顶部答案中一样)。
  • 使用int64_t和带有记忆化递归 => 32ms。它只适用于小输入(就像顶部答案中的所有算法一样)。它专注于优化递归函数。
  • 使用bignum和与上述相同的算法 => 32ms。由于我使用的bignum库是使用64位基本类型实现的,如果结果适合小数,与直接使用int64_t没有区别。我添加了这个版本,因为评论中提到比较C++的int64_t和Python的无限长度整数是不公平的。
  • 使用bignum + 数学公式 => ~~0.14 ms。这个版本也适用于更大的输入。

总结:如果使用数学计算,比最优的Python答案快3000倍以上;如果不使用数学计算,快13-15倍。我使用的是这个单头文件大数库。Stackoverflow有答案长度限制,所以我无法将文件复制到这里。

// ackermann.cpp
#include <iostream>
#include <vector>
#include <chrono>
#include <string>
#include "num.hpp"
using namespace std;

class MyTimer {
    std::chrono::time_point<std::chrono::system_clock> start;

public:
    void startCounter() {
        start = std::chrono::system_clock::now();
    }

    int64_t getCounterNs() {
        return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::system_clock::now() - start).count();
    }

    int64_t getCounterMs() {
        return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start).count();
    }

    double getCounterMsPrecise() {
        return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::system_clock::now() - start).count()
                / 1000000.0;
    }
};

extern "C" {
  int64_t ackermann(int64_t m, int64_t n) {
    static std::vector<int64_t> cache[4];
    // special signal to clear cache
    if (m < 0 && n < 0) {      
      for (int i = 0; i < 4; i++) {
        cache[i].resize(0);
        cache[i].shrink_to_fit();      
      }
      return -1;
    }
    
    if (n >= cache[m].size()) {
      int cur = cache[m].size();
      cache[m].resize(n + 1);
      for (int i = cur; i < n; i++) cache[m][i] = ackermann(m, i);
    }

    if (cache[m][n]) return cache[m][n];
    if (m == 0) return cache[m][n] = n + 1;
    
    // These 3 lines are kinda cheating, since it uses math formula for special case
    // So I commented them out because the question is about optimizing recursion.
    // if (m == 1) return cache[m][n] = n + 2;
    // if (m == 2) return cache[m][n] = 2 * n + 3;
    // if (m == 3) return cache[m][n] = (1LL << (n + 3)) - 3;

    if (n == 0) return cache[m][n] = ackermann(m - 1, 1);

    return cache[m][n] = ackermann(m - 1, ackermann(m, n - 1));        
  }

  Num ackermann_bignum_smallres(int64_t m, int64_t n) {
    static std::vector<Num> cache[4];
    // special signal to clear cache
    if (m < 0 && n < 0) {      
      for (int i = 0; i < 4; i++) {
        cache[i].resize(0);
        cache[i].shrink_to_fit();      
      }
      return -1;
    }
    
    if (n >= cache[m].size()) {
      int cur = cache[m].size();
      cache[m].resize(n + 1);
      for (int i = cur; i < n; i++) cache[m][i] = ackermann(m, i);
    }

    if (cache[m][n] > 0) return cache[m][n];
    if (m == 0) return cache[m][n] = n + 1;

    if (n == 0) return cache[m][n] = ackermann(m - 1, 1);

    return cache[m][n] = ackermann(m - 1, ackermann(m, n - 1));
  }

  //-----
  Num bignum_pow(const Num& x, const Num& n) {
    if (n == 0) return 1;
    Num mid = bignum_pow(x, n / 2);
    if (n % 2 == 0) return mid * mid;
    else return mid * mid * x;
  }

  Num ackermann_bignum(Num m, Num n) {
    if (m <= 1) return n + (m + 1);
    else if (m == 2) return Num(2) * n + 3;
    else if (m == 3) return bignum_pow(2, n + 3) - 3;
    else {
      cout << "Don't put m >= 4\n";
      exit(1);
    } 
  }
}

Num dummy = 0;
int main(int argc, char* argv[])
{
  int test_type = 0;
  if (argc > 1) {
    try {
      test_type = std::stoi(string(argv[1]));
    } catch (...) {
      test_type = 0;
    }
  }
  int lim = (test_type == 0) ? 63 : 17;

  MyTimer timer;
  timer.startCounter();
    
  for (int m = 0; m <= 3; m++)
  for (int n = 0; n <= lim; n++) {
    if (test_type == 0) {
      dummy = ackermann_bignum(m, n);      
    } else if (test_type == 1) {
      dummy = ackermann_bignum_smallres(m, n);
    } else {
      dummy = ackermann(m, n);      
    }
    cout << "ackermann(" << m << ", " << n << ") = " << dummy << "\n";    
  }

  cout << "ackermann cost = " << timer.getCounterMsPrecise() << "\n";
}


使用以下命令编译上述代码:g++ ackermann.cpp -shared -fPIC -O3 -std=c++17 -o ackermann.so 要单独运行,请使用以下命令:
g++ -o main_cpp ackermann.cpp -O3 -std=c++17
./main_cpp

然后在同一个文件夹中运行这个脚本(修改自@Stefan Pochmann的答案)。
def A_Stefan_row_class(m, n):
    class A0:
        def __getitem__(self, n):
            return n + 1
    class A:
        def __init__(self, a):
            self.a = a
            self.n = 0
            self.value = a[1]
        def __getitem__(self, n):
            while self.n < n:
                self.value = self.a[self.value]
                self.n += 1
            return self.value
    a = A0()
    for _ in range(m):
        a = A(a)
    return a[n]


from collections import defaultdict

def A_Stefan_row_lists(m, n):
    memo = defaultdict(list)
    def a(m, n):
        if not m:
            return n + 1
        if m not in memo:
            memo[m] = [a(m-1, 1)]
        Am = memo[m]
        while len(Am) <= n:
            Am.append(a(m-1, Am[-1]))
        return Am[n]
    return a(m, n)


from itertools import count

def A_Stefan_generators(m, n):
    a = count(1)
    def up(a, x=1):
        for i, ai in enumerate(a):
            if i == x:
                x = ai
                yield x
    for _ in range(m):
        a = up(a)
    return next(up(a, n))


def A_Stefan_paper(m, n):
    next = [0] * (m + 1)
    goal = [1] * m + [-1]
    while True:
        value = next[0] + 1
        transferring = True
        i = 0
        while transferring:
            if next[i] == goal[i]:
                goal[i] = value
            else:
                transferring = False
            next[i] += 1
            i += 1
        if next[m] == n + 1:
            return value


def A_Stefan_generators_2(m, n):
    def a0():
        n = yield
        while True:
            n = yield n + 1
    def up(a):
        next(a)
        a = a.send
        i, x = -1, 1
        n = yield
        while True:
            while i < n:
                x = a(x)
                i += 1
            n = yield x
    a = a0()
    for _ in range(m):
        a = up(a)
    next(a)
    return a.send(n)


def A_Stefan_m_recursion(m, n):
    ix = [None] + [(-1, 1)] * m
    def a(m, n):
        if not m:
            return n + 1
        i, x = ix[m]
        while i < n:
            x = a(m-1, x)
            i += 1
        ix[m] = i, x
        return x
    return a(m, n)


def A_Stefan_function_stack(m, n):
    def a(n):
        return n + 1
    for _ in range(m):
        def a(n, a=a, ix=[-1, 1]):
            i, x = ix
            while i < n:
                x = a(x)
                i += 1
            ix[:] = i, x
            return x
    return a(n)


from itertools import count, islice

def A_Stefan_generator_stack(m, n):
    a = count(1)
    for _ in range(m):
        a = (
            x
            for a, x in [(a, 1)]
            for i, ai in enumerate(a)
            if i == x
            for x in [ai]
        )
    return next(islice(a, n, None))


from itertools import count, islice

def A_Stefan_generator_stack2(m, n):
    a = count(1)
    def up(a):
        i, x = 0, 1
        while True:
            i, x = x+1, next(islice(a, x-i, None))
            yield x
    for _ in range(m):
        a = up(a)
    return next(islice(a, n, None))


def A_Stefan_generator_stack3(m, n):
    def a(m):
        if not m:
            yield from count(1)
        x = 1
        for i, ai in enumerate(a(m-1)):
            if i == x:
                x = ai
                yield x
    return next(islice(a(m), n, None))


def A_Stefan_generator_stack4(m, n):
    def a(m):
        if not m:
            return count(1)
        return (
            x
            for x in [1]
            for i, ai in enumerate(a(m-1))
            if i == x
            for x in [ai]
        )
    return next(islice(a(m), n, None))


def A_templatetypedef(i, n):
    positions = [-1] * (i + 1)
    values = [0] + [1] * i
    
    while positions[i] != n:       
        values[0]    += 1
        positions[0] += 1
            
        j = 1
        while j <= i and positions[j - 1] == values[j]:
            values[j] = values[j - 1]
            positions[j] += 1
            j += 1

    return values[i]

import ctypes
mylib = ctypes.CDLL('./ackermann.so')
mylib.ackermann.argtypes = [ctypes.c_int64, ctypes.c_int64]
mylib.ackermann.restype = ctypes.c_int64

def c_ackermann(m, n):
    return mylib.ackermann(m,n)

funcs = [
    c_ackermann,
    A_Stefan_row_class,
    A_Stefan_row_lists,
    A_Stefan_generators,
    A_Stefan_paper,
    A_Stefan_generators_2,
    A_Stefan_m_recursion,
    A_Stefan_function_stack,
    A_Stefan_generator_stack,
    A_Stefan_generator_stack2,
    A_Stefan_generator_stack3,
    A_Stefan_generator_stack4,
    A_templatetypedef
]

N = 18
args = (
    [(0, n) for n in range(N)] +
    [(1, n) for n in range(N)] +
    [(2, n) for n in range(N)] +
    [(3, n) for n in range(N)]
)

from time import time

def print(*args, print=print, file=open('out.txt', 'w')):
    print(*args)
    print(*args, file=file, flush=True)
    
expect = none = object()
for _ in range(3):
  for f in funcs:
    t = time()
    result = [f(m, n) for m, n in args]
    # print(f'{(time()-t) * 1e3 :5.1f} ms ', f.__name__)
    print(f'{(time()-t) * 1e3 :5.0f} ms ', f.__name__)
    if expect is none:
        expect = result
    elif result != expect:
        raise Exception(f'{f.__name__} failed')
    del result
  print()

  c_ackermann(-1, -1)

输出:

   32 ms  c_ackermann
 1897 ms  A_Stefan_row_class
 1427 ms  A_Stefan_row_lists
  437 ms  A_Stefan_generators
 1366 ms  A_Stefan_paper
  479 ms  A_Stefan_generators_2
  801 ms  A_Stefan_m_recursion
  725 ms  A_Stefan_function_stack
  716 ms  A_Stefan_generator_stack
 1113 ms  A_Stefan_generator_stack2
  551 ms  A_Stefan_generator_stack3
  682 ms  A_Stefan_generator_stack4
 1622 ms  A_templatetypedef

2
这个解决方案对于mylib.ackermann(3, 63)(以及其他很多情况)是错误的。它应该返回9223372036854775808,但实际上返回了一些特定于架构的错误值,比如在amd64上返回1。这是因为这个值无法适应int64_t类型。 - pts
2
这个解决方案对于 mylib.ackermann(3, 63)(以及许多其他情况)是不正确的。它应该返回9223372036854775808,但实际上返回了一些特定体系结构上错误的值,比如在amd64上返回1。这是因为这个值无法适应int64_t类型。 - pts
2
这个解决方案对于mylib.ackermann(3, 63)(以及其他很多情况)是错误的。它应该返回9223372036854775808,但实际上返回了一些特定于架构的错误值,比如在amd64上返回1。这是因为这个值无法适应int64_t类型。 - undefined
我刚刚使用大数法添加了2个额外的答案,这应该涵盖了所有可能的情况。我不会去烦恼m >= 4,因为无论如何这个数字都太大了。 - Huy Le
这个解决方案是错误的。 - David NoHorizon
显示剩余14条评论

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