算法篇——素数筛

  今天带来作者发布的第一篇算法系列文章——素数筛。素数筛中包含了埃氏筛和欧拉筛(线性筛),这两种筛法都是来解决自然数n以内判断素数的算法,那现在就开启我们今天的教学。

一、素数、合数、因数

  在开始之前,我们先复习一下在中学阶段的一些数学术语,方便我们后面的学习。

  1.素数:也叫质数,是指在大于1的自然数中,除了1和它本身以外不再有其他因数的自然数。比如:2、3、5、7、11等等

  2.合数:是素数的反义词,是指在大于1的整数中除了能被1和本身整除外,还能被其他数(0除外)整除的数。与之相对的是质数,而1既不属于质数也不属于合数。最小的合数是4。

  3.因数:是指整数a除以整数b(b≠0) 的商正好是整数而没有余数,我们就说b是a的因数。

二、判断质数的朴素算法

现给定一需求:输入一个数字n,判断其是否为质数,若是则返回true,不是则返回false

  在我们平常实现代码中我们更多会这么写:

bool isprime(int n){
    for(int i = 2;i<n;i++){
        if(n % i == 0){
            return false;
        }
    }
    return true;
}

  在这段代码中,输入的数字近似从头遍历到尾,时间复杂度为O(n),这毋庸置疑,也是最简单的写法,那么我们首先进行第一轮优化,在这个遍历中能否少遍历几次?数学好的人可能已经想到了,我们可以将遍历的范围改成 n \sqrt n n ,具体为什么呢?若a是一个合数,那么一定可以被表示成a = pq,其中p,q>1,我们不难推出其中的一个数一定不超过 a \sqrt a a ,因为如果全都超过 a \sqrt a a ,pq必然大于a,这样等式就不成立了,再进一步,一个合数一定存在一个小于等于 a \sqrt a a 的质数p,使a = p*q成立,推到这里相比各位已经豁然开朗了,我们在遍历的时候将边界设置为 a \sqrt a a 就完全可以找到数n除去1与自己的因数,因此给出如下代码:

bool isprime(int n){
    for(int i = 2;i*i<=n;i++){
        if(n % i == 0){
            return false;
        }
    }
    return true;
}

  上述代码中,不采用sqrt(n)的原因是其函数是由二分答案实现,有复杂度、有误差,而i*i则不会。


  在经过上述的优化后,我们就将原先的O(n)复杂度降到了O( a \sqrt a a ),看起来已经优化到了极限,速度已经很快,那现在我们在原先的场景上加一些改变:

输入一个数字n,求从2到n的所有质数

  对于这种题目,如果用原先的朴素算法复杂度将变为O(n2),这在竞赛中的大数据量里是无法胜任的,因此我们可以再进一步的优化,也就是我们本章的主题——筛法。

三、埃氏筛

  由上面的疑问,一位希腊(就是你知道的那个古希腊)的数学家提出了一种算法,可以得到n以内的所有素数,这种算法即为埃氏筛。

具体的思路也很简单,在前面我们得到了一个结论:若a为合数,一定存在质数p,使得p≤ a \sqrt a a ,由此结论我们可以知道,质数的倍数一定是合数,因此我们可以定义一个数组,让所有质数的倍数全都标记一遍,直到达到边界值n停止。这样标记数组中未被标记的元素则是素数,而通过前面的结论我们也可以得出,在通过较小质数筛掉合数的过程中,只需要不超过 n \sqrt n n 的质数即可完成筛选(这里不明白的可以举例子,假如筛100内的质数,则只需要不超过10的质数:2、3、5、7,即可对100以内的所有质数进行全部筛选)。

下面我将通过图片演示这一过程:

  在图中,我用不同颜色的线划出斜杠代表被不同的素数的倍数所筛选掉的合数,在全部完成后我们就可以发现,图中未被标记的元素也恰好为质数,这就是埃氏筛的全过程。

  那说了这么多,接下来我们用代码来实现一下,帮助各位理解:

#include <bits/stdc++.h>//万能头文件,MSVC不能用
using namespace std;

const int N = 10000010;//定义数组大小

bool st[N];//定义标记数组,值为true则是合数,false为质数
//st的下标也直接对应该数的值,比如st[2]为false,则2位质数

void get_primes(int n){
    st[0] = true;
    st[1] = true;//对0和1两个特殊的数进行处理
    for(int i = 2;i*i<=n;i++){
        if(!st[i]){//如果st没有被标记
            for(int j = i + i;j<=n;j+=i){
                //每一次循环从i的倍数开始,结束时再加上i的倍数
                st[j] = true;
            }
        }
    }
}

int main() {
    get_primes(100);
    for(int i = 2;i<=100;i++){
        if(!st[i]){
            cout << i << endl;
        }
    }
    system("pause");
    return 0;
}

  而对于上述算法的时间复杂度分析,因为篇幅原因就不在这里推导了,埃氏筛的时间复杂度为O(n·log(logn)),在一定数据量下(1e6范围内)约等于线性复杂度,但超过这个数据量后和O(n)复杂度的差距也会越来越大,接下来我们引出本章最后的优化——欧拉筛(线性筛)。

四、欧拉筛(线性筛)

线性筛的实现原理

  在我们刚刚展示的图表中可以发现有一部分合数被不同的质数重复标记,比如6、10等,因此增加了运算次数,所以有没有其他办法可以避免重复操作,使合数只被一个质数所标记?

  我们通过上面的例子来发现规律,6分解因数(除1和本身)为2和3,10分解因数为2和5,以此类推,再结合我们前面用到的推论:

一个合数一定存在一个小于等于 a \sqrt a a 的质数p,使a = p*q成立

那我们就不难推出:对于一个合数,其只被最小质因数筛,而与之相对应的就是非本身的最大因数,比如对于28这个数字,非本身最大因数是14,与之对应的最小质因数则为2,这样就可以做到筛选时不重复且不漏掉合数,总结出公式则为:

非自身最大因数 X 最小质因数 = 该合数

对于这个结论也可以运用唯一分解定理,**任何一个大于1的整数n 都可以分解成若干个素因数的连乘积,如果不计各个素因数的顺序,那么这种分解是惟一的。**将其式子中的素数提取并找到最小的素数,也可得到上述式子,方法很多就不一一列举,即使没有前置芝士也可以通过数感推导出来,明确我们的最终目的:找到一个数的最小质因子时就停止后续标记

线性筛的代码实践

  有了这个结论接下来就是代码实践了,因为在这个过程中会用到以前保存过的质数,所以为了方便使用,与上次的不同的是添加了存储质数的数组,先给出实现代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 10000010;//定义数组大小

int primes[N],cnt;
bool st[N];

void get_primes(int n){
    st[0] = true;
    st[1] = true;
    for(int i = 2;i<=n;i++){
        if(!st[i]){
            primes[cnt++] = i;
        }
        for(int j = 0;primes[j]<=n/i;j++){
            st[primes[j]*i] = true;//素数的倍数一定是合数,且primes[j]一定是primes[j]*i的最小质因数
            if(i%primes[j] == 0){
                break;
            }
        }
    }
}

当我们看到上面代码时有些人可能是一脸懵:“不是只提取每个数的最小质因数嘛,最后代码和埃氏筛怎么差了这么多?”我们先看看两段代码的区别(存储质数除外),外层循环上范围从i*i<=n变为了i<=n,细心的同学也许发现了,两个代码中i表示的意义发生了变化,我们先要去探讨这段代码的执行思路是什么:

 1.先处理0和1两个特殊值,i从2开始遍历

 2.判断i是否在标记数组中被标记,如果没有被标记则放入到质数数组内

 3.来到了第二重的循环,而在循环的判断条件中写了primes[j]<=n/i,对于这个的解释网上五花八门,简单来说就是为下一条语句服务,下一条语句是对满足条件进行标记,而我们的边界就是n,因此我们只需让primes[j]*i≤n做标记,就可以完成所有的筛选,所以边界只需n/i,在循环的判断条件中,体现了i的两个作用,第一个用途和埃氏筛一样,作为筛选的合数,第二个用途则为当倍数。下面循环体内的两条语句做出解释:

  a. primes[j]*i的意义是一个素数的i倍标记为true,与埃氏筛思路相同

  b. 由于primes数组是单调递增的,所以当i%primes[j] == 0时,primes[j]一定是i的最小质因数

  c. 不管if条件是否成立,primes[j]一定是primes[j]*i的最小质因数

因此这段代码达成了我们最初的目的:用该数的最小质因数的倍数来标记此数,并保证该数不被其他质因数重复标记

简要分析线性筛时间复杂度

  很多初学者在看完线性筛的实现代码时可能都会提出疑问:“这代码两重循环,怎么可能达到线性复杂度?”对于复杂度的计算这里不多说了,但计算方法并不能单单以循环来判断,一般双重循环跑满或近似跑满我们复杂度才会为O(n2),而在这个算法中,每一个数从头到位只被标记一次,一个数的非最小质因数也不会是其重复标记,因此该算法不重且不漏,我们可能初步确定其复杂度为O(n),更专业的证明方法也请去网上查询专业文章,这里就不多说了。

五、总结

  在本章中我们讲了判断质数的朴素做法、埃氏筛、线性筛,希望在以后碰到类似题时可以让你有不一样的思路,而这篇文章也是作者写的第一篇算法类文章,如果有错误也请及时纠正,关于筛法的相关题目,也会在后续给出例题与题解。


  好了本期内容到这里就结束了,如果你认为该文章对你有一点点帮助,也请希望收藏我的主页或者关注微信公众号:ModCx,如果有什么疑问或者建议也可在微信公众号后台留言,你们的支持就是我的动力,让我们下期再见~

  • 6
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值