本文需要求解的问题是:输入一个数字n,输出比n小的所有素数。
基础
我们不着急开始线性筛的证明,从基础开始,先来了解一下什么是素数,什么是合数。
合数和因数
假定有一个数x,它能被两个数相乘表达出来,即a*b=x,而且这个a、b都不为0,那么这个数x就是合数,与此同时a、b就是它的两个因数。
素数
假定有一个数x,它只能被1和它本身相乘所表示,那么这个数就是素数。
由此,我们知道了一个概念:自然数的集合是由素数和合数两种数组合起来的。
质因数
既是质数也是因数的数就是质因数。
例如,5是一个质数,同时也是25的因数,那么这个数也就是25的一个质因数。
整除
除法都会吧?
如果有a*b=c,我们就称c能够整除a或者a能够整除b。我们记作c|b或者c|a。
记住上面的基础知识,这对于你接下来对每个方法的理解很重要!
接下来我们从暴力破解到一点点的优化到素数筛的发展逐一的介绍:
暴力求解
想要判断一个数是不是素数,从他的定义入手:
假定有一个数x,它只能被1和它本身相所表示,那么这个数就是素数。
我们就看看它有没有除了1和它本身之外的因数呗,直接循环遍历比他小的所有值就可以了:
#include <stdio.h>
int main() {
int n;
int flag = 1; //1:默认值,认为当前遍历到的值为素数
scanf("%d", &n);
for (int i = 2; i <= n; i++) {
for (int j = 2; j < i; j++) {
if (i % j == 0) { //发现当前遍历到的值有其他的因数,不是素数flag = 0
flag = 0;
break; //并且不再循环
}
}
if (flag) printf("%d\n", i); //输出素数
flag = 1; //为下一次循环做准备,重新将flag置为1
}
return 0;
}
这就是最简单的暴力破解算法,对于每一个数我们都去寻找比他小的“可能存在的因数”,当然,你也可以发现:偶数一定不是素数的,1、11、111、1111一定都是素数,基于此你也可以产生很多优化:但是今天的主题并不是这个,再此就不细究。
真正的筛——埃式筛
普通的暴力破解有个特点:
我们一直在关注素数的特性,想着如何去将素数找出来,但是却忽略了合数。
假设我们将所有的合数都进行标记,那剩下的数不就都是素数了吗?
根据合数的定义我们知道:合数一定存在不为1和它本身的因数,那么假设有一个数q,那么1q,2q,3q,4q...一定是合数呀!
基于此,真正的筛,诞生了!
我们观察自然数,0和1不去考虑,他们本身就不是合数,那么2是一个素数,既然这样,我们就把2的倍数全部标记!这些数都是合数!
之后发现3是素数,那么3的所有倍数也都是合数!继续标记!
......
如何用代码来表示我们的想法呢?
我们可以开辟一块足够大的空间,也就是一个数组,这个数组各部分的语义如下:
- 下标:表示每一个数字
- 存储内容:表示对应下标数字的状态,也就是说是否为素数
接下来举个例子:
假如我们想要输出100之前的所有素数,我们的数组就要大于100,初始时每个存储空间存储的都为0,表示我们假定所有数都是素数(就像一个筛糠的过程,一开始糠全部在筛子上面)。
然后依次遍历每一个值,从2开始,我们发现2是素数,那么接下来我们就取循环标记数组中下标为2的倍数的值,将他们存储的空间中记为1,表示他们为合数。
遍历到3,继续去标记3的倍数,以此类推......
代码如下:
#include <stdio.h>
#include <math.h>
int book[1000000] = {0}; //数组
int main() {
int n;
int temp; //标记过程中遍历到的倍数的最大值
scanf("%d", &n);
for(int i = 2; i <= n; i++) {
temp = n / i; //倍数的最大值就是输入数字n除以当前素数
if(!book[i]) {
for (int j = 2; j <= temp; j++) {
book[j * i] = 1;
}
}
}
for(int i = 2; i <= n; i++) {
if (!book[i]) printf("%d\n", i); // 循环输出所有素数
else continue;
}
return 0;
}
我们会发现一个问题:
当i = 2 => 2i=4,3i=6,4i=8...
当i = 3 => 2i=6,3i=9,4i=12...
当i = 4 => 2i=8...
发现了吗?我们似乎重复标记了一些数字,那该怎么解决这个问题呢?
接下来就是埃式筛的升级版,也是最终版——线性筛:
线性筛
我们来分析一下重复标记的原因:在上面的埃式筛中,当我们遇到素数后,将它的倍数全部标记,由此可以推断:一个数被重复标记的原因是因为它是不同质数的因数。
也就是说,我们使用了多个质因数去标记了这个合数。
例如:
12被2和3标记过,30被2、3、5同时标记过。
分析出了原因,优化方向就呼之欲出了:
我们只要使用最小质因数去标记这个数(十分重要,这是线性筛的核心原理所在)就行了,具体该怎么做呢?
具体来说就是维护一个标记数组 arr 和一个已有素数数组 primes ,然后,我们从2开始遍历所有数 i ,并且:(接下来这两条好好理解!!核心部分!!)
①把遇到所有未被标记为合数的数 i (埃式筛中从小到大遍历时遇到的一个个质数)存到 primes 里去;
②以 i 为第一个因子,分别以 primes 里每个质数 j 为第二个因子,求积,可以断定 j∗i 必为合数,故标记之;同时,若 mod(i,j)==0 ,说明 j 是 i∗j 的一个质因数,又因为 primes 数组中的元素是递增的, j 是第一个可以除断 i 的,故可断定 j 是 i 的最小质因数,同时也是 i∗j 的最小质因数,那么更进一步也可断定j 之后的质数 j′ 一定不是 i∗j′ 的最小质因数,故结束 primes 的遍历。
上代码:
#include <stdio.h>
#include <math.h>
int arr[1000000] = {0}; //对应上面的埃式筛的book数组
int primes[1000000] = {0}; //用于存储目前发现的质数的数组
int main() {
int n, pos = 0; //n的语义没变,pos表示primes数组中存放个数,同时对应其下标,从1开始
scanf("%d", &n);
for (int i = 2; i <= n; i++) {
if(!arr[i]) primes[++pos] = i; //同埃式筛,如果未被标记,那么这个数为素数,放入primes数组
for (int j = 1; j <= pos; j++) { //这一部分是标记部分,对应我文章中写的两条来理解!!
if (i * primes[j] > n) break; //超出范围,停止循环
arr[i * primes[j]] = 1; // 记住,我们始终只让最小的质因数作为因数去标记后面的数字!!
if (i % primes[j] == 0) break; //灵魂语句,当发现i能被该质数整除时,那么这个primes[i]就是最小质因数,我们让最小质因数去标记的目的达到了,结束循环!
}
}
for (int i = 2; i <= n; i++) {
if (!arr[i]) printf("%d\n", i);
}
return 0;
}
这个图片说明了线性筛的执行过程(从左到右从上到下):
红色是被优化的部分。
为了帮助理解,我们根据代码过程和前几个数进行一下过程模拟:
我们分块进行:
首先,所有的数都默认为素数,i 从2开始遍历,
2为素数,丢到primers数组中。
接下来开始标记环节:当前i为2,数组中只有2
因此只遍历一个值:2 * 2 = 4,此时4被标记;
此时2 % 2 = 0,停止循环,说明4的最小质因数是2
接下来,i 遍历到3,
3也是素数,丢到primers数组中。
标记环节:i 为3,数组中有2、3两个值
2 * 3 = 6, 3 * 3 = 9,此时6和9被标记
此时3 % 3 = 0,停止循环,说明9的最小质因数是3
接下来,i 遍历到4,
4不是素数,跳过
接下来,i 遍历到5,
5也是素数,丢到primers数组中。
标记环节:i 为5,数组中有2、3、5两个值
2 * 5 = 10, 3 * 5 = 15,5 * 5 = 25此时10和15和25被标记
此时5 % 5 = 0,停止循环,说明25的最小质因数是5
......
线性筛的改进实际上就是“每个合数只被它的最小质因数筛去”,但实现的时候需要以下两个逻辑支撑:
一、每个合数只筛一次
①算法特性决定了找到的素数只能从自身开始乘。比如找到素数5时,它的2、3、4倍已经删不到了,这样避免了重复删。(虽然单独列出但也可以合并进②)
②if(i%primers[j]==0)break;语句实现不重复删。证明很简单,自然数 i 从最小素数2开始乘, i×primers[j] 以每个 primers[j] 为最小质因数,直到 primers[j] 整除 i 时,i×primers[j] 还是以primers[j]为最小质因数,但下一个数 i×primers[j+1]=(i/primers[j])×primers[j]×primers[j+1]中primers[j+1]就不是最小质因数了,所以不必继续乘了。
二、不能干扰到运用埃氏筛核心原理找素数
①与②共同实现不重复筛的同时导致了素数倍数的缺失,比如程序运行到s7时,7是个标记素数(没被当合数删除),那么素数2、3、5要从2倍一直删到倍数大于7才能保证7确实是个素数,但明眼的看表就知道缺失了好多素数倍数啊,怎么能证明这些缺失不影响素数的确定呢?
证明:首先①②中不重复筛的合数最终会出现在图表中(延迟删去)。设L是标记素数,把小于L的所有素数倍数放入集合Q中,用q表示Q中任意素数倍数,则q<L<2L,由于图中素数倍数的大小排列特性,q<2L意味着小于L的素数倍数都会在步骤s(L)之前被删去,这样得证①和②不影响素数的确定。因L不被小于它的所有素数整除,所以L是素数。
综上就是线性筛的算法逻辑,为证明它需要考虑的出乎意料地多,理解“if(i%primers[j]==0)break;”语句也才仅仅是理解皮毛(值得一提的是,即使删去此语句,余下的部分也是相当优秀的程序)。
线性筛部分参考博客: