很多学算法的朋友都有这样一种体验:提起素数好像都知道,可只要话题一转,变成“从 1 到一千万里快速筛出全部素数”,就开始在埃氏筛和欧拉筛之间犹豫。我第一次系统认真想欧拉筛,是在准备某个算法专题整理的晚上,被“线性筛为什么真的是 O(n)”这个问题问得头皮发麻——当时能背模板,也写出了正确答案,但每一行为什么这么写,说不利索。这篇内容,想把从素数定义、暴力试除、埃氏筛到欧拉筛的整条认知线理顺。它适合刚接触数论基础的中学生、编程入门者,也适合准备算法笔试、竞赛的读者,因为欧拉筛几乎是常见筛法题里默认要掌握的工具。整篇围绕素数与欧拉筛展开,不光给代码,更想把每一行背后的代价和理由讲清楚。
1. 素数定义里最容易忽略的三个边界
1.1 一字之差,数学性质完全不同
教科书上的素数定义通常是这样:一个大于 1 的自然数,如果除了 1 和它自身以外不再有其他正因数,就称为素数。这句话读起来很短,真正放到代码里却有不少容易出错的地方。
先看自然数范围。素数问题默认只在正整数范围内讨论,负数不是素数,0 也不是素数。很多同学第一次写判断函数时,会写一个 for 循环从 2 试到 n-1,但忘了先处理 n <= 1 的情况。于是输入 1 时返回 true,输入 0 时也返回 true,整个结果马上乱掉。
再看 2 这个特殊情况。2 是唯一一个偶数素数,也是最小的素数。判断一个偶数是否为素数时,直接返回 false 就够了;而判断奇数时,可以用更小的循环范围。这些看起来是琐碎细节,但所有筛法代码的初始化都绕不开它们:比如数组一开始全部标记为 true,就必须手动把 isPrime[0] 和 isPrime[1] 改成 false。
1.2 1 被“开除”出素数队伍的原因
很多人小时候都问过一个问题:1 为什么不是素数?它也确实“只有 1 和它本身”两个概念相符。但在数学上,如果 1 也算素数,整个数论体系会受到很大冲击。
最关键的是唯一分解定理:任何一个大于 1 的自然数,都能唯一地分解成有限个素数的乘积,并且在不计顺序的情况下写法唯一。这句话被称为算术基本定理,是数论的基石之一。如果 1 是素数,那么 6 = 2 × 3 = 1 × 2 × 3 = 1 × 1 × 2 × 3,分解方式就无限多种,“唯一性”立刻失效。为了避免这种混乱,数学里把 1 单独归为“单位数”,既不是素数,也不是合数。
在实际写筛法时,这个定义边界还会带来一个问题:很多代码用“没有被筛掉”来表示素数,于是 1 如果忘记特殊处理,就会莫名其妙“混进”素数列表。刚开始写欧拉筛时,我就在 memset 之后漏掉了对 0 和 1 的修正,最后统计素数个数时总是多出 2 个,排查了很久才发现是初始化问题。
1.3 素数是数论世界的“积木”
为什么要反复研究素数?因为素数在乘法意义上是不可再分的“原子”。约定 2、3、5、7、11……这些数没法再拆成两个更小的正整数相乘,而 12、18、30 这样的合数都能拆。唯一分解定理告诉我们,所有大于 1 的整数都能由素数拼出来。
这一点在现实生活中也有很多影子。比如需要设计一个散列表的容量时,很多人偏爱用素数,理由是可以降低哈希冲突的概率;再比如密码学里,大素数的分解困难程度往往决定了整套系统的安全性。对于一个学算法或者学数学的人来说,素数本身就是理解很多理论的起点。从“这个数是素数吗”到“怎么快速得到一大段素数表”,再到“怎么把每个合数只标记一次”,背后是一套非常自然的递进思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先验证暴力:从试除法看性能瓶颈
2.1 最直观的写法,代价有多大
如果只问“一个数 n 是不是素数”,最朴素的思路当然是枚举所有可能因子。把代码写出来后,大概是这个样子:
c复制bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i < n; ++i) {
if (n % i == 0) return false;
}
return true;
}
这套代码逻辑很干净:只要中途找到一个能整除的 i,就说明 n 还有别的因子,不是素数。但它的问题是,判断一次要跑 n-2 次取模。单独判断一个 13 位左右的数还会很慢,更别说要判断从 1 到 100000 之间每一个数了。
假设要把 n 以内的所有素数都找出来,外层要遍历 n 个数,内层每次最坏要枚举到 i,总复杂度会膨胀到 O(n²) 级别。在竞赛或者日常项目里,n 一旦到 10000,这个代码可能还能勉强跑完;n 到 100000 就已经非常吃力;n 到 1000000 基本会让人失去耐心。所以第一个要优化的问题就是:试除范围真的需要到 n 吗?
2.2 只试到平方根就够了的证明
结论是:判断 n 是否为素数,只需要试除 2 到 √n 之间的整数。原因是,如果 n 有一个大于 √n 的因子 a,那么它一定同时有一个小于 √n 的因子 b,因为 a × b = n,而 a > √n 时,b 必然小于 √n。既然小的那个因子已经被检查过了,大的那个就没有必要再试。
用生活里的例子类比就是:你要检查一个房间里的物品有没有成对出现,只需要从门口往房间中央扫一遍,如果前半段没发现匹配,后半段也不可能出现新的匹配,因为对称的另一半已经在你走过的区域里了。
优化后的代码可以写成:
c复制bool isPrime(int n) {
if (n < 2) return false;
if (n == 2) return true;
if (n % 2 == 0) return false;
for (int i = 3; 1LL * i * i <= n; i += 2) {
if (n % i == 0) return false;
}
return true;
}
这里有两个细节值得注意:一是用 1LL * i * i <= n,避免 i 很大时 i² 直接 int 溢出;二是先排除偶数,循环从 3 开始每次加 2,只检查奇数因子。这些都不是为了炫技,而是在真实环境中能明显减少取模次数的基础优化。
2.3 单点判断和批量筛数,是两个不同的问题
如果只是偶尔判断一个数,试除法已经足够。比如判断 9973 这样的数,从 2 试到 100 左右就结束了,很快。可一旦需求变成“把 1 到 1000000 之间所有素数输出”,即使每个数只试到平方根,总耗时也会让人皱眉。逐个调用 isPrime,每个数平均也要做几百次取模,整体可能达到上亿次操作。
这时候我们才想到反转思路:不要“一个一个验”,而是准备一张很长的表,把明显是合数的数全部划掉,剩下没被划掉的就是素数。这个反向过程就是筛法。其实我最早学筛法时也花了一段时间才完成思维转换:暴力是“问一个数是不是素数”,筛法是“直接批量生产素数表”。两者的目标不同,适用场景自然也完全不同。
3. 从“逐个验证”到“批量筛除”:埃氏筛为什么仍留遗憾
3.1 反向操作的第一个大台阶
埃拉托斯特尼筛法,简称埃氏筛,思路非常直观。先假设 2 到 n 的所有数都可能是素数,然后从 2 开始,把 2 的倍数全部划掉;接着找到下一个还没被划掉的数 3,把 3 的倍数全部划掉;再找到 5,把 5 的倍数全部划掉……这样循环下去,表中留下的就是素数。
给定 n = 20,过程是这样的:先划掉 4、6、8、10、12、14、16、18、20;接着划掉 9、15;再考虑 5,5 的倍数中 10 和 15 虽然还是合数,但已经划过了;继续到后面,没有新的数可划。最后剩下 2、3、5、7、11、13、17、19。
标准实现可以写成:
c复制vector<char> isPrime(n + 1, 1);
isPrime[0] = isPrime[1] = 0;
for (int i = 2; 1LL * i * i <= n; ++i) {
if (isPrime[i]) {
for (long long j = 1LL * i * i; j <= n; j += i) {
isPrime[j] = 0;
}
}
}
内层循环从 i² 开始是刻意为之。因为 i 的某个倍数,比如 i × k,只要 k 小于 i,就一定在之前处理更小的质因子时被划过了。从 i² 开始能省掉大量无意义的重复标记。
3.2 埃氏筛的复杂度确实不错,但不是完美
埃氏筛的时间复杂度近似 O(n log log n)。这个复杂度对大多数场景已经很友好,n = 10⁷ 时,跑一遍也就几百毫秒量级。它的问题不在于“慢得离谱”,而在于“同一个合数会被重复标记”。
举例来说,数字 30 会被 2 的倍数标记一次,被 3 的倍数标记一次,被 5 的倍数标记一次。虽然从 i² 开始后不会出现所有倍数都重复的情况,但当 n 很大时,一个数有几个不同质因子,就可能被对应几个不同质数各划一次。
重复标记的代价合起来,让埃氏筛从严格意义上不能做到“每个合数只访问一次”。如果只是筛素数表,这点开销通常可以接受;但在更高级的数论问题里,常需要保留每个合数对应的最小质因子,甚至要计算积性函数,这时候“重复标记”就不只是常数开销,而是会让统计逻辑出错。
3.3 埃氏筛给欧拉筛留下的问题
如果说埃氏筛处理数字像“把所有含因子 p 的房间都推开门看一眼”,那么它的问题在于同一扇门会被不同人反复打开。想象一个同学站在门口负责登记本子上所有名字,一会儿小明来划一次,一会儿小红来划一次,每个人都做一次完整动作,浪费时间不说,还可能发生混乱。
真正理想的筛法应该是:每个合数由且仅由一个“负责人”处理一次。这样才能让总操作次数和合数数量保持同一量级。什么人最适合当负责人?答案是:这个合数的最小质因子。比如 30 的最小质因子是 2,那么 30 应该只由 2 来标记;不被 3 或 5 重复标记。这个思想就是欧拉筛的出发点。
4. 线性筛的基础:最小质因子是谁,就让谁来负责
4.1 先理解“最小质因子”这个武器
把一个合数做质因数分解后,它可能有好几个质因子,其中数值最小的那个,就是最小质因子。比如 45 = 3 × 3 × 5,最小质因子是 3;120 = 2 × 2 × 2 × 3 × 5,最小质因子是 2;77 = 7 × 11,最小质因子是 7。最小质因子在筛法里是个核心角色,一旦确定了每个合数的“归属”,重复问题就消失了一半。
如果想让 30 只被 2 筛掉,可以找一个外层变量 x 和一个当前质数 p,让 p 正好等于 30 的最小质因子,同时 x = 30 / p = 15。也就是说,当外层循环走到 x = 15,内层质数列表走到 p = 2 时,我们让 15 × 2 = 30,并把它标记为合数。
但这里立刻出现一个问题:外层循环不是只遍历素数,而是从 2 到 n 每个整数都会被访问,为什么必须每个整数都作为外层呢?因为一个合数 m 的最小质因子如果是 p,那么 m / p 有可能是合数、有可能是素数。比如 18 = 2 × 9,外层 x 是 9,是合数;30 = 2 × 15,外层 x 是 15,是合数;21 = 3 × 7,外层 x 是 7,是素数。所以内层必须对“当前所有整数”都开放,不能只拿素数去乘以另一个素数,否则会漏掉大量情况。
4.2 核心循环里“筛完就停”的意义
欧拉筛内层循环的写法并不复杂,真正让人不舒服的是其中的 break 条件。标准框架是这样的:
c复制for (int i = 2; i <= n; ++i) {
if (isPrime[i]) primes[cnt++] = i;
for (int j = 0; j < cnt; ++j) {
if (i * primes[j] > n) break;
isPrime[i * primes[j]] = 0;
if (i % primes[j] == 0) break;
}
}
理解这个循环最好的方式,是把它看成一个“如何分配合数归属”的过程:当前外层循环的 i,扮演的是“合数除以最小质因子之后剩下的那一半”。内层从小到大枚举质数 p,让 i × p 成为一个待标记的合数。
一旦发现 i % p == 0,说明 p 已经能够整除 i。如果此刻不停止,而是继续用下一个更大的质数 q 去生成 i × q,那么这个新合数的最小质因子并不一定是 q,因为 p 已经整除 i,p 一定也整除 i × q,而且 p 比 q 小。也就是说,这个合数本应等以后某个更大的外层数来生成,不该现在用 q 提前标记。所以代码在这里必须 break,保证每个合数只会被它的最小质因子处理一次。
4.3 为什么这能让复杂度从 O(n log log n) 降到 O(n)
理解完归属规则后,线性性质就很好解释了。从 2 到 n,每个外层 i 都会进入内层循环,但内层循环不会每个素数都遍历完,而是在遇到“能整除 i 的质数”后立刻停止;如果乘积超过 n,也会立刻停止。
对每一个合数 m,设它的最小质因子是 p,那么唯一一次被标记,发生在外层循环到达 i = m / p 的时候。由于 m 最大是 n,因此 m / p 一定小于 m,会在它该出现的时刻出现。与此同时,比 p 更大的质数不会越过 break 去标记它,比 p 更小的质数又不可能是它的最小质因子。每一个合数被标记且只被标记一次,整体操作次数大约等于 n 加上合数数量,所以复杂度是 O(n)。这也是欧拉筛常被叫作“线性筛”的原因。
