什么是素数?
素数的特性,只能被1和自己整除
优化方法:
被除数:步长去偶,后去5的倍数
除数:起始去2,步长去偶,选取中值平方根,
合数特性:每个合数都能拆解成素数的乘积,利用这个们可以使用素数作为除数,比奇数更精简了,这里需要列表复用素数。
孪生素数特性:2,3之后的每个素数都是6的倍数相邻数。
最后将以上优化点合一,测试效率如何。
# %%timeit
# 算法1
n = 100
count = 0
for x in range(2, n): # 被除数空间
for i in range(2, x): # 除数空间
if x % i == 0:
break
else:
count += 1 #
print(x, end=' ')
print('\n', count)
print('-' * 30)
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
25
------------------------------
这样我们使用素数基本定义对于100以内素数的求解,就可以说达成了基本要求。
但是呢,很明显这个方法过于原始,以至于做了很多无用功而导致算法效率低下,存在很多的可以优化提升的地方。
一般来讲在这里我们很容易想到,用折半思想进行优化,在被除数区间中,我们很容易注意到,偶数一定会被2整除,因此一定不是素数,也就是说素数一定是个奇数,我们被除数区间的偶数全部去掉,那么我们算法理论上效率提升了一倍。
那么除数区间是不是也可以去除偶数呢?这当然可以,每个偶数的公约数都有一个2,也就是说能整除偶数的必然也是个偶数,所以我们可以再继续从除数空间剔除偶数。
那么如何实现除数空间去偶呢?我使用range函数生成一个可迭代对象,而这个range函数提供的步长step刚好可以实现这一点,通过把步长置为2,我们可从一个奇数直接跳到下一个奇数,如3,5,7,9…。
除此之外,我们不难发现一旦除数大于‘被除数的一半’而没有数可以整除它,那么再对后面的除数取模也已经是无济于事了,只能是平白多了无用功。这里的‘被除数的一半’经过计算我们不难得出,指的是其平方根,例如:25 = 5 * 5 ,一旦25过了算术平方根5,那么就相当于对之前的除过的数再一次对称计算。因此我们取到算数平方根作为‘中点’。
这里有一点需要注意,range函数是左闭右开区间,且只接受int整型作为参数,因此我们需要测试边界问题,一般去情况下都会在计算值的基础上+1。
# %%timeit
# 算法2
n = 100
count = 1
for x in range(3, n, 2):
for i in range(3, int(x**0.5)+1, 2): ## 平方根折半优化
if x % i == 0:
break
else:
count += 1
print(x, end=' ')
print('\n', count)
print('-' * 30)
3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
25
------------------------------
我们可以通过ipython提供的内建命令 %%timeit 实现对算法的效率的测试,不过因为100以内素数太少,看不出差距,我们把这个区间放大100倍,求100000以内,我们可以在最后打印一个素数的总数,以确保算法上没有出错,十万以内素数应当是9592,大家可以自行比对
%%timeit
代码块1
代码块2
测试的以上两段代码的时候,注意要把print输出语句注释起来,不然每次输出I/O速度可比内存操作慢太多了,更别提CPU的计算速度了。
未优化前代码效率是:
26.6 s ± 625 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
优化后代码效率是:
87.8 ms ± 2.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
很明显,这两个算法差距可以说是天壤之别了!算法的优劣在可以刻表现的明明白白。
这两个算法,看似只改动了3处,但这个优化带了的效率提升是非常可观的,可能求素数这么算法,除了面试可能会问问,日常的工作业务中根本用不上,但是就是因为这个算法很简单,才更能凸显算法本身的魅力,一点点改动,可能有大大的不同。每个细节考虑不周都可能拖累整个算法效率。
这种对算法时间复杂度优化的思想才是一个合格程序员应有的思维。编程编的就是数据,怎么编的高效,怎么用数据结构与算法才能让代码变得更有效率,更优雅,而这就是我们应当追求的。
以上这个优化算法基本上就是只使用基础定理可以做到的最高效算法了。在这个基础上还想优化,无非就是还是从被除数与除数两个空间入手。虽然现在被除数只剩下了奇数但是我们仍能发现凡是个位数是5的奇数都能被5整除,我们也可以考虑将能被5的倍数在被除数空间中全部去除,不过这个提升就不是很明显了,毕竟即使要剔除5的倍数,也仍然要对所有被除数做模5运算,这一点是避免不了的。如果还想再进一步比较明显的提升算法效率就得从其他维度入手了。
通常我们考虑一个算法的性能优劣有两个复杂度,一个是时间复杂度,另一个是空间复杂度(内存是有限资源),这个两个复杂度通常是动态平衡的辩证关系,根据实际需求我们可以考虑使用空间换时间,还是用时间换空间,两者相互转化。在一般情况下,我们都已有限解决时间复杂度问题,毕竟延时才是物联网时间里的头号敌人。但是这个问题不能相当然的就把内存空间问题放置Play,只关心时间复杂度问题,因为解释器的内存的GC回收机制,很可能会引起极大的性能占用,导致其他服务中断。在时间与空间的问题上,我们还是要辩证的看待,始终牢记具体问题具体分析这一马克思主义活的灵魂!
回到咱们这个算法上,我们可以考虑使用空间换时间的方法以提升算法效率。这里我们在引入一个与质数相关的定理:合数一定是质数的乘积,合数分解后,一定是质数的乘积,合数一定可以找到一个质数来整除的。
根据这个合数定理,我们可以知道,能被质数整除的肯定不是质数,即无法整除质数的是质数。所以我们根据这个结论,就可以把之前每一轮算出的质数保存起来给下一次判断使用。这里我们很自然的就会选择列表作为保存质数的数据类型,不过我们这样吧每个质数保存进列表,而不是打印释放,实际上就是空间(列表空间)换时间(算法效率)。
下面这个段代码就是利用素数列表的算法。
# %%timeit
# 算法3
n = 10000
count = 1
prime = []
for m in range(3, n, 2): # 奇数
for i in prime: # 从质数列表中提取质数
if m % i == 0: # 被质数整除的是合数,直接跳出循环
break
else: # 没有被质数整除的是质数
count += 1
prime.append(m)
print(count)
print('-' * 30)
该算法使用一个素数列表作为除数空间,将每次挑选出的素数放入其中,重复使用,一举将除数空间缩小到最低限度。
通过命令%%timeit计算效率后我们发现实际的效率为
2.19 s ± 44.3 ms per loop
这可比第二个算法的毫秒级要慢多了,这是怎么回事?这么定睛一看,原来是除数空间也就是除数列表我们并没有进行折半处理,而是使用了完整的除数空间,那么无用计算量当然是极大的了。接下来,我们类似与算法2就对这段代码进行改进优化。
# %%timeit
# 算法4
n = 100000
count = 1
prime = []
for m in range(3, n, 2): # 奇数
flag = True
for i in prime: # 从质数列表中提取质数
if m % i == 0: # 被质数整除的是合数,直接跳出循环
flag = False
break
if i > int(m**0.5): # 超过边界就是将flag置为True,跳出循环
flag = True
break
if flag: # 没有被质数整除的是质数
count += 1
prime.append(m)
print(count)
print(primenumbers)
print('-' * 30)
这段算法中,引入一个标志位flag,用于判断是否为素数。这里折半的判断不难选择,阅读代码很容易能理解。
但是这样做就ok了么?还是一样咱们在试试%%timeit来测试该算法的性能。
203 ms ± 9.14 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
我们发现,这折半优化后效率确实是有很大提升,但是这样跟上面的算法2做比较不就是仍然比不过了么?这就是我在上面说过的,算法2已经是接近基本定理下优化的最好情况了,一般情况下,我们认为算法2的效率是可以接受的。
那么问题来了,不是说好的空间换时间可以提升效率么?怎么反而占用了空间,时间上也满了下来呢?难道是理论靠不住?其实这里有个细节,是关于边界判断语句:
if i > int(m**0.5):
这段语句放在了内层循环中,也就是说每次进入内层循环都要在算一次 m**0.5 ,这样无疑是增加了重复工作量。这样找到了问题所在,那么说改就改,把边界判断条件的计算放到外层循环中。
# %%timeit
# 算法5
n = 100000
count = 1
prime = []
for m in range(3, n, 2): # 奇数
flag = True
edge = int(m**0.5) # 除数空间的平方根折半,放在外层循环中,减少重复计算
for i in prime: # 从质数列表中提取质数
if m % i == 0: # 被质数整除的是合数,直接跳出循环
flag = False
break
if i > edge: # 超过边界就是将flag置为True,跳出循环
flag = True
break
if flag: # 没有被质数整除的是质数
count += 1
prime.append(m)
print(count)
print(prime)
print('-' * 30)
这样我们再来测试一遍算法5的效率。
算法5:
55 ms ± 1.7 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
算法2:
87.8 ms ± 2.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
这样我们的时间换空间终于有了成效,比算法2的效率明显高出了许多,算法5这种优化程度,一般在设计原理没有更大提升的情况下,是很有效率的算法。
这里我们不难发现一个问题,就是一个算法可能原本使用的原理不是那么的有优势,但是经过多次细节优化后的效率也是可以接受的。反过来讲,如果算法仅仅在原理上具有优势,而不去改进每个细节,那么实际效果不会是相当然的就一定比原理劣势但是优化很多的老练算法强。这一点在工作中一定要注意,可能有很多细节是你的新算法还没测试并优化过的。
到了这里我们的求素数的算法是不是已经大功告成了呢?很遗憾,还没结束,接下来还有一种从原理上改进的方法。
孪生素数:就是指相差2的素数对,分布在6的倍数前后,例如3和5,5和7,11和13…。
根据定理,我们可以得到很多种算法,这里我们选用变化步长的方法实现,从一对孪生素数到另一对孪生素数直接步长step为4,而一对孪生素数之间step为2,由于是变步长的方式,这里我们选用while循环实现。
#%%timeit
# 算法6
n = 100000
count = 3
m = 7
step = 4
while m < n:
if m % 5 != 0: # 去除5的倍数
for i in range(3, int(m**0.5)+1, 2): # 除数空间的平方根折半
if m % i == 0:
break
else:
count += 1
print(m)
m += step # 7
step = 4 if step == 2 else 2 # 控制步长step
print(count)
print('-' * 30)
87.2 ms ± 930 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
这段算法看起来很之前的似乎有很大差异,但其实仔细阅读会发现,和之前的算法其核心语句是一样的,不过是改变了被除数空间的大小,语法上最主要的不同是使用step代替了range函数,而这里可能会出现解释器优化上的差距。
算法6:
87.2 ms ± 930 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
看起来和使用基本定理的算法2差不多,接下来我们在刚才使用的素数列表优化方法加入其中。
#%%timeit # 3
# 算法7
n = 100000
count = 3
prime = [3, 5]
print(prime)
m = 7
step = 4
while m < n:
if m % 5 != 0: # 去除5的倍数
edge = int(x ** 0.5) # 除数空间的平方根折半
flag = True
for i in prime: # 复用素数列表
if m % i == 0:
flag = False
break
if m > edge:
flag = True
break
if flag:
count += 1
prime.append(m) # 复数列表 追加 新的复数
print(m)
m += step # 7
step = 4 if step == 2 else 2 # 控制步长step
print(count)
21.5 ms ± 757 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
这样从原理上,算法7的被除数与除数两个空间都已经最优化处理了,几乎是没有多余的计算了,从理论上看这个算法可以说是最优化的,那么我们赶紧测试一下看看效率是不是真的和理论相符。
算法7:
21.5 ms ± 757 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
算法5:
55 ms ± 1.7 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
很明显,算法7优胜了,符合我们对该算法的预期。该算法保持核心取模运算,通过孪生素数定理缩减被除数空间大小,通过使用列表结构复用质数空间,在原理上实现了最优化,从结果上来看,效率也确实是最佳的。当然也许从其他维度上再进一步优化提升,比如是不是可以从解释器优化的角度上看等等,只是由于本人目前的能力还远远不够,只能留待日后讨论。若有大佬喷碰巧路过,还望指点一二。
至此,我们已经把入门算法求解素数研究清楚了。