我最近一直在为自己的娱乐研究和测试各种斐波那契算法,并偶然地或多或少地提出了经典O(n)时间和O(1)空间动态编程实现的替代实现。
请考虑以下两个功能:
BigInt fib_dp_classic(int n) {
if (n == 0) {
return 0;
}
BigInt x = 0, y = 1, z;
for (int i = 2; i <= n; ++i) {
z = x + y;
x = y;
y = z;
}
return y;
}
和
BigInt fib_dp_mod(int n) {
BigInt x = 0, y = 1, z = 1;
for (int i = 0; i < n; ++i) {
switch (i % 3) {
case 0:
y = x + z;
break;
case 1:
z = x + y;
break;
case 2:
x = y + z;
break;
}
}
switch (n % 3) {
case 0:
return x;
break;
case 1:
return y;
break;
case 2:
return z;
break;
}
}
在我的机器上,使用fib_dp_classic计算百万分之一的斐波那契数需要花费6.55秒,而使用fib_dp_mod需要花费2.83秒,即使打开-O3也不会改变太多。关于mod版本为什么更快,我真的没有什么好主意。是因为经典版本中的额外商店说明比第二个版本中的mod贵吗?我的理解是,编译器应将所有三个变量都放入两个版本的寄存器中,而计算mod实际上是相当昂贵的;不是吗?
实际上,我只是将两者都通过compiler explorer进行了设置,并且一旦启用优化,它们都仅使用寄存器。当然,这只是使用整数,而不是我实际用于基准测试的基于GMP的bigint。是否有一些奇怪的GMP实施细节可能是这里引起的?
更新:我什至都跟踪malloc()是否可能是罪魁祸首,fib_dp_classic使用130个系统调用(对于n = 1000000),而fib_dp_mod使用133。所以仍然没有真正的线索...
更新2:是的,缓冲区副本是罪魁祸首(正如geza所指出的),我因没有意识到而愚蠢。这是两个替代版本及其基准测试结果:
BigInt fib_dp_move(int n) {
if (n == 0) {
return 0;
}
BigInt x = 0, y = 1, z;
for (int i = 2; i <= n; ++i) {
z = std::move(x) + y;
x = std::move(y);
y = std::move(z);
}
return y;
}
这需要2.84秒,因此与mod版本相当,因为它消除了不必要的副本。
BigInt fib_dp_swap(int n) {
if (n == 0) {
return 0;
}
BigInt x = 0, y = 1, z;
for (int i = 2; i <= n; ++i) {
z = x + y;
swap(x, y);
swap(y, z);
}
return y;
}
(来自geza)也运行了2.84秒,因此再次与mod版本等效,因为它以基本上相同的方式消除了副本,只是调用swap()
而不是使用move语义。
答案 0 :(得分:8)
您的第一个函数对"user": list(g)
使用一个加法和3个副本-所有这些操作都非常耗时。第二个功能使用一个加法和一个复制操作-这是节省的地方,您可以使用[
{
"group": "fans",
"user": [
{
"first": "John",
"last": "Smith"
},
{
"first": "Alice",
"last": "White"
}
]
},
{
"group": "students",
"user": [
{
"first": "Ben",
"last": "Smith"
},
{
"first": "Joan",
"last": "Carpenter"
}
]
}
]
保存2个复制操作。
答案 1 :(得分:3)
在这种情况下,将GMP版本与简单的int版本进行比较是没有意义的。 Fib(1000000)是一个〜86KB的数字,与简单的int
相比,它的运行速度快得多。对于int
,在某些情况下,副本基本上可以是自由操作,而对于GMP编号,它涉及86KB缓冲区的副本。
(请注意,当然,副本不会总是为86KB。开始时为〜0KB,但是随着例程的进行,它会增长到86KB。随着数字的增加,例程变得越来越慢,所以大部分时间都花在数字很大的时候)
假设质量为BigInt
,我们在每次迭代中都有以下操作计数:
如您所见,经典版本会额外复制2个副本(请注意,在质量实现中,x=y+z
不包含副本)。副本的速度与添加的速度相同(我的意思是,添加的速度可能比副本慢2到3倍)。因此,这解释了经典例程的〜2.3倍速度降低。
请注意,如果BigInt
是使用引用计数的实现,则x=y
操作基本上可以是自由操作,因为它不需要复制,只需增加一个计数器(在在这种情况下,经典例程的速度将与mod 1相同。)
最后一点:如果可以使用非复制swap
操作,大概可以加快经典版本的速度:
BigInt fib_dp_swap(int n) {
if (n == 0) {
return 0;
}
BigInt x = 0, y = 1, z;
for (int i = 2; i <= n; ++i) {
z = x + y;
x.swap(y); // Note the 2 swaps here
y.swap(z);
}
return y;
}
使用gmpxx,该例程与mod版本同时运行,并且更加简单。
这是因为swap
操作可能比副本便宜得多。交换只需要交换BigInt
内部的指针,而一个副本则需要一个〜86KB的内存副本。
答案 2 :(得分:0)
在经典情况下是一种数据依赖的情况,每次迭代需要两个周期,因为
z = x + y;
x = y; // depends on y from previous loop.
y = z; // depends on z
在mod情况下,每个迭代仅取决于前一个迭代,因此每个循环可以执行一次。
此外,%3并不是真正的模3,而是一些编译器魔术,使之变得简单。实际上,您可以通过编写来进一步优化它
for (int i = 0; i < n; i+=3) {
y = x + z;
z = x + y;
x = y + z;
}
}
额外的计算成本将保存在额外的控制逻辑中。