← 目录 / 算法文档 · 模块一 数学基础 / 1.8 线性筛素数

1.8 线性筛素数

一次性"筛"出一个范围内所有的质数,而不是对每个数都跑一遍 1.3 节的 IsPrime——模块一的最后一站。

本页目录
① 为什么需要"批量筛"

1.3 节的 IsPrime 能很快判断一个数是不是质数。但如果题目要求"找出 1 到 100 万之间所有的质数",对每个数都单独跑一遍 IsPrime,总共要做大约 100万 × √(100万) 次运算——这是一个非常大的数字,太慢了。

这一节换一个思路:不再一个一个去"问"每个数"你是不是质数",而是反过来——每找到一个质数,就把它的所有倍数都标记成"不是质数"。标记完之后,没被标记过的数,自然就是质数。这种"批量打标记"的办法,就是筛法

② 基础写法:埃氏筛法

这是最早、最经典的筛法,叫埃拉托斯特尼筛法(简称埃氏筛):从 2 开始,如果一个数还没被标记过,它就是质数,把它的倍数(2倍、3倍、4倍...)全部标记成"合数";然后看下一个数,重复同样的操作。

用埃氏筛法找出 2~30 之间的质数
第 1 步:2 没被标记,是质数 —— 标记 2 的所有倍数
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
第 2 步:3 没被标记,是质数 —— 标记 3 的所有倍数(6、12、18、24、30 之前已经被 2 标记过了)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
第 3 步:5 没被标记,是质数 —— 标记 5 的倍数(25 是唯一新被标记的);之后 7、11... 都找不到新的倍数可标了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
绿色是确定的质数,粉色是"这一步新标记"的合数,灰色虚线是"之前已经标记过"的合数。最终剩下没被标记的 2 3 5 7 11 13 17 19 23 29,正是 2~30 之间全部的质数。
C++ · 埃氏筛法
1vector<int> Sieve(int n)
2{
3 vector<bool> isComposite(n + 1, false);
4 vector<int> primes;
5 for (int i = 2; i <= n; i++)
6 {
7 if (!isComposite[i]) // 没被标记过,是质数
8 {
9 primes.push_back(i);
10 for (int j = i * 2; j <= n; j += i) // 标记 i 的所有倍数
11 {
12 isComposite[j] = true;
13 }
14 }
15 }
16 return primes;
17}
③ 优化:从 i×i 开始标记

看一下第 3 步标记 5 的倍数时发生的事——5×2=105×3=155×4=20 这几个数,早就已经被 2 或 3 标记过了,重新标记一遍纯属浪费。其实可以直接从 5×5=25 开始标记——因为任何比 i×i 小的 i 的倍数,必然能写成"更小的数 × i",而这个"更小的数"自己也是某个更小质数的倍数,早就被那个更小的质数标记过了。这个优化和 1.3 节"只检查到 √n"是同一个道理。

C++ · 埃氏筛法(从 i×i 开始标记)
1vector<int> Sieve(int n)
2{
3 vector<bool> isComposite(n + 1, false);
4 vector<int> primes;
5 for (int i = 2; i <= n; i++)
6 {
7 if (!isComposite[i])
8 {
9 primes.push_back(i);
10 for (long long j = (long long)i * i; j <= n; j += i)
11 {
12 isComposite[j] = true;
13 }
14 }
15 }
16 return primes;
17}
⚠️
注意 i * i 要小心溢出:如果 n 比较大(比如十万以上),i 也可能达到几百,i * iint 计算可能会超出范围。写成 (long long)i * i,先把其中一个数转换成 long long 再相乘,可以避免这个问题。
💡
不过,这个优化还没有解决根本问题。即使从 i×i 开始标记,12 还是会被 2 标记一次(当 i=2 时),也会被 3 标记一次(当 i=3 时,但因为 12<3×3=9 不成立所以这次反而不会发生——这里只是想说明,合数被多个质数重复标记这件事,光靠"从 i×i 开始"是消除不掉的,只是减少了一部分)。下一节要解决的,正是这个"重复劳动"的问题。
④ 进阶:线性筛

回头看 2~30 这个例子:12 被标记了两次——一次是 2 的倍数(2×6),一次是 3 的倍数(3×4);30 更夸张,被 2、3、5 各标记了一次,一共三次。这些重复劳动加起来,让埃氏筛法没有真正做到"每个数只处理一次"。

线性筛(也叫欧拉筛)要解决的就是这个问题:让每一个合数只被标记一次——具体来说,只让它的"最小质因子"标记它,其他比这个更大的质因子,都不准再标记。

12 在两种筛法下被标记的次数
埃氏筛法
12 被 2×6 标记一次,又被 3×4 标记一次 —— 重复劳动了 1 次
线性筛
只允许 12最小质因子(也就是 2)标记它,3 不准再标记 —— 只标记 1 次

具体代码是这样写的——从小到大处理每一个数 i,把它和"目前已经找到的每一个质数"依次配对相乘,每配对出一个新的合数,就记下"这个合数的最小质因子是谁":

C++ · 线性筛(欧拉筛)
1vector<int> LinearSieve(int n)
2{
3 vector<int> minPrimeFactor(n + 1, 0); // 0 表示还不知道
4 vector<int> primes;
5 for (int i = 2; i <= n; i++)
6 {
7 if (minPrimeFactor[i] == 0) // 没被标记过,i 自己是质数
8 {
9 minPrimeFactor[i] = i;
10 primes.push_back(i);
11 }
12 for (int j = 0; j < (int)primes.size(); j++)
13 {
14 int p = primes[j];
15 if (p > minPrimeFactor[i] || (long long)p * i > n)
16 {
17 break; // 超过了i的最小质因子,停止配对
18 }
19 minPrimeFactor[p * i] = p;
20 }
21 }
22 return primes;
23}

关键就在第 15~18 行这个判断:一旦配对用的质数 p 超过了 i 自己的最小质因子,就立刻 break 停止配对——因为再往下配对出来的那个合数,应该交给"i 自己的最小质因子"去标记,不该让现在这个更大的质数抢先标记。用 i = 6 走一遍,看看这个判断具体在做什么:

i = 6 时为什么只标记一次,就停下来了
配对 2×6=12
质数 2 ≤ 6 自己的最小质因子(2)—— 允许配对,标记 12 的最小质因子是 2
原本想配对 3×6=18
质数 3 > 6 自己的最小质因子(2)—— 触发第 15 行的判断,立刻 break,不允许这次配对
为什么不能让 3 配对 6 得到 18?因为 18 真正的最小质因子是 218=2×9),不是 3。如果这里放行,18 就会被"提早"标记成"最小质因子是 3",等到后面 i=9 时,2×9=18 又会被正确地标记一次——同一个数被标记了两次,又走回了埃氏筛法的老问题。break 在这里,恰恰是为了把 18 "让"给后面更合适的机会去标记。
🎯
因为每个合数确确实实只被标记一次,整个算法做的"标记"次数刚好等于合数的个数,时间复杂度是严格的 O(n)——这也是"线性筛"这个名字的来源。比埃氏筛法的 O(n log log n) 更快,不过对于 CSP-J/S 常见的数据范围,两者实际跑起来的速度差距并不算特别夸张,重要的是理解这个"只标记一次"的思路。
⑤ 完整代码与对比

n = 1000 数一数两种筛法总共做了多少次标记:

筛出 1~1000 的所有质数 —— 总标记次数对比
埃氏筛法
1958 次
线性筛
831 次
1~1000 之间一共有 831 个合数。线性筛恰好标记了 831 次——一个合数都不多标,一个都不少标;埃氏筛法标记了 1958 次,多出来的 1127 次,全部都是像 1230 那样被多个质数重复标记的浪费。
写法核心思路时间复杂度
埃氏筛法每找到一个质数,标记它的所有倍数O(n log log n)
埃氏筛(优化)从 i×i 开始标记,跳过早被标记过的部分O(n log log n),常数更小
线性筛每个合数只由它的最小质因子标记一次O(n)
📌
模块一到这里就结束了。从最基础的拆数字(1.1),到判断回文、质数,再到约数、倍数、进制转换、快速幂,最后是批量筛质数——这一路用到的核心工具,几乎都是"循环 + 取余/整除"这一个骨架的变体。后面模块二开始,会进入枚举、模拟这些更偏"解题思路"而不是"数学技巧"的内容。