数值计算优化方法C/C++(六)——统计质数个数(访存优化以及vector-bool的坑)

(原谅我不知道标题里怎么加<>,标题里那个是vector<bool>)
这是最近看到一篇博客《3秒钟内统计出小于1亿的素数个数》激起了我的兴趣,然后做了许多测试,这里展示一下。

算法

首先说一下质数的统计算法,查找某个范围内的所有质数,基本方法就是筛法。筛法的基本原理就是将2到要查找的范围内的所有整数先存下来,然后从最小的质数2开始,把所有质数的倍数都筛去,最后数组剩下的就是全部的质数。

比如要找10以内的质数,那么首先建立数组
{2,3,4,5,6,7,8,9,10}
第一步筛去2的倍数剩下的就是{2,3,5,7,9}

这时2的下一个数字必然也是质数,于是筛掉3的倍数得到{2,3,5,7}

这时3的下一个数字5也是质数,但是5的平方已经大于10了,所以这时已经找到了所有10以内的质数。

这就是最基本的筛法,这个算法的速度远快于一个一个数字判断是不是质数。

算法虽然简单,但是我发现真正实现的时候还是有很多需要注意的地方

利用链表实现

首先我们来一个最容易想到也最容易理解的筛法实现。利用链表将2到一亿(不包括一亿)的所有整数都存起来,然后从链表头部2开始筛掉2的倍数,只要访问到一个节点能被2整除就把这个节点删掉,筛完2的倍数之后2的下一个节点自然就是下一个质数,于是就开始筛2的下一个节点的倍数,直到筛到刚刚大于一亿的平方根(也就是10000)的质因数时就找到了所有质数,输出链表长度就是一亿以内的质数数量了。具体代码如下

void list_prime(int n)
{
	list<int> nums(n-1);
	auto p1=nums.begin();
	for(int i=2;i<n;i++) *(p1++)=i;
	p1=nums.begin();
	while(*p1<pow(n,0.5))
	{
		auto p2=p1;
		p2++;
		while(p2!=nums.end())
		{
			if((*p2)%(*p1)) p2++;
			else p2=nums.erase(p2);
		}
		p1++;
	}
	cout<<nums.size()<<endl;
}

测试一下

int main()
{
	auto s=clock();
	#define TN 100000000
	list_prime(TN);
	cout<<double(clock()-s)/CLOCKS_PER_SEC<<"s\n";
	return 0;
}

由于我的机器太垃圾,测试后发现死机了(尴尬)。然后改成一千万测了一下用时

664579
20.0658s

好吧这个方法虽然容易想到也容易实现,但是确实太菜了。我推测这个方法慢主要是两方面原因,第一是链表数据虽然删除节点很快,但是由于链表数据本身不连续,访问较慢,因此效果不理想。另外一个原因是这个方法中每访问一个节点我们就要算一次取余,这相当于做了一次除法,但其实通过一些设计我们可以减少除法的次数。

利用vector实现

分析了前面的问题,首先测试一下vector实现的筛法,模仿前面的链表的做法,我们仅仅用vector来替换链表,其它计算方式不变我们写一个新的筛法。

void vector_prime(int n)
{
	vector<int> nums;
	nums.resize(n-2);
	for(int i=2;i<n;i++) nums[i-2]=i;
	auto p1=nums.begin();
	while(*p1<pow(n,0.5))
	{
		auto p2=p1;
		p2++;
		int tp=p2-nums.begin();//tp标记为当前可用位置,tp前的所有数都是不能被当前用于筛去合数的质因数整除的数
		while(p2<nums.end())
		{
			if((*p2)%(*p1)==0) p2++;
			else
			{
				nums[tp]=*p2;
				p2++;
				tp++;
			}
		}
		p1++;
		nums.resize(tp);
	}
	cout<<nums.size()<<endl;
}

这次怂一点,先测个一千万的质数,用时为

664579

1.36286s

是之前的1/20,我们再测一下一亿,用时

5761455
32.2423s

还好能算出来。可以看到只是使用vector代替list其它的计算没有改变,性能得到了明显的提升,这也说明由于list中数据的不连续造成了前一个版本性能的极度低下,甚至给我搞死机了。

优化计算过程

接下来我们解决另一个问题,就是前面说到的多次取余问题。其实如果我们每次筛掉的数不从数组里面去除,而只是标记为非质数,这样我们每次筛除合数时就完全可以利用下标来找到某一个数的倍数。例如筛除2的倍数时只需要从第一个数2开始以2为步长让每个访问到的数都置零,如果筛除3的倍数则从3的位置开始以3为步长每个访问到的数都置零。同样筛到第一个大于10000的质因数为止。

void vector_prime2(int n)
{
	vector<int> nums(n-2);
	for(int i=2;i<n;i++) nums[i-2]=i;
	int p1=0;
	while(nums[p1]<pow(n,0.5))
	{
		for(int i=p1+nums[p1];i<nums.size();i+=nums[p1]) nums[i]=0;
		p1++;
		while(nums[p1]==0) p1++;
	}
	p1=0;
	for(int i=0;i<nums.size();i++)
	{
		if(nums[i]==0) continue;
		else
		{
			nums[p1]=nums[i];
			p1++;
		}
	}
	nums.resize(p1);
	cout<<nums.size()<<endl;
}

先用一千万测试一下这个实现

664579
0.249079s

可以看到快了非常多,再测一下一亿

5761455
2.8559s

这一下速度又快了90%。

提高缓存利用率

好了到这里我们在list版本中提到的两个问题,非连续访存和重复取余的问题都解决了,似乎就没什么可以改进的了。而且对比那篇博客里的Python算法我们也成功的超过了他(他的Python代码我的机器要跑接近4s,这个程序已经打败他了)。

然而还没有结束,那篇博客中提到将计算分块可以加速计算。一开始我始终没有想明白,后来我恍然大悟,这里是一个Cache的问题。一亿的数据显然Cache不可能存的下。如果我们每次筛选某个质因数的倍数时都把一亿个数中的所有倍数都筛除,那么我们每筛去一个质因数的倍数就要从主存读取一亿个数据。因为我们只筛小于10000的质因数的倍数,所有总共需要筛除1229次(小于10000的质数有1229个),也就是说需要从主存读取一亿个数据1229次,总计就是1229亿的数据量(其实并没有这么多,因为每次筛除掉的合数并不能达到一亿,但是确实每个合数基本都是从主存里读取和修改的并没有很好的利用Cache)。而如果我们以Cache的大小为block,分块筛除我们就可以减少从主存读取的次数,假定以L2 Cache的大小进行分块,我的机器L2 Cache是256K,总共大约6万个整型数据,那么以6万为block,分块完成筛除只需要从主存把所有数据读取一次,剩余的访问都是从Cache中完成的。显然这样应该更快,而且一定更省内存,于是按照那篇博客的算法得到了下面的版本。

void vector_prime_step1(int n,vector<int> &nums)
{
	nums.resize(n-2);
	for(int i=2;i<n;i++) nums[i-2]=i;
	int p1=0;
	while(nums[p1]<pow(n,0.5))
	{
		//int p2=p1+1;
		for(int i=p1+nums[p1];i<nums.size();i+=nums[p1]) nums[i]=0;
		p1++;
		while(nums[p1]==0) p1++;
	}
	p1=0;
	for(int i=0;i<nums.size();i++)
	{
		if(nums[i]==0) continue;
		else
		{
			nums[p1]=nums[i];
			p1++;
		}
	}
	nums.resize(p1);
}
void vector_prime_kernel(int s,int e,vector<int> &nums)
{
	int l=nums.size();
	nums.resize(l+e-s);
	nums[l]=s;
	for(int i=l+1;i<nums.size();i++) nums[i]=nums[i-1]+1;
	int p1=0;
	while(nums[p1]<pow(e,0.5)&&p1<l)
	{
		int p2=(nums[p1]-s%nums[p1])%nums[p1]+l;//!!!!!
		for(int i=p2;i<nums.size();i+=nums[p1]) nums[i]=0;
		p1++;
	}
	p1=l;
	for(int i=l;i<nums.size();i++)
	{
		if(nums[i]==0) continue;
		else
		{
			nums[p1]=nums[i];
			p1++;
		}
	}
	nums.resize(p1);
}
void vector_prime3(int n,int _block)
{
	vector<int> nums;
	int t=pow(n,0.5)+1;
	vector_prime_step1(t,nums);
	for(;t<n;t+=_block) vector_prime_kernel(t,min(n,t+_block),nums);
	cout<<nums.size()<<endl;
}

用一亿测一下

5761455
0.623077s

可以看到性能又一次得到了明显提升。这里说一下这个分块的精髓,这个分块比较麻烦的地方在于寻找每一块中第一个能被当前用于筛选的质因数整除的合数的位置。如果从块的起始位置开始一个一个比较,同样可能需要很多次取余才能找到,但是那篇博客中巧妙利用对分块的起始值两次取余直接找到了块内第一个能被当前质因数整除的数的下标(注释的那一行)。

进一步提高缓存利用率

到现在还有能加速的地方吗?答案是还有。我们这里每个分块相当于是开辟一个6万的整型数组,用于筛选,但是实际上由于block内的数值是连续的,因此通过下标0-59999很容易就可以映射到分块内的数值上。而且其中保存的数值本身并没有太大意义,真正有意义的是block中某一个位置是不是0,既然如此用bool来代替int就可以将block中的整数增加到原来的4倍,block的数量减少为原来的1/4,速度就能更快。

void vector_prime_step1(int n,vector<int> &nums)
{
	nums.resize(n-2);
	for(int i=2;i<n;i++) nums[i-2]=i;
	int p1=0;
	while(nums[p1]<pow(n,0.5))
	{
		//int p2=p1+1;
		for(int i=p1+nums[p1];i<nums.size();i+=nums[p1]) nums[i]=0;
		p1++;
		while(nums[p1]==0) p1++;
	}
	p1=0;
	for(int i=0;i<nums.size();i++)
	{
		if(nums[i]==0) continue;
		else
		{
			nums[p1]=nums[i];
			p1++;
		}
	}
	nums.resize(p1);
}
void vector_prime_kernel2(int s,int e,vector<int> &nums,vector<bool> &tmp)
{
	int l=nums.size();
	tmp.resize(e-s);
	for(int i=0;i<tmp.size();i++) tmp[i]=true;
	int p1=0;
	while(nums[p1]<pow(e,0.5)&&p1<l)
	{
		int p2=(nums[p1]-s%nums[p1])%nums[p1];
		for(int i=p2;i<tmp.size();i+=nums[p1]) tmp[i]=false;
		p1++;
	}
	for(int i=0;i<tmp.size();i++)
	{
		if(tmp[i]) nums.push_back(s+i);
	}
}
void vector_prime4(int n,int _block)
{
	vector<int> nums;
	vector<bool> tmp;
	tmp.resize(_block);
	int t=pow(n,0.5)+1;
	vector_prime_step1(t,nums);
	for(;t<n;t+=_block) vector_prime_kernel2(t,min(n,t+_block),nums,tmp);
	cout<<nums.size()<<endl;
}

block大小改为24万,用一亿测试一下

5761455
0.702684s

速度反而变慢了(尴尬)。我仔细思考了一下整个流程,这个方案肯定是可行的。完全不能理解为什么会变慢,直到我突然想起很久以前看到的STL中的vector<bool>似乎做了特殊处理,每一个bool只占了一个bit,并不是一个字节,但是由于数据处理都是以字节来处理的,所以这种一个bit的数据一个一个处理会非常慢。于是打开搜索引擎果然发现是这个问题vector的模板中特化了vector<bool>。但是我还是不死心,做了最后一次挣扎,既然bool只占一个bit那我把block再乘8行不行呢?于是用新的block大小我又跑了一次,并没有什么卵用。

于是改变策略,用vector<char>代替vector<bool>,再做一次

void vector_prime_step1(int n,vector<int> &nums)
{
	nums.resize(n-2);
	for(int i=2;i<n;i++) nums[i-2]=i;
	int p1=0;
	while(nums[p1]<pow(n,0.5))
	{
		//int p2=p1+1;
		for(int i=p1+nums[p1];i<nums.size();i+=nums[p1]) nums[i]=0;
		p1++;
		while(nums[p1]==0) p1++;
	}
	p1=0;
	for(int i=0;i<nums.size();i++)
	{
		if(nums[i]==0) continue;
		else
		{
			nums[p1]=nums[i];
			p1++;
		}
	}
	nums.resize(p1);
}
void vector_prime_kernel3(int s,int e,vector<int> &nums,vector<char> &tmp)
{
	int l=nums.size();
	tmp.resize(e-s);
	for(int i=0;i<tmp.size();i++) tmp[i]=1;
	int p1=0;
	while(nums[p1]<pow(e,0.5)&&p1<l)
	{
		int p2=(nums[p1]-s%nums[p1])%nums[p1];
		for(int i=p2;i<tmp.size();i+=nums[p1]) tmp[i]=0;
		p1++;
	}
	for(int i=0;i<tmp.size();i++)
	{
		if(tmp[i]) nums.push_back(s+i);
	}
}
void vector_prime5(int n,int _block)
{
	vector<int> nums;
	vector<char> tmp;
	tmp.resize(_block);
	int t=pow(n,0.5)+1;
	vector_prime_step1(t,nums);
	for(;t<n;t+=_block) vector_prime_kernel3(t,min(n,t+_block),nums,tmp);
	cout<<nums.size()<<endl;
}

同样用24万的block再试一次

5761455
0.545931s

果然快了,虽然不多但是确实性能又一次得到了提升。

其实这里如果只统计质数个数的话可以不用每个block都在nums数组后面push_back,只要返回统计出来的每个block中的质数个数就可以。如果不push_back,速度还可以提高更多,我之前简单测了一下0.3s左右就可以出结果。而且这样还可以使用多线程并行多个block分别做统计,最后再归约,这样效率可以提升的更多,不过这里就不再继续了。


另外再说一嘴,线性筛法理论上的时间复杂度是比本文的算法更小的。因为本文所用的筛法每个质因数在筛除其倍数的时候可能它的某一个倍数已经是被别的质因数筛除过了,因此会重复筛除,线性筛法可以通过一些骚操作使得每个合数只被它最小的质因数筛除一次。不过线性筛法我没太搞明白,我在网上找了一份写好的代码。

#include<bits/stdc++.h>
using namespace std;
#define loop(i,start,end) for(register int i=start;i<=end;++i)
#define clean(arry,num) memset(arry,num,sizeof(arry))
#define ll long long
#include <ctime>
template<typename T>void read(T &x){
	char r=getchar();T neg=1;x=0;
	while(r>'9'||r<'0'){if(r=='-')neg=-1;r=getchar();}
	while(r>='0'&&r<='9'){x=(x<<1)+(x<<3)+r-'0';r=getchar();}
	x*=neg;
}
const int maxn=1e8+10;
int v[maxn],prime[maxn],nfp=0;
inline void shai(int n){
	clean(v,0);clean(prime,0);nfp=0;
	loop(i,2,n){
		if(!v[i]){
			v[i]=i;
			prime[++nfp]=i;
		}
		loop(j,1,nfp){
			if(prime[j]*i>n||prime[j]>v[i])break;
			v[i*prime[j]]=prime[j];
		}
	}
}
int main(){
	int n;read(n);
    auto s=clock();
	shai(n);
	printf("%d内的质数个数%d\n",n,nfp);
    cout<<"use time : "<<double(clock()-s)/CLOCKS_PER_SEC<<"s\n";
	return 0;
}

这个程序找一亿的质数要1s左右,原因很简单就是Cache利用率低。我暂时还没太搞明白线性筛的工作原理,所以怎么优化我暂时不太清楚,如果有人会希望可以讲一下,让我学习学习。

上一篇:数值计算优化方法C/C++(五)——矩阵转置优化示例(访存优化和SIMD的使用)
下一篇:没有了

发布了22 篇原创文章 · 获赞 9 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/artorias123/article/details/102973537
今日推荐