素数筛入门

最近在学习数据结构的时候遇到一个题目:要我们求n以内的素数。一开始觉得很简单,一下就用它的特性--只能被1和它本身整除做完了,但是后面想了想,这种方法用了两层循环,这也就意味着我的这个想法时间复杂度较高。后面就想有没有降低时间复杂度的方法,去了解以后大受震撼,原来一个简单的找素数的问题竟然有这么多的解法,而且时间复杂度这么低。所以写了这篇博客,来共同学习一下素数筛问题吧。

一、基础知识:

        什么是素数?

        素数也叫质树,是指在大于1的自然数中,除了1和它本身以外不在有其他因数的自然数。

        如果有其他因子的数字,都不是素数,这类数字称之为合数,概念上和素数相对。

        假如整数n除以m,结果是无余数的整数,那么我们称m就是n的因子。 需要注意的是,唯有被除树,除数,商皆为整数,余数为零时,此关系才成立。反过来说,我们称n为m的倍数。

        要留意的是:

        有一种说法是“因子不限正负”,不过通常情况下只取正因子。

        由此可知:

        1.素数都大于1;

        2.素数是自然数,因此素数都是大于一的正整数。

        3.素数有且仅有两个因子,1和它本身,也就是通过乘法想得到这个素数只能够用1×它本身来得到。

        4.由上面可以得到,自然界中,最小的素数是2。

        所以,素数筛问题就是在一个区间内筛查这个区间内的所有素数。

        这里给出1~100以内的素数以供检验:

范围素数
(1,10]2,3,5,7
(10,20]11,13,17,19
(20,30]23,29
(30,40]31,37
(40,50]41,43,47
(50,60]53,59
(60,70]61,67
(70,80]71,73,79
(80,90]83,89
(90,100]97

 

二、朴素算法:

        2.1 试除法:

         试除法是我们能够想到的最容易理解的方法。根据它只能被1和它本身整除的性质。所以我们可以去找它是否有其他不是1和本身的因子,如果没有那么就是素数,如果有那就是合数。

        这里我们取的除数一般都是从二开始,一直到它本身减一( [2,n - 1] ),通过本身的数和我们取的除数来做取余数,如果取余数的结果为0,那么就说明这个除数是它的一个因子,也就是说这个数不是素数。这样筛查完一遍以后,其余的数就都是素数。

        代码:

#include<stdio.h>
int main()
{
    int N;
    int flag = 0;
    scanf("%d",&N);
    if(N >= 2){
    printf("2\n");
    for(int i = 3;i <= N;i ++){
        for(int j = 2;j < i;j ++){
            if(i % j == 0){
                flag = 1;
            }
        }
        if(flag == 0){
            printf("%d\n",i);
        }
        flag = 0;
        }
    }
    return 0;
}

        2.2 优化算法一:

       上面的算法我们可以知道它的时间复杂度为O(n * n)。

        上面的算法首先是从[2,n-1]这个区间对每个数来判断是否是素数,但是我们可以来对[2,n-1],这个区间里面的所有数来分析一下。

        首先,根据基础知识我们可以知道,数分奇数和偶数,对于偶数来说我们都知道,偶数可以被2整除,所以[2,n-1]这个区间里面除2以外的所有偶数都不是素数。

        因此如果我们把这个区间里面的偶数都去除掉的话,那么我们这里就少了一半的数据要去通过内层的循环,所以减少了数据的处理,时间复杂度也会有下降。

        代码:

#include<stdio.h>
int main()
{
    int N;
    int flag = 0;
    scanf("%d",&N);
    if(N >= 2){
    printf("2\n");
    for(int i = 3;i <= N;i += 2){    //只需要把i++变成i+=2,因为是从3开始,所以每次取值都是奇数
        for(int j = 2;j < i;j ++){
            if(i % j == 0){
                flag = 1;
            }
        }
        if(flag == 0){
            printf("%d\n",i);
        }
        flag = 0;
        }
    }
    return 0;
}

        2.3 优化算法二:

        上面的算法我们少了一半的数据去做下面的循环,时间减少了一半,时间也减少了一半。

        我们在上面的算法基础上可以再做优化,上面的优化算法是从被除数的角度去优化的,这次我们从除数的角度去优化。

        首先,由于我们上面的优化算法一已经把所有能被二整除的数给排除掉了,所以剩下要判断的数都是奇数,又我们可以知道奇数不能被2整除,也不能被2的倍数整除。所以我们可以把除数中所有的偶数去掉,这样就可以使每次算余数时要计算的数据量减少了。

        上面都是对数的性质进行讨论,然后通过数的奇偶性减少数据计算量,接下来我们来根据计算的范围来优化代码,一开始我们想到在[2,n-1]这个范围对数n进行试除,我们可以想到,对于数n,如果它有除1和它本身以外的其他的因子a和b(a,b必定是成对出现的),就可以得到n = a * b,因子a,b是一种负相关的关系,a变大b就变小,a变小b就变大,当a最小为2,时,b最大为x/2。而a最大也只能为x/2,因为b最小只能为2。因此,可以推断出,x如果有其他因子,那么这个因子一定是在[2,x/2]范围内。基本原理和上面一致,都是试除法。在新的范围内如果没有可以整除的数字,那么就说明n没有其他因子,说明n是素数。

        因此这样就把范围缩小到n/2,进行了一点优化。

        代码:

#include<stdio.h>
int main()
{
    int N;
    int flag = 0;
    scanf("%d",&N);
    if(N >= 2){
    printf("2\n");
    for(int i = 3;i <= N;i += 2){
        for(int j = 3;j < (i / 2) ;j += 2){    //只需要把这里的i除2,j每次加2,但是这里j是从3开始
            if(i % j == 0){
                flag = 1;
            }
        }
        if(flag == 0){
            printf("%d\n",i);
        }
        flag = 0;
        }
    }
    return 0;
}

        2.4优化算法三:

        上面的优化算法二,是将试除的范围减少一半,时间复杂度降低了,那么,我们是否有别的办法能够减少更多试除的范围呢。

        在上面的方法二中,因子虽然都是在[2,n/2]这个范围内,但因子在数轴上,总是一个前一个后。在此范围内,如果前半部分都没有找到因子,那么必然后半部分的数字也不是因子。那么这个前后的分界线在哪里呢?

        数字的因子有一个特点,就是成对出现的(两个成对的因子相乘才等于原来的数字),并且成对出现的因子分布在sqrt(n)的两侧

        简单证明一下为什么一定分布在sqrt(n)两侧:假设k=sqrt(n),那么k*k=n

        如果成对的两个因子不分布在k的两侧,比如同时大于k或者同时小于k。因为k*k=n,那么这两个成对因子相乘的结果要么是大于n,要么是小于n。既然相乘结果都不是n,那么就不是n的因子,则与最开始的假设相悖。

        得到上面的性质之后,就可以对朴素算法再进行优化。

        因为因子是成对出现,并且分布在sqrt(n)两侧。那么判断数字有无其他因子的时候,判断范围可以从[2,n-1]缩小到[ 2,sqrt(n) ]。(如果[2, sqrt(n) ]没有因子,那么[ sqrt(n) , n-1]也不会有因子。原因就是前面提到的,因子是成对出现的)。

        代码:

#include <math.h>
#include<stdio.h>
int main()
{
    int N;
    int flag = 0;
    scanf("%d",&N);
    if(N >= 2){
    printf("2\n");
    for(int i = 3;i <= N;i += 2){
        for(int j = 3;j < sqrt(i) ;j += 2){    //把i/2换成sqrt(i)
            if(i % j == 0){
                flag = 1;
            }
        }
        if(flag == 0){
            printf("%d\n",i);
        }
        flag = 0;
        }
    }
    return 0;
}

        这里我们还可以再降低一点点时间复杂度,这要涉及到不同的运算所用的时间是有差别的,了解可以知道同一个用加法和乘法的时间复杂度是不相同的。

        加法的时间复杂度比乘法的时间复杂度要低一些,在算法分析中,加法操作的时间复杂度是O(1),即常数时间复杂度,因为加法操作只需要进行一次运算。

        相比之下,乘法操作的时间复杂度是O(logn),其中n是乘法操作中涉及的数的位数。这是因为乘法操作涉及多次相乘,而相乘的位数会随着数值的增大而增加,因此乘法操作的时间复杂度会随着数值的大小而增加。

        因此我们可以去猜想是否开方(sqrt)的时间复杂度和相乘的时间复杂度相比,哪一个算法时间复杂度要更低一些呢。事实上,开方的时间复杂度是比相乘的时间复杂度更高的。通过查阅资料可以了解到,计算平方根通常涉及更复杂的数学运算,需要使用一些特定的算法来逼近平方根的值。一般而言,乘法的时间复杂度是O(logn),即常数时间复杂度,因为乘法运算只需要进行相乘操作。而对于sqrt函数,通常采用的是数值计算方法,比如牛顿迭代法等,这些方法需要进行多次迭代计算来逼近平方根的值,因此时间复杂度会相对较高。

        所以在每次的循环迭代中,把j < sqrt(i) 替换成 j * j < i可以减少一点整个算法的时间复杂度。

三、素数筛-埃氏筛(埃拉托斯特尼筛法):

        3.1筛选法:

        上面的朴素算法以及各种优化方法,都是把区间内的数字变成的单一数字进行判断,从而得知这个数字是否为素数。但在实际问题中,往往需要获取一个区间内所有素数,或者在短时间内多次查询判断。上面的算法虽然经过多次优化降低了一些时间复杂度,但是实际用起来,因为它的双重循环,导致它的时间复杂度还是很高,有时候无法满足我们的条件,所以还有没有更好的方法呢?答案是必然的。

        大佬们为了应对这样的需求,他们会进行预处理:对某一区间进行素数挑选,把素数挑选出来,存储到另外一个地方或者标记起来。要实现这样的预处理,使用上面的朴素算法,就需要双层循环,这样时间复杂度大概是O(n^2)。接下来介绍的这两种算法,正好是把某一区间内的素数都筛选出来,且时间复杂度也不高。

        上面的朴素算法,我们是尝试把数字拆分为因子相乘的形式,对范围区间内的数字进行判断时,,则需要对每个数字都进行拆分,那么我们是否可以使用另外一种方式呢?这里我们选择另外一种方式:反向构造。

        我们事先不知道一个数是否为素数,但是我们可以创造出合数(非素数)。数字可以划分为素数和合数两大类,那么当我们把某一范围内的合数都标记出来,则剩下的数字就是素数。如何构造合数呢?在大于1的数字中任取两个数字a,b相乘即可,这样得到的结果c必然有1,a,b,c四个因子(当a=b时为三个因子)那么c必然是合数。   

        总结成算法就是

        1.设定两个数组,一个数组包括范围内的所有数字,用来标记此数字是否被访问过;另外一个数组用来存储已经筛选出来的素数(如果仅仅是查询某个数是否为素数,则访问数组就可以实现这个功能,素数数组是方便筛选过程结束后可以输出筛选出的素数)
        2.将访问数组中访问标记设为未访问,将素数数组清空,将0,1等非素数访问标记设为已访问(素数标记是设为未访问还是已访问,根据自己的逻辑习惯设定)
       3. 从2开始循环,直到范围的上界,判断每一个数字是否被访问过,访问过的是非素数(合数),未访问过的是素数
        4.接着对这个数字进行倍增操作,从两倍开始,直到若干倍后到达上界为止,这其中得到的数字结果的访问标记都设为已访问。重复步骤3,4,直至3中循环结束。

        这里步骤4中是第二个循环,用来进行倍增操作,凡是在此过程中得到的数字,都是构造出来的合数。而步骤3中,循环到当前数字未被访问过,则此数字是素数。因为如果此数字是合数的话,那么它的因子一定是小于自身的。那么在前面从小数开始循环的时候,就会遇到因子,更会在这个因子倍增的过程中,把数字给标记掉。所以如果步骤3访问当某个数字是未标记时,那么这个数字一定是素数。

        举例说明:12是合数,有2,3,4,6;从小数开始循环、倍增时,2倍增时会把12标记掉,同样地,3,4,6也会把12标记掉。所以如果访问到a未被标记,那么a一定是素数

        这个算法的好处就是,利用了一次倍增,得到了后面若干个数字的结果。循环到这些数字的时候,就不再需要花时间去计算,直接可以得到结果。这里用了一个必不可少的标记数组,花费了一定的空间。**这里实际上就是典型的用空间来换取时间。**花费更多的内存空间,来换取时间的减少。时间和空间的取舍,也讲求经济性。

        代码:

#include <math.h>
#include<stdio.h>
#include <string.h>
int main()
{
    int vis[101];    //vis用来判断数字是否被访问过
    int prime[101];    //prime用来存放筛选出来的素数
    int n = 0;
    int k = 0;
    scanf("%d",&n);
    memset(vis, 0, sizeof(int) * 101);    //这是将vis数组里的元素全部初始化为0,方便后续访问标记
    for(int i = 2;i <= n;i ++){    //素数是从2开始,所以直接跳过0和1
        if(vis[i] == 0){    //判断是否被访问过,被访问过就不是素数
            prime[k ++] = i;    //将不是素数的值放入prime数组,方便后面输出
        }
        for(int j = 2;i * j <= n;j ++){
            vis[i * j] = 1;    //根据筛选法,一个数i的j倍一定是个合数,来访问合数
        }
    }
    for(int i = 0;i < k;i ++){
        printf("%d ",prime[i]);
    }
    return 0;
}

        3.2埃氏筛:

        上面的构造方法,是对每个数字都进行倍增,但实际上这样的效率很低,造成了很多的重复。

        比如说对2进行倍增之后,会对4,6,8,10,12,14,16,18,20,22,24,26,28,30等等进行标记

        而轮到4的时候,就会对8,12,16,20,24,28,32等等进行倍增

        可以发现这其中有大量的重复,这相当于重复标记了,并且这只是举了2和4的例子,后面还有更多的重复。而造成重复的根本原因就是后面进行倍增的数字,如4,是前面数字倍增后的结果。可以想到,4的倍数,8的倍数,必然是2的倍数,那么使用4,8进行倍增无疑是以不同的步长重复标记。4和8这类数字,都是已经被标记过的数字,换言之也就是合数。那么就是不应该对合数进行倍增,因为合数必然是某个素数的倍数(一定是某个素数的倍数吗?必然是,这是一个递归定义,必然终结于素数)。对合数进行倍增,就是在重复标记。

        这里引入一个数学定理–唯一分解定理,也是算术基本定理:算术基本定理可表述为:任何一个大于 1 的自然数 N, 如果 N 不为**质数**,那么 N 可以唯一分解成有限个质数的乘积—-唯一分解定义。

        因此合数的因子中一定有个素数,循环是从小到大的,那对最小的素数进行倍增之后,就不需要对其倍数结果进行倍增。(即对3进行倍增之后,就不在需要对6,9,12进行倍增)因此上面的算法,进行一点改进,就可以大大提升效率,而这种算法就是埃拉托斯特尼筛法,简称埃氏筛。

        代码:

#include <math.h>
#include<stdio.h>
#include <string.h>
int main()
{
    int vis[101];
    int prime[101];
    int n = 0;
    int k = 0;
    scanf("%d",&n);
    memset(vis, 0, sizeof(int) * 101);
    for(int i = 2;i <= n;i ++){
        if(vis[i] == 0){
            prime[k ++] = i;
            for(int j = 2;i * j <= n;j ++){    //这里和上面的代码的区别就是把倍增筛选的循环放到if判断语句内,这样,就只进行素数的倍增筛选
                vis[i * j] = 1;
            }
        }
    }
    for(int i = 0;i < k;i ++){
        printf("%d ",prime[i]);
    }
    return 0;
}

        3.3优化方法一:

        观察上面的代码我们可以发现,优化以后,筛选的时候依然会有重复筛选的情况出现。

        这是因为,即使我们这里是素数的倍增,但是我们的倍数也可以是一个素数,就相当于素数a乘素数b(这里a>b),这时是a的倍增筛选过程,但当到达b的筛选过程,它依然是从2开始倍增筛选,到一定时间就会出现素数b乘素数a,由乘法交换率可知,a*b = b*a,因此这个数重复筛选了两次。

        所以我们倍增筛选每次就从i开始往后就可以了,这样就又可以节省一半的时间。

        代码:

#include <math.h>
#include<stdio.h>
#include <string.h>
int main()
{
    int vis[101];
    int prime[101];
    int n = 0;
    int k = 0;
    scanf("%d",&n);
    memset(vis, 0, sizeof(int) * 101);
    for(int i = 2;i <= n;i ++){
        if(vis[i] == 0){
            prime[k ++] = i;
            for(int j = i;i * j <= n;j ++)    //这里从i开始避免a*b = b*a的时间浪费
                vis[i * j] = 1;
            }
        }
    }
    for(int i = 0;i < k;i ++){
        printf("%d ",prime[i]);
    }
    return 0;
}

        3.4优化方法二:

        接下来的优化其实就和朴素算法的优化算法一、二、三一样,从开始就排除掉偶数和用sqrt来缩短判断的范围。

        这个和上面的想法类似,就不过多解释了,看明白上面的就很容易理解:

        代码:

#include <math.h>
#include<stdio.h>
#include <string.h>
int main()
{
    int vis[101];
    int prime[101];
    int n = 0;
    int k = 0;
    scanf("%d",&n);
    memset(vis, 0, sizeof(int) * 101);
    vis[0]=vis[1]=1;
    vis[2]=0;
    for(int i=4;i<=n;i+=2)//这里把偶数全部标记为1,非素数
        vis[i]=1;

    for(int i=3;i*i<=n;i+=2)//这里只是把 i<=n 改为 i*i<=n
    {
        if(vis[i]==0)//对素数进行倍增
        {
            for(int j=i;i*j<=n;j++)//这里把j=2改为j=i
                vis[i*j]=1;
        }
    }

    //上面的循环,只循环到sqrt(n),因此要存储范围内的素数,需要重头遍历一遍
    prime[k++]=2;
    for(int i=3;i<=n;i+=2)//只遍历奇数
    {
        if(vis[i]==0)
            prime[k++]=i;
    }
    for(int i = 0;i < k;i ++){
        printf("%d ",prime[i]);
    }
    return 0;
}

四、素数筛-欧拉筛:

        上面多次优化了埃氏筛,使它的时间复杂度不断降低,其中优化方法二是对范围的优化,这大同小异就不聊了,但是优化一是对它重复筛选进行优化,虽然我们优化了素数倍增时的重复问题,但是仔细想一下还是可以看出它依旧有重复筛选。

        例如,当我们要筛选掉12这个合数时,从2开始倍增到6,也就是2 * 6 = 12,这样就筛选了一次,但是,当我们从3开始倍增到4,也就是3 * 4 = 12,这样就导致对12筛选了两次,但是上面埃氏筛的所有优化都不能排除掉这个问题,所以,大佬们就又创造出了欧拉筛来解决这个问题。

        根据唯一分解定理可知,每个合数都有一个最小素因子。而欧拉筛的基本思想是,让每个合数被其自身的最小素因子筛选,而不会被重复筛选。欧拉筛的框架和埃氏筛大致相同,区别点在于第二层循环对倍增过程的操作。

        埃氏筛是,只要是素数就进行倍增。而欧拉筛是用当前遍历到的数字i,去乘以已经在素数表中的素数。

        首先,这样就保证了是以素数进行倍增,相较于使用任何数字进行倍增的情况,是已经优化过了。比如说此时i=12,前面有素数2,3,5,7,11。在欧拉筛中就是用2,3,5,7,11去乘以6,进行倍增筛除。因为 i 是从小到大进行循环,会乘以前面的每一个素数,这就保证了每个素数的倍数都不会被错过。并且每个素数的倍增过程都是从平方开始,就和前面埃氏筛优化方法一种一样,可以有效避免重复。

        代码:

#include <math.h>
#include<stdio.h>
#include <string.h>
int main()
{
    int vis[101];
    int prime[101];
    int n = 0;
    int k = 0;
    scanf("%d",&n);
    memset(vis, 0, sizeof(int) * 101);
    for(int i=2;i<=n;i++)
    {
        if(vis[i]==0)//i是素数,则存起来
            prime[k++]=i;
        for(int j=0;j<k;j++)//进行倍增,用i去乘以i之前(包括i)的素数
        {
            if(i*prime[j]>n)//倍增结果超出范围,退出
                break;

            vis[i*prime[j] ]=1;//将倍增结果进行标记

            if(i%prime[j]==0)//i是前面某个素数的倍数时,也需要退出
                break;
        }
    }
    for(int i = 0;i < k;i ++){
        printf("%d ",prime[i]);
    }
    return 0;
}

五、总结:

        这篇博客主要将素数筛的内容,朴素算法是大部分人最容易想到的方法,而埃氏筛和欧拉筛则需要了解,其中缩短时间复杂度的一系列优化算法很值得去细细品味,因为在竞赛方面很有帮助。

  • 29
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值