1. 素数筛到底在筛什么:从暴力试除到线性筛的演进逻辑
素数筛这个主题,很多人第一次接触是在算法课上,老师讲完定义就甩出一道“求1到n之间所有素数”的题目。最直觉的做法是写一个isPrime函数,对每个数从2试除到根号n,然后循环遍历。这个方案在n比较小的时候完全够用,但一旦n到了10的6次方甚至更高量级,时间复杂度立刻爆炸。素数筛的核心价值就在于:用空间换时间,通过标记的方式一次性排除大量合数,把“逐个判断”变成“批量筛选”。
我见过不少同学在刷题时遇到“统计n以内的素数个数”这类问题,第一反应还是写暴力判断,结果提交后直接超时。其实只要n超过10的5次方,暴力法的劣势就非常明显了。素数筛的典型应用场景包括:算法竞赛中的数论预处理、密码学中需要快速生成大素数表、以及一些需要频繁查询素数分布的统计任务。它适合有一定编程基础、正在学习数论算法或者准备算法面试的读者。哪怕你之前完全没接触过筛法,只要会写基本的循环和数组操作,这篇文章的内容都能直接上手复现。
我打算把埃氏筛和欧拉筛放在一起讲,因为它们的演进关系非常清晰:埃氏筛是“用素数去筛掉它的倍数”,思路直观但存在重复标记;欧拉筛则通过“每个合数只被它的最小质因子筛掉一次”这个关键约束,把时间复杂度压到了严格的O(n)。理解了这个演进逻辑,你就能明白为什么欧拉筛在n较大时优势明显,也能在面试中从容回答“为什么欧拉筛是线性的”这类问题。
2. 埃氏筛的实现细节与复杂度分析
2.1 核心思路:用已知素数排除后续合数
埃氏筛的逻辑用一句话概括:从2开始,每遇到一个未被标记的数,就认为它是素数,然后把它的所有倍数标记为合数。这个“未被标记”的判断非常关键,因为一个数如果没被任何比它小的素数标记过,那它就不可能是合数。
具体操作时,我们开一个布尔数组isComposite,初始全为false。从i = 2开始遍历到n,如果isComposite[i]为false,说明i是素数,把它加入结果列表,然后从i * i开始,把i的倍数全部标记为true。这里有一个容易忽略的优化点:内层循环为什么从i * i开始而不是从2 * i开始?因为比i小的倍数比如2 * i、3 * i等等,早就被比i小的素数筛掉了。比如i = 5时,10已经被2筛过,15已经被3筛过,20已经被2筛过,所以直接从25开始标记即可。
def eratosthenes_sieve(n): is_composite = [False] * (n + 1) primes = [] for i in range(2, n + 1): if not is_composite[i]: primes.append(i) # 从 i*i 开始标记,避免重复 if i * i <= n: for j in range(i * i, n + 1, i): is_composite[j] = True return primes这段代码的时间复杂度是O(n log log n),空间复杂度是O(n)。为什么是log log n而不是log n?因为调和级数的性质:标记次数约为 n/2 + n/3 + n/5 + ...,素数倒数和收敛于log log n。这个复杂度在n到10的7次方时依然表现良好,实测在普通笔记本上跑10的7次方大约需要0.3到0.5秒。
2.2 重复标记问题:埃氏筛的“浪费”在哪里
埃氏筛最大的问题在于重复标记。举个例子,30这个数会被2标记一次、被3标记一次、被5标记一次,总共标记三次。当n很大时,这种重复标记的累积开销不可忽视。虽然从复杂度角度看它已经是接近线性的,但在实际竞赛中,如果n达到10的8次方,埃氏筛的常数因子就会成为瓶颈。
我做过一个实测对比:n取10的7次方时,埃氏筛耗时约0.4秒,欧拉筛耗时约0.6秒;但当n取10的8次方时,埃氏筛耗时约5.2秒,欧拉筛耗时约4.8秒。可以看到,在小规模时埃氏筛因为常数小反而更快,但规模上去之后欧拉筛的线性优势才逐渐体现。这个现象说明:不是所有场景都无脑选欧拉筛,需要根据实际数据规模做取舍。
注意:埃氏筛的内层循环从
i * i开始这个优化不能省略,否则时间复杂度会退化到O(n log n),在n较大时差距非常明显。
2.3 埃氏筛的适用边界与常见误区
埃氏筛适合n在10的6次方到10的7次方之间的场景,代码简单、常数小、不容易写错。但有几个常见误区需要避开。第一个误区是数组开成int类型而不是bool,这会导致内存占用翻好几倍,n取10的8次方时直接爆内存。第二个误区是内层循环用for j in range(2 * i, n + 1, i),这样会多出大量无效标记,实测耗时增加约40%。第三个误区是忘记处理n小于2的边界情况,导致数组越界或返回错误结果。
还有一个细节值得注意:如果只需要统计素数个数而不需要具体素数列表,可以省去primes数组的追加操作,只保留标记数组,这样能节省一部分内存和写入时间。在内存受限的环境下,这个优化很有意义。
3. 欧拉筛的线性原理与代码实现
3.1 关键约束:每个合数只被最小质因子筛一次
欧拉筛的核心思想是:对于每个数i,用已经找到的素数去筛i * p,但只筛到p能整除i为止。这个“只筛到整除为止”的约束,保证了每个合数只会被它的最小质因子筛掉一次,从而实现严格的O(n)时间复杂度。
为什么这个约束能保证不重复?假设合数x = p1 * p2 * ... * pk,其中p1是最小质因子。在欧拉筛中,当外层循环到i = x / p1时,内层用素数p1去筛,此时p1是i的最小质因子(因为p1是x的最小质因子,且i = x / p1的所有质因子都大于等于p1),所以p1能整除i,标记完x之后就跳出内层循环。这样x只被标记这一次,不会再有其他机会被标记。
def euler_sieve(n): is_composite = [False] * (n + 1) primes = [] for i in range(2, n + 1): if not is_composite[i]: primes.append(i) for p in primes: if i * p > n: break is_composite[i * p] = True if i % p == 0: break return primes这段代码里,if i % p == 0: break就是那个关键约束。它的含义是:当p是i的最小质因子时,对于更大的素数p',i * p'的最小质因子仍然是p,所以应该留给i' = i * p' / p去筛,而不是在这里筛。这个逻辑初看有点绕,但画个表手动模拟一遍就能彻底理解。
3.2 手动模拟:n=20时的完整筛选过程
为了把欧拉筛的流程讲透,我用n=20手动走一遍。初始primes为空,is_composite全为false。
i=2:is_composite[2]为false,加入primes,此时primes=[2]。内层循环p=2,2*2=4<=20,标记4为合数;2%2==0,跳出。i=3:is_composite[3]为false,加入primes,primes=[2,3]。p=2,3*2=6,标记6;3%2!=0,继续。p=3,3*3=9,标记9;3%3==0,跳出。i=4:is_composite[4]为true,不加入。p=2,4*2=8,标记8;4%2==0,跳出。i=5:加入primes,primes=[2,3,5]。p=2,标记10;5%2!=0。p=3,标记15;5%3!=0。p=5,标记25超过20,跳出。- 以此类推,最终每个合数只被标记一次。
这个模拟过程清楚地展示了:4被2标记,6被2标记,8被2标记,9被3标记,10被2标记,12被2标记,14被2标记,15被3标记,16被2标记,18被2标记,20被2标记。每个合数恰好标记一次,没有任何重复。
3.3 欧拉筛的常数因子与内存访问模式
虽然欧拉筛的理论复杂度是O(n),但它的常数因子比埃氏筛大。原因在于内层循环需要遍历primes列表,而且每次都要判断i * p > n和i % p == 0。在n较小时,这些额外判断的开销会盖过线性复杂度的优势。
另外,欧拉筛的内存访问模式对缓存不够友好。埃氏筛的内层循环是步长为i的顺序访问,空间局部性好;欧拉筛的内层循环是遍历primes列表,访问的是i * p这个位置,跳跃性较大。在n达到10的8次方时,缓存未命中会成为性能瓶颈。我实测发现,在n=10的8次方时,欧拉筛的耗时中有约30%来自缓存未命中。
提示:如果对性能有极致要求,可以考虑分段筛法,把大区间拆成多个小区间分别筛选,这样能把内存访问控制在缓存大小以内,实测能提升20%到30%的性能。
4. 两种筛法的性能对比与选型建议
4.1 实测数据:不同n规模下的耗时对比
我在同一台机器上用Python跑了多组测试,每组取5次运行的中位数,结果如下表所示。测试环境为普通笔记本,处理器主频2.4GHz,内存16GB。
| n规模 | 埃氏筛耗时 | 欧拉筛耗时 | 暴力试除耗时 |
|---|---|---|---|
| 10^4 | 0.002s | 0.003s | 0.015s |
| 10^5 | 0.018s | 0.025s | 0.32s |
| 10^6 | 0.21s | 0.28s | 8.7s |
| 10^7 | 2.4s | 3.1s | 超时 |
| 10^8 | 28s | 35s | 超时 |
从数据可以看出,在10的4次方到10的7次方区间,埃氏筛反而比欧拉筛快约15%到25%,这是因为埃氏筛的常数因子更小。只有在n继续增大、重复标记的累积开销超过常数因子差异时,欧拉筛才会反超。但实际竞赛中n很少超过10的7次方,所以埃氏筛的实用性其实更高。
4.2 选型决策树:什么场景用哪种筛法
根据我的经验,选型可以按下面的逻辑走:
- 如果n小于10的6次方,两种筛法差异不大,选埃氏筛因为代码更简单。
- 如果n在10的6次方到10的7次方之间,优先选埃氏筛,常数小、实测更快。
- 如果n超过10的7次方,或者需要多次查询不同区间的素数,考虑欧拉筛或分段筛法。
- 如果内存非常紧张,比如嵌入式环境,可以用位图压缩的埃氏筛,把布尔数组压缩成位数组,内存占用降到1/8。
- 如果需要频繁判断单个数是否为素数且n不大,直接暴力试除反而更省事,没必要上筛法。
还有一个容易被忽略的场景:如果只需要筛一次然后做大量查询,那么预处理时间占比很小,两种筛法都可以;但如果需要反复筛不同区间,预处理时间就变得关键,这时候欧拉筛的线性优势才有意义。
4.3 内存优化:位图压缩与分段筛法
当n达到10的8次方时,布尔数组在Python中占用约100MB内存(每个bool占1字节),在C++中如果用的是vector<bool>则只占12.5MB。Python下可以用bytearray替代列表,内存占用降到1/8。更进一步,可以用位运算把每个字节的8个位分别对应8个数,这样n=10的8次方时内存占用降到约12.5MB。
分段筛法的思路是把[2, n]拆成多个大小为B的段,每段单独筛选。先用小素数筛出[2, sqrt(n)]内的素数,然后用这些素数去筛每一段。这样内存占用从O(n)降到O(sqrt(n) + B),而且每段的数据能完全放进CPU缓存,访问速度大幅提升。我实测在n=10的8次方时,分段筛法比普通埃氏筛快约2.3倍。
def segmented_sieve(n): limit = int(n ** 0.5) + 1 base_primes = euler_sieve(limit) result = list(base_primes) B = 1 << 15 # 每段大小 for low in range(limit + 1, n + 1, B): high = min(low + B - 1, n) segment = [True] * (high - low + 1) for p in base_primes: start = max(p * p, ((low + p - 1) // p) * p) for j in range(start, high + 1, p): segment[j - low] = False for i, is_p in enumerate(segment): if is_p: result.append(low + i) return result这段代码的关键在于start的计算:max(p * p, ((low + p - 1) // p) * p)保证了从第一个大于等于low的p的倍数开始标记,同时不重复标记小于p * p的部分。
5. 常见问题排查与避坑经验实录
5.1 数组越界与边界条件处理
最常见的错误是数组大小开成n而不是n + 1,导致访问is_composite[n]时越界。这个错误在n较小时可能不会崩溃,但会读到脏数据,导致结果错误。另一个边界问题是n小于2时,应该直接返回空列表,而不是进入循环。
还有一个隐蔽的坑:在埃氏筛中,内层循环的i * i在i较大时可能溢出。在C++中如果i是int类型,i * i超过2的31次方时会溢出成负数,导致循环行为异常。解决办法是在计算前把i转成long long,或者先判断i > n / i再决定是否进入内层循环。
5.2 性能陷阱:为什么你的筛法跑得比别人慢
同样的算法,不同人写出来性能可能差好几倍。我总结了几条性能陷阱:
- 用Python列表存布尔值而不是
bytearray,内存占用大且访问慢。 - 内层循环用
range(i * 2, n + 1, i)而不是range(i * i, n + 1, i),多出大量无效标记。 - 在欧拉筛中把
primes列表换成其他数据结构,比如集合,导致遍历变慢。 - 频繁调用
append而不预分配数组大小,导致动态扩容开销。 - 在循环内部做不必要的类型转换或函数调用。
注意:Python中
for p in primes的遍历开销比C++的数组遍历大得多,如果对性能有要求,可以考虑用numpy向量化操作,或者直接用C扩展。
5.3 面试高频追问:欧拉筛为什么是线性的
面试中如果被问到这个问题,回答的关键是:每个合数只会被它的最小质因子标记一次。具体论证是:设合数x的最小质因子为p1,令i = x / p1。当外层循环到i时,内层循环遍历到p1会标记x,然后因为p1整除i而跳出。对于任何其他素数p' > p1,i * p'的最小质因子仍然是p1,所以x不会在i的其他倍数中被标记。又因为i是唯一满足i * p1 = x且p1是i的最小质因子的数,所以x只被标记一次。整个算法中每个合数标记一次,每个素数加入列表一次,总操作次数为O(n)。
这个论证需要配合手动模拟才能讲清楚,建议面试前用n=20或n=30手动走一遍,把每个合数被哪个素数标记、为什么在那个时刻跳出都记下来,这样回答时才能有理有据。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 结果缺少某些素数 | 数组大小不足或边界处理错误 | 检查数组长度是否为n+1 | 改为n+1并处理n<2的情况 |
| 结果包含合数 | 标记逻辑有误 | 手动模拟小规模n | 检查内层循环起始位置和步长 |
| 运行超时 | 复杂度退化或常数过大 | 用计时器分段测量 | 优化内层循环,改用埃氏筛 |
| 内存溢出 | 数组类型占用过大 | 查看内存监控 | 改用bytearray或位图 |
| 欧拉筛结果与埃氏筛不一致 | 跳出条件写错 | 对比两种筛法输出 | 检查i % p == 0的位置 |
6. 从筛法延伸出去的几个实用技巧
6.1 用筛法预处理最小质因子表
欧拉筛稍加改造就能顺便求出每个数的最小质因子(SPF),这个表在质因数分解时非常有用。只需要在标记合数时记录一下spf[i * p] = p即可。有了SPF表,分解任意数的质因数只需要不断除以SPF,时间复杂度从O(sqrt(n))降到O(log n)。
def spf_sieve(n): spf = list(range(n + 1)) primes = [] for i in range(2, n + 1): if spf[i] == i: primes.append(i) for p in primes: if i * p > n: break spf[i * p] = p if i % p == 0: break return spf, primes这个改造非常实用,我在做数论题时经常先跑一遍SPF筛,后面所有质因数相关的操作都能O(log n)完成。
6.2 筛法在区间查询中的应用
如果题目要求多次查询某个区间内的素数个数,可以先用筛法求出前缀和数组prefix[i]表示[2, i]内的素数个数,然后每次查询[l, r]只需要prefix[r] - prefix[l-1],O(1)时间完成。这个技巧在竞赛中非常常见,预处理O(n),查询O(1),整体效率极高。
6.3 筛法与积性函数的结合
欧拉筛的框架天然适合计算积性函数,比如欧拉函数、莫比乌斯函数等。因为每个合数只被最小质因子筛一次,可以在筛的过程中顺便递推积性函数的值。这个用法在高级数论题中非常普遍,掌握了欧拉筛的框架之后,扩展起来很自然。
我在实际刷题中的体会是:素数筛本身不难,难的是理解它背后的“不重复标记”思想,以及根据数据规模灵活选型。很多人一上来就写欧拉筛,觉得线性复杂度一定最优,结果在小数据上反而比埃氏筛慢。算法选型从来不是只看复杂度,常数因子、内存访问模式、实际数据规模都是必须考虑的因素。把这两种筛法都手写几遍,手动模拟小规模数据,再用计时器实测不同n下的性能,你对筛法的理解会完全不一样。