我们以leetcode204题计数质数来讲解素数筛问题
题目描述:
给定整数 n ,返回 所有小于非负整数 n 的质数的数量 。
示例 1:
输入:n = 10
输出:4
解释:小于 10 的质数一共有 4 个, 它们是 2, 3, 5, 7 。
示例 2:输入:n = 0
输出:0
示例 3:输入:n = 1
输出:0来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/count-primes
首先我们给出常规方法:
CountPrime函数用于计算小于n的非负整数的质数数量,我们用一个循环来遍历从2开始到n的所有数字,并判断该数字是否是质数,如果是则sum++。IsPrime用来判断一个数是否为素数,我们从2
开始遍历到n,判断这个数是否是n的因子,如果是则证明该数不是质数,遍历结束没有找到因子则返回真。
下面给出代码:
#include<iostream>
#define ll long long
using namespace std;
ll sum = 0;
bool IsPrime(ll n)
{
for (ll i = 2; i < n; i++)
{
if (n % i == 0)
return false;
}
return true;
}
void CountPrime(ll n)
{
for (ll i = 2; i < n; i++)
{
if (IsPrime(i))
{
sum++;
}
}
}
int main(void)
{
ll num = 1e6;
time_t first, second;
first = time(NULL);
CountPrime(num);
second = time(NULL);
cout << "共" << sum << "个质数,用时" << second - first <<"s" << endl;
return 0;
}
我们给出数据大小为1e6,得出结果
用时303s可以看出这个时间复杂度是相当的高的为
我们对上面的算法可以进行一些改进,我们在判断素数的时候遍历到n,但实际情况两个数相乘x * y = n,那么x,y必须有一个大于一个小于,所以我们只需要遍历到即可,实际复杂度
下面给出更改后的代码,其中i < n,是为了排除n=2时的影响,读者可以自行思考一下
bool IsPrime(ll n)
{
for (ll i = 2; i < sqrt(n) + 1 && i < n; i++)
{
if (n % i == 0)
return false;
}
return true;
}
下面给出运行结果:
可以看出这次用时1s比未改进前快了300倍。
下面介绍进阶方法埃氏筛:
什么是埃氏筛法?我们用一个数组isPrime来维护从1到n所有数的状态,其中0表示素数,1表示合数。我们遍历到其中一个数i的时候,把i的所有倍数全部记录为合数以此来减少循环的次数。那么为什么这个方法可以筛选出所有的质数,假设我们需要判断的数字为n,那么在判断之前,我们已经遍历了从[2,n-1]内的所有值以及把它们的倍数都标记为合数,所以n的因子是否在[2,n-1]内的情况已经清楚。
#include<iostream>
#include<vector>
#define ll long long
using namespace std;
ll sum = 0;
void CountPrime(ll n)
{
vector<int> isPrime(n);
for (ll i = 2; i < n; i++)
{
if (isPrime[i] == 0)
sum++;
for (ll j = 2 * i; j < n; j += i)
{
isPrime[j] = 1;
}
}
}
int main(void)
{
ll num = 1e6;
time_t first, second;
first = time(NULL);
CountPrime(num);
second = time(NULL);
cout << "共" << sum << "个质数,用时" << second - first << "s" << endl;
return 0;
}
下面给出运行结果:
可以看出用时为0s速度相当的快,此算法的时间复杂度为O(nloglogn),所以为了便于后面算法效率的比较,我们将数据增加为1e9
下面给出运行结果:
这就是埃氏算法的最快情况吗,显然我们还可以再进行优化,我们注意每次更新标记是每次都是从j = 2 * i开始,我们假设i = 10,那么我们就是要标记20,30,40...为合数,但是其中2 * 10是不是在i=2的时候已经标记过一次,因为我们在i = 2时需要标记4,6,8,10...10 * 2。所以我们可以令j = i * i,这样就排除了[2,i-1]中重复计算。用数学语言解释就是,假设x * i = y,x = zi',则y的质因数中有z,y的判断在z遍历时已经完成。
下面给出优化代码:
void CountPrime(ll n)
{
vector<int> isPrime(n);
for (ll i = 2; i < n; i++)
{
if (isPrime[i] == 0)
sum++;
for (ll j = i * i; j < n; j += i)
{
isPrime[j] = 1;
}
}
}
下面给出运行结果:
可以看出用时为103秒,比改进前快了2倍左右
下面我们再来介绍埃氏筛的进阶改法,欧拉筛法:
我们以12为例子,12 = 2 * 6,12 = 3 * 4,那么12在2的时候标记了一次,在3的时候又被标记了一次,我们怎么解决这个重复的问题呢,这时我们引入一个定理:
正整数的唯一分解定理,即:每个大于1的自然数均可写为质数的积,而且这些素因子按大小排列之后,写法仅有一种方式。
也即是i = a * b * c * ...,我们假设a是最小质因数。我们简化为i = a * m,其中m为b * c * ...,a为最小质因数,我们用一个数组prime来存储目前的素数集合,我们设目前遍历到的数字为i,由正整数的唯一分解定理可知,i = a * m,假设a为最小质因数,那么在遍历到i之前已经确定了[2,i-1]内所有质数的倍数了,比如说i = 12,那么在i = 6,prime[j] = 2时已经标识过一次。所以,我们可以通过i % prime[j] 来判断是否当前i分解后的质数是否在已经遍历的质数序列中,如果有则直接可以退出循环。
下面给出优化代码:
void CountPrime(ll n)
{
vector<int> isPrime(n);
vector<int> prime;
for (ll i = 2; i < n; i++)
{
if (isPrime[i] == 0)
{
sum++;
prime.push_back(i);
}
for (ll j = 0; j < prime.size() && i * prime[j] < n; j++)
{
isPrime[i * prime[j]] = 1;
if (i % prime[j] == 0)
break;
}
}
}
运行结果为:
可以看出用时为50秒,比改进前快了2倍左右