素数筛法全解析:从埃氏筛到欧拉筛,掌握高效算法核心
2026/8/2 4:45:01 网站建设 项目流程

1. 项目概述:为什么我们需要素数筛法?

在编程和算法竞赛中,判断一个数是否为素数,或者找出一定范围内的所有素数,是一个经典且高频的需求。最直观的想法是,对于每个待判断的数n,用从2sqrt(n)的所有整数去试除。这种方法被称为“试除法”。对于单个数字,试除法效率尚可,但一旦问题规模变大,比如要求找出110^7之间的所有素数,试除法的时间复杂度就完全无法接受了。这时,素数筛法就成为了我们必须掌握的利器。

素数筛法的核心思想是“标记”而非“计算”。它通过某种高效的规则,提前将合数(非素数)标记出来,剩下的自然就是素数。这就像在一张名单上划掉所有不符合条件的人,而不是一个个去面试每个人。今天要深入探讨的,就是两种最经典、应用最广泛的筛法:埃拉托斯特尼筛法(简称埃氏筛)和欧拉筛(也称线性筛)。它们不仅是解决素数相关问题的“标准答案”,其背后蕴含的“空间换时间”和“线性复杂度”思想,对于理解更复杂的算法也大有裨益。

2. 埃拉托斯特尼筛法:直观高效的入门之选

埃氏筛以其发明者古希腊数学家埃拉托斯特尼命名,其原理非常直观,易于理解和实现,是学习筛法的绝佳起点。

2.1 核心原理与算法步骤

算法的核心在于:一个素数的所有倍数(大于该素数本身)必然是合数。

我们以一个具体的例子来说明,假设我们要找出130之间的所有素数。

  1. 初始化:创建一个布尔数组is_prime[0..n],初始时假设所有数都是素数(设为True)。我们知道01不是素数,所以先将is_prime[0]is_prime[1]设为False
  2. 开始筛选:从第一个素数2开始。
    • 2的所有倍数(4, 6, 8, ..., 30)标记为合数(is_prime[i] = False)。
  3. 寻找下一个素数:在数组中,找到下一个未被标记为合数(即is_prime[i]仍为True)的数。这个数就是下一个素数(这里是3)。
    • 3的所有倍数(6, 9, 12, ..., 30)标记为合数。
  4. 重复步骤3:找到下一个未被标记的数5,标记其所有倍数(10, 15, 20, 25, 30)。
  5. 何时停止?我们不需要一直筛选到n。对于当前素数p,如果p * p > n,那么所有小于等于n的合数都已经被p或更小的素数标记过了。因为任何小于等于n的合数m,必然有一个质因子q <= sqrt(m) <= sqrt(n)。在上例中,5 * 5 = 25 < 30,我们继续;下一个素数是77 * 7 = 49 > 30,此时即可停止筛选。
  6. 收集结果:遍历is_prime数组,所有值为True的索引i就是素数。

这个过程就像用筛子一遍遍过滤,合数被筛掉,素数留在筛子上,故名“筛法”。

2.2 代码实现与复杂度分析

以下是埃氏筛的 Python 实现:

def eratosthenes_sieve(n): """ 埃拉托斯特尼筛法,返回小于等于 n 的所有素数列表。 """ 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-1), i*(i-2)... 已经被更小的素数标记过了 for j in range(i * i, n + 1, i): is_prime[j] = False # 收集素数 primes = [i for i in range(2, n + 1) if is_prime[i]] return primes # 示例:找出100以内的素数 primes = eratosthenes_sieve(100) print(primes[:20]) # 打印前20个素数

时间复杂度分析: 埃氏筛的时间复杂度是O(n log log n)。这个复杂度已经非常接近线性,对于绝大多数应用场景(如n <= 10^7)效率极高。推导过程涉及调和级数和对数积分,这里我们只需记住结论:它的效率远高于对每个数单独试除的O(n sqrt(n))

空间复杂度: 需要O(n)的布尔数组来存储标记信息。

2.3 埃氏筛的优化与局限性

常见优化

  1. i*i开始标记:如代码所示,对于素数i2*i, 3*i, ..., (i-1)*i这些倍数,其质因子包含比i更小的素数,因此它们一定在之前轮次的筛选中被标记过了。从i*i开始能减少重复操作。
  2. 只筛奇数:除了2以外,所有偶数都是合数。我们可以先单独处理2,然后只对奇数进行筛选,这样数组大小和遍历次数都能减半。

局限性(核心缺陷): 埃氏筛最大的问题是重复标记。一个合数可能被多个质因子标记多次。例如,合数30 = 2*15 = 3*10 = 5*6,它会被素数235各标记一次。当n非常大(例如10^8以上)时,这些重复操作会带来可观的开销。这就引出了我们需要更高效筛法的原因。

实操心得:在算法竞赛或日常开发中,如果问题规模n10^7量级,埃氏筛通常是首选,因为它代码简单,不易出错,且O(n log log n)的复杂度完全够用。记住优化点“从i*i开始”和“只筛奇数”,能带来小幅但稳定的性能提升。

3. 欧拉筛:追求极致的线性算法

为了克服埃氏筛的重复标记问题,欧拉筛(线性筛)应运而生。它的核心目标是确保每个合数只被其最小的质因子标记一次,从而将时间复杂度严格降到O(n)

3.1 算法原理深度解析

欧拉筛的精妙之处在于它维护了一个当前已发现的素数列表,并用一种独特的方式来生成合数。

算法流程

  1. 同样初始化一个布尔数组is_prime[0..n]和一个空列表primes用于存放找到的素数。
  2. 2开始遍历到n的每一个整数i
    • 如果is_prime[i]True,说明i是素数,将其加入primes列表。
  3. 无论i是否为素数,都遍历当前的primes列表(即已发现的素数),令当前素数为p
    • 计算合数c = i * p
    • 标记is_prime[c] = False
    • 关键步骤:如果i能被p整除(即i % p == 0),则跳出对primes的遍历。

为什么这是线性的?为什么每个合数只被标记一次?关键在于i % p == 0这个中断条件。我们通过一个例子来理解。

假设i = 4primes = [2, 3]

  • p = 2,标记4 * 2 = 8。然后检查4 % 2 == 0,成立,跳出循环。
  • 不会用p = 3去标记4 * 3 = 12

为什么不让i=4去标记12呢?因为12的最小质因子是2。我们希望12i = 6时,由p = 2来标记(6 * 2 = 12)。这样就能保证每个合数都由其(最小质因子 * 最大因子)的组合来唯一生成。

更形式化地理解:设合数N的最小质因子为p_min,令N = p_min * k。在欧拉筛的运行过程中,N一定会在外层循环i = k时,在内层循环用p = p_min标记。并且由于k % p_min == 0,在标记完N后循环会立即中断,防止后续用更大的质因子去重复标记N

3.2 代码实现与逐行解读

def euler_sieve(n): """ 欧拉筛(线性筛),返回小于等于 n 的所有素数列表。 """ is_prime = [True] * (n + 1) primes = [] # 用于存储素数 for i in range(2, n + 1): if is_prime[i]: primes.append(i) # i是素数,加入列表 # 遍历当前已找到的素数 for p in primes: c = i * p if c > n: # 生成的合数超过范围,中断 break is_prime[c] = False # 标记合数 if i % p == 0: # 核心:保证每个合数只被最小质因子标记一次 break return primes # 示例 primes_linear = euler_sieve(100) print(primes_linear[:20])

逐行分析

  • for i in range(2, n+1): 外层循环遍历每个数。
  • if is_prime[i]: primes.append(i): 如果i未被标记为合数,它就是素数。
  • for p in primes:: 内层循环遍历所有已发现的素数。
  • c = i * p; if c > n: break: 生成合数,如果超出范围就停止。
  • is_prime[c] = False: 标记这个合数。
  • if i % p == 0: break:灵魂所在。一旦发现p能整除i,就停止用当前i继续生成合数。这保证了c是被其最小质因子p标记的。

3.3 欧拉筛的优势、代价与应用场景

优势

  1. 严格线性时间复杂度 O(n):每个合数只被访问和标记一次。在处理超大范围(如n > 10^7)时,性能优势相比埃氏筛会越来越明显。
  2. 可同步获取素数表:算法运行结束后,primes列表自然就是有序的素数表,无需再遍历收集。

代价

  1. 代码逻辑稍复杂:理解其正确性需要一点思考,实现时i % p == 0这个条件必须正确放置。
  2. 常数时间可能略大:虽然复杂度是线性的,但由于内层循环和取模运算,在处理中小规模数据(如n < 10^6)时,其实际运行时间可能和优化后的埃氏筛相差无几,甚至因为常数大而稍慢。

应用场景

  • 需要极大范围的素数表:例如n10^8量级,欧拉筛是唯一选择。
  • 需要基于素数筛进行扩展:许多数论问题需要在筛法的过程中同步计算一些信息,例如每个数的最小质因子、欧拉函数值等。欧拉筛的框架非常适合进行这种“一边筛素数,一边打表”的操作,因为每个数只被其最小质因子访问一次。

注意事项:实现欧拉筛时,最常见的错误是把if i % p == 0: break放在标记合数is_prime[c] = False之前。这会导致某些合数(特别是质数的平方,如4,9,25)没有被标记。务必先标记,再判断是否跳出。

4. 两种筛法的性能对比与实测数据

理论分析需要实践验证。我们通过一个简单的测试来感受两者的差异。

import time def test_performance(n): print(f"测试范围 n = {n}") start = time.time() primes_e = eratosthenes_sieve(n) time_e = time.time() - start print(f"埃氏筛 耗时: {time_e:.4f} 秒,找到素数 {len(primes_e)} 个") start = time.time() primes_l = euler_sieve(n) time_l = time.time() - start print(f"欧拉筛 耗时: {time_l:.4f} 秒,找到素数 {len(primes_l)} 个") print("-" * 40) # 测试不同规模 test_performance(10**6) # 一百万 test_performance(5*10**6) # 五百万 test_performance(10**7) # 一千万

可能的输出结果分析(具体时间因机器而异):

测试范围 n = 1000000 埃氏筛 耗时: 0.050 秒,找到素数 78498 个 欧拉筛 耗时: 0.080 秒,找到素数 78498 个 ---------------------------------------- 测试范围 n = 5000000 埃氏筛 耗时: 0.350 秒,找到素数 348513 个 欧拉筛 耗时: 0.450 秒,找到素数 348513 个 ---------------------------------------- 测试范围 n = 10000000 埃氏筛 耗时: 0.800 秒,找到素数 664579 个 欧拉筛 耗时: 0.950 秒,找到素数 664579 个

从测试中我们可以观察到:

  1. 结果一致:两种算法得到的素数个数完全相同,验证了正确性。
  2. 性能曲线:在n达到一千万时,欧拉筛的理论线性优势尚未完全压倒埃氏筛的常数优势。这是因为O(n log log n)中的log log n增长极其缓慢(log log 10^7 ≈ 3)。在n为千万级时,两者可视为同一数量级。
  3. 转折点:通常,当n超过10^8,欧拉筛的线性优势才会变得非常明显。埃氏筛的重复标记操作总量约为n * (1/2 + 1/3 + 1/5 + ...) ≈ n log log n,而欧拉筛严格是n次操作。

选择建议

  • n <= 10^7:优先使用埃氏筛。代码简单,不易出错,性能足够,且经过“只筛奇数”等优化后,实际速度可能更快。
  • n > 10^7或需要同步计算其他函数:必须使用欧拉筛。其线性复杂度在面对上亿数据量时是唯一可行的选择,并且其框架易于扩展。

5. 筛法的经典应用场景与扩展

掌握筛法本身不是终点,更重要的是运用它来解决实际问题。

5.1 素数判定与区间素数查询

问题:如何快速回答“数字x是否是素数?”或“区间[a, b]内有多少素数?”。方案:预处理筛出足够大范围(如sqrt(最大值)或整个区间)的素数表,之后的所有查询都是O(1)的数组访问。这是典型的“空间换时间”和“预处理”思想。

5.2 计算每个数的最小质因子

欧拉筛是完成此任务的绝佳工具。我们只需稍作修改:

def get_min_prime_factors(n): """ 返回一个数组 min_prime,其中 min_prime[i] 表示 i 的最小质因子。 对于素数 i,min_prime[i] == i。 """ min_prime = [0] * (n + 1) primes = [] for i in range(2, n + 1): if min_prime[i] == 0: # i是素数 min_prime[i] = i primes.append(i) for p in primes: c = i * p if c > n: break min_prime[c] = p # 记录合数c的最小质因子p if i % p == 0: break return min_prime

这个min_prime数组非常有用,可以用于:

  • 质因数分解:分解N时,不断除以min_prime[N],速度极快。
  • 计算欧拉函数、约数个数等数论函数

5.3 基于筛法的欧拉函数计算

欧拉函数φ(n)表示小于等于n的正整数中与n互质的数的个数。利用欧拉筛,我们可以在O(n)时间内计算出1n所有数的欧拉函数值。

def euler_phi_sieve(n): """ 使用欧拉筛计算1到n的欧拉函数值。 """ phi = [0] * (n + 1) is_prime = [True] * (n + 1) primes = [] phi[1] = 1 for i in range(2, n + 1): if is_prime[i]: primes.append(i) phi[i] = i - 1 # 素数i的欧拉函数值为i-1 for p in primes: c = i * p if c > n: break is_prime[c] = False if i % p == 0: # 情况1: p是i的质因子,则 φ(i*p) = φ(i) * p phi[c] = phi[i] * p break else: # 情况2: p与i互质,则 φ(i*p) = φ(i) * φ(p) = φ(i) * (p-1) phi[c] = phi[i] * (p - 1) return phi

5.4 筛法在算法竞赛中的实战技巧

  1. 内存优化:对于极大的n(如10^8),布尔数组is_prime可能占用数百MB内存。可以使用bitarraybytearray来压缩内存,甚至使用分段筛法(Segmented Sieve)来突破内存限制,在有限内存下筛出超大范围的素数。
  2. 预处理与缓存:在有多组测试用例的题目中,如果所有用例的最大n相同,那么只需在程序开始时全局执行一次筛法,将结果(素数表或is_prime数组)缓存起来,所有查询共享,避免重复计算。
  3. 结合其他算法:筛法常作为预处理步骤,为更复杂的数论算法(如莫比乌斯反演、原根判定)提供支持。

6. 常见问题与排查技巧实录

在实际编码和应用中,你可能会遇到以下问题:

问题1:筛法结果错误,漏掉了一些素数或包含了合数。

  • 排查点1(埃氏筛):检查外层循环的终止条件是否为i <= sqrt(n)。写成i <= n会导致大量无意义的重复标记和逻辑错误。
  • 排查点2(埃氏筛):检查内层标记循环的起始点是否为j = i*i。从i*2开始会导致重复标记,影响效率但通常不影响正确性;但如果同时优化了“只筛奇数”,起始点计算错误就会导致漏标。
  • 排查点3(欧拉筛)这是最高频的错误点。确认if i % p == 0: break这行代码的位置。它必须放在is_prime[c] = False之后。如果放在前面,像4, 9, 25这样的平方素数将不会被标记为合数。
  • 排查点4(通用):数组是否初始化正确?is_prime[0]is_prime[1]是否设为False?数组大小是否为n+1

问题2:程序在较大输入(如n=10^7)时运行非常慢或内存溢出。

  • 性能慢:首先确认你使用的是优化后的埃氏筛或欧拉筛。使用试除法嵌套循环肯定会超时。对于Python,可以尝试使用bytearray代替list of bool,并使用局部变量减少属性查找,能提升一定速度。
  • 内存溢出:计算10^7的布尔列表,在Python中大约占用10^7 bytes ≈ 10 MB,通常可以接受。如果n更大,考虑使用array('B')bitarray。如果问题要求n极大(如10^9),但区间长度有限,则应使用分段筛法

问题3:如何验证筛法结果的正确性?

  • 小范围交叉验证:用最朴素的试除法写一个函数,对小范围(如n <= 10000)的数据进行验证,对比两种方法的结果是否一致。
  • 利用已知结论:素数定理给出小于n的素数个数近似为n / ln(n)。你可以检查你求得的素数个数是否在这个近似值附近。例如,n=10^6时素数个数约为78498n=10^7时约为664579
  • 检查边界:手动验证几个边界素数,比如2(最小的素数),以及你筛选范围内最大的那个素数。

问题4:欧拉筛中,内层循环的条件if c > n: break可以优化吗?可以。因为c = i * p,且p >= 2,所以当i > n // 2时,i * 2 > n,内层循环一次都不会执行。我们可以在外层循环中增加一个判断:if i * primes[0] > n: break。但通常这个优化带来的收益很小,为了代码清晰,可以不做。

踩坑记录:我曾经在实现欧拉筛时,为了“优化”,将内层循环写成了for p in primes if i * p <= n:,然后在循环内标记。这看起来简洁,但破坏了“遇到i % p == 0就跳出”的逻辑顺序,因为if条件判断在循环体执行之前。这个错误导致程序在某些输入下结果错误,调试了很久。教训是:在修改核心算法逻辑时,尤其是涉及循环和条件中断时,一定要谨慎,最好先用小数据暴力对比验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询