浅谈素数判断与素数筛
首先,什么是素数。
素数就是质数。
质数是指在大于1的自然数中,除了1和它本身以外不再有其他因数的自然数。(来自百科)
然后现在想一想我们应该如何判断一个大于1的自然数n是不是素数。
根据定义,我们只需要确定除了1和它本身的数都不可以整除它。
最简单的,我们使用一个循环,从2到n-1,依次判断是否可以整除n,时间复杂度为O(n)
如果所有的都不行,说明n是个素数。
在循环中,如果发现n可以被除了1和它本身的一个数整除,说明n不是素数,这时候我们就可以退出循环了。
代码可以写成如下形式:
#include<bits/stdc++.h> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } int n,f=0; int main(){ n=rd(); for(int i=2;i<n;i++){//记住务必要从2开始,在n-1结束,因为所有的非零自然数都可以被1和它本身整除 if(n%i==0){ f=1;//标记n可以被整除 break; } } if(f==1) cout<<"no";//如果标记改变,说明n可以被一个其他的数整除,说明n不是素数 else cout<<"yes";//如果标记没有改变,说明从2到n-1没有一个数可以整除n,说明n是素数 return 0; }
然后我们来想想如何优化这个代码。
很显然,n不可能被大于n/2的数整除
所以我们可以把循环改为从2到n/2,此时的时间复杂度为O(n/2)
for(int i=2;i<n/2;i++){ if(n%i==0){ f=1; break; } }
为什么不用判断n/2的情况呢,因为如果n可以被n/2整除,那么就可以被2整除,就已经判断过了。
由此我们可以将代码进一步优化,因为如果要判断一个数x可以整除n,我们只需要知道n/x是不是可以整除n,因为当x>x/n时,n/x会在x之前被判断,所以我们最多只需要判断到可能的最接近的一对约数中小的一个,而最接近的一对约数中小的那个不可能超过√n,所以我们只要判断2到√n中是否有能够整除n的数就可以了。
代码如下:
for(int i=2;i<=sqrt(n);i++){//sqrt函数用来求开放向下取整 if(n%i==0){ f=1; break; } }
注意这里的循环要包括√n,因为n有可能是一个完全平方数。
此时判断素数的时间复杂度已经变成了O(√n)。
现在我们继续提速,我们来想一想,如果一个数不可以被2整除,那么肯定也不能被2的倍数整除,所以我们在判断的时候可以省略掉2的倍数
代码就变成了这样:
for(int i=2;i<=sqrt(n);i+=2){ if(n%i==0){ f=1; break; } }
时间复杂度升级为O(√n/2)
还可以更快吗?
答案是可以
根据上一个改进方法,我们发现一个数如果不能被一个素数整除,也就不可能被这个素数的倍数整除,于是我们可以只判断一个数能否被小于√n的素数整除即可,但是我们不可能在判断的过程中知道哪些数是素数。
为了最优化时间,我们要尽量去掉绝对不可能是素数的数。
我们能不能找到一个更简单的表示素数的方法?
我们看可以发现,素数的形式必定为6x+1或6x+5
因为6x被6整除,6x+2=2(3x+1),6x+3=3(2x+1),6x+4=2(3x+2)
所以我们在判断的时候只判断形如6x+1或6x+5且小于等于√n的数是否可以整除n即可。
因为每6个数只需要判断2个数,时间复杂度被降低为了O(√n/3)
代码如下:
#include<iostream> #include<algorithm> #include<cstring> #include<string> #include<cmath> #include<cstdio> #include<cstdlib> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } int n,f=0; int main(){ n=rd(); if(n==2||n==3){//特殊判断 cout<<"yes"; return 0; } if(n%2==0||n%3==0){//筛掉形如6x+2,6x+3,6x+4的数 cout<<"no"; return 0; } for(int i=6;i<=sqrt(n);i+=6){//每次加6,判断两侧是否可以整除n if(n%(i-1)==0||n%(i+1)==0){ f=1;//标记n可以被整除 break; } } if(f==1) cout<<"no";//如果标记改变,说明n可以被一个其他的数整除,说明n不是素数 else cout<<"yes";//如果标记没有改变,说明从2到n-1没有一个数可以整除n,说明n是素数 return 0; }
很显然,判断n是不是素数的最快的方法是判断小于√n的素数能否整除n。
虽然在判断一个数的时候我们用不了这种方法,但是这个思路却可以拓展出一个新的领域——素数筛。
在此我提到了一个思想,判断一个数是否为素数的最好方法是判断小于√n的素数中有没有可以整除n的。
因为一个数如果不可以被一个数整除,那么就肯定不可以被这个数的倍数整除,而素数因为没有任何约数,所以我们要对所有的素数进行判断
进而,我们不需要判断所有的合数,因为合数必定是至少一个素数的倍数
一个数的约数的约数一定是这个数的约数,那么如果一个数的约数都是合数,就必定还有一些约数的约数还没有被找到,那么说明这些约数不是当前这个数的全部约数,也就说明一个数的约数不可能全都是合数,也就证明了我们刚才的命题。
好的,现在我们已经大致上证明了为什么判断一个数是否是素数只需要判断比√n小的素数中有没有可以整除它的。
好了,现在我们来看一个实际情况
Galon同学因为在上课睡觉,老师给他出了一道题,并且表示做不出来以后的GPA就是零分,但是因为Galon同学平时只知道学习python而疏忽了算法,对这道题一点思路都没有,身为Galon同学的同桌,你是他唯一可以依靠的人了,你可以帮帮他吗?
首先,我们从最简单的思路出发。
要判断一共有多少个素数,只需要从1到N循环一遍,然后依次判断每一个数是不是素数,是的话就让答案+1
因为判断一个数是不是素数的时间复杂度是O(√n)
而我们要从1到N依次判断,所以这个算法的时间复杂度是O(n√n/3),约等于O(n√n)
虽然这是最简单的算法,但是我们还是写一下吧。
代码如下:
#include<iostream> #include<algorithm> #include<cstring> #include<string> #include<cmath> #include<cstdio> #include<cstdlib> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } inline void write(int x){ if(x<0) putchar('-'),x=-x; if(x>9) write(x/10); putchar(x%10+'0'); return ; } int n,ans; inline int check(int x){ int f=0; if(x==2||x==3){//特殊判断 return 1; } if(x%2==0||x%3==0){//筛掉形如6x+2,6x+3,6x+4的数 return 0; } for(int i=6;i-1<=sqrt(x);i+=6){//每次加6,判断两侧是否可以整除n if(x%(i-1)==0||x%(i+1)==0){ f=1;//标记n可以被整除 break; } } if(f) return 0;//如果标记改变,说明n可以被一个其他的数整除,说明n不是素数 return 1;//如果标记没有改变,说明从2到n-1没有一个数可以整除n,说明n是素数 } int main(){ n=rd(); for(int i=2;i<=n;i++){//从2开始,因为1既不是合数也不是素数 if(check(i)){ ans++; } } cout<<ans; return 0; }
然后我们来改进这个算法,当我们判断x是否为素数的时候,我们已经判断了所有小于x的整数是否为素数,于是我们可以将前面的素数都保存在一个数组中,然后在判断x的时候,循环之前的所有的素数,然后判断其中有没有可以整除x的数,如果都不能整除x,说明x也是素数,然后我们就要把x存进数组,用于之后的判断。
因为每次判断一个数的时候只需要循环它前面的素数的个数次,这个算法的时间复杂度就变成了O(n*(n之前的素数的个数))
下面给出代码:
#include<iostream> #include<algorithm> #include<cstring> #include<string> #include<cmath> #include<cstdio> #include<cstdlib> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } inline void write(int x){ if(x<0) putchar('-'),x=-x; if(x>9) write(x/10); putchar(x%10+'0'); return ; } int n,ans; int pos; int p[100006];//p数组中存的是素数,pos存的是当前的素数个数 inline int check(int x){ for(int i=1;i<=pos&&p[i]<=sqrt(x);i++){ if(x%p[i]==0){ return 0; } } return 1; } int main(){ n=rd(); for(int i=2;i<=n;i++){//从2开始,因为1既不是合数也不是素数 if(check(i)){ ans++; p[++pos]=i; } } cout<<ans; return 0; }
很显然,在这个代码中,每一个数要被判断小于它的素数的个数次,但是我们知道,在其中起到作用的只有可以整除x的素数,所以我们要尽量省去判断其他素数的次数。
于是我们就可以想到一个新的算法:(来自百度百科)
埃拉托斯特尼筛法,简称埃氏筛或爱氏筛,是一种由希腊数学家埃拉托斯特尼所提出的一种简单检定素数的算法。要得到自然数n以内的全部素数,必须把不大于根号n的所有素数的倍数剔除,剩下的就是素数。
这段文字其实已经说到了这个算法的精髓,当我们要判断小于n的素数时,我们只需要剔除其中小于√n的素数的所有的倍数即可,所以我们可以直接从素数出发,删除那些倍数。
我们首先从2开始,用一个循环删去2小于n的所有的倍数,然后现在剩下的最小的自然数就是下一个素数,因为这说明小于它的所有质数都不可以整除它,然后我们就循环这个新的素数的倍数并且删掉它们。
而如何完成删除这个操作呢?
我们可以定义一个数组book,book[i]表示i是否是一个素数,如果i是一个素数那么我们就把book[i]的值就为0,反之则为1。
开始的时候,我们先定义book[2]=0,当我们循环2的倍数的时候,我们将所有的book[2k]赋为1(2k<=n k为正整数)
删除所有2的倍数后,我们就去寻找下一个book值为0的数,就是我们下一个处理的素数。
这个代码的时间复杂度为n/2+n/3+n/5+……+n/(小于√n的最大的素数)
而这样算下来约等于O(n*log(log n))
#include<iostream> #include<algorithm> #include<cstring> #include<string> #include<cmath> #include<cstdio> #include<cstdlib> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } inline void write(int x){ if(x<0) putchar('-'),x=-x; if(x>9) write(x/10); putchar(x%10+'0'); return ; } int n,ans; int book[1000006];//存的是每个数是否为素数 inline int solve(int x){ for(int i=2;i*x<=n;i++) book[i*x]=1;//每次删去小于n的所有的当前素数的倍数 } int main(){ n=rd(); for(int i=2;i<=n;i++){//从2开始,因为1既不是合数也不是素数 if(book[i]==0){ ans++; solve(i); } } cout<<ans; return 0; }
然后我们再来想,如果我们要判断一个数是不是素数,只需要判断这个数是不是合数,而判断一个数是不是合数,只需要找到一个可以整除它的不是1和它本身的数,而更进一步来讲,我们只需要找到一个小于√n的可以整除它的素数,如果找到了,说明它不是素数。
在刚才的埃氏筛法中,每个数都会别小于√x的素数删去一遍
我们能不能让一个数只被判断一次呢,这样我们就可以实现线性的时间复杂度,也就是理论上的O(n)
这样的神级算法是存在的,也是我们今天的主角,欧拉筛
和埃氏筛一样,我们同样利用一个数组book来存一个数是不是素数,是的话我们就要把它的倍数删去
不同的是,我们不是直接把一个素数的倍数删去,而是在其中加一些判断,保证一个数不会被重复的删掉两次。
如何做到这一点,我们用另一个数组p来存之前所有筛出来的素数
当我们判断x是不是素数时,我们循环之前找到的所有素数,并且删去x*p[i]
当然,我们并不是将所有的素数都删去,而是要附加一些条件
(1)
当我们发现x*p[i]大于n时,我们就退出素数的循环,因为之后的素数都大于p[i],超过了n,也就超出了我们需要判断的范围
(2)
如果x是p[i]的倍数,我们也要退出素数的循环,因为之后的素数如果继续删,就会出现重复,我们在这里用数学证明:
因为x=p[i]*v
所以x*p[i+1]=(p[i]*v)*p[i+1]
我们又知道p[i]<p[j+1]
所以p[i+1]*x在之后一定会以被删去,以p[i]*x'的形式,x'=x*p[i+1]
以此类推,之后的素数就都不用判断了
于是我们就得到了最后的算法
下面给出代码注释:
#include<iostream> #include<algorithm> #include<cstring> #include<string> #include<cmath> #include<cstdio> #include<cstdlib> using namespace std; inline int rd(){ int x=0,f=1; char ch=getchar(); for(;!isdigit(ch);ch=getchar()) if(ch=='-') f=-1; for(;isdigit(ch);ch=getchar()) x=x*10+ch-'0'; return x*f; } inline void write(int x){ if(x<0) putchar('-'),x=-x; if(x>9) write(x/10); putchar(x%10+'0'); return ; } int n,ans; int book[1000006];//存的是每个数是否为素数 int pos; int p[100006];//存的是当前找的的素数,从小到大排列 int main(){ n=rd(); for(int i=2;i<=n;i++){//从2开始,因为1既不是合数也不是素数 if(book[i]==0){ ans++; p[++pos]=i;//记录新的素数 } for(int j=1;j<=pos;j++){ if(i*p[j]>n) break;//如果超出了边界,之后的就都不用判断了 book[i*p[j]]=1; if(i%p[j]==0) break;//如果可以整除,说明之后会判断到 } } cout<<ans; return 0; }
其实不难发现,我们判断素数的速度虽然越来越快,但是需要的空间也越来越多,第一个算法我们需要存储的只有一个整数n,但是对于欧拉筛,我们就要利用一个大小为n的数组。
同时我们可以完成的操作也更多,比如说后面两种算法,不仅仅可以求出素数的个数,也可以用来判断从1到n任意一个数是不是素数,而前两种则做不到。
随着时间的升级,我们要在空间上花费更多,我们在使用的时候可以根据题目要求选择。
(完)