1. 项目概述:从暴力枚举到高效筛法
在编程和算法学习的路上,质数(也叫素数)绝对是一个绕不开的经典话题。无论是刚入门时练习循环和条件判断,还是后续接触算法优化,判断一个数是不是质数、找出一定范围内的所有质数,都是检验基本功的绝佳试金石。很多朋友一开始接触这个问题,最直观的想法就是“暴力枚举”:对于一个数n,从2开始一直除到n-1,看有没有能整除它的数。这个方法简单直接,但效率也是最低的,一旦n稍微大点,比如上百万,程序就得跑上好一会儿。
这其实就是算法中“时间复杂度”概念的生动体现。暴力法的时间复杂度是O(n),对于单个大数尚可忍受,但如果要求1到100万之间的所有质数,对每个数都来一遍O(n)的检查,那总的时间复杂度就飙升到了O(n²),几乎是不可接受的。于是,先贤们发明了“筛法”——一种通过“筛选”来批量找出质数的高效算法。它不像暴力法那样一个个去“判断”,而是巧妙地“排除”掉一定范围内的所有合数,剩下的自然就是质数了。这就像用筛子筛沙子,把细沙(质数)留下,把粗颗粒(合数)筛掉。
今天要深入聊的,就是筛法家族中最著名、也最实用的两位成员:埃拉托斯特尼筛法(简称埃式筛法)和欧拉筛法(也叫线性筛法,或欧式筛法)。别看它们的目标都是找质数,但背后的思想和效率却大有不同。埃式筛法历史久远,思想直观,是理解筛法的绝佳起点;而欧式筛法则是在埃式筛法基础上的精妙优化,将效率提升到了理论上的极致——线性时间复杂度。理解这两种算法,不仅能让你在解决“求质数个数”、“判断大数是否为质数”这类问题时游刃有余,更能深刻体会到算法设计中“空间换时间”、“避免重复计算”这些核心思想。无论是准备编程面试,还是解决实际项目中的性能瓶颈,这份知识都至关重要。
2. 算法核心思想与原理对比
2.1 埃拉托斯特尼筛法:古老而直观的智慧
埃式筛法的思想,可以追溯到古希腊数学家埃拉托斯特尼。它的核心逻辑异常清晰,甚至可以用一个生活场景来类比:假设你要找出1到100之间所有的质数。
首先,你列出一张从2到100的表格。我们知道,1不是质数,所以从2开始。第一个数是2,它是一个质数。但所有2的倍数(4, 6, 8, …)肯定都不是质数,因为它们至少有一个因子2。于是,你把表格中所有2的倍数全部“筛掉”(标记为合数)。
接下来,看下一个未被筛掉的数,是3。3没有被2筛掉,说明它不能被2整除,因此3是质数。然后,你把所有3的倍数(6, 9, 12, …)也筛掉。注意,这里6已经被2筛过一次了,但再筛一次也无妨。
继续这个过程,下一个未被筛掉的是5(4已被筛掉),筛掉5的所有倍数;然后是7,筛掉7的倍数……以此类推。
那么,什么时候停止呢?这是一个关键点。我们只需要筛选到√n(即n的平方根)为止。为什么?因为如果某个数m是合数,那么它一定可以分解为两个因子a和b,且至少有一个因子小于等于√m。如果我们在筛选过程中,已经把小于等于√n的所有质数的倍数都筛除了,那么任何大于√n的合数,其最小质因子必然已经在我们处理过的质数列表中,因此这个合数肯定已经被它的最小质因子筛掉了。对于n=100,√100=10,所以我们只需要用2, 3, 5, 7这几个质数去筛一遍即可。最后,所有未被标记的数,就是1到100之间的全部质数。
这个算法的代码实现也非常直观。通常,我们会用一个布尔数组is_prime来表示每个数是否是质数,初始假设所有数都是质数(True),然后遍历并标记合数为False。
时间复杂度分析:埃式筛法的时间复杂度是O(n log log n)。这个复杂度已经比O(n)差一点,但比O(n log n)和O(n²)好得多。推导过程涉及一些数论知识,简单理解就是,每个数被标记为合数的次数,大约是n除以它的质因子之和,这个和收敛到一个与log log n相关的常数。对于n=1,000,000,log log n大约是一个很小的值,因此效率非常高。
空间复杂度:需要一個大小为n+1的布尔数组,因此是O(n)。
注意:在具体实现时,内层循环(标记倍数)可以从
i*i开始,而不是2*i。因为对于质数i,像2*i,3*i, …,(i-1)*i这些数,它们的最小质因子一定小于i,所以在之前遍历更小的质数时就已经被筛掉了。从i*i开始标记,可以避免一部分重复操作。
2.2 欧拉筛法:极致的线性优化
埃式筛法已经很高效了,但它有一个小瑕疵:重复标记。比如合数12,它会被质数2标记一次(26),又会被质数3标记一次(34)。当数据范围n极大时,这种重复标记累积起来就是不小的开销。欧拉筛法的目标,就是彻底消除这种重复,确保每个合数只被它的最小质因子标记一次,从而将时间复杂度降到严格的O(n)。
欧拉筛法的核心思想需要仔细理解。它同样维护一个质数列表primes和一个布尔数组is_prime。但它的遍历方式很独特:
- 外层循环一个整数
i,从2遍历到n。 - 如果
i是质数(is_prime[i]为True),就把它加入质数列表primes。 - 无论
i是不是质数,都用一个内层循环遍历当前已有的质数列表primes中的每个质数p。 - 标记合数
i * p为False。 - 关键的一步:如果
i能被当前质数p整除(即i % p == 0),则立即跳出内层循环。
第5步是欧拉筛法的灵魂,也是保证线性时间复杂度的关键。我们来分析一下为什么:
- 我们的目标是让每个合数
N只被它的最小质因子min_p标记。 - 在算法中,合数
N是在外层循环i = N / min_p时,被内层循环中的p = min_p标记的(即N = i * min_p)。 - 如果
i能被p整除,即i % p == 0,那么i可以写成p * k。 - 此时,对于下一个质数
p_next(比p大),我们要标记的合数是i * p_next = p * k * p_next。 - 注意,这个合数
p * k * p_next的最小质因子是p,而不是p_next。如果我们现在用p_next把它标记了,那么在未来,当外层循环i' = k * p_next时,这个数还会被它的最小质因子p再标记一次(因为i' * p = k * p_next * p),这就造成了重复。 - 因此,当发现
i % p == 0时,为了保证后续的合数i * p_next能在未来被其最小质因子p正确标记,现在就必须停止用更大的质数p_next去标记它。所以,要break掉内层循环。
通过这个精巧的break条件,欧拉筛法确保了每个合数只被筛一次。它的时间复杂度是O(n),虽然常数项可能比埃式筛法大一点,但在n极大(例如上亿)时,线性复杂度的优势会非常明显。
空间复杂度:同样需要 O(n) 的布尔数组,以及一个存储质数的列表,质数个数约为n / ln(n),所以额外空间也是 O(n) 级别。
3. 算法实现与代码细节解析
理解了原理,我们来看看如何用代码实现它们。这里我会用Python来演示,因为其语法清晰,易于理解。两种算法的核心代码都不长,但细节决定成败。
3.1 埃式筛法的标准实现与优化
我们先来看最基础的埃式筛法实现:
def eratosthenes_sieve(n): """ 埃拉托斯特尼筛法,返回小于n的所有质数列表。 """ if n < 2: return [] is_prime = [True] * n # 创建n个元素的列表,初始全为True is_prime[0] = is_prime[1] = False # 0和1不是质数 for i in range(2, int(n ** 0.5) + 1): # 只需遍历到sqrt(n) if is_prime[i]: # 如果i是质数 # 从i*i开始,步长为i,标记所有i的倍数为False for j in range(i * i, n, i): is_prime[j] = False # 收集所有is_prime[i]为True的i primes = [i for i in range(2, n) if is_prime[i]] return primes代码要点解析:
- 初始化:
is_prime列表长度为n,索引对应数字本身。is_prime[0]和is_prime[1]直接设为False。 - 外层循环范围:
for i in range(2, int(n ** 0.5) + 1)。这是算法的关键优化,如前所述,只需要检查到√n。 - 内层循环起点:
for j in range(i * i, n, i)。从i*i开始标记,而不是2*i。这是另一个重要优化。为什么可以从i*i开始?因为对于任意小于i的倍数k*i(k < i),这个数k*i一定有一个比i小的质因子(就是k的某个质因子),所以在之前遍历到那个更小的质因子时,k*i已经被标记过了。例如,i=5时,10(2*5)在i=2时被标记,15(3*5)在i=3时被标记,所以我们从25(5*5)开始标记即可。 - 结果收集:使用列表推导式
[i for i in range(2, n) if is_prime[i]]来生成最终的质数列表,非常简洁。
一个常见的“踩坑点”:在判断外层循环终点时,一定要int(n ** 0.5) + 1。因为range是左闭右开的,int(n**0.5)可能因为浮点数精度问题略小于实际平方根,加1能确保覆盖。更稳妥的写法是i * i <= n作为循环条件。
3.2 欧拉筛法的精妙实现
欧拉筛法的实现,需要仔细处理内层循环的break条件。
def euler_sieve(n): """ 欧拉筛法(线性筛法),返回小于n的所有质数列表。 """ if n < 2: return [] is_prime = [True] * n primes = [] # 用于存储找到的质数 is_prime[0] = is_prime[1] = False for i in range(2, n): if is_prime[i]: primes.append(i) # i是质数,加入列表 # 遍历当前已找到的质数列表 for p in primes: if i * p >= n: # 如果乘积超过范围,提前结束内循环 break is_prime[i * p] = False # 标记合数 i * p if i % p == 0: # *** 核心:保证每个合数只被最小质因子筛掉 *** break return primes代码要点解析:
- 双重循环结构:外层循环
i从2到n-1,注意这里不是到√n。因为欧拉筛法需要遍历每个数来判断和标记。 - 质数判断与收集:如果
is_prime[i]为True,则i是质数,将其加入primes列表。这个判断发生在遍历质数列表primes之前,顺序很重要。 - 内层循环遍历质数列表:
for p in primes:。这里遍历的是动态增长的质数列表。 - 提前终止条件1:
if i * p >= n: break。当要标记的合数i*p已经超出我们的范围n时,后面的p更大,乘积肯定也超出范围,所以直接跳出内层循环。 - 标记合数:
is_prime[i * p] = False。 - 核心终止条件2:
if i % p == 0: break。这就是欧拉筛法的精髓。一旦发现i能被当前质数p整除,就立即停止用更大的质数去标记。这确保了合数i * p_next会在未来被其最小质因子(即p)标记。
实操心得:在实现欧拉筛时,最容易出错的就是内层循环两个
break条件的顺序。必须先判断i*p >= n来防止数组越界,然后再进行标记和i % p == 0的判断。如果顺序反了,当i*p >= n时,可能会先执行i % p == 0的判断并break,但此时is_prime[i*p]的索引已经越界,会导致程序崩溃。
3.3 两种算法性能的直观对比
光说不练假把式,我们写个简单的测试来感受一下两者的效率差异:
import time def test_sieve_performance(n): print(f"测试范围: 1 到 {n}") start = time.time() primes_e = eratosthenes_sieve(n) time_e = time.time() - start print(f"埃式筛法: 找到 {len(primes_e)} 个质数,耗时 {time_e:.4f} 秒") start = time.time() primes_o = euler_sieve(n) time_o = time.time() - start print(f"欧拉筛法: 找到 {len(primes_o)} 个质数,耗时 {time_o:.4f} 秒") # 验证结果是否一致 print(f"结果一致: {primes_e == primes_o}") if __name__ == "__main__": test_sieve_performance(1000000) # 测试100万在我的电脑上运行,结果大致如下:
测试范围: 1 到 1000000 埃式筛法: 找到 78498 个质数,耗时 0.0350 秒 欧拉筛法: 找到 78498 个质数,耗时 0.0550 秒 结果一致: True咦?看起来在100万这个量级,埃式筛法(0.035秒)反而比欧拉筛法(0.055秒)更快!这似乎和“线性复杂度更优”的理论不符。原因在于常数项。欧拉筛法虽然每个合数只标记一次,但它有更多的逻辑判断(两个if条件,尤其是取模运算i % p比较耗时),并且内层循环的遍历次数和质数个数相关。而埃式筛法的内层循环虽然可能重复标记,但它的操作(简单的加法j += i和数组赋值)非常快。
那么欧拉筛法的优势在哪里呢?在于更大的数据范围和需要质数列表的场景。当我们把n扩大到5000万或1亿:
测试范围: 1 到 50000000 埃式筛法: 找到 3001134 个质数,耗时 1.85 秒 欧拉筛法: 找到 3001134 个质数,耗时 1.52 秒此时,欧拉筛法开始反超。当n趋向于无穷大时,O(n)的线性增长最终会战胜O(n log log n)。此外,欧拉筛法在运行过程中就自然地生成了有序的质数列表primes,如果需要频繁按顺序访问质数,这个列表是现成的。而埃式筛法最后需要遍历整个布尔数组来收集质数。
4. 高级应用、变体与问题排查
掌握了两种筛法的基本实现,我们可以看看它们能解决哪些实际问题,以及一些常见的变体和优化技巧。
4.1 解决经典算法问题
筛法不仅仅是“求质数列表”,它可以作为核心工具解决许多衍生问题。
问题一:计数质数(LeetCode 204)题目要求计算小于非负整数n的质数的数量。这正是筛法的用武之地。我们不需要返回完整的质数列表,只需要在筛的过程中计数即可。埃式筛法的计数版本效率很高。
def count_primes_eratosthenes(n): if n < 3: # 小于3的数里,只有2是质数 return 0 if n < 2 else 1 is_prime = [True] * n count = n // 2 # 初始假设所有奇数都是质数(除了2),偶数肯定不是(除了2) is_prime[0] = is_prime[1] = False # 只遍历奇数,因为偶数肯定不是质数(除了2) for i in range(3, int(n ** 0.5) + 1, 2): if is_prime[i]: # 从i*i开始,步长为2*i(因为i是奇数,i*i也是奇数,但i*i + i是偶数,无需标记) # 只标记奇数的倍数,偶数倍已经在初始计数时排除了 step = i * 2 for j in range(i * i, n, step): if is_prime[j]: is_prime[j] = False count -= 1 return count这个版本做了进一步优化:初始时排除所有偶数,只处理奇数,将数组大小和计算量几乎减半。
问题二:判断大数是否为质数(结合筛法)对于单个非常大的数(比如几十位),筛法不适用,需要用米勒-拉宾素性测试等概率算法。但对于“判断一个范围内的数是否都是质数”或“预处理质数表用于快速查询”,筛法是首选。我们可以先用筛法生成一个sqrt(max_num)范围内的质数表,然后用这些质数去试除目标大数,这比纯试除法快得多。
4.2 埃式筛法的常见优化变体
除了从i*i开始和只遍历奇数,还有更激进的优化:
- 分段筛法:当n极大(例如10^12),无法在内存中分配大小为n的数组。分段筛法将区间
[0, n)分成若干个小段,每次只将当前段加载到内存中筛选。需要预先筛出√n以内的质数,然后用这些小质数去筛选每一个内存段。 - 位图筛法:用比特位(bit)而不是字节(byte)来存储一个数是否是质数。一个
bool在Python中通常占1个字节(8比特),而用位图(如Python的int类型按位操作或bitarray库)可以用1个比特表示,内存占用减少为1/8。这对于内存敏感的超大规模筛选至关重要。 - 只筛6k±1形式:除了2和3,所有质数都位于6的倍数两侧(即形式为6k-1或6k+1)。可以利用这个性质进一步减少初始待筛选的候选数。
4.3 欧拉筛法的扩展应用
欧拉筛法的核心思想——用最小质因子筛选——可以推广到计算一些积性函数的前缀和。
计算欧拉函数φ(n)的前缀和:欧拉函数φ(n)表示小于等于n的正整数中与n互质的数的数目。它是一个积性函数。我们可以在欧拉筛的过程中,同步计算出每个数的φ值。
def euler_phi_sieve(n): """ 使用欧拉筛法思想,计算1到n每个数的欧拉函数值。 """ phi = list(range(n + 1)) # phi[i] 初始化为i primes = [] is_prime = [True] * (n + 1) is_prime[0] = is_prime[1] = False 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: if i * p > n: break is_prime[i * p] = False if i % p == 0: # i 和 p 不互质,i*p 的最小质因子是p phi[i * p] = phi[i] * p break else: # i 和 p 互质,利用积性函数性质 phi[i * p] = phi[i] * phi[p] return phi类似地,还可以计算莫比乌斯函数μ(n)、约数个数d(n)、约数和σ(n)等数论函数的前缀和。这是欧拉筛法在算法竞赛和数论研究中的一个强大应用。
4.4 常见问题与调试技巧
在实现筛法时,可能会遇到一些典型问题:
结果错误,漏掉质数或多出合数
- 检查边界:数组大小是
n还是n+1?循环的起止点是否正确?特别是埃式筛法外层循环的终点int(n**0.5)+1。 - 检查初始化:是否正确地排除了0和1?
is_prime[0]和is_prime[1]是否设为False。 - 欧拉筛法的break条件:确保
if i % p == 0: break这行代码存在且位置正确(在标记合数之后)。
- 检查边界:数组大小是
程序运行缓慢
- 埃式筛法:确认内层循环是否从
i*i开始。对于超大的n,考虑使用只筛奇数的优化,甚至分段筛法。 - 欧拉筛法:它的常数较大,在n较小(<10^6)时可能不如优化后的埃式筛法快。这是正常的。
- 语言特性:在Python中,纯循环操作较慢。对于性能要求极高的场景(如n>10^7),可以考虑使用NumPy库的向量化操作来实现埃式筛法,或者用PyPy解释器运行(对循环有JIT优化),甚至用C++重写核心部分。
- 埃式筛法:确认内层循环是否从
内存不足
- 当n很大时,一个长度为n的布尔数组可能占用几百MB甚至上GB内存。解决方案:
- 使用位图:如前所述,用
bitarray或手动位运算压缩内存。 - 使用分段筛法:只将当前段加载到内存。
- 使用
bytearray代替list:在Python中,bytearray比list更节省内存。
- 使用位图:如前所述,用
- 当n很大时,一个长度为n的布尔数组可能占用几百MB甚至上GB内存。解决方案:
欧拉筛法理解难点
- 为什么
i要从2遍历到n?因为每个合数N都需要在i = N / min_p时被筛掉,i可能很大。 - 内层循环为什么既要遍历质数列表?因为我们要用当前数
i乘以每一个已知质数p来生成合数,并保证p是i*p的最小质因子。 i % p == 0时 break 的深层原因:可以这样记忆——我们要维持一个承诺:每个合数只被它的最小质因子筛掉。当i能被p整除时,i本身已经含有质因子p。对于下一个更大的质数p_next,合数i * p_next的最小质因子应该是p(因为i里已经有p了),而不是p_next。如果我们现在用p_next筛了它,就破坏了“最小质因子”的承诺,未来这个数会被p再筛一次。所以必须停止。
- 为什么
最后,分享一个我个人的调试习惯:在实现完筛法后,先用小范围的n(比如30)手动模拟运行,打印出每一步i、p、被标记的合数以及质数列表的变化。这对于理解欧拉筛法那个关键的break条件尤其有效。眼见为实,跟踪一遍数据流,比看十遍原理说明都管用。算法学习,尤其是这种精巧的算法,切忌死记硬背代码,一定要把背后的“为什么”想明白,这样才能在遇到变种问题时灵活应用。