第 40 章末尾留了一句话:分解一个数要试到 √a。 那么很自然的下一个问题是:如果要把 1 到 n 的每个数都办一遍呢?
一个一个地试,是 O(n√n);而这一章要讲的两种筛法把它压到
O(n log log n) 和 O(n) —— 办法是掉个头:不再问「这个数是不是质数」,
而是从质数出发,去把它的倍数一个个划掉。
★ 而这一章真正的收获有两个,都在后半段:
- 线性筛那句
if (i % p == 0) break;到底保证了什么(第 6 步,两句话证完); - ⚠⚠ 划的次数少,不等于跑得快 —— 第 10 步那张表会把这句话钉死。
1 一句话问题
给定 n(
1 ≤ n ≤ 10⁷)和 m 个询问x_1 … x_m(1 ≤ m ≤ 10⁵,1 ≤ x_i ≤ n):
- 第一行输出 1..n 里质数的个数;
- ★ 第二行输出 m 个数:第 i 个是
x_i的最小质因子(x_i = 1时输出0);- 第三行输出 1..n 里最大的那个质数(一个都没有就输出
0)。
| 边界 | 题面怎么规定 | 卡住哪个 bug |
|---|---|---|
| 1 不是质数 | x = 1 输出 0(1 没有质因子) | wrongOne |
| n 可以等于 1 | 这时候一个质数都没有,第三行输出 0 | wrongEmpty |
| 完全平方数 | 试除必须写 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 → 2、9 → 3、25 → 5、29 → 29、30 → 2。
三处特意安排:
- 询问里有
x = 1—— 卡wrongOne; - 询问里有 4、9、25、16 这些完全平方数 —— 卡
wrongSq; - n = 30 是合数 —— 卡
wrongEnd(埃氏筛内层写成j < n时,30 自己没被划掉)。
⚠ 而第六个(wrongEmpty)在这组数据上一个字都不错 ——
它只在 n = 1 时现形,这一点第 9 步会算给你看。
3 暴力:一个数一个数地审
点「运行 ▶」看结果
如果 x = a × b 且 a ≤ b,那么 a ≤ √x。
所以只要 √x 以内没有约数,就再也不会有了。 一句话的事,可它是这一章所有次数的起点。
⚠ 而它作为标准答案有一个好处:思路和筛法完全不同 —— 筛法是「从质数出发去划掉合数」,它是「对每个数单独审问一遍」 (第 9、15 章那条规矩:标准答案最好换个想法写)。
4 第一次掉头:埃氏筛
点「运行 ▶」看结果
比 i² 小的 i 的倍数是 k·i(k < i),它一定有一个比 i 小的质因子 ——
那个质因子在更早的一轮里就已经把它划掉了。所以 2i … (i−1)i 全是白划。
⚠ 但这只是个常数级的省事,改变不了复杂度:
点「运行 ▶」看结果
第 5 步那张表会量出来:两者只差 1.33 倍(而线性筛差的是 2.4 倍)。
comp[j] = true 只记了「j 是合数」,没记是谁划的;
而且 j 会被好几个质数反复划,最后一次划它的那个并不是最小的。
⇒ 所以 erat.cpp 要回答「最小质因子」,只能再拿质数表试除一遍。
★ 而线性筛是白送的 —— 第 7 步。
5 慢在哪:换尺子,数「划了多少次」
试除 / 埃氏筛(2i 起)/ 埃氏筛(i·i 起)/ 线性筛 / 线性筛忘了 break ——
五份代码的输出逐字节相同(check:viz 里 300 轮钉着)。
第 36 章立的「随机对拍第四个盲区」,第 37~40 章各一次现场,这是第六次。
点「运行 ▶」看结果
| 做法 | n = 10⁵ 划的次数 | n = 10⁶ | 是线性筛的几倍 |
|---|---|---|---|
| 试除法(每个数单审) | 2 745 694 | 67 740 404 | 30.4 × |
| 埃氏筛(从 2i 起) | 256 808 | 2 775 210 | 2.84 × |
| 埃氏筛(从 i·i 起) | 193 078 | 2 122 048 | 2.14 × |
| 线性筛,忘了 break | 193 078 | 2 122 048 | 2.14 × |
| ✓ 线性筛 | 90 407 | 921 501 | 1.00 × |
★★ 这张表里有两条等号,都不是「大约」:
① 线性筛划的次数 = 合数的个数。 10⁵ 以内有 9 592 个质数、90 407 个合数 —— 而线性筛正好划了 90 407 次。
每个合数被划掉一次,一个不多一个不少。这就是「线性」两个字的全部含义。
② ★★★「线性筛忘了 break」划的次数,恰好等于「埃氏筛从 i·i 起」。 两个 n 上都一字不差 —— 而且这是能证的,因为两边划的是同一批数对:
- 埃氏筛从 i·i 起:对每个质数 p 划
k·p(k ≥ p)⇒ 数对{(k, p) : p 质数, p ≤ k, kp ≤ n}; - 线性筛忘了 break:对每个 i 遍历已经找到的质数(也就是所有
p ≤ i)⇒ 数对{(i, p) : p 质数, p ≤ i, ip ≤ n}。
两个集合一模一样。 ∎
★ 所以「忘了 break」不是「退化到埃氏筛那个量级」,是一步不差地就是埃氏筛。
6 ★ 关键一步:那句 break 到底保证了什么
点「运行 ▶」看结果
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·p(i = 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 ★ 顺手白送的东西:最小质因子
线性筛划掉 i·p 的时候,划它的那个 p 正好就是它的最小质因子 ——
所以只要把 minp[i*p] = p 记下来,minp[] 就自动成型了。
有了它,分解质因数从 O(√x) 掉到 O(log x):一路除最小质因子就行。
⚠ 对照埃氏筛:它只记了「是不是合数」,要拿最小质因子还得再试除一遍(见 erat.cpp)。
★★ 这才是线性筛真正比埃氏筛强的地方 —— 不是快,是它顺手多给了一样东西。 (第 10 步会看到:论速度,它并没有赢。)
8 动画一:谁划掉了谁
- 每个格子右下角那个小字,就是划掉它的那个质数(也就是它的最小质因子)。
- 每个格子最多被划一次 —— 盯着看,不会有哪个格子被划第二次。
- ★ 那句 break 生效的那一帧会被标出来。想想看:如果不 break,下一步会划掉谁? (答案:一个「最小质因子不是 p′」的合数 —— 也就是重复。)
⚠ trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐步比
(划了谁 / 用的哪个 p / 这一步有没有 break / 累计次数)。
点「运行 ▶」看结果
9 ★ 对拍:六个错误版本,两个只在「特定的那个数」上现形
| 错误版本 | 靠什么现形 |
|---|---|
wrongBreakFirst break 站在标记之前 | 漏筛一大片,第一行就不对 —— 几乎白送 |
wrongMinp minp[i*p] = minp[i] | 询问里要有带小质因子的合数(6、12、18…) |
wrongEnd 埃氏筛 j < n | ★ n 得是合数(n 是质数时它完全正确) |
wrongSq 试除 i*i < x | ★ 询问里要有质数的平方(4、9、25…) |
wrongOne x = 1 输出 1 | ★★ 询问里必须有 x = 1 |
wrongEmpty 第三行没判空 | ★★ 必须造出 n = 1 |
★★ 最后两条要的不是值域、不是规模、也不是结构,而是「你有没有把那个特定的数塞进去」。 而随机撞上它们的概率是 1/n 和 1/50 —— n 一大就全没了。
这是第 38 章那条「题面多问一句兜的是随机数据碰不到的那个边界」的另一张脸: 这一次不是题面的问题,是生成器的问题。
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
输入 1 3
1 1 1
正解 0
0 0 0
0
wrongEmpty (直接崩掉 —— primes.back() 在空表上是未定义行为)⚠ 而它在上面那组 n = 30 的数据上输出和正解一模一样。
★ 一个 bug 只在一个特定的输入上现形,而那个输入随机撞不到 —— 这就是接下来那张表要解决的问题。
300 轮实测(种子 1..300,最终档 10):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
wrongBreakFirst(break 站错边) | 263 / 300 | 第 1 轮 |
wrongSq(i*i < x) | 263 / 300 | 第 1 轮 |
wrongMinp(minp[i]) | 259 / 300 | 第 1 轮 |
wrongEnd(j < n) | 228 / 300 | 第 1 轮 |
wrongOne(x = 1) | 228 / 300 | 第 1 轮 |
wrongEmpty(n = 1) | 37 / 300 | 第 5 轮 |
erat / slowErat / noBreak(只是快慢不同) | ★ 0 / 300 | — |
| 档位 | 相对档位 0 改了什么 | BrkFst | Minp | End | Sq | One | Empty |
|---|---|---|---|---|---|---|---|
| 0(顺手写法) | n ∈ [2,50]、m ∈ [3,8]、询问在 [1,n] 里随机 | 291 | 255 | 198 | 291 | 74 | ★ 0 |
| 1 | ★ 12% 的概率直接令 n = 1 | 253 | 211 | 181 | 253 | 111 | ★ 37 |
| 2 | ★ 询问里专门塞 1 | 291 | 232 | 198 | 291 | 220 | 0 |
| 3 | ★ 询问里专门塞完全平方数 | 291 | 237 | 198 | 291 | 131 | 0 |
| 4 | n 放大到 [2,3000] | 300 | 300 | 262 | 300 | ★ 1 | 0 |
| 5 | ★ n 只取质数 | 265 | 211 | ★ 0 | 265 | 105 | 0 |
| 6 | ★ n 只取合数 | 300 | 263 | ★ 300 | 300 | 69 | 0 |
| 7 | 询问条数拉长到 [20,40] | 291 | 284 | 198 | 291 | 206 | 0 |
① ★★★ 档位 5 和 6 是这一章最干净的一对:
n 只取质数 →
wrongEnd0 / 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,靠运气等不来 —— 只能专门造。
| 档位 | 内容 | BrkFst | Minp | End | Sq | One | Empty | 最弱支 |
|---|---|---|---|---|---|---|---|---|
| 9 | 1 + 2 + 3 | 253 | 175 | 181 | 253 | 255 | 37 | 37 |
| 10(最终档) | 9 + 4(n 放大) | 263 | 259 | 228 | 263 | 228 | 37 | ✓ 37 |
| 11(对照) | 10 减去「专门造 n = 1」 | 300 | 295 | 262 | 300 | 212 | ★ 0 | 0 |
| 12(对照) | 10 减去「询问里塞 1」 | 263 | 262 | 228 | 263 | ★ 49 | 37 | 37 |
① 「专门造 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),wrongOne 有 228,
同时 Minp 从 175 涨到 259、End 从 181 涨到 228。
★★ 第 32、35、36、37、39、40 章那条「调优不可加」的又一次 —— 而这次是正面的那一种: 一处单独看是负分的改动,和另一处配起来就成立了。 道理也说得清:放大 n 让「筛」这件事本身更难,而塞 1 把边界补了回来 —— 两处管的根本不是同一件事。
③ 对照档 12 量的是「塞 1」值多少:wrongOne 49 → 228,接近五倍。
10 ★★ 划的次数少,不等于跑得快
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 051 | ✓ 0.04 秒 |
| ✓ 线性筛 | 9 335 420 | 0.045 秒 |
| 埃氏筛(从 2i 起) | 29 465 738 | 0.05 秒 |
| 线性筛,忘了 break | 22 850 051 | 0.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 倍。)
点「运行 ▶」看结果
⚠ 顺带一件必须写下来的事:我动笔前以为「忘了 break」会让 minp[] 记错。实测没有。
原因是外层 i 从小到大跑,同一个合数会被好几对 (i, p) 写到,
而最后一次写入的是 i 最大的那一对,也就是 p 最小的那一对 —— 正好是最小质因子。
它自己把自己纠正回来了。
★ 「这样写会错」和「这样写会慢」,得分清楚。 OI 圈里流传的说法把它归成了前者;实测是后者。
11 自测
- 那句
if (i % p == 0) break;保证了「每个合数只被它的最小质因子划掉一次」 —— 于是划的次数 = 合数个数,一个不多一个不少。 - 线性筛真正的好处不是快,是它顺手给了你
minp[]。 ⚠ 实测 n = 10⁷ 上它并不比埃氏筛快 —— 次数少了六成,可它是跳着写的。 ★★ 最硬的证据:「忘了 break」和埃氏筛划的次数完全相同,耗时却差一倍多。 - 有些 bug 只在一个特定的输入上现形(
n = 1、x = 1、完全平方数), 而随机数据撞上它们的概率是 1/n。 这类边界只能专门造, 而且规模一放大,边界就更碰不到了。
⚠ 下一章(快速幂)会把这一章的「掉个头」再用一次:
a^b 不用乘 b 次 —— a^b = (a^(b/2))²,接第 12 章的分治。
而取模的那些坑(乘法溢出、负数取模)会单独讲。