阶段 8 · 数学 · 第 41 章

质数:试除 → 埃氏筛 → 线性筛

★ 关键一步是那句 `if (i % p == 0) break;` —— 它保证**每个合数只被它的最小质因子划掉一次**。⚠ 而这一章最反常识的一件事在最后:线性筛划的次数只有埃氏筛的四成,**可它并不更快**。

例题:筛出 1..n 的质数 + 回答最小质因子 建议用时:120 分钟
上一章分解一个数要试到 √a,这一章要一次把 1..n 全办了

第 40 章末尾留了一句话:分解一个数要试到 √a。 那么很自然的下一个问题是:如果要把 1 到 n 的每个数都办一遍呢?

一个一个地试,是 O(n√n);而这一章要讲的两种筛法把它压到 O(n log log n)O(n) —— 办法是掉个头:不再问「这个数是不是质数」, 而是从质数出发,去把它的倍数一个个划掉。

★ 而这一章真正的收获有两个,都在后半段:

  1. 线性筛那句 if (i % p == 0) break; 到底保证了什么(第 6 步,两句话证完);
  2. ⚠⚠ 划的次数少,不等于跑得快 —— 第 10 步那张表会把这句话钉死。

1 一句话问题

给定 n(1 ≤ n ≤ 10⁷)和 m 个询问 x_1 … x_m1 ≤ m ≤ 10⁵1 ≤ x_i ≤ n):

  • 第一行输出 1..n 里质数的个数
  • ★ 第二行输出 m 个数:第 i 个是 x_i最小质因子x_i = 1 时输出 0);
  • 第三行输出 1..n 里最大的那个质数(一个都没有就输出 0)。
★ 三个边界,题面里全都写死了 —— 它们各卡住一个 bug
边界题面怎么规定卡住哪个 bug
1 不是质数x = 1 输出 0(1 没有质因子)wrongOne
n 可以等于 1这时候一个质数都没有,第三行输出 0wrongEmpty
完全平方数试除必须写 i*i <= x,少个等号就漏wrongSq

⚠ 而第二问那个「最小质因子」不是凑数的: 它是线性筛顺手白送的东西(第 7 步),埃氏筛却得再花一次力气才能给出来。 ★ 「题面多问一句」的第七次(第 35~40 章连着六次)—— 而这次多问的那一句,正好指着两种筛法真正的差别

2 手算一遍:n = 30

★ 这组数据是特意排的:六个错误版本里有五个当场现形
30 12
1 2 4 9 12 18 25 27 29 30 16 21

第一行 10        (2 3 5 7 11 13 17 19 23 29)
第二行 0 2 2 3 2 2 5 3 29 2 2 3
第三行 29

一个个对:1 → 0(题面规定)、2 → 2(质数,最小质因子是自己)、 4 → 29 → 325 → 529 → 2930 → 2

三处特意安排:

  • 询问里有 x = 1 —— 卡 wrongOne
  • 询问里有 4、9、25、16 这些完全平方数 —— 卡 wrongSq
  • n = 30 是合数 —— 卡 wrongEnd(埃氏筛内层写成 j < n 时,30 自己没被划掉)。

⚠ 而第六个(wrongEmpty)在这组数据上一个字都不错 —— 它只在 n = 1 时现形,这一点第 9 步会算给你看。

3 暴力:一个数一个数地审

brute.cpp标准答案:试除法(和「筛」是完全不同的思路)
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么试除只要试到 √x

如果 x = a × ba ≤ b,那么 a ≤ √x所以只要 √x 以内没有约数,就再也不会有了。 一句话的事,可它是这一章所有次数的起点。

⚠ 而它作为标准答案有一个好处:思路和筛法完全不同 —— 筛法是「从质数出发去划掉合数」,它是「对每个数单独审问一遍」 (第 9、15 章那条规矩:标准答案最好换个想法写)。

4 第一次掉头:埃氏筛

erat.cpp⚠ 不是 bug:答案和正解逐字节相同,只是划的次数多
输入(stdin)
输出
点「运行 ▶」看结果
★ 内层为什么能从 i² 开始

小的 i 的倍数是 k·ik < i),它一定有一个比 i 小的质因子 —— 那个质因子在更早的一轮里就已经把它划掉了。所以 2i … (i−1)i 全是白划。

⚠ 但这只是个常数级的省事,改变不了复杂度

slowErat.cpp⚠ 也不是 bug:内层从 2i 起,白划一大堆 —— 答案照样一字不差
输入(stdin)
输出
点「运行 ▶」看结果

第 5 步那张表会量出来:两者只差 1.33 倍(而线性筛差的是 2.4 倍)。

⚠ 而埃氏筛有一件做不到的事,正好是题面第二问

comp[j] = true 只记了「j 是合数」,没记是谁划的; 而且 j 会被好几个质数反复划,最后一次划它的那个并不是最小的

⇒ 所以 erat.cpp 要回答「最小质因子」,只能再拿质数表试除一遍。 ★ 而线性筛是白送的 —— 第 7 步。

5 慢在哪:换尺子,数「划了多少次」

★ 五份代码答案完全一样,所以只能数次数

试除 / 埃氏筛(2i 起)/ 埃氏筛(i·i 起)/ 线性筛 / 线性筛忘了 break —— 五份代码的输出逐字节相同check:viz 里 300 轮钉着)。

第 36 章立的「随机对拍第四个盲区」,第 37~40 章各一次现场,这是第六次

count.cpp五种做法并排,数划掉 / 试除的次数
输出
点「运行 ▶」看结果
做法n = 10⁵ 划的次数n = 10⁶是线性筛的几倍
试除法(每个数单审)2 745 69467 740 40430.4 ×
埃氏筛(从 2i 起)256 8082 775 2102.84 ×
埃氏筛(从 i·i 起)193 0782 122 0482.14 ×
线性筛,忘了 break193 0782 122 0482.14 ×
✓ 线性筛90 407921 5011.00 ×

★★ 这张表里有两条等号,都不是「大约」:

① 线性筛划的次数 = 合数的个数。 10⁵ 以内有 9 592 个质数、90 407 个合数 —— 而线性筛正好划了 90 407 次。

每个合数被划掉一次,一个不多一个不少。这就是「线性」两个字的全部含义。

② ★★★「线性筛忘了 break」划的次数,恰好等于「埃氏筛从 i·i 起」。 两个 n 上都一字不差 —— 而且这是能证的,因为两边划的是同一批数对

  • 埃氏筛从 i·i 起:对每个质数 p 划 k·pk ≥ p)⇒ 数对 {(k, p) : p 质数, p ≤ k, kp ≤ n}
  • 线性筛忘了 break:对每个 i 遍历已经找到的质数(也就是所有 p ≤ i)⇒ 数对 {(i, p) : p 质数, p ≤ i, ip ≤ n}

两个集合一模一样。

★ 所以「忘了 break」不是「退化到埃氏筛那个量级」,是一步不差地就是埃氏筛

6 ★ 关键一步:那句 break 到底保证了什么

fast.cpp正解:线性筛(欧拉筛)
输入(stdin)
输出
点「运行 ▶」看结果
★★ 两句话,把「每个合数只被划一次」证完
for (int p : primes) {
    if ((long long)i * p > n) break;
    minp[i * p] = p;              // 用 p 去划掉 i*p
    if (i % p == 0) break;        // ★★ 全章的灵魂
}

① 每个合数都被划到了。 设合数 c 的最小质因子是 p,写 c = i·pi = c/p)。 因为 p 是 c 的最小质因子,所以 i 的每个质因子都 ≥ p —— 于是内层走到 p 的时候还没 break(前面那些质数都比 p 小、都不整除 i),c 就在这一步被划掉了。

② 每个合数只被划一次。 内层用 p 去划 i·p 时,那句 break 保证了 p ≤ i 的最小质因子, 于是 p 一定是 i·p 的最小质因子。 而一个合数的最小质因子只有一个、对应的 i = c/p 也只有一个 —— (i, p) 这一对是唯一的,所以只会被划一次。∎

★ 那句 break 干的事,一句话说清:一旦 p 整除 i,就不许再往大的质数走。 再走下去,划的那个 i·p' 的最小质因子就不是 p' 了(而是 p),那就重复了。

总划掉次数 = 合数个数 = O(n)。

7 ★ 顺手白送的东西:最小质因子

★ minp[] 在筛的过程中天然就填好了,一分钱不用多花

线性筛划掉 i·p 的时候,划它的那个 p 正好就是它的最小质因子 —— 所以只要把 minp[i*p] = p 记下来,minp[] 就自动成型了。

有了它,分解质因数从 O(√x) 掉到 O(log x):一路除最小质因子就行。

⚠ 对照埃氏筛:它只记了「是不是合数」,要拿最小质因子还得再试除一遍(见 erat.cpp)。

★★ 这才是线性筛真正比埃氏筛强的地方 —— 不是快,是它顺手多给了一样东西。 (第 10 步会看到:论速度,它并没有赢。)

8 动画一:谁划掉了谁

筛子:格子里的小字 = 划掉它的那个质数(也就是它的最小质因子)
线性筛划 19 次 / 埃氏筛(i·i 起)24 次 / 埃氏筛(2i 起)33 次
第 1 / 31 步
★ 累计划掉 0质数 0
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 … 30。接下来从小到大扫,没被划掉的就是质数。
怎么看这个动画
  • 每个格子右下角那个小字,就是划掉它的那个质数(也就是它的最小质因子)。
  • 每个格子最多被划一次 —— 盯着看,不会有哪个格子被划第二次。
  • ★ 那句 break 生效的那一帧会被标出来。想想看:如果不 break,下一步会划掉谁? (答案:一个「最小质因子不是 p′」的合数 —— 也就是重复。)

trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐步比 (划了谁 / 用的哪个 p / 这一步有没有 break / 累计次数)。

trace.cpp动画照着它画:每一次「划」,以及那句 break 什么时候生效
输出
点「运行 ▶」看结果

9 ★ 对拍:六个错误版本,两个只在「特定的那个数」上现形

★ 这一章的清单里,有两条是前面几章没出现过的形状
错误版本靠什么现形
wrongBreakFirst break 站在标记之前漏筛一大片,第一行就不对 —— 几乎白送
wrongMinp minp[i*p] = minp[i]询问里要有带小质因子的合数(6、12、18…)
wrongEnd 埃氏筛 j < nn 得是合数(n 是质数时它完全正确)
wrongSq 试除 i*i < x★ 询问里要有质数的平方(4、9、25…)
wrongOne x = 1 输出 1★★ 询问里必须有 x = 1
wrongEmpty 第三行没判空★★ 必须造出 n = 1

★★ 最后两条要的不是值域、不是规模、也不是结构,而是「你有没有把那个特定的数塞进去」。 而随机撞上它们的概率是 1/n1/50 —— n 一大就全没了。

这是第 38 章那条「题面多问一句兜的是随机数据碰不到的那个边界」的另一张脸: 这一次不是题面的问题,是生成器的问题。

wrongBreakFirst.cpp✗ 那句 break 站在标记之前 —— 和忘了 break 正好是一对
输入(stdin)
输出
点「运行 ▶」看结果
wrongMinp.cpp✗ minp[i*p] = minp[i](想当然)
输入(stdin)
输出
点「运行 ▶」看结果
wrongEnd.cpp✗ 埃氏筛内层写成 j < n —— n 自己没划到
输入(stdin)
输出
点「运行 ▶」看结果
wrongSq.cpp✗ 试除写成 i*i < x —— 漏掉完全平方数
输入(stdin)
输出
点「运行 ▶」看结果
wrongOne.cpp✗ x = 1 时输出 1(题面规定输出 0)
输入(stdin)
输出
点「运行 ▶」看结果
wrongEmpty.cpp✗ 第三行没判空 —— ⚠ 在 n = 30 上一个字都不错
输入(stdin)
输出
点「运行 ▶」看结果
★ n = 1 这一组,专门跑给你看
输入        1 3
            1 1 1

正解        0
            0 0 0
            0

wrongEmpty  (直接崩掉 —— primes.back() 在空表上是未定义行为)

⚠ 而它在上面那组 n = 30 的数据上输出和正解一模一样

★ 一个 bug 只在一个特定的输入上现形,而那个输入随机撞不到 —— 这就是接下来那张表要解决的问题。

对拍器
★ 这个生成器有七个旋钮,其中两个是「专门把某个数塞进去」—— 不塞的话,那两个 bug 一轮都抓不到。

300 轮实测(种子 1..300,最终档 10):

故意写错的地方被抓第几轮
wrongBreakFirst(break 站错边)263 / 300第 1 轮
wrongSqi*i < x263 / 300第 1 轮
wrongMinpminp[i]259 / 300第 1 轮
wrongEndj < n228 / 300第 1 轮
wrongOnex = 1228 / 300第 1 轮
wrongEmptyn = 137 / 300第 5 轮
erat / slowErat / noBreak(只是快慢不同)0 / 300
★★ 生成器:一个旋钮的两头,正好是一个 bug 的坟墓和天堂
档位相对档位 0 改了什么BrkFstMinpEndSqOneEmpty
0(顺手写法)n ∈ [2,50]、m ∈ [3,8]、询问在 [1,n] 里随机291255198291740
112% 的概率直接令 n = 125321118125311137
2询问里专门塞 12912321982912200
3询问里专门塞完全平方数2912371982911310
4n 放大到 [2,3000]30030026230010
5n 只取质数26521102651050
6n 只取合数300263300300690
7询问条数拉长到 [20,40]2912841982912060

① ★★★ 档位 5 和 6 是这一章最干净的一对:

n 只取质数 → wrongEnd 0 / 300;n 只取合数 → 300 / 300。 因为那个 bug 是「埃氏筛少划了 n 自己」—— n 是质数的时候,它本来就不该被划掉。 一端把 bug 变成了一份完全正确的程序,另一端让它必现。 (第 38 章「n 是不是 2 的幂」那次的第三次现场。)

② ⚠⚠ 档位 4 是这一章最该警惕的一档。 把 n 放大到 3000:三列直接满分 300 —— 看着是全表最好的一档。 wrongOne 掉到了 1 / 300。 理由很直白:随机询问撞到 x = 1 的概率是 1/n —— n 从 50 涨到 3000,这个概率就掉了六十倍。

★★ 规模一大,边界就碰不到了。

③ ⚠ 而 wrongEmpty 在前八档里有七档是 0。 n = 1[1,50] 里只占 1/50,靠运气等不来 —— 只能专门造。

★ 定档:一处「明显有代价」的改动,和一处「组合起来才成立」的改动
档位内容BrkFstMinpEndSqOneEmpty最弱支
91 + 2 + 32531751812532553737
10(最终档)9 + 4(n 放大)2632592282632283737
11(对照)10 减去「专门造 n = 1」30029526230021200
12(对照)10 减去「询问里塞 1」263262228263493737

① 「专门造 n = 1」的账要老实写:它把别的列全拉低了。 291 → 253、255 → 211、291 → 253 —— 因为 12% 的轮次里 n = 1,那些轮次什么都测不到

★ 但它是唯一wrongEmpty 非 0 的旋钮(0 → 37)。 把 0 变成非 0,掉多少抓获率都划算(第 30 章那条)—— 0 / 300 是「完全测不到」,37 / 300 是「第 5 轮就抓住」,这两者不是量的差别。

② ★★ 而「放大 n」这一处,单独加是灾难、组合起来却是最好的。 单独看(档位 4)它把 wrongOne 打成 1 / 300; 可配上「询问里专门塞 1」之后(档位 10),wrongOne228, 同时 Minp 从 175 涨到 259、End 从 181 涨到 228。

★★ 第 32、35、36、37、39、40 章那条「调优不可加」的又一次 —— 而这次是正面的那一种: 一处单独看是负分的改动,和另一处配起来就成立了。 道理也说得清:放大 n 让「筛」这件事本身更难,而塞 1 把边界补了回来 —— 两处管的根本不是同一件事。

③ 对照档 12 量的是「塞 1」值多少wrongOne 49 → 228,接近五倍。

gen.cpp(十三个档位)七处改动全部可重跑,包括那两个「专门塞某个数」的旋钮

10 ★★ 划的次数少,不等于跑得快

三种筛法划的次数(★ 而它们的答案完全相同)
1 … 10000 里有 1229 个质数、8770 个合数
埃氏筛(内层从 2i 起)
23,071
埃氏筛(内层从 i·i 起)
16,981
✓ 线性筛(每个合数只划一次)
8,770
★★ 线性筛划的次数 8,770 = 合数个数 8,770(一个不多一个不少)
⚠⚠ 别把这张图读成「线性筛快多少倍」—— 实测 n = 10⁷ 时线性筛 0.045 秒、埃氏筛(i·i 起)0.04 秒,**线性筛并不更快**。 次数少了六成,可它是**跳着写**的,埃氏筛是**连续写**的,缓存把差距吃回去了。
⚠ 「线性筛忘了 break」划的次数**恰好等于**埃氏筛(i·i 起)—— 这是能证的恒等式, 见 count.cpp 开头。
★★★ 这一章最反常识的一张表:线性筛并没有赢

本机实测n = 10⁷,不带询问,五次取稳定值):

g++ -O2 -o genBig genBig.cpp && g++ -O2 -o fast fast.cpp && g++ -O2 -o erat erat.cpp
./genBig 10000000 0 > big.txt
time ./fast < big.txt
做法划的次数(n = 10⁷)本机耗时
埃氏筛(从 i·i 起)22 850 0510.04 秒
✓ 线性筛9 335 4200.045 秒
埃氏筛(从 2i 起)29 465 7380.05 秒
线性筛,忘了 break22 850 0510.09 秒
试除法1 746 210 133按秒算

★★ 两件事都要说清楚:

① 线性筛划的次数只有埃氏筛的 41%,可它并不更快。

「线性筛比埃氏筛快」是一句流传很广的口诀 —— 这台机器上复现不出来。 (第 29、32、33、34 章那条「口诀要拿实测复核」的第五次。)

② ⚠ 而下面这一行才是真正的证据: 「忘了 break」和「埃氏筛(i·i 起)」划的次数一模一样(都是 22 850 051,第 5 步证过), 可耗时差了一倍多(0.09 vs 0.04)。

★★★ 同样的次数,代价可以差一倍。 埃氏筛内层是 j += i —— 连续地扫过去,缓存全命中; 线性筛(以及忘了 break 的那份)写的是 minp[i*p] —— 跳着写,每一下都可能是一次缓存缺失。

⇒ 所以这一章的结论要写得准确一点:

线性筛值得写,但理由不是「它更快」,而是「它顺手给了你 minp[]」(第 7 步)。 要论筛质数本身的速度,埃氏筛(从 i·i 起)在这台机器上是最好的选择。

(第 39 章那条「两把尺子会当场打架」的第二次现场 —— 那一章是 1.6 倍 vs 30 倍,这一章是 1 倍 vs 2 倍。)

noBreak.cpp⚠ 不是 bug:忘了 break 只影响次数、不影响答案 —— 而这一点我预判错了
输入(stdin)
输出
点「运行 ▶」看结果

⚠ 顺带一件必须写下来的事:我动笔前以为「忘了 break」会让 minp[] 记错。实测没有。 原因是外层 i 从小到大跑,同一个合数会被好几对 (i, p) 写到, 而最后一次写入的是 i 最大的那一对,也就是 p 最小的那一对 —— 正好是最小质因子。 它自己把自己纠正回来了。

「这样写会错」和「这样写会慢」,得分清楚。 OI 圈里流传的说法把它归成了前者;实测是后者。

11 自测

自测清单0 / 10
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 那句 if (i % p == 0) break; 保证了「每个合数只被它的最小质因子划掉一次」 —— 于是划的次数 = 合数个数,一个不多一个不少。
  2. 线性筛真正的好处不是快,是它顺手给了你 minp[] ⚠ 实测 n = 10⁷ 上它并不比埃氏筛快 —— 次数少了六成,可它是跳着写的。 ★★ 最硬的证据:「忘了 break」和埃氏筛划的次数完全相同,耗时却差一倍多。
  3. 有些 bug 只在一个特定的输入上现形(n = 1x = 1、完全平方数), 而随机数据撞上它们的概率是 1/n。 这类边界只能专门造, 而且规模一放大,边界就更碰不到了

⚠ 下一章(快速幂)会把这一章的「掉个头」再用一次: a^b 不用乘 b 次 —— a^b = (a^(b/2))²,接第 12 章的分治。 而取模的那些坑(乘法溢出、负数取模)会单独讲。