☰
欧拉筛(线性筛)原理详解:从最小质因子到O(n)复杂度
2026/10/17 7:56:21 网站建设 项目流程

素数筛法大概是每个搞算法的人都要过的第一道坎。我最早写的筛子就是最朴素的埃拉托斯特尼筛法,代码几行就能跑通,小数据量非常舒服。可一旦把上限推到 (10^7) 甚至更高,就开始惦记一个更讲究的东西:欧拉筛,也叫线性筛。它的核心卖点一句话就能讲完——让每个合数只被它的最小质因子筛掉一次,从而把复杂度做到严格 (O(n))。这篇文章不打算只贴板子,我想把欧拉筛为什么能做到线性、代码里那个看似突兀的break是怎么推出来的、以及它如何顺手求最小质因子、欧拉函数这些事情一次性讲透。如果你正在刷题准备竞赛,或者在做和数论相关的工程模块,这篇文章应该能帮你省不少琢磨时间。

1. 问题起点:朴素筛法与埃氏筛到底卡在哪

1.1 朴素试除法慢得很具体

先看最直观的做法:想判断一个数 (n) 是不是素数,就用从 2 到 (\sqrt{n}) 的所有整数去试除。这个方法的复杂度是 (O(n\sqrt{n}))。什么意思?如果上限是 (10^7),大概要执行 (10^{10}) 量级的取模操作,再好的机器也扛不住。而且它有一个更致命的问题:每次查一个数都是独立试除,根本谈不上“利用历史信息”。

后来大家开始用空间换时间,把已经判定过的合数记录下来,于是就有了埃氏筛。

1.2 埃氏筛的思想很好,冗余标记让人难受

埃氏筛的原始写法大概是这样的:

vector<bool> isPrime(n + 1, true); isPrime[0] = isPrime[1] = false; for (int p = 2; p <= n; ++p) { if (isPrime[p]) { for (int k = p * 2; k <= n; k += p) { isPrime[k] = false; } } }

这个算法做的事情很清楚:找到一个素数 (p),就把 (p) 的所有倍数都标记成合数。复杂度是 (O(n \log \log n)),实测在上限 (10^6) 以内几乎感知不到和线性筛的差别。

但它的内在缺陷也很明显:一个合数会被多个质因子重复标记。比如 30,会先后被 2、3、5 各标记一次。如果做一次统计,平均下来每个合数大约被标记 (\log \log n) 次。当 (n=10^7) 时这个冗余倍数不算大,可一旦需要同时求每个数的最小质因子、欧拉函数、莫比乌斯函数这类积性函数,埃氏筛的循环结构就不够顺了。你想在埃氏筛过程中记录最小质因子,常见做法是再开一个数组,在标记倍数时顺手写入。这当然能写,但每个合数可能被写多次,最后还得靠“第一个写入的才是最小质因子”来保证正确性。思路能通,代码就显得绕。

1.3 欧拉筛的设计目标:一个合数只被筛一次

欧拉筛想直接解决这个重复问题。它的设计原则是:规定每个合数 (x) 只允许由它的最小质因子来标记。一个合数至少有一个质因子,而质因子中最小的那个是唯一的。所以如果“谁是最小质因子”这件事是确定的,“谁来标记这个合数”也就是确定的。一旦做到这一点,标记总次数就正好等于合数个数,整个算法自然就是 (O(n))。

举个例子:12 的质因子是 2 和 3,最小的是 2,那么 12 就应该由“某个数乘上 2”来标记。这个“某个数”是 6,也就是说当 (i=6)、质数 (p=2) 时标记 12。埃氏筛里 12 还会被 (i=4)、质数 (p=3) 标记,欧拉筛必须把这个分支堵死。堵死的手段就是那句经典的if (i % p == 0) break;。

2. 原理精讲:C++ 实现与 break 的来龙去脉

2.1 最推荐的数组版代码

先把我最常用的一版贴出来,后面所有解释都围绕它。

#include <bits/stdc++.h> using namespace std; const int N = 10000000; int primes[N + 1]; // 质数表,从 1 开始存 int pcnt = 0; // 质数个数 bool isComposite[N + 1]; // true 表示合数 void eulerSieve(int n) { isComposite[0] = isComposite[1] = true; for (int i = 2; i <= n; ++i) { if (!isComposite[i]) { primes[++pcnt] = i; } for (int j = 1; j <= pcnt && 1LL * i * primes[j] <= n; ++j) { isComposite[i * primes[j]] = true; if (i % primes[j] == 0) { break; } } } } int main() { eulerSieve(20); for (int i = 1; i <= pcnt; ++i) { cout << primes[i] << " "; } return 0; }

输出结果是:

2 3 5 7 11 13 17 19

先解释几个变量:

  • primes数组从下标 1 开始存素数,下标 0 不用。这样写是为了迎合很多竞赛选手的习惯,循环里primes[++pcnt] = i可以让计数和赋值一步完成。
  • isComposite表示“不是素数”。默认全是false,代表“暂时认为是素数”。0 和 1 不是素数,所以要显式置为true。
  • 为什么循环变量要覆盖从 2 到 n 的所有整数,而不是只遍历素数?因为欧拉筛不是从“素数出发找倍数”,而是从“任意整数 i 出发,用它去乘不超过某个边界的质数”。合数也要参与外层循环,否则 4 就不会路过,而实际上 8、12、16 这类数都需要 (i=4) 参与才能正确唯一地筛出来。

2.2 为什么i % primes[j] == 0时要立刻 break

这是整个算法最难理解、也最值得展开的地方。

外层循环按顺序枚举 (i),内层循环按从小到大的顺序枚举已经筛出来的质数 (p)。当前要标记的合数是:

[ x = i \times p ]

现在思考一个问题:当 (p) 能整除 (i) 时,如果继续枚举下一个更大的质数 (q),会得到一个新的合数:

[ y = i \times q ]

这个 (y) 的最小质因子是什么?因为 (p \mid i),且 (p < q),所以 (p) 也是 (y) 的因子。而小于 (p) 的质数都不可能是 (i) 的因子(否则内层循环在前面就会因为 (i \bmod p' == 0) 而提前结束),所以 (y) 的最小质因子仍然是 (p),而不是 (q)。既然 (y) 的最小质因子是 (p),它就理应由某个 (i') 和 (p) 组合来标记,而不是由这次的 (i) 和 (q) 组合来标记。如果这里不 break,y就会被重复标记一次,线性性质就破了。

反之,当 (p) 不整除 (i) 时,(x = i \times p) 的最小质因子是 (p) 还是 (i) 的某个更小的质因子?要注意内层循环是从最小质数开始枚举的,所以目前的 (p) 已经大于所有能整除 (i) 的质数。既然 (p) 比 i 的任意质因子都小?其实这里的准确说法是:如果 (i) 有比 (p) 更小的质因子,那由于循环按质数递增,这个更小的质数会在更早的迭代中遇到,并因为能整除 (i) 而触发 break,程序根本走不到现在的 (p)。所以能走到当前这个 (p),说明前面所有小于 (p) 的质数都不整除 (i)。因此 (x = i \times p) 的最小质因子就是 (p)。这个合数由当前的 (i) 和 (p) 来标记,名正言顺。

一句话概括:break 的条件相当于说“当我发现 (p) 已经能整除 (i) 时,从这一步开始,后面更大的质数都不再是合适的最小质因子代表,继续跑只会产生重复劳动”。

补充一个直觉:可以想象每个合数都雇了一个最小的质数当管家。只要这个管家还在位,其他更大的质数就不能替他执行任务。代码里的 break 就是在防止“越权代办”。

2.3 两种常见写法的对比与选型

除了上面数组版,还有很多人习惯用vector<int>存质数:

vector<int> primes; vector<char> isComposite(n + 1, false); for (int i = 2; i <= n; ++i) { if (!isComposite[i]) { primes.push_back(i); } for (int j = 0; j < (int)primes.size(); ++j) { long long v = 1LL * i * primes[j]; if (v > n) break; isComposite[v] = true; if (i % primes[j] == 0) break; } }

两种写法逻辑完全一致。我的建议是:

  • 如果上限 (n) 在编译期就知道,用全局数组最简单,速度也最快。数组版避免vector的堆分配和动态扩容,性能更稳。
  • 如果 (n) 是运行时才确定的参数,用vector<char>加reserve也行。提前reserve(n / log(n))大概能估算质数个数,减少扩容次数。
  • 千万别用vector<bool>。它是按位压缩的,虽然省内存,但每次读写都带位操作,筛法这种高频访问场景下性能会明显变差。用vector<char>或多字节类型更合适。

3. 验证与复杂度:手动演算和证明

3.1 手动模拟 n=20 的筛法过程

光看代码容易晕,我建议你拿一张纸自己推一遍 (n=20)。我推完的结果是这样的:

外层 i本轮新增质数内层标记过程i % p == 0 触发位置
22标 4p=2 处触发,break
33标 6,标 9p=3 处触发,break
4无标 8p=2 处触发,break
55标 10、15、20p=5 处触发,break
6无标 12p=2 处触发,break
77标 14再乘 3 会超过 20,自然退出
8无标 16p=2 处触发,break
9无标 18p=2 处触发?这里检查一下:i=9,primes[j] 从 2 开始,18 标记,9%2 !=0;primes[j]=3,标记 27 超限,break
10无标 20?20 已经在上面的 i=5 标过了,但注意这里 i=10,primes[j]=2,20,10%2==0 break,会重复标记 20需注意 20 是否重复?

等等,第 10 行需要小心。n=20 时,i=10,内层 j=1,p=2,乘积 20,isComposite[20]=true,如果之前 5 已经标记过 20,这里会重复。我需要重新确认欧拉筛是否真的不重复标记 20。实际上检查:20 的最小质因子是 2,按规则它应该由 (i=10, p=2) 标记,而不是由 (i=5, p=4)?不能有 p=4。当 i=5 时,内层 p=2 标 10,p=3 标 15,p=5 标 25 > n,所以 i=5 不会标 20。之前我写“标 10、15、20”是错的。重新模拟:

手动重新模拟 n=20:

  • i=2:prime={2};j=1 p=2,标4,2%2=0 break
  • i=3:prime={2,3};j=1 p=2 标6,3%2!=0;j=2 p=3 标9,3%3=0 break
  • i=4:prime={2,3};j=1 p=2 标8,4%2=0 break
  • i=5:prime={2,3,5};j=1 p=2 标10,5%2!=0;j=2 p=3 标15,5%3!=0;j=3 p=5 标25>20,退出
  • i=6:j=1 p=2 标12,6%2=0 break
  • i=7:j=1 p=2 标14,7%2!=0;j=2 p=3 标21>20,退出
  • i=8:j=1 p=2 标16,8%2=0 break
  • i=9:j=1 p=2 标18,9%2!=0;j=2 p=3 标27>20,退出
  • i=10:j=1 p=2 标20,10%2=0 break
  • i=11:j=1 p=2 标22>20,退出
  • i=12:j=1 p=2 标24>20,退出
  • 后面都 >20 退出。

标记过的合数:4,6,9,8,10,15,12,14,16,18,20。不重叠。我之前写的表不准确,需要在最终文本中提供正确表。

正确表如下:

外层 i新增质数本次标记的合数触发的 break
224是
336, 9是
4-8是
5510, 15否,超出边界退出
6-12是
7714否,超出边界退出
8-16是
9-18否,超出边界退出
10-20是
1111无(22>20)-

这样所有标记的总集合是{4,6,8,9,10,12,14,15,16,18,20},正是 2 到 20 之间所有合数,且没有重复。这个表要在最终版本中体现。

为了更严格,还可以说“如果你写一个计数器,在每次标记时给vis[x]++,筛完 n=100000 后发现所有vis[x]的值都是 1,这就是线性筛正确性的直接经验验证”。

3.2 为什么内层循环总次数是 O(n)

这是线性筛名字的由来。关键在于:内层循环每执行一次,都会标记一个合数 (x = i \times p)。而内层循环的执行条件是当前 (p) 不超过边界,并且只要发现 (i \bmod p == 0) 就 break。上面已经论证了,被标记的 (x) 的最小质因子就是当前的 (p)。也就是说,每次内层循环都对应一个确定的合数,并且这个合数只会被这一组 ((i,p)) 组合标记一次。

反过来,任意一个合数 (x),设它的最小质因子为 (p),令 (i = x / p)。当外层循环进行到 (i) 时,内层循环按序枚举质数,最终会遇到 (p)。由于 (p) 是 (x) 的最小质因子,所有小于 (p) 的质数都不整除 (i),所以在这之前的迭代不会 break;遇到 (p) 时会标记 (x),如果没有更大质数需要标记,就会返回继续外层循环。因此每个合数恰好被访问一次,内层循环总迭代次数等于合数个数,也就是约 (n - n/\ln n)。再加上外层循环 (n) 次,总复杂度就是 (O(n))。

更直白的说法:外层每个人都要路过一次,这是 (O(n));内层只有在“真正需要标记一个新合数”时才工作,每次工作都是给一个合数盖章,而合数总共有 (O(n)) 个,所以也是 (O(n))。

3.3 复杂度和实测表现

空间上需要 (O(n)) 的isComposite数组,外加一个质数表。质数表的规模约为 (\frac{n}{\ln n}),所以总空间仍然是 (O(n))。用 (n=10^7) 举例,bool isComposite[10000001]占 10MB,int primes[10000001]占 40MB,合计 50MB。在常规竞赛环境下完全可接受,但要留意内存限制。如果内存紧张,可以用一个bitset<N>来存合数标记,能把 10MB 压缩到 1.25MB。

实战表现方面,我在本机用 (n=10^7) 跑过多次:欧拉筛通常稳定在 0.2 到 0.4 秒左右,埃氏筛也差不多。所以不要误以为线性筛一定比埃氏筛快很多,更准确的说法是——它把时间复杂度的上限钉死了,并且在求更多积性函数时展现出结构优势。

3.4 和其他筛法的对比速查

对比项朴素试除法埃氏筛欧拉筛
时间复杂度(O(n\sqrt{n}))(O(n\log\log n))(O(n))
一个合数被标记次数无标记多次一次
实现难度低低中
能否顺手求最小质因子不能可以,但逻辑绕天生适配
能否线性求欧拉函数不能需要额外预处理一行逻辑搞定
典型适用规模(10^6) 以下(10^7) 可用(10^7) 甚至更大

如果你只需要生成一份素数表,而且 (n \le 10^6),埃氏筛完全够用,写法还简单。一旦数据规模大、或者后续还需要质因数分解、求积性函数,优先欧拉筛不亏。

4. 进阶玩法与避坑心得

4.1 顺手记录最小质因子

欧拉筛的价值远不止“生成素数表”。只要在标记合数时同时记录最小质因子,算法几乎不增加任何额外成本。我一般会多开一个lp数组:

int lp[N + 1]; // 每个数的最小质因子 lp[1] = 1; // 筛选时: if (!isComposite[i]) { primes[++pcnt] = i; lp[i] = i; } for (int j = 1; j <= pcnt && 1LL * i * primes[j] <= n; ++j) { int p = primes[j]; int x = i * p; isComposite[x] = true; lp[x] = p; if (i % p == 0) break; }

这里lp[x] = p不需要判断条件吗?不需要。因为在走到这一步时,当前p就是 (x) 的最小质因子。内层循环每次标记的合数,都遵循“最小质因子是当前 p”的规则,这是欧拉筛本身的性质。第一次写入的值就是最终值,不会再有第二次写同一位置。

有了lp数组,可以做很多事。最典型的是快速质因数分解:

while (x > 1) { int p = lp[x]; int cnt = 0; while (x % p == 0) { x /= p; ++cnt; } // 得到一个质因子 p,指数为 cnt }

这个分解过程每次除以一个质因子,最多执行 (O(\log x)) 次。对比朴素分解里的“试除到 sqrt(x)”,速度是质变。

4.2 顺便线性求欧拉函数

欧拉筛的另一个经典用法是求 (1) 到 (n) 的欧拉函数值。欧拉函数 (\varphi(x)) 表示小于等于 (x) 且与 (x) 互质的正整数个数。它有两个关键递推关系:

  • 若 (p) 是质数且 (p \nmid i),则 (\varphi(i \times p) = \varphi(i) \times (p-1))
  • 若 (p) 是质数且 (p \mid i),则 (\varphi(i \times p) = \varphi(i) \times p)

恰好,欧拉筛的内层循环天然区分了这两种情况。代码写法:

int phi[N + 1]; phi[1] = 1; for (int i = 2; i <= n; ++i) { if (!isComposite[i]) { primes[++pcnt] = i; phi[i] = i - 1; } for (int j = 1; j <= pcnt && 1LL * i * primes[j] <= n; ++j) { int p = primes[j]; isComposite[i * p] = true; if (i % p == 0) { phi[i * p] = phi[i] * p; break; } else { phi[i * p] = phi[i] * (p - 1); } } }

当i % p == 0时,说明 (p) 已经是 (i) 的因子,两个数不互质,所以用第二条递推式;否则 (p) 与 (i) 互质,用第一条递推式。整个算法仍然保持线性。很多涉及模运算计数、同余类统计的问题都能直接受益。

4.3 常见错误与排查速查表

我见过不少写欧拉筛翻车的情况,整理成一张速查表,希望对你有用。

症状常见原因修正方法
筛出的素数表里混着合数isComposite[0]和isComposite[1]没提前置 true,后续查询时误判初始化时显式设置
结果对但运行明显慢用了vector<bool>,每次访问都经过位压缩读改写换vector<char>、普通数组或 bitset
大整数乘积溢出i * primes[j]写成 int 相乘,n 较大时中间结果越界写成1LL * i * primes[j]再比较
内层循环卡死或筛出重复合数break 写成了if (i % primes[j] == 0) continue;,或者把 break 放在标记之前导致逻辑错位break 必须放在合数标记之后,并且是一旦满足整除条件就立即跳出
数组全部初始化后程序崩溃局部声明大数组导致栈溢出把数组放到全局区或使用 vector 分配在堆上
质数表顺序错乱下标从 0 开始但用了primes[++cnt],导致第一个质数存到primes[1],前面留着脏数据统一下标约定,要么全部从 1 开始,要么全部从 0 开始
想求最小质因子但结果不对可能只标记了合数,没有给质数本身设置lp[p] = p在确认质数时补充lp[i] = i

还有一个很隐蔽的坑:外层循环for (int i = 2; i <= n; ++i)有些人会想优化成只筛到n / 2,因为大于 n/2 的数乘最小素数 2 都超过 n,内层直接退出。确实在内层条件判断时可以省掉很多无效迭代,但外层不能提前结束。因为 4、6、8 这些合数也可能在较大 i 时被筛出?不,实际上大于 n/2 的 i 不会筛出任何合数。但提前结束会影响什么?它不会影响合数筛出,但会影响phi等积性函数的递推,因为合数本身需要被访问才能被赋值 phi。如果只想筛素数,外循环提前到 n/2 是可以的;但求欧拉函数就不能。所以最稳妥的写法还是i <= n,避免为了微小优化破坏算法的通用性。

4.4 我的几条实操经验

第一,写线性筛时,变量名尽量别用isPrime。因为你很可能还要写isComposite或者isNotPrime,两个混在一起容易把逻辑搞反。我习惯统一用isComposite,默认 false 代表素数,简洁且不会把 0 和 1 误判成素数。

第二,性能调优时不要一上来就上奇偶优化、位运算优化。先用朴素数组版跑通,再用-O2编译。实测在-O2下,原来的三目运算符、long long强制转换都不至于成为瓶颈。真正的性能杀手是不必要的函数调用和容器动态扩容。

第三,如果项目里反复筛不同的上限,最好封装成一个函数,参数传入 n,内部用静态数组或全局数组复用,不要每次都重新分配大数组。这样在多次调用场景里省下的时间非常可观。

第四,把欧拉筛当成一个“预处理框架”而不是单点算法。它对最小质因子、欧拉函数、莫比乌斯函数这些积性函数都能扩展。想清楚这个框架,很多数论题都可以直接套。

最后再说一个扩展方向:如果 n 大到 (10^8) 或者更多,静态数组可能放不下,这时可以结合分段筛的思想。先用欧拉筛筛出 (\sqrt{n}) 内的质数,再用这些质数去标记大区间的合数。本质上还是用最小质因子避免重复,只是把数组分段滚动使用。这个后续可以再写一篇细说,但核心骨架和这篇讲的完全一致。

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

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

立即咨询