尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

质数筛法全解析:从埃式筛到欧拉筛的原理、实现与优化

质数筛法全解析:从埃式筛到欧拉筛的原理、实现与优化 1. 项目概述从“数数”到“筛数”的质数求解之旅在编程和数学的交叉领域判断和寻找质数素数是一个经典且基础的问题。无论是算法竞赛、密码学基础还是日常编程练习高效地找出一定范围内的所有质数都是我们必须掌握的核心技能。最直观的方法即对每个数进行试除其时间复杂度为O(n√n)当n达到百万甚至千万级别时就显得力不从心了。这时“筛法”便闪亮登场。它不再是一个一个地去“判断”而是聪明地、批量地去“排除”合数从而高效地“筛选”出质数。今天我们就来深入剖析两种最经典的筛法埃拉托斯特尼筛法简称埃式筛和欧拉筛法也称线性筛。我将结合自己多年的算法教学和实战经验带你不仅理解它们的原理更要掌握其实现细节、性能瓶颈和优化技巧让你在面对“求N以内所有素数”这类问题时能够游刃有余。2. 算法原理深度解析埃式筛与欧式筛的思想内核2.1 埃拉托斯特尼筛法古老而直观的智慧埃式筛法的思想可以追溯到古希腊其核心逻辑异常简洁明了从2开始将每个质数的所有倍数标记为合数。核心步骤拆解初始化一个长度为n1的布尔数组is_prime默认所有元素为True表示假设所有数都是质数。从i 2开始遍历到n。如果is_prime[i]为True那么i就是一个质数。接着将所有i的倍数jj i*i, i*ii, i*i2i, ...直到n的is_prime[j]标记为False。遍历完成后所有is_prime值为True的索引就是质数。为什么从i*i开始标记这是一个关键的优化点。考虑质数i5它的倍数5*210和5*315已经在质数2和3的筛选中被标记过了。实际上对于任意质数p任何小于p*p的p的倍数其最小质因子一定小于p因此必然已经被更小的质数筛过了。从p*p开始标记避免了大量重复操作是埃式筛效率提升的关键一步。时间复杂度分析埃式筛的时间复杂度是O(n log log n)。这个复杂度已经非常优秀远优于试除法。其推导与调和级数及质数分布定理有关直观理解就是每个合数只被其最小质因子筛去一次虽然实际操作中可能被标记多次总的标记次数约为n/2 n/3 n/5 ...收敛于n log log n。2.2 欧拉筛法追求极致的线性效率埃式筛虽然高效但存在一个瑕疵有些合数会被重复标记。例如30 2*15 3*10 5*6在埃式筛中它会被质数2、3、5各标记一次。欧拉筛法的目标就是确保每个合数只被其最小质因子筛掉一次从而达到严格的O(n)线性时间复杂度。核心思想与步骤初始化布尔数组is_prime和空列表primes用于存放找到的质数。从i 2遍历到n。如果is_prime[i]为True则将i加入质数列表primes。遍历当前已找到的质数列表primes中的每个质数p a. 计算合数x i * p。 b. 如果x n则跳出内层循环。 c. 将is_prime[x]标记为False。 d.关键判断如果i % p 0则跳出内层循环。核心逻辑解读外层循环i它代表我们正在用哪个数去乘质数表里的质数以生成新的合数。i本身可以是质数也可以是合数。内层循环p遍历已知质数表。i % p 0的奥义这是欧拉筛的灵魂。它保证了每个合数只被其最小质因子筛掉。当i % p 0时意味着p是i的最小质因子因为我们是按顺序遍历质数表的。设i p * k。那么对于当前质数p我们正在标记的合数是x i * p p * k * p。如果我们不跳出循环继续用下一个更大的质数p_next去乘i会得到合数y i * p_next p * k * p_next。注意这个合数y的最小质因子是p因为p是i的因子且p p_next。它本应该在未来当外层循环i k * p_next时被质数p筛掉因为i * p k * p_next * p y。如果现在用p_next筛了y就造成了重复筛选。因此当i % p 0时立即跳出就阻止了用非最小质因子去筛合数的行为。注意欧拉筛的内层循环条件通常是for p in primes和if i * p n: break。if i % p 0: break这个判断必须放在标记合数is_prime[i*p] False之后否则像4这样的合数i2, p2就无法被正确标记。3. 核心细节解析与实操要点3.1 埃式筛的优化空间与实现陷阱虽然埃式筛的原理简单但实现上仍有优化细节。优化1仅遍历奇数除了2以外所有偶数都不是质数。我们可以在初始化数组时只考虑奇数。这样数组大小减半遍历次数也减半。具体做法是is_prime数组索引i对应数字2*i3例如is_prime[0]代表数字3。这种“压缩存储”在n极大时能有效节省内存但代码可读性会下降通常用于极限优化场景。优化2外层循环终止条件外层循环i只需要遍历到√n即int(n**0.5)。因为如果n有一个大于√n的因子d那么它必然有一个小于√n的因子n/d。因此所有小于等于n的合数其最小质因子一定小于等于√n。当i超过√n后i*i已经大于n内层标记循环不会执行所以提前结束是安全的。实现陷阱数组越界与类型选择越界问题在内层循环for j in range(i*i, n1, i)中当i较大时i*i可能超出整数范围在C/Java中可能导致溢出甚至直接超过n。在Python中虽无溢出但循环条件i*i n是必须的。布尔数组 vs 比特数组对于极大的n例如10^8一个bool数组可能占用约100MB内存Python的listofbool更大。可以使用bytearray或第三方库如bitarray进行极致的内存优化用1个比特表示一个数的状态。3.2 欧拉筛的“线性”保证与边界处理欧拉筛的O(n)复杂度非常诱人但其正确性完全依赖于if i % p 0: break这一条件。为什么是线性每个合数x只会被标记一次且是在i x / min_prime_factor(x)且p min_prime_factor(x)时被标记。外层循环i从2到n每个i都会进入内层循环但内层循环对于每个i其执行次数等于i的最小质因子的排名或者说是i的质因子分解中最小质因子的指数部分决定的。数学上可以证明所有i的内层循环总次数与n成线性关系。边界条件与细节循环顺序务必先标记合数is_prime[i*p]False再判断if i % p 0。顺序反了质数的平方如4, 9, 25就无法被标记为合数。内层循环终止条件if i * p n: break这个判断可以放在循环条件中也可以放在循环体内break。放在条件中更清晰。质数列表初始化通常将primes初始化为空列表并将is_prime[0] is_prime[1] False。也可以预先将2加入质数列表外层循环从3开始只遍历奇数实现一个“奇数版”的欧拉筛性能更优。实操心得在大部分编程竞赛和日常应用中当n 10^7时经过优化如仅遍历奇数到√n的埃式筛已经足够快且代码更简单不易出错。当n在10^7到10^8之间或者对时间复杂度要求极其严格时欧拉筛的线性优势才会明显体现。但欧拉筛的代码逻辑稍复杂需要仔细理解才能正确实现。4. 代码实现与性能对比实测4.1 埃式筛的Python实现与注释def eratosthenes_sieve(n: int) - list: 埃拉托斯特尼筛法返回小于等于n的所有质数列表。 优化仅需筛到sqrt(n)从i*i开始标记。 if n 2: return [] # 初始化布尔数组索引代表数字True表示是质数 is_prime [True] * (n 1) is_prime[0] is_prime[1] False # 0和1不是质数 # 外层循环只需遍历到 sqrt(n) for i in range(2, int(n**0.5) 1): if is_prime[i]: # 如果i是质数 # 内层循环从i*i开始步长为i标记i的所有倍数为合数 # 注意range的stop参数是n1确保能取到n for j in range(i * i, n 1, i): is_prime[j] False # 收集所有质数 primes [i for i, flag in enumerate(is_prime) if flag] return primes # 测试 if __name__ __main__: n 30 primes eratosthenes_sieve(n) print(f质数列表 (n{n}): {primes}) print(f质数个数: {len(primes)})4.2 欧拉筛的Python实现与注释def euler_sieve(n: int) - list: 欧拉筛法线性筛返回小于等于n的所有质数列表。 核心每个合数只被其最小质因子筛掉一次。 if n 2: return [] is_prime [True] * (n 1) primes [] # 存放质数的列表 is_prime[0] is_prime[1] False for i in range(2, n 1): if is_prime[i]: primes.append(i) # i是质数加入列表 # 遍历当前已找到的质数 for p in primes: composite i * p if composite n: # 生成的合数超出范围中断 break is_prime[composite] False # 标记合数 # 关键如果i能被p整除说明p是i的最小质因子 # 为保证合数只被最小质因子筛后续循环应终止 if i % p 0: break return primes # 测试 if __name__ __main__: n 30 primes euler_sieve(n) print(f质数列表 (n{n}): {primes}) print(f质数个数: {len(primes)})4.3 性能对比实测与数据分析理论需要实践检验。我编写了一个简单的测试脚本在相同的环境下普通笔记本电脑Python 3.9对比两种算法在不同规模n下的表现。import time def test_performance(): test_cases [10**5, 10**6, 5*10**6] # 10^7以上埃式筛时间较长可根据需要调整 for n in test_cases: print(f\n 测试 n {n:,} ) # 测试埃式筛 start time.time() _ eratosthenes_sieve(n) end time.time() print(f埃式筛耗时: {end - start:.4f} 秒) # 测试欧拉筛 start time.time() _ euler_sieve(n) end time.time() print(f欧拉筛耗时: {end - start:.4f} 秒) if __name__ __main__: test_performance()实测结果示例仅供参考具体时间因机器而异 测试 n 100,000 埃式筛耗时: 0.0080 秒 欧拉筛耗时: 0.0150 秒 测试 n 1,000,000 埃式筛耗时: 0.0950 秒 欧拉筛耗时: 0.1800 秒 测试 n 5,000,000 埃式筛耗时: 0.5200 秒 欧拉筛耗时: 0.9500 秒结果分析出乎很多初学者的意料在这个量级下欧拉筛的纯Python实现反而比埃式筛慢这与理论上的O(n)优于O(n log log n)似乎矛盾。原因在于常数因子大O记号忽略了常数因子。欧拉筛虽然总操作次数少但每次操作都涉及列表primes的遍历、乘法、取模等这些操作在Python中的开销相对较大。内存访问模式埃式筛的内层循环for j in range(i*i, n1, i)是连续内存访问现代CPU的缓存预取机制能很好地优化这种模式。而欧拉筛的内层循环需要跳跃式地访问primes列表和is_prime数组缓存不友好。Python解释器开销Python的循环和函数调用开销很大。埃式筛的循环结构更简单解释器优化得更好。重要提示这个结论仅限于Python等解释型语言。在C、Java等编译型语言中欧拉筛的线性优势在n较大时如 10^7会非常明显通常比埃式筛快一倍以上。因此算法选择必须结合编程语言特性。5. 高级应用、变体与常见问题排查5.1 筛法的扩展应用筛法不仅能求质数列表稍作修改就能解决一系列衍生问题。应用1快速计算区间内质数个数如果需要多次查询不同区间[L, R]内的质数个数可以使用分段筛。先筛出√R以内的所有质数再用这些质数去筛区间[L, R]。这样预处理√R的复杂度是固定的每次查询的复杂度约为O((R-L) log log R)非常适合多次查询。应用2求每个数的最小质因子欧拉筛在运行过程中天然地记录了每个合数是被哪个质数p筛掉的这个p就是该合数的最小质因子。我们可以用一个数组min_prime_factor来记录这在数论问题中非常有用例如快速质因数分解。def get_min_prime_factors(n): 使用欧拉筛变体返回一个数组mpfmpf[x]表示x的最小质因子x2 mpf [0] * (n 1) # Minimum Prime Factor primes [] for i in range(2, n 1): if mpf[i] 0: # i是质数 mpf[i] i primes.append(i) for p in primes: if p mpf[i] or i * p n: break mpf[i * p] p return mpf应用3计算欧拉函数φ(n)欧拉函数φ(n)表示小于等于n的正整数中与n互质的数的个数。利用筛法和质因数分解可以在近似O(n)的时间内计算出1到n所有数的欧拉函数值。5.2 常见问题与排查技巧实录在实现和使用筛法时我踩过不少坑这里总结几个典型问题。问题1程序运行结果错误漏掉了一些质数或多出了一些合数。可能原因1埃式筛外层循环终止条件错误。如果写成了for i in range(2, n1)虽然结果正确但效率低下。如果错误地写成了for i in range(2, int(n**0.5))注意range的右边界是开区间应该int(n**0.5) 1。可能原因2埃式筛内层循环起始点错误。如果从2*i开始会做大量重复工作。务必从i*i开始。可能原因3欧拉筛if i % p 0: break的位置放错。必须放在is_prime[composite] False之后。排查方法用小的n如20进行测试单步调试或打印出每一步的i,p, 被标记的composite以及实时的is_prime数组状态与手工演算过程对比。问题2程序在n较大时如10^7运行非常慢或内存溢出。可能原因1使用了低效的数据结构。在Python中用listofbool存储状态会占用较大内存。可以考虑使用bytearray。is_prime bytearray(b\x01) * (n 1) # 每个元素1字节比bool列表省空间 is_prime[0] is_prime[1] 0可能原因2埃式筛没有使用“仅筛奇数”的优化。当n很大时这能减少近一半的工作量和内存。可能原因3算法本身的时间复杂度限制。对于Python当n达到10^8时即使是优化后的埃式筛也可能需要数十秒。此时应考虑使用C等语言重写核心部分或者使用如sympy库中的sieve工具它是用C实现的非常快。问题3需要频繁查询一个很大的n是否是质数。不建议每次查询都运行一次筛法或试除法。建议如果查询范围上限N_max已知可以预处理一次筛法将结果布尔数组或质数列表保存起来。后续每次查询都是O(1)的数组访问操作。这是典型的“空间换时间”策略。问题4关于“每两个连续正整数的平方之间必有至少两个素数”这是一个著名的数论猜想Legendres conjecture至今未被证明或证伪。筛法无法证明这类理论问题但可以用来做实验验证。例如你可以写一个程序遍历一个很大的范围检查对于每一个整数k区间(k^2, (k1)^2)内是否至少存在两个质数。筛法可以高效地生成这些区间内的质数列表供你验证。这体现了筛法作为强大计算工具在辅助数学研究中的价值。
返回列表