今天我在timeline上刷到了@Pika369 的这篇文章:https://zhuanlan.zhihu.com/p/165877869zhuanlan.zhihu.com
文章挺详细,还跑了实验,但这是我人生中第
次看到有人犯将大数算术运算的复杂度视为
的“错误”了
在一个更贴近现实的计算模型中,将两个
位大整数相加需要
的时间,而大整数相乘需要
(暴力算法)或
(FFT)的时间。python的乘法使用的是Karatsuba算法
,下文简记为
。可以看出,单纯按照“进行大整数运算的次数”来衡量算法复杂度是极不准确的,还需要考虑到大整数的长度以及算术运算的种类。知道了这点之后,我们可以重新分析一下原文中提到的各个计算Fibonacci数列算法的复杂度,并与实际运行时间进行对照
算法一:暴力递推
def F1(n):
a,b=1,1
for i in range(n-2): a,b=b,a+b
return b
根据fibonacci数的通项公式,我们可以看出第
个fibonacci数的长度为
。这里我们做了
次大数加法,总复杂度为
。
时运行时间为1.10s。把递归改成递推解决爆栈问题后跑
的数据还是小看它了。
算法零:通项公式法
经评论区提醒补充分析一下这个,
。
从数值计算的角度来说,根据数值分析的知识可知只需要对运算过程中涉及到的实数保留
位精度就可以了。高精度实数开根可以在
时间内完成,而exp可以用AGM方法在
时间内完成
。
从符号计算的角度来说,因为域扩张
是一个有限扩张,所以维护下
前的系数就可以了
前的系数一定为0,不会产生精度问题(
就是一个可怜的工具人)。实际运算和快速幂是等价的。
这里有用mpfr库写的C++代码,还是很快的,不过我们就不欺负python了。
算法二:矩阵的快速幂
import sympy as sym
def Matrix_fastPower(A,n):
size = A.shape[0]
result = sym.eye(size)
base = A
power = n
while power > 0:
if power % 2 == 1:
result = result * base
power = power // 2
base = base * base
return result
def F2(n):
return Matrix_fastPower(sym.Matrix([[1,1],[1,0]]),n)[1,0]
把快速幂看成递归的话,递归深度为
,大整数的算术运算总次数也为
。但注意到递归过程中大整数的长度呈几何级数变化
,即一次
位大整数乘法的常数倍。
时运行时间为81.6s,可以看出算法的常数比较大。
算法三:Fibonacci的一组恒等式
,
。这个算法是Dijkstra在1978年发现的。
from functools import lru_cache
@lru_cache(None)
def F3(n):
if n == 1 or n == 2:
return 1
k = n // 2
a = F3(k)
b = F3(k + 1)
if n % 2:
return a * a + b * b
else:
return a * (2 * b - a)
复杂度的计算和算法二类似,因为递归过程中大整数的长度呈几何级数变化,总复杂度也为
。
时运行时间为5.53s,常数比算法二小了很多。
注:原文代码在我本机的运行时间为5.71s,并且实际上“预先生成字典”对运行时间并没有什么影响。(与原作者确认过了)
算法3.1:
这里以对算法三的一个小改动为例,展示学会正确的复杂度分析可以如何帮助我们优化算法。
注意到算法三虽然进行了
次大整数运算,但复杂度的瓶颈为最后一次乘法,这与实验结果是相符的:最后对
的计算(根据
的奇偶性需要1~2次乘法,这里为1次)花费了2.42s,占据了总运行时间的44%。可想而知,只要能在最后这次乘法上优化一点常数,总时间也可以被提升一个常数。这时候,我们需要一点关于凑因式分解的初中知识:
先考虑
的情形。想法是多递归一层:令
,
,则
。令
,直接用递归式计算的话这里需要一次长度为
的乘法,以及三次长度为
的乘法(
)。但我们可以换一种方法计算多项式的值:令
,
,则
。这样,长度为
的乘法被减少到了两次(计算
与
)。
其他情况同理。对于
,
。
对于
,
。
对于
,
。
def F4(n):
a=F3(n//4)
b=F3(n//4+1)
t=a*(2*b-a)
t1=b*(2*a+b)
if n%4==0:
return t*(t1*2-3*t)
elif n%4==1:
c=t1-t
return t*t+c*c
elif n%4==2:
return (t1-t)*(t1+t)
else:
c=t1-t
return c*c+t1*t1
时运行时间为5.05s,仅为算法三的91%。
(
时运行时间比为
,
时为
,
时为
。)
最后可以用以上技巧重新实现算法三,将运行时间进一步降低到原来的88%。起到的效果和这篇文章14%(
为偶数时)。
def F3_(n):
if n==0: return 0,1
a,b=F3_(n//2)
t=a*(2*b-a)
t1=b*(2*a+b)
if n%2==0: return t,t1-t
else: return t1-t,t1
def F4_(n):
a,b=F3_(n//4)
t=a*(2*b-a)
t1=b*(2*a+b)
if n%4==0: return t*(t1*2-3*t)
elif n%4==1: return t*t+(t1-t)**2
elif n%4==2: return (t1-t)*(t1+t)
else: return (t1-t)**2+t1*t1
注:写文章的时候我不小心用32位python跑的,所以所有运行时间都那么慢。改回64位之后所有瓶颈是乘法的代码都可以快4倍左右,就不改正文了。
参考