Bootstrap

素数筛(埃氏筛、欧拉筛)

本文会从最基础的知识讲起,一步步深入,使文章更易于理解。

首先,对于判断一个数n是否是素数,我们有多种方法,最容易理解的即枚举2 ~ n - 1之间的所有数,若存在数x能整除n(即 n % x == 0),则n不是素数,否则n是素数。该方法的时间复杂度为O(n).

bool isPrime(int n){
    for(int i = 2; i < n; i++){
        if(n % i == 0)return false;//n能被i整除,则n不是素数
    }
    return true;
}

那么能不能对以上的判定素数的方法进行优化呢?答案是可以的!

我们知道若存在两个大于等于2的正整数x, y满足 x * y == n, x <= y。则有 n % x == 0,n % y == 0。且x <= sqrt(n),y >= sqrt(n),因为如果x和y都小于sqrt(n),则x * y必定小于n;反之,如果x和y都大于sqrt(n),则x * y必定大于n。所以,可以整除n的数是成对出现、一大一小分布在根号n两边的,如果我们找遍2 ~ sqrt(n),都找不到数可以整除n的话,则n必定是素数。改进后的时间复杂度为O(n ^ 1/2)。

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

那么问题来了,如果我给你一个数字n,让你判断有多少个小于n的素数,即力扣的第204题,你该怎么办呢?

理所当然应该想到可以从2到n,对每个数进行判断,从而输出素数的个数。

但这样做的时间复杂度太大了,如果n的数量级达到10^5甚至更高,则所需的计算时间是无法接受的。

所以引出了埃氏筛法,时间复杂度为O(nloglogn)

该方法的核心思想是:从2开始,将每个质数的倍数都标记为合数,以达到筛选素数的目的。

int countPrimes(int n) {
    bool* isprime = new bool[n]();
    int num = 0;
    for(int i = 2; i < n; i++){
        if(!isprime[i]){
            num++;
            for(int j = 2 * i; j < n; j += i)isprime[j] = true;
        }
    }
    return num;
}

上述代码实现时,每当isprime[i]的值为false时,则i为素数,i的倍数都为合数,所以我们将i的倍数置为true,表示其不是素数。

为什么当遍历到i且isprime[i]为false时即可确定i为素数呢,难道不会漏筛吗?首先我们第一个遍历的数为2,isprime[2]为false,2是素数。所以当i == 2时,isprime[i]为false,i为素数成立。那么对于任意的i > 2,当我们遍历到i时,则我们一定先遍历了[2, i - 1]之间的所有数,若isprime[i]为false,则证明i不是[2, i - 1]中任意素数的倍数,即i只能被1和它自身整除,所以i为素数。

上述的埃氏筛还可以做一些优化,可以将j = i * 2 改为 j = i * i。若 x ∈ [2, i - 1],则 x * i 一定已经被标记为合数了,因为在遍历到i之前,我们一定已经遍历过x了,若x的最小质因数为k,k * y == x,则x的i倍即k的 i * y倍若小于n则一定已经被标记过了。

虽然埃氏筛可以极大的提高效率,但当数据量达到10^7时,它的速度仍然很慢。因为埃氏筛会存在重复筛除的情况,例如12会被2和3筛除,由于2 * 6已经将12筛除,则3 * 4筛除12时属于无用功,一个数有多少质因子就会被筛除多少次,这里面存在着大量的资源浪费。那么如何避免重复筛除从而提高效率呢?欧拉筛可以解决这一问题。

欧拉筛:在埃氏筛法的基础上,让每个合数只被它的最小质因子筛选一次,以达到不重复的目的。

int countPrimes(int n) {
    bool* isprime = new bool[n]();
    vector<int> prime(1);
    for(int i = 2; i < n; i++){
        if(!isprime[i]){
            prime[0]++;
            prime.push_back(i);
        }
        for(int j = 1; j <= prime[0] && prime[j] * i < n; j++){
            isprime[prime[j] * i] = true;
            if(i % prime[j] == 0)break;
        }
    }
    return prime[0];
}

从2到n - 1遍历,当我们遍历到i时,如果isprime[i]为false,则i为素数,这个条件在i == 2时是成立的,于是我们接着将当前已经得到的素数的i倍标记为合数,直到 i % prime[j] == 0 (假设此时i / prime[j] = p)时退出循环,因为如果此时不退出,我们将prime[j + 1] * i标记为合数时是一种重复标记,我们令u = prime[j + 1] * i = prime[j + 1] * prime[j] * p,则可知u的最小质因子是prime[j],我们不应当用prime[j + 1]的i倍来标记,而应当在i增加到i == prime[j + 1] * p时,用prime[j]的i倍来筛除u,这样u才是被它的最小质因数筛除的。

上面我们说明了欧拉筛不会重复筛,那么为什么欧拉筛不会漏筛呢?

假设我们要筛掉数a,且a的最小质因数为p1,令a = p1 * b。那么显然b < a,b先被外层循环碰到。现在从小到大遍历prime中的素数,并将它们的b倍标记为合数。因为p1是a的最小质因数,所以b的最小质因数必不小于p1(a的最小质因数一定小于b的最小质因数,因为a是b的倍数,所以b的质因数也是a的质因数),这样就保证p1 * b筛掉a前不会跳出循环。即使b的最小质因数等于p1,也会先筛掉a后再退出循环。令a等于全体合数,就保证了每个合数都会被筛掉。

欧拉筛和埃氏筛是否会漏筛问题其实都可以用数学归纳法来证明,有兴趣的可以自己去了解。
 

;