素数判断这件事,几乎所有学编程的人都绕不过去。从最朴素的试除法,到埃氏筛,再到欧拉筛(线性筛),这条路既是一道经典算法题,也是理解数论、性能优化和代码细节打磨的好教材。我花了不少时间把这三种方式串起来研究,尤其是欧拉筛和埃氏筛的区别,网上很多资料讲得比较含糊,实际操作中也有不少容易踩的坑。这篇就说清楚它们各自的原理、适用场景,以及我实际调试时的一些经验,帮你直接拿来用。
这篇文章适合刚学完循环和数组、想进阶看算法的新手,也适合准备面试、刷题时想彻底搞懂素数筛法的同学。看完之后,你不仅能写出一个能跑出结果的筛法代码,还能明白为什么在数据量到一千万、一亿的时候,代码的写法会直接决定你等一秒还是等一分钟。
1. 内容整体设计与思路拆解
1.1 从“判断一个数是不是素数”说起
素数也叫质数,是大于1且只有1和它本身两个因数的自然数。判断一个数n是否为素数,最直白的做法就是从2一直试到n-1看能不能整除。这个方式写起来太容易了,但效率也很容易让人崩溃——如果n是几千万,这个循环基本就跑不动了。
大家通常的优化是只试到√n,因为如果n有一个大于√n的因数,那么它一定对应一个小于√n的因数。这个结论在数学上没什么问题,实际刷题的时候也够用。但如果要判断很多个数是否素数,比如100万次查询,每次就算只试到根号,累积起来开销也很大。
这个时候就会想到一个思路:能不能一次性把某个范围里所有的素数都标记出来?筛法就是干这个的。它牺牲一点内存,换来一个范围内所有素数的快速获取,典型的空间换时间。按照我的使用习惯,如果题目求的是“1到N之间有多少素数”或者需要频繁做素数判断,那直接上筛法,基本不犹豫。
1.2 埃氏筛和欧拉筛各自解决的问题
埃氏筛(埃拉托斯特尼筛法)的核心思想是“素数的倍数一定是合数”。从2开始,把2的所有倍数标记为合数,然后找到下一个未被标记的数3,把3的所有倍数标记为合数,以此类推。等筛完一圈,没被标记的就是素数。
这个思路简单直观,代码也短,但存在一个重复标记的问题。比如6,它既是2的倍数又是3的倍数,在筛2时标记一次,筛3时又标记一次。数字越大,被重复标记的次数就越多,整体开销会明显上涨。
欧拉筛(也叫线性筛)就是为了消除重复标记而出现的。它要求每个合数只被它的最小质因数筛掉一次,这样每个数字最多被访问一次,整体复杂度从埃氏筛的O(n log log n)降到了严格的O(n)。所以理论上n特别大的时候,欧拉筛更稳。
不过这里要说明一点:欧拉筛虽然复杂度更优,但实际跑起来并不一定会比优化得好的埃氏筛快好几倍。因为埃氏筛常数小,加上从2到√n只筛一遍就很省了。但当n达到千万、亿这个级别,欧拉筛的优势就能体现在内存访问和标记次数上。我自己实测的结果是,一亿以内的素数统计,欧拉筛确实比常规埃氏筛快那么一截,但差距没有想象中那么悬殊。说到底,理解两者的原理,比单纯比谁快更重要。
1.3 选型建议:什么场景用哪个
- 只需要判断少数几个数:直接试除,用√n优化,写起来最快,最简单。
- 要判断1到N所有素数,N在10^6到10^7量级:两种筛都行,埃氏筛连优化带普通写法都能接受。
- 要求输出每个数的最小质因数,或者要做积性函数前缀和之类的操作:必须上欧拉筛,因为它在筛的过程中天然记录最小质因数,这是埃氏筛不方便提供的。
- 在算法竞赛或面试中,如果题目空间限制很紧,可以选择埃氏筛的bitset写法,内存占用更低。
我在实际开发中还有一个体会:如果只是做题,不追求输出素数列表,而是求区间素数个数,那可以用分段筛(区间筛)。但那是另一个话题了,先不展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 埃氏筛的朴素写法与优化细节
先给一个最基本、也能直接上手的Python版本埃氏筛:
python复制def eratosthenes(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
for i in range(2, int(n ** 0.5) + 1):
if is_prime[i]:
for j in range(i * i, n + 1, i):
is_prime[j] = False
return [i for i, val in enumerate(is_prime) if val]
这里有两个很多人一开始看不懂的细节。
第一个,外层循环为什么只到√n?这跟试除法的逻辑是一样的:如果当前这个数i是合数,那么在枚举到√n之前,它必然已经被更小的质因子标记过,所以不会被当作质数处理;如果它是质数,那它的倍数中,比ii小的那些,已经在枚举更小质数时被标记过了。所以从ii开始标记是合理的,也是性能优化的关键。
第二个,为什么从i*i而不是从2i开始?假设现在i是5,那么5×2=10、5×3=15、5×4=20其实都已经被2和3标记过了。因为10是2的倍数,15是3的倍数,20是2的倍数。只有从25开始,才轮到5作为最小质因数起作用。这个细节能减少很多无效标记。
如果你用C++,写法类似,只是要注意数据范围,n稍微大一点就要用long long。n=10^7时,bool数组占10MB;Python的bool列表虽然封装得更重,但也不是不能跑。如果内存紧张,就考虑用bytearray或者bitset。
2.2 埃氏筛到底能被优化到什么程度
朴素版埃氏筛已经能应对大多数场景。不过性能焦虑的人可能还会做这三个优化:
- 只用奇数筛。因为除了2以外,所有偶数都是合数,直接跳过偶数,内存少一半,标记次数也少一半。实现时要把数组下标和实际数字做一个映射。
- 使用bitset。C++里可以把标记数组用bitset存储,内存压到原来的1/8。Python里可以用bytearray,虽然不能直接bit压缩,但比list[bool]省很多。
- 外层循环只枚举到√n;内层交叉时优先跳过已有标记的合数。
我个人觉得,刷题或者做演示时,做第一项优化就够了,第二项属于极端内存优化的场景,第三项代码复杂度提升但收益有限。关键还是先跑对,再优化。
2.3 欧拉筛的代码细节:为什么“每个合数只筛一次”
直接用Python写一个欧拉筛的经典实现:
python复制def euler_sieve(n):
is_prime = [True] * (n + 1)
primes = []
for i in range(2, n + 1):
if is_prime[i]:
primes.append(i)
for p in primes:
if i * p > n:
break
is_prime[i * p] = False
if i % p == 0:
break
return primes
核心逻辑就两句话:标记i * p为合数;当i % p == 0时立刻break。
这个break非常关键。举个例子,当前i=6,primes里已经存了[2,3,5]。先标记6×2=12,然后发现6%2==0,所以中断,不会去标记6×3=18、6×5=30。为什么不标记18?因为18的最小质因数是2,应该等到i=9的时候再来标记,那时候9×2=18。如果现在标记了,等于重复标记了一次,欧拉筛严谨意义上的“线性”就被破坏了。
要是没有这个break,欧拉筛就退化成带剪枝的埃氏筛,复杂度不再线性。这是欧拉筛最容易被忽略、也最值得深入研究的地方。
同样,欧拉筛也可以写成C++版本,维护一个primes数组,逻辑完全一致。需要注意的依然是数组越界问题,乘出来的数可能超过int范围,建议中间用long long判断。
2.4 两种筛法的对比表
| 项目 | 埃氏筛 | 欧拉筛 |
|---|---|---|
| 时间复杂度 | O(n log log n) | O(n) |
| 每个合数被标记次数 | 可能多次 | 恰好一次 |
| 代码难度 | 较低 | 中等 |
| 额外信息 | 只表示是否素数 | 能保留每个数的最小质因数 |
| 内存占用 | 一个布尔数组 | 布尔数组+质数列表 |
| 适用于n的范围 | 10^7量级可接受 | 10^7~10^8量级更稳定 |
这个表不是用来比谁更强,而是给选型时参考。如果你的需求就是列出一亿以内的素数个数,两种方案都可行,我见过很多选手只用埃氏筛也跑得很好。但欧拉筛在模型上更适合扩展,因为它把每个合数和它的最小质因数绑定在一起,这是很多数论算法的前置步骤。
3. 实操过程与核心环节实现
3.1 用欧拉筛求100万以内的素数个数
我拿一个具体的例子走一遍流程。假设我们要统计一百万以内的素数个数,并且想看看两种筛法各需要多少时间。
先准备一个计时脚本,分别在本地跑埃氏筛和欧拉筛:
python复制import time
def eratosthenes_count(n):
is_prime = [True] * (n + 1)
is_prime[0] = is_prime[1] = False
for i in range(2, int(n ** 0.5) + 1):
if is_prime[i]:
step = i
start = i * i
is_prime[start:n+1:step] = [False] * (((n - start) // step) + 1)
return sum(is_prime)
def euler_sieve_count(n):
is_prime = [True] * (n + 1)
primes = []
for i in range(2, n + 1):
if is_prime[i]:
primes.append(i)
for p in primes:
if i * p > n:
break
is_prime[i * p] = False
if i % p == 0:
break
return len(primes)
n = 1_000_000
t0 = time.time()
c1 = eratosthenes_count(n)
t1 = time.time()
c2 = euler_sieve_count(n)
t2 = time.time()
print(c1, t1 - t0)
print(c2, t2 - t1)
这份代码在Python的slice赋值上做了个小优化。埃氏筛的is_prime[start:n+1:step] = [...]这种写法比一层for j in range(...)快不少,因为循环被下沉到C层了。注意列表长度要计算准确,不然赋值会报错。我首次写出这个切片赋值时就没算对长度,浪费了不少调试时间。
跑完结果里,两者都能正确得到78498这个数,也就是一百万以内素数的个数。时间差异在Python里并不明显,因为Python本身解释器开销主导了耗时。如果换成C++,差距会清晰一些,但也不至于天壤之别。这个例子想说明的是:理解算法、写出正确代码,比盲目追求“最牛筛法”更重要。
3.2 欧拉筛如何顺带求出最小质因数
除了能快速得到素数列表,欧拉筛还天然能拿到每个合数的最小质因数。做法很简单:在is_prime[i * p] = False时,顺带记录一个min_prime[i * p] = p。
python复制def euler_sieve_with_min_prime(n):
min_prime = [0] * (n + 1)
primes = []
for i in range(2, n + 1):
if min_prime[i] == 0:
min_prime[i] = i
primes.append(i)
for p in primes:
v = i * p
if v > n:
break
min_prime[v] = p
if i % p == 0:
break
return min_prime, primes
这里判断min_prime[i] == 0相当于判断i是不是素数,因为最小质因数在筛法开始时都会给每个素数赋自己本身的值。这个数组在后续分解因数时特别有用:对任意x,它的最小质因数是min_prime[x],除掉后再看剩下的数,循环就能高效分解出所有质因数。
这个能力是埃氏筛不容易直接提供的。如果你需要快速分解很多数,或者求积性函数的值,那么欧拉筛几乎是最合适的选择。我在一次练习中需要给一百万个随机数做质因数分解,就是用这个min_prime数组做的预处理,速度快得让人很舒服。
3.3 实现时需要警惕的数组越界与性能陷阱
写筛法代码时,最容易翻车的是边界。
第一个陷阱是数组长度。比如求1到n之间的素数,数组要申请n+1个元素,下标从0到n。否则下标n访问不到,会直接报错或拿到脏数据。
第二个陷阱是乘法溢出。C++里i * p如果超过int最大值,可能变成负数,v > n的判断就失效了。稳妥的做法是判断i > n / p,而不是i * p > n,用除法来避免乘法溢出。
cpp复制for (int p : primes) {
if (i > n / p) break;
is_prime[i * p] = false;
if (i % p == 0) break;
}
第三个陷阱是Python的大内存问题。[True] * (n + 1)在n等于一亿时运行起来很慢,而且每个布尔对象其实是Python对象,内存远超你的直觉。真要跑一亿的话,建议用bytearray(b'\x01') * (n + 1)或者直接换C++。Python的优势是写起来方便,不适合做极限性能验证。
第四个陷阱是欧拉筛的break条件。一定要先标记,再判断i % p == 0,顺序不能反。如果先break再标记,就会漏掉某些合数。这个顺序我一开始搞反过,结果某个区间内少统计了素数,排查了很久。
3.4 实操完整流程演示
我给你一个可以直接照着操作的完整流程。
第一步:确认需求。比如我这里要得到n=1000000范围内所有素数,并输出数量。
第二步:选筛法。如果要进一步使用最小质因数,就选欧拉筛;如果没有额外需求,选埃氏筛,代码简单。
第三步:写核心函数。参考上文代码,先写埃氏筛,测试小范围n=20,手动核对素数表:2、3、5、7、11、13、17、19。没问题后,再换成欧拉筛同样测试。
第四步:性能对比。可以用time模块计时。在n=10^7时,我推荐用C++测,数据更客观。Python里也可以测,但主要看相对趋势。
第五步:保存结果。如果需要把素数列表输出到文件,要注意格式问题。比如每行10个,用空格分隔,节省空间。
这套流程是我自己在本地验证时用的,基本能覆盖初学到中等难度的使用场景。
4. 常见问题与排查技巧实录
4.1 为什么结果会多出1或者少掉2
新手最容易遇到的问题:算出来的素数个数不对劲,常见表现为把1也算成素数,或者忘了标记0和1。
在埃氏筛里,is_prime[0] = is_prime[1] = False这两行绝对不能省。欧拉筛因为是从2开始遍历的,不会出现1被误判,但也没必要多此一举存0和1,所以通常没问题。
如果统计时用sum(is_prime),那么把0和1标记成False是必须的,否则count会多2个。这个问题看似基础,但在写自定义筛法时特别常见,我见过很多人在刷LeetCode题时因为这个原因反复提交失败。
4.2 欧拉筛少算素数,问题通常出在break顺序
欧拉筛写错,最典型的症状是素数列表不完整。比如n=30,标准答案应该是10个素数,但有人的写法只出来9个。原因基本是if i % p == 0: break放在了标记之前。
看这个错误版本:
python复制for p in primes:
if i * p > n:
break
if i % p == 0:
break
is_prime[i * p] = False
这个写法把“确定某个倍数不是素数”和“防止重复标记”搞混了。当i=4,p=2,在错误版本里,因为4%2==0,直接break,但4×2=8根本没有被标记为合数,8就漏掉了。所以最终得到的素数表会多出8这个“伪素数”。
正确的顺序是:先标记,再判断break。无论会不会break,当前这个倍数都是合数,都必须标记掉。
4.3 埃氏筛的slice赋值长度对不上
Python优化写法里,最常报的错是:
code复制ValueError: attempt to assign sequence of size X to extended slice of size Y
这个就是切片删除/赋值时的长度不一致。解决方案是计算正确数量,或者直接别用这种优化,改写成普通循环。
对于n=1000000以内的版本,普通循环完全够用,没必要为了小而短的代码去折腾slice赋值。性能优化要放在有明确瓶颈的地方,而不是在能跑得动的场景里制造复杂度。
4.4 标记数组的初始化速度也很重要
当你把n调到一亿,你会发现不仅仅是筛法本身的循环耗时间,初始化一个一亿长度的布尔列表也非常耗时。Python里[True] * (n + 1)已经算比较快的初始化方式,但依然要存一亿个引用,内存占用约800MB,很可能直接内存溢出。
所以我在做n比较大的实验时,优先用C++。如果是Python,就用bytearray:
python复制is_prime = bytearray(b'\x01') * (n + 1)
这个内存占用直接把一百多MB降到一亿字节,大约100MB,至少还能跑。不过要记得,bytearray里的元素是整数0或1,不是布尔值,使用时要保持一致,别用if not is_prime[i]这种依赖隐式转换的写法。
4.5 性能对比时要关掉其他干扰
我在对比筛法性能时有个习惯:先把代码跑三遍,取中间值,避免首次运行时的缓存、解释器预热等问题影响判断。另外,计时只包住筛法本身,不包含打印输出,因为print会重度拖慢程序,导致性能数据失真。
如果是C++,注意开启编译优化,比如-O2,否则你甚至无法感受到质数筛法的真正威力。用未优化代码比较两种筛法,往往得到“埃氏筛更快”的错误结论。
4.6 经验小技巧:能用数学性质减少的问题就不要盲目上算法
最后分享一个小技巧。判断单个数是否素数,除了试除到√n,还可以先排除几个特殊情况:n小于2直接false;n等于2或3直接true;n是偶数直接false;然后从5开始,步长可以交替加2和加4,跳过3的倍数。这个写法叫6k±1优化,可以把试除法循环次数再压缩三分之一左右。
如果你要判断的单个数是10^12级别的,那就要用Miller-Rabin这种概率性素性测试了,不在本篇文章的核心范围内。这里只想表达:先搞清楚数据规模和计算频率,再决定用什么方案,永远比拿到题就写筛法更靠谱。
结尾
筛法这块,我在实际编码过程中踩过的坑,基本都写在上面了。要说最深刻的体会,还是那个break的顺序问题。也许你看书时觉得“每个合数只被最小质因数筛掉一次”这句话特别简单,但真正落实到代码里,在没有运行结果反馈的情况下,很少有人能一遍写对。这也是我为什么反复强调要先在小范围数据上核对素数表,而不是直接冲着一百万去跑。
最后再分享一个我常用的验证技巧:随便挑几个数,比如n=50,把筛法得到的素数表打印出来,和手写的素数表逐项比对。同时可以加一个断言,统计数量等于已知素数个数。比如50以内的素数个数是15个,如果筛出来长度是15,基本说明逻辑没大问题。这个习惯虽然简单,但能帮你省下大量排查时间。后续如果遇到需要分解质因数、求欧拉函数、莫比乌斯函数之类的题目,你也会发现欧拉筛这套“记录最小质因数”的框架特别好扩展。希望这篇文章能让你少走几步弯路。
