我想知道是否有一个版本的阿克曼函数具有更好的时间复杂度比标准的变化。
这不是作业,我只是好奇。我知道Ackermann函数除了作为性能基准之外没有任何实际用途,因为它是深度递归的。我知道数字增长得非常快,我对计算它不感兴趣。
尽管我使用Python 3,整数不会溢出,但我确实有有限的时间,但我自己根据Wikipedia上的定义实现了一个版本,并计算了非常小的值的输出,只是为了确保输出是正确的。
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)
上面的代码是直接翻译的图片,而且速度极慢,不知道怎么优化,是不是不可能优化呢?
我能想到的一件事是记忆它,但递归是向后运行的,每次递归调用函数时,参数都是以前没有遇到过的,每个连续的函数调用参数都减少而不是增加,因此函数的每个返回值都需要计算,当你第一次用不同的参数调用函数时,记忆没有帮助。
Memoization只有在你再次使用相同的参数调用它时才有帮助,它不会计算结果,而是会检索缓存的结果,但是如果你使用任何输入(m,n)>=(4,2)调用函数,无论如何都会使解释器崩溃。
我还根据这个answer实现了另一个版本:
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)
我已经用不同的参数测试过无数次了,它总是给出同样的效率提升(这是没有的)。
7条答案
按热度按时间egdjgwm81#
解决方案
我最近写了一堆解决方案,都是基于templatetypedef提到的那篇论文。许多使用生成器,每个m值一个,产生n = 0,n = 1,n = 2等的值。这一个可能是我最喜欢的:
说明
考虑生成器
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处产生。它就是这个生成器产生的前一个值(x)。a(m-1)
生成器在索引x处产生。因此a(m)
生成器迭代a(m-1)
生成器,并获取索引i == x处的值。基准测试
以下是计算所有A(m,n)的时间,其中m ≤ 3且n ≤ 17,也包括templatetypedef的解:
注意:使用数学见解/公式的解决方案甚至更快(* 快得多 *)是可能的,请参阅我的评论和pts's answer。我故意没有这样做,因为我对 * 编码 * 技术感兴趣,以避免深度递归和避免 * 重新 * 计算。我得到的印象是,这也是问题/OP想要的,他们现在证实了这一点(在删除的答案下,如果你有足够的声誉,可见)。
代码
jhiyze9q2#
v0基本上就是你的代码:
这在2.49s内计算ack(3,9)。ack()被调用11164370次。当然,我们可以缓存已经计算的值,从而节省对函数的大量调用。
v1对缓存使用dict,并且仅当结果不在该高速缓存中时才进行计算:
这在0.03s内计算ack(3,9),并且ack(3,9)不再适合于测量执行时间。这次ack()被调用了12294次,节省了大量的时间。但我们可以做得更好。从现在开始,我们使用ack(3,13),目前运行时间为0.23s。
v2只关心其中m > 0的高速缓存,因为m == 0的情况是微不足道的。这样,空间复杂度就有所降低:
这在0.18s内完成ack(3,13)。但我们可以做得更好。
v3通过每次迭代只计算一次该高速缓存中的键来节省一点时间:
这一次它运行0.14s。我肯定不能在午夜前后做更多的事情,但我会再考虑一下。Ez jómulatság,férfi munka volt -对于那些知道这意味着什么的人来说。
nmpmafwu3#
下面是我对Grossman-Zeitman algorithm的Python实现的尝试,它在O(A(i,n))时间内迭代计算A(i,n)。有关此算法如何工作的描述,请检查链接的问题。
考虑到内部循环非常紧密,并且没有递归,我怀疑这可能会优于您最初发布的基本递归解决方案。然而,我还没有对这个实现进行正确性或时间测试;它基于我在链接问题中编写的Java代码。
pexxcrt24#
但令人惊讶的是,缓存根本没有帮助:
那是因为你做错了只有您的
Ackermann
被备忘录化,而不是您的A
。递归调用都转到A
。m的次数,n = 3,8,包括正确记忆的版本
B
:关于
B
:B.cache_info())
,看看该高速缓存效果如何:所以B
有5,119个“真实的的”调用(它必须工作的地方)和1,029个从缓存中应答的调用。如果没有备忘录,它被调用了2,785,999次。代码:
ddhy6vgd5#
TL;DR阿克曼函数增长如此之快,以至于对于(m >= 4且n >= 3),结果将不适合RAM。对于较小的m或n值,直接(无需递归)快速计算值非常容易。
我知道这个答案无助于优化递归函数调用,但它提供了一个在真实的的当代计算机上计算Ackermann函数值的快速解决方案(通过分析),并且它提供了问题第一段的直接答案。
我们希望将结果存储在计算机上的无符号二进制大整数变量中。为了存储值2B,我们需要(b + 1)位的数据,以及(c1 + c2 * ceil(log 2(b)))位的报头用于存储长度b本身。(c1是非负整数,c2是正整数。)因此,我们需要多于B位的RAM来存储2b。
具有大量RAM的计算机:
让我们看看Knuth的向上箭头符号(https://en.wikipedia.org/wiki/Knuth%27s_up-arrow_notation)中哪些值小于 Big:
2 ^(2363)== 2(2**363)==大>=大。
2 ^^ 1 == 2;
2 ^^ 2 == 22 == 4;
2 ^^ 3 == 2(22)== 16;
2 ^^ 4 == 2(2**(22))== 65536;
2 ^^ 5 == 2(2**(22))== 265536 == 2**(216)<大;
2 ^^ 6 == 2(2**(2**(22)))== 2(2**65536)>=大。
2 ^^^ 1 == 2;
2 ^^^ 2 == 2 ^^ 2 == 4;
2 ^^^ 3 == 2 ^^(2 ^^ 2)== 65536;
2 ^^^ 4 == 2 ^^(2 ^^(2 ^^ 2))== 2 ^^ 65536 >= 2 ^^ 6 >=大。
2 ^^^^ 1 == 2;
2 ^^^^ 2 == 2 ^^^ 2 == 4;
2 ^^^^ 3 == 2 ^^^(2 ^^^ 2)== 2 ^^^ 4 == 2 ^^ 65536 >= 2 ^^ 6 >=大。
下面是如何在Python中计算可行值:
假设m >= 3,ack(m,n)== up_arrow(m - 2,n + 3)- 3(参见https://en.wikipedia.org/wiki/Ackermann_function#Table_of_values),我们可以计算阿克曼函数的可行值:
vsmadaxz6#
你在经文中问这两个问题:
理论上阿克曼函数能被优化吗?
当然,你可以简单地实现它,然后用技术方法优化它(例如存储或存储中间值等)。但我认为这不是你真正要问的问题。
如果不是,是否可以肯定地证明它的时间复杂度不能降低?
优化与时间复杂度无关。“O”符号从常数乘法因子中抽象出来。你可以有两个算法,其中一个计算阿克曼函数的时间或空间是另一个的百万分之一,但它们仍然具有相同的O复杂度。
引用Wikipedia
Ackermann函数,以Wilhelm Ackermann命名,是最简单的1和最早发现的非原始递归的完全可计算函数的例子之一。
不是原始递归意味着它
比任何原始递归函数都要快。
“原始递归”基本上是...一切毫无疑问,作为一个普通的程序员,你所接触到的每一个函数,无论多么困难或庞大,都是原始递归的。你必须在real hard中搜索非原始递归函数。
是的,proven证明了Ackermann不是原始递归的。发现它实际上并非如此可能不会让你赚钱,但肯定会让你的名字在理论计算机科学社区中得到尊重。
你不能优化这种复杂性--把你的程序看作是一种不同的格式,用来证明阿克曼确实是原始递归的;你只需要把它转换回数学/逻辑术语,瞧。事实上,这并没有发生在许多年来,连同存在的“证明积极”一样,一个链接告诉你,它是,事实上,行为作为广告。
注意:最后,所有这些也必须从阿克曼函数的Angular 来看,阿克曼函数很可能被设计成具有这种属性-正如维基百科页面提到的那样,在它被发现或创建之前,有些人认为 * 所有 * 可计算函数都是原始递归的。虽然我不知道是什么驱使先生。阿克曼在20世纪20年代就开始这样做了,现在Ackerman函数确实是理论计算机科学中复杂性理论的基石;一个非常引人入胜的故事。
gxwragnw7#
这个问题有Python标签,但是使用
ctypes
调用C++代码也是Python的一个特性。它比顶级Python答案快20000倍。如果不使用特殊情况下的数学优化,速度将提高约13- 15倍。每个m
都有一个直接的数学解,但我假设您想要一个类似递归的解。计算所有对
m <= 3, n <= 17
的阿克曼数的时间成本:使用
g++ ackermann.cpp -shared -fPIC -O3 -std=c++17 -o ackermann.so
编译上面的代码要单独运行,请使用
然后在同一个文件夹中运行这个脚本(修改自@Stefan Pochmann answer)
输出: