Python入门经典算法学习--求素数

经典算法学习–求素数


求100之内素数


    

什么是素数?python

素数的特性,只能被1和本身整除程序员

优化方法:web

被除数:步长去偶,后去5的倍数面试

除数:起始去2,步长去偶,选取中值平方根,算法

合数特性:每一个合数都能拆解成素数的乘积,利用这个们可使用素数做为除数,比奇数更精简了,这里须要列表复用素数。编程

孪生素数特性:2,3以后的每一个素数都是6的倍数相邻数。数据结构

最后将以上优化点合一,测试效率如何。app


  1. 基本求解
        按照定义,从1 开始 到 n-1都没有找到整除它的数,就是质数。
# %%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之内素数的求解,就能够说达成了基本要求。svg

    可是呢,很明显这个方法过于原始,以致于作了不少无用功而致使算法效率低下,存在不少的能够优化提高的地方。函数


  1. 基本算法优化

    通常来说在这里咱们很容易想到,用折半思想进行优化,在被除数区间中,咱们很容易注意到,偶数必定会被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运算,这一点是避免不了的。若是还想再进一步比较明显的提高算法效率就得从其余维度入手了。


  1. 素数列表复用

    一般咱们考虑一个算法的性能优劣有两个复杂度,一个是时间复杂度,另外一个是空间复杂度(内存是有限资源),这个两个复杂度一般是动态平衡的辩证关系,根据实际需求咱们能够考虑使用空间换时间,仍是用时间换空间,二者相互转化。在通常状况下,咱们都已有限解决时间复杂度问题,毕竟延时才是物联网时间里的头号敌人。可是这个问题不能至关然的就把内存空间问题放置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这种优化程度,通常在设计原理没有更大提高的状况下,是颇有效率的算法。

    这里咱们不难发现一个问题,就是一个算法可能本来使用的原理不是那么的有优点,可是通过屡次细节优化后的效率也是能够接受的。反过来说,若是算法仅仅在原理上具备优点,而不去改进每一个细节,那么实际效果不会是至关然的就必定比原理劣势可是优化不少的老练算法强。这一点在工做中必定要注意,可能有不少细节是你的新算法还没测试并优化过的。

    到了这里咱们的求素数的算法是否是已经大功告成了呢?很遗憾,还没结束,接下来还有一种从原理上改进的方法。


  1. 孪生素数解法

    孪生素数:就是指相差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优胜了,符合咱们对该算法的预期。该算法保持核心取模运算,经过孪生素数定理缩减被除数空间大小,经过使用列表结构复用质数空间,在原理上实现了最优化,从结果上来看,效率也确实是最佳的。固然也许从其余维度上再进一步优化提高,好比是否是能够从解释器优化的角度上看等等,只是因为本人目前的能力还远远不够,只能留待往后讨论。如有大佬喷碰巧路过,还望指点一二。

至此,咱们已经把入门算法求解素数研究清楚了。