任务

求解第 10,0000、100,0000、1000,0000 ... 个素数(要求精确解)。

想法

Sieve of Eratosthenes

学习初等数论的时候曾经学过埃拉托斯特尼筛法(Sieve of Eratosthenes),这是一种非常古老但是非常有效的求解\(p_n\)的方法,其原理非常简单:从2开始,将每个素数的各个倍数都标记成合数。

其原理如下图所示:

图引自维基百科

埃拉托斯特尼筛法相比于传统试除法最大的优势在于:筛法是将素数的各个倍数标记成合数,而非判定每个素数是否是素数的倍数,使用了加法代替了除法,降低了时间复杂度。

举个简单的例子,已知2、3、5、7是素数,求小于49的其余素数。

试除法

对于每一个数\(m ( 7 < m < 49 )\)而言,都要进行如下判定:

  • 求出 \({\lceil}\sqrt{m}{\rceil} + 1 = n\) ,试除 $ n $以内的素数即可判定 $ m $是否为素数。
  • m / 2 ... 不整除。
  • m / 3 ... 不整除。
  • ...

对于m而言,只有两种情况可以结束试除:

  1. m 是素数,要把所有候选素数都试除一遍。
  2. m 是合数,可以被某个素数整除。

对于试除法而言,假设新找到 x 个素数, y 个合数 ,需要试除的素数因子有 m 个,则至少需要做 $ x * m + y $次除法。

埃拉托斯特尼筛法

  • 2,2 + 2 = 4, 4 + 2 = 6 ... 48 + 2 = 50 > 49,下一个。
  • 3,3 + 3 = 6, 6 + 3 = 9 ... 48 + 3 = 51 > 49,下一个。
  • 5,5 + 5 = 10, 10 + 5 = 15 ... 45 + 5 = 50 > 49,下一个。
  • 7, 7 + 7 = 14, 14 + 7 = 21 ... 42 + 7 = 49 = 49,下一个。
  • 没有小于 8 的素数了,停止标记,没有标记到的都是素数。

实际上,假设在 \(\sqrt{x}\) 以内有 $ m $个素数,在 \((\sqrt{x},x]\)范围内又找到了 $n $个素数,可以明显看到试除法和筛法的差异:

  1. 试除法至少要做 $ n * m + x - n - m (1)$次除法
  2. 筛法需要做 $ x - n - m (2)$次加法

做一次除法运算的时间要大于加法,且有 \((1)\) > \((2)\),所以试除法开销远比筛法大。

现在回到问题本身,问题本身是求解第 n 个素数,所以我们一开始并不知道我们需要在多大的范围内求解素数,但是许多数学家给了我们很多定理,比如这个第\(n\)个素数\(p_n\)的不等式。

\[p_n < n \ln n + n \ln \ln n\! ( n ≥ 6 )
\]

有了这个函数,我们可以在输入 $ n $之后用它估算第 $ n $个素数的上界,继而求解。

优化一:朴素筛法

输入的 \(n\) 值为素数的序号,假设求得的素数上界为 limit。为了让筛法跑得更快,在内存允许的范围内,我们可以直接用一个limit大小的数组 sieve[limit] 求解:下标是int,值为bool,数组初始值均为 true,表示都未标记。假设下标为 index

  • index = 2,sieve[index] = true,开始标记,逐次将 sieve[index+2]标记为false,直到下标超过limit。
  • index = 3,sieve[index] = true,开始标记,逐次将 sieve[index+3]标记为false,直到下标超过limit。
  • index = 4, sieve[index] = false,跳过。

    ...

按照上述方式标记完成后,最终在数组中过滤一遍,求得第 n 个未被标记的下标值,即为第 n 个素数。

附代码如下:

public static void Normal_Sieve(int nth)
{
int startTime = System.Environment.TickCount;
int limit = nth < 6 ? 25 : (int)(nth * (Math.Log(nth) + Math.Log(Math.Log(nth))));
int count = 0; List<bool> is_prime = new List<bool>(limit+1); for (int i = 0; i < limit+1; i++)
is_prime.Add(true); for (int i = 2; i * i <= limit; i++)
if (is_prime[i])
for (int j = i * i; j <= limit; j += i)
is_prime[j] = false; for(int i=2;i<is_prime.Count();i++)
{
if (is_prime[i])
count++;
if(count == nth)
{
Console.WriteLine("The nth_prime is:{0} SpentTime:{1}ms",i,Environment.TickCount- startTime);
break;
}
}
}

优化二:位筛法

位筛法相比于简单筛法的改进就是:用1个比特位来标记某个下标是否是素数。1个bool类型要占 8 位,用1个比特位可以使程序在极限内存容量的情况下,比普通筛法能多计算一些素数。

举个例子,按照上面的普通筛法,因为内存大小的限制最多只能求解第 1000,000,000 个素数,那么位筛法就可以求解到第 7000,000,000 个素数。

下面附上位筛法的代码

public static void Bit_Sieve(int nth)
{
int startTime = Environment.TickCount;
int limit = nth < 6 ? 25 : (int)(nth * (Math.Log(nth) + Math.Log(Math.Log(nth))));
int count = 0; int total = limit + 1;
int sqrt = (int)Math.Sqrt(limit) + 1; //[31 30 29 ... 0] every number maps to a bit in uint.
List<uint> is_prime = new List<uint>((total >> 5) + 1); for (int i = 0; i < (total >> 5) + 1; i++)
is_prime.Add(0x0); for (int i = 2; i <= sqrt; i++)
// is_prime[i>>5] bit i % 32 == 0 means it is a prime
if ((is_prime[i >> 5] & (1 << (i & 31))) == 0)
{
for (int j = i * i; j <= total; j += i)
// is_prime[j>>5] bit j % 32 = 1;
is_prime[j >> 5] |= (uint)1 << (j & 31);
} for (int i = 2; i < total; i++)
{
if ((is_prime[i >> 5] & (1 << (i & 31))) == 0)
{
count++;
if (count == nth)
{
Console.WriteLine("The {0}th_prime is:{1} SpentTime:{2}ms",nth , i, Environment.TickCount - startTime);
break;
}
}
}
}

在位筛法中大部分运算都是移位、与和或的运算,所以在测试时发现要比简单的筛法更快一些。

优化三:局部筛法

假设计算得出的第\(n\)个素数上界为\(x\),在位筛法中,我们申请了一个大小为\(x bit\)的数组用来标记合数。随之而来一个问题,难道在筛法中,我们必须使用 \(x bit\)才能求出第 n 个素数吗?

当然不是,对于素数上界\(x\),其实不需要这么大的空间,我们只需把\(\sqrt{x}\)以前的素数保存下来即可。

为什么一定是 \(\sqrt{x}\)呢?想象一下最暴力的试除法:它在判定一个数是否为素数时,需要遍历试除\(\sqrt{x}\)及以内的素因子。如果\(x\)是一个合数,则必然存在一个素因子整除\(x\),且其值小于等于\(\sqrt{x}\)。那么反过来想一下,在筛法中,如果\(\sqrt{x}\)及以内的所有素因子都没有标记\(x\)为合数,那么它一定是一个素数了。

基于这样的思想,我们将\([0,\sqrt{x}]\)内的素数保存下来,然后对\([\sqrt{x},x]\)分段去扫,每扫一段就记录一下本段扫到了多少个素数。这样每次需要载入内存的就只有用于扫描的小素数数组被扫描的段数组,相比位筛法可以节省更多内存空间。

下面举个简单的例子来说明这种算法:

假设要求解第11个素数(31),我们估计出上界为 36,然后下面就是局部筛法的求解过程。

  • \(\sqrt{36}=6\),然后利用埃拉托斯特尼筛法求出[2,6]内的素数,即数组 [2,3,5],此时素数有 3 个。
  • 申请一个大小为10的数组 **A **存储被扫描段。
  • 将[7,36]内的元素按照10个元素一组的方式分成三组(Ps:最后一组不够10个元素)
  • 初始化数组A,此时数组A的下标[0,9]分别映射到[7,16]。
    • 用素数2开始标记,因为8、10、12...是2的倍数,所以标记A[1]、A[3]、A[5]...为合数。
    • 用素数3开始标记,因为9、12、15...是3的倍数,标记A[2]、A[5]、A[8]...为合数。
    • 用素数5开始标记,因为10、15是5的倍数,标记A[3]、A[8]为合数。
    • 扫描A数组内未被标记元素下标为:0、4、6、10,所以这一段有 4 个素数,把它们对应的实际数字存入素数数组。
    • 现在已经发现了 7 个素数,还未达到预期的11个,继续扫描。
  • 重新初始化数组A,此时数组A的下标[0,9]分别映射到[17,26]。
    • 与上述过程一样,用素数2、3、5,分别扫描一遍。
    • 记下未被标记的数字,这一段扫描到了 2个素数,到现在已经发现了 9个素数。
  • 重新初始化数组A,继续扫描寻找。

局部筛法的求解过程如上所述,因为每个时刻内存中只需要一个段的内存来存放需要扫描的段,而不需要一次性把所有的段都加载到内存中进行筛选,所以其对内存的要求更低。

下面附上局部筛法的代码

public static void Local_Bit_Sieve(int nth)
{
int startTime = Environment.TickCount;
int limit = nth < 6 ? 25 :(int)(nth * (Math.Log(nth) + Math.Log(Math.Log(nth))));
int sqrt = (int) Math.Sqrt(limit) + 1; //Get all primes which are less than \sqrt{limit}
List<uint> isPrime = new List<uint>((sqrt >> 5) +1);
for (int i = 0; i < (sqrt >> 5) + 1; i++)
isPrime.Add(0x0);
for (int i = 2; i * i <= sqrt; i++)
// is_prime[i>>5] bit i % 32 == 0 means it is a prime
if ((isPrime[i >> 5] & (1 << (i & 31))) == 0)
{
for (int j = i * i; j <= sqrt; j += i)
// is_prime[j>>5] bit j % 32 = 1;
isPrime[j >> 5] |= (uint)1 << (j & 31);
} //smallPrimes store the primes
List<int> smallPrimes = new List<int>();
for (int i = 2; i < sqrt; i++)
{
if ((isPrime[i >> 5] & (1 << (i & 31))) == 0)
{
smallPrimes.Add(i);
}
} int segSize = Math.Max(sqrt,256 * 256); uint[] primeSeg = new uint[segSize]; //allPrimes store all primes which are found.
List<int> allPrimes = new List<int>();
allPrimes.AddRange(smallPrimes); int high = segSize << 5;
//chunk [2,limit] into different segments
for (int low = sqrt; low <= limit; low += segSize << 5)
{
Array.Clear(primeSeg,0,segSize);
//for each prime, use them to mark the [low,low + segSize]
foreach (var sPrime in smallPrimes)
{
int initValue = low % sPrime;
for (int i = (initValue == 0 ? initValue : sPrime - initValue); i < high; i += sPrime)
{
primeSeg[i >> 5] |= (uint) 1 << (i & 31);
}
} for (int i = 0; i < high; i++)
{
if ((primeSeg[i >> 5] & (1 << (i & 31))) == 0)
allPrimes.Add(i + low);
} if (allPrimes.Count() > nth)
{
Console.WriteLine("The {0}th_prime is:{1} SpentTime:{2}ms",nth, allPrimes[nth-1],
Environment.TickCount - startTime);
break;
}
}
}

优化四:局部筛法段优化

我们之前之所以说使用朴素筛法可以跑得飞快,是因为从直觉上说,朴素筛法一次性将所有的数据都加载到内存中,一次筛完。而局部筛法却需要筛取多次,筛取多次好像时间开销就要比一次加载筛取要多。

看起来局部筛法好像需要筛多次,所以它所耗费的时间就要比一次筛的要慢,但实际上却不是这样。实际上,计算机系统中Cache的存在对局部筛法更加有利,所耗费的时间也更小。我们分段筛选,反而更加符合空间上的局部性,所以程序可以更高效地利用Cache,Cache的存取速度要远大于内存,所以局部筛法耗费的时间要比朴素筛更少。这个问题可以这么形容:是用一个振动频率比较慢,但是很大的筛子筛选比较快,还是用连续用多个振动频率较快,但是比较小的筛子筛快?

我们可以通过选择一个合理的段大小来减少 Cache miss的概率。我电脑上的L1Cache容量是 256KB,所以最后我选择了 256 * 256 作为段的大小。

Benchmark

n 简单筛法 位筛法 局部位筛法
1,000,000 1.75s 0.938s 0.5s
2,000,000 4.562s 4.328s 2.156s
3,000,000 7.063s 7.140s 3.140s
5,000,000 13.328s 11.407s 4.750s
8,000,000 22.484s 17.110s 9.140s
10,000,000 23.563s 22.347s 11.078s
20,000,000 57.219s 53.313s 22.734s

目前的测试只是大致估计,这个数字不是很准,仅供参考。

[软工课程博客] 求解第N个素数的更多相关文章

  1. [BUAA软工]第一次博客作业---阅读《构建之法》

    [BUAA软工]第一次博客作业 项目 内容 这个作业属于哪个课程 北航软工 这个作业的要求在哪里 第1次个人作业 我在这个课程的目标是 学习如何以团队的形式开发软件,提升个人软件开发能力 这个作业在哪 ...

  2. 2020BUAA软工个人博客作业-软件案例分析

    2020BUAA软工个人博客作业-软件案例分析 17373010 杜博玮 项目 内容 这个作业属于哪个课程 2020春季计算机学院软件工程(罗杰 任健) 这个作业的要求在哪里 个人博客作业-软件案例分 ...

  3. 2020BUAA软工个人博客作业

    2020BUAA软工个人博客作业 17373010 杜博玮 项目 内容 这个作业属于哪个课程 2020春季计算机学院软件工程(罗杰 任健) 这个作业的要求在哪里 个人博客作业 我在这个课程的目标是 学 ...

  4. [敏捷软工团队博客]Beta阶段事后分析

    设想和目标 我们的软件要解决什么问题?是否定义得很清楚?是否对典型用户和典型场景有清晰的描述? 我们的软件要解决的问题是:现在的软工课程的作业分布在博客园.GitHub上,没有一个集成多种功能的一体化 ...

  5. [敏捷软工团队博客]Beta阶段项目展示

    团队成员简介和个人博客地址 头像 姓名 博客园名称 自我介绍 PM 测试 前端 后端 dzx 秃头院的大闸蟹 大闸蟹是1706菜市场里无菜可卖的底层水货.大闸蟹喜欢音乐(但可惜不会),喜欢lol(可惜 ...

  6. [敏捷软工团队博客]项目介绍 & 需求分析 & 发布预测

    项目 内容 2020春季计算机学院软件工程(罗杰 任健) 博客园班级博客 作业要求 团队项目选择 我们在这个课程的目标是 在团队合作中锻炼自己 这个作业在哪个具体方面帮助我们实现目标 了解项目整体情况 ...

  7. [敏捷软工团队博客]The Agiles 团队介绍&团队采访

    项目 内容 课程:北航-2020-春-敏捷软工 博客园班级博客 作业要求 团队作业-团队介绍和采访 团队名称来源 The Agile is The Agile. 敏捷就是敏捷.我们只是敏捷的践行者罢了 ...

  8. 自我介绍&软工实践博客点评

    想想既然写了点评博客,那就顺便向同学们介绍下自己吧. 我是16届计科实验班的,水了两件小黄衫,于是就来当助教了_(:_」∠)_ 实话说身为同届生来当助教,我心里还是有点虚的,而且我还是计科的..感觉软 ...

  9. [敏捷软工团队博客]Beta阶段发布声明

    项目 内容 2020春季计算机学院软件工程(罗杰 任健) 博客园班级博客 作业要求 Beta阶段发布声明 我们在这个课程的目标是 在团队合作中锻炼自己 这个作业在哪个具体方面帮助我们实现目标 对Bet ...

随机推荐

  1. 2298: [HAOI2011]problem a

    Description 一次考试共有n个人参加,第i个人说:"有ai个人分数比我高,bi个人分数比我低."问最少有几个人没有说真话(可能有相同的分数) 这句话的意思就等于是说第i个 ...

  2. WCF简单实例--用Winform启动和引用

    以订票为例简单应用wcf程序,需要的朋友可以参考下 本篇转自百度文档,自己试过,确实可以用. 以订票为例简单应用wcf 新建一个wcf服务应用程序 在IService1.cs定义服务契约 namesp ...

  3. [JSOI2008]星球大战starwar

    嘟嘟嘟 维护联通块自然想到并查集,然而题中说是删边,不是很好做,因此我们可以离线下来然后倒序操作,就变成了添加边的同时维护联通块数量. 首先我们把k次打击后剩的边都添加到图中,表示倒序时的初始状态.然 ...

  4. Android APP的字体设置

    Android系统自带有对字体的设置,这些设置是对字体的显示方式的设置,比如加粗,倾斜,下划线,字号等,但是对于字体本身,比如设置为楷体,隶书等貌似没有.Android系统只有一种默认的,如果需要修改 ...

  5. Cannot find module '../lib/utils/unsupported.js'

    运行npm run clean出错: throw err; ^ Error: Cannot find module '../lib/utils/unsupported.js' at Function. ...

  6. [转]深入理解MFC中程序框架

    最近抽空复习了一下MFC的内容,觉得一篇博文写的不错. 原文内容太多直接给出链接吧:深入理解MFC中程序框架 链接2:深入浅出话VC++(2)——MFC的本质 链接3:MFC单文档/视图结构穷追猛打

  7. php 操作时间、日期类函数

    <?php // time() echo "time(): ",time(); echo "\n"; // strtotime() echo " ...

  8. Xcode添加全局引用文件pch

    Xcode6之前有PrefixHeader.pch文件在写项目的时候,大部分宏定义.头文件都导入在这个pch文件,虽然方便,但会增加Build的时间,所以Xcode6以及之后的版本去除了PrefixH ...

  9. python中安装Tensorflow

    执行命令:pip install --upgrade tensorflow 即可.

  10. English_word_learning

    这次报名参加了学院的21天打卡活动,说实话,也是想给自己一个积累的平台. 毕竟,真的有时候感觉挺弱的 有的人用了一年考完了四六级,而有人却用四年还未考完. 听到有一位学长因为自己的四级成绩没有达到48 ...