第 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暴力:一个数一个数地审
// 标准答案 —— 试除法:一个数一个数地问「它是质数吗」//// 为什么它存在:// ① 它是对拍里的标准答案,思路和「筛」**完全不同** ——// 筛法是**从质数出发去划掉合数**,试除法是**对每个数单独审问一遍**// (第 9、15 章那条规矩:标准答案最好换个想法写)。// ② ★ 它同时是这一章的反面教材:判断一个数要试到 √x,// 把 1..n 全问一遍就是 **O(n√n)** —— n = 10⁶ 时已经要按秒算了。//// ⚠ 两个边界,题面里都写死了,这里也要写死:// · **1 不是质数**(它只有一个约数);// · 最小质因子:x = 1 时题面规定输出 0,x 是质数时它自己就是最小质因子。//// 复杂度 O(n√n + m√n)。
#include <bits/stdc++.h>using namespace std;
/** x 的最小质因子;x = 1 时返回 0 */int minPrimeFactor(int x) { if (x == 1) return 0; for (int i = 2; (long long)i * i <= x; i++) // ⚠ i*i 要用 long long 兜住 if (x % i == 0) return i; return x; // 没被整除过 ⇒ 它自己是质数}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
int cnt = 0, mx = 0; for (int x = 2; x <= n; x++) if (minPrimeFactor(x) == x) { cnt++; mx = x; }
string out = to_string(cnt); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(minPrimeFactor(x)); out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(mx); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
如果 x = a × b 且 a ≤ b,那么 a ≤ √x。
所以只要 √x 以内没有约数,就再也不会有了。 一句话的事,可它是这一章所有次数的起点。
⚠ 而它作为标准答案有一个好处:思路和筛法完全不同 —— 筛法是「从质数出发去划掉合数」,它是「对每个数单独审问一遍」 (第 9、15 章那条规矩:标准答案最好换个想法写)。
4第一次掉头:埃氏筛
// ⚠ 这不是错误版本 —— 埃拉托色尼筛(埃氏筛)。答案和正解**逐字节相同**,只是划的次数多。//// 想法比线性筛还要老实:**从 2 开始,每碰到一个没被划掉的数,就把它的倍数全划掉。**//// ```cpp// for (int i = 2; i <= n; i++)// if (!comp[i]) // i 是质数// for (long long j = (long long)i * i; j <= n; j += i) // ★ 从 i² 开始就够了// comp[j] = true;// ```//// ★ **为什么内层能从 i² 开始,而不是 2i?**// 比 i² 小的 i 的倍数是 k·i(k < i),它一定有一个比 i 小的质因子 ——// 那个质因子在更早的一轮就已经把它划掉了。**所以 2i … (i−1)i 全是白划。**// ⚠ 但这只是个常数级的省事,**它改变不了复杂度**(slowErat.cpp 把两者并排量了出来)。//// ⚠ 而它和线性筛的真正差别在这儿:// **一个合数有几个不同的质因子,就会被划几次**(12 = 2²·3 会被 2 和 3 各划一次)。// 总次数是 **Σ n/p ≈ n·ln ln n**,也就是 O(n log log n) —— 比线性多一个几乎不涨的因子。//// ⚠⚠ **可它求不出最小质因子**(题面第二问要的那个):// `comp[j] = true` 只记了「j 是合数」,没记「是谁划的」;// 而且 j 会被好几个质数反复划,**最后一次划它的那个并不是最小的**。// 所以这一份得再花一次力气:拿筛出来的质数表去试除。// ★ 这正是线性筛白送的东西 —— 第 7 步会把这件事说清楚。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;bool comp[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) if (!comp[i]) for (long long j = (long long)i * i; j <= n; j += i) // ★ 从 i² 开始 comp[j] = true;
vector<int> primes; for (int i = 2; i <= n; i++) if (!comp[i]) primes.push_back(i);
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; int mp = 0; if (x > 1) { mp = x; // ⚠ 求最小质因子只能再试除一遍 for (int p : primes) { if ((long long)p * p > x) break; if (x % p == 0) { mp = p; break; } } } out += to_string(mp); out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
比 i² 小的 i 的倍数是 k·i(k < i),它一定有一个比 i 小的质因子 ——
那个质因子在更早的一轮里就已经把它划掉了。所以 2i … (i−1)i 全是白划。
⚠ 但这只是个常数级的省事,改变不了复杂度:
// ⚠ 这不是错误版本 —— 埃氏筛,但内层**从 2i 开始**而不是从 i² 开始。//// 答案和正解**逐字节相同**,只是白划了一大堆:// 比 i² 小的那些 i 的倍数(2i、3i、…、(i−1)i),每一个都**早就被更小的质因子划过了**。//// ★ 它在这一章的角色,和第 38 章的 buildSlow、第 39 章的 noLazy 一样:// **一个只影响复杂度、不影响答案的写法** —— 对拍原理上一个字都看不见(第 36 章那个第四盲区)。// ⇒ 尺子只能换成「数划了多少次」:count.cpp 数的就是这个。//// ⚠ 而它值得单独留一份,是因为「从 i² 开始」这个优化**看起来很像**能改变复杂度 ——// 实测告诉你它只是个常数(正文第 5 步那张表:两者差不到两倍,而线性筛差的是好几倍)。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;bool comp[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) if (!comp[i]) for (long long j = (long long)i * 2; j <= n; j += i) // ⚠ 从 2i 开始:白划一大堆 comp[j] = true;
vector<int> primes; for (int i = 2; i <= n; i++) if (!comp[i]) primes.push_back(i);
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; int mp = 0; if (x > 1) { mp = x; // ⚠ 求最小质因子只能再试除一遍 for (int p : primes) { if ((long long)p * p > x) break; if (x % p == 0) { mp = p; break; } } } out += to_string(mp); out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
第 5 步那张表会量出来:两者只差 1.33 倍(而线性筛差的是 2.4 倍)。
comp[j] = true 只记了「j 是合数」,没记是谁划的;
而且 j 会被好几个质数反复划,最后一次划它的那个并不是最小的。
⇒ 所以 erat.cpp 要回答「最小质因子」,只能再拿质数表试除一遍。
★ 而线性筛是白送的 —— 第 7 步。
5慢在哪:换尺子,数「划了多少次」
试除 / 埃氏筛(2i 起)/ 埃氏筛(i·i 起)/ 线性筛 / 线性筛忘了 break ——
五份代码的输出逐字节相同(check:viz 里 300 轮钉着)。
第 36 章立的「随机对拍第四个盲区」,第 37~40 章各一次现场,这是第六次。
// ★ 这一章的尺子:**数一数每个数被划掉了多少次**//// 为什么它存在:// 试除 / 埃氏筛(从 i² 起)/ 埃氏筛(从 2i 起)/ 线性筛 / 线性筛忘了 break// —— 五份代码的**答案逐字节相同**,对拍在它们身上一个字都看不见// (第 36 章立的第四个盲区,这是第六次现场)。★ 次数可复现,秒数不可复现。//// ★★ 而这一章的计数有一个特别干净的含义:// **线性筛的划掉次数 = 合数个数**(每个合数正好被划一次)——// 这不是「大约」,是**等号**。表里那一列可以直接和「n − 质数个数 − 1」对上。//// 口径:// · trial :试除法里那个 `x % i` 执行了多少次;// · eratI2 :埃氏筛(内层从 i² 起)执行 `comp[j] = true` 多少次;// · erat2I :埃氏筛(内层从 2i 起)同上 —— ⚠ 只是常数差别,复杂度没变;// · linear :线性筛执行 `minp[i*p] = p` 多少次 —— ★ 正好等于合数个数;// · noBreak :线性筛删掉那句 break 之后,同一句执行了多少次。//// ★★★ 而这张表里藏着一条**恒等式**(实测每个 n 都成立,而且能证):// **「线性筛忘了 break」划的次数,恰好等于「埃氏筛从 i*i 起」划的次数。**// 证明:两边划的其实是**同一批数对**。// · 埃氏筛从 i*i 起:对每个质数 p,划 j = k·p(k ≥ p、j ≤ n)⇒ 数对 {(k, p) : p 质数, p ≤ k, kp ≤ n};// · 线性筛忘了 break:对每个 i,内层遍历**已经找到的**质数(也就是所有 p ≤ i),// 划 i·p(i·p ≤ n)⇒ 数对 {(i, p) : p 质数, p ≤ i, ip ≤ n}。// **两个集合一模一样。** ∎// ⇒ ★ 所以「忘了 break」不是「退化成埃氏筛那个量级」,是**一步不差地就是埃氏筛**。//// 用法:./count n 打印那张表// ./count n csv 同一批数字,给脚本和 check:viz 读
#include <bits/stdc++.h>using namespace std;
static string padDisp(const string& s, int width) { int disp = 0; for (unsigned char c : s) { if ((c & 0xC0) == 0x80) continue; disp += (c < 0x80) ? 1 : 2; } return s + string(max(0, width - disp), ' ');}
int main(int argc, char** argv) { const int n = (argc > 1) ? atoi(argv[1]) : 100000; const string mode = (argc > 2) ? argv[2] : "";
long long trial = 0, eratI2 = 0, erat2I = 0, linear = 0, noBreak = 0; int primeCnt = 0;
{ // 试除法 for (int x = 2; x <= n; x++) for (int i = 2; (long long)i * i <= x; i++) { trial++; if (x % i == 0) break; } } { // 埃氏筛(从 i² 起) vector<char> comp(n + 1, 0); for (int i = 2; i <= n; i++) if (!comp[i]) { primeCnt++; for (long long j = (long long)i * i; j <= n; j += i) { comp[j] = 1; eratI2++; } } } { // 埃氏筛(从 2i 起) vector<char> comp(n + 1, 0); for (int i = 2; i <= n; i++) if (!comp[i]) for (long long j = (long long)i * 2; j <= n; j += i) { comp[j] = 1; erat2I++; } } { // 线性筛 vector<int> minp(n + 1, 0), pr; for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; pr.push_back(i); } for (int p : pr) { if ((long long)i * p > n) break; minp[i * p] = p; linear++; if (i % p == 0) break; } } } { // 线性筛,但忘了 break vector<int> minp(n + 1, 0), pr; for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; pr.push_back(i); } for (int p : pr) { if ((long long)i * p > n) break; minp[i * p] = p; noBreak++; } } }
const long long composites = (long long)n - primeCnt - 1; // 1 既不是质数也不是合数
if (mode == "csv") { printf("trial,%lld\neratI2,%lld\nerat2I,%lld\nlinear,%lld\nnoBreak,%lld\nprimes,%d\ncomposites,%lld\n", trial, eratI2, erat2I, linear, noBreak, primeCnt, composites); return 0; }
printf("n = %d,其中质数 %d 个、合数 %lld 个\n\n", n, primeCnt, composites); printf("%s %s %s\n", padDisp("做法", 22).c_str(), padDisp("划掉 / 试除的次数", 22).c_str(), padDisp("是线性筛的几倍", 18).c_str()); struct Row { const char* name; long long v; }; Row rows[5] = { {"试除法(每个数单审)", trial}, {"埃氏筛(从 2i 起)", erat2I}, {"埃氏筛(从 i*i 起)", eratI2}, {"线性筛,忘了 break", noBreak}, {"✓ 线性筛", linear}, }; for (auto& r : rows) printf("%s %s %s\n", padDisp(r.name, 22).c_str(), padDisp(to_string(r.v), 22).c_str(), padDisp(linear ? to_string((double)r.v / (double)linear).substr(0, 6) : "-", 18).c_str());
printf("\n★★ 线性筛划掉的次数 = %lld,合数个数 = %lld —— %s\n", linear, composites, linear == composites ? "**一个不多一个不少**" : "⚠ 对不上"); printf("★★★ 「线性筛忘了 break」和「埃氏筛从 i*i 起」:%lld vs %lld —— %s\n", noBreak, eratI2, noBreak == eratI2 ? "**一次不差,而且这是能证的**" : "⚠ 不相等"); printf("★ 五种做法的答案完全一样 —— 这张表里的差别,对拍一个字都看不见。\n"); return 0;}点「运行 ▶」看结果
| 做法 | 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 到底保证了什么
// 正解 —— 线性筛(欧拉筛):**每个合数只被它的最小质因子筛掉一次**//// ============ ★ 关键一步:那句 break 站在哪儿 ============//// ```cpp// for (int i = 2; i <= n; i++) {// if (!minp[i]) { minp[i] = i; primes.push_back(i); } // i 没被划掉 ⇒ 它是质数// for (int p : primes) {// if ((long long)i * p > n) break;// minp[i * p] = p; // ★ 用 p 去划掉 i*p,而 p 就是 i*p 的最小质因子// if (i % p == 0) break; // ★★ 全章的灵魂,就是这一句// }// }// ```//// ★ **为什么那句 `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 时,p ≤ i 的最小质因子,// 所以 **p 一定是 i·p 的最小质因子**。而一个合数的最小质因子只有一个、// 对应的 i = c/p 也只有一个 ⇒ **(i, p) 这一对是唯一的**,所以只会被划一次。// 而那句 break 干的正是「一旦 p 整除 i,就不许再往大的质数走」——// 再走下去,划的那个 i·p' 的最小质因子就不是 p' 了(是 p),就重复了。//// ⇒ **总的划掉次数 = 合数个数 = O(n)。这就是「线性」两个字的全部含义。**//// ⚠ 对比埃氏筛:它对每个质数 p 把 p²、p²+p、… 全划一遍,// 一个合数有几个不同的质因子就被划几次 ⇒ **O(n log log n)**。// 两者答案完全一样,差的只是次数 —— 这一章又是一个「对拍看不见慢」的现场。//// ============ ★ 顺手白送的东西:最小质因子 ============//// `minp[x]` 在筛的过程中天然就填好了,一分钱不用多花。// 有了它,**分解质因数就从 O(√x) 掉到 O(log x)**(一路除最小质因子)——// 题面第二问要的正是它。//// 复杂度 O(n)。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = p; // ★ p 就是 i*p 的最小质因子 if (i % p == 0) break; // ★★ 全章的灵魂 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
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 / 累计次数)。
// 动画的文字版 —— 线性筛的每一步:**谁被谁划掉了**//// 为什么它存在:动画是用 TypeScript 重写一遍算法画出来的,// 万一两边不一致,学生看到的画面就是在骗人(第 27 章那次就是这么被抓住的)。// `check:viz` 拿它和动画**逐步**比:每一步划掉了哪个数、用的是哪个质数、有没有在这一步 break。//// 输出每一行是一次「划」:// 划 <合数> = <i> × <p> [break] 累计 <k>// 其中 `[break]` 表示这一步之后那句 `if (i % p == 0) break;` 生效了。//// ★ 盯住两件事:// ① 每个合数**只出现一次** —— 这就是「线性」;// ② 每次划的那个 p,**正好是那个合数的最小质因子**。//// 用法:./trace [n] 默认 30
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { const int n = (argc > 1) ? atoi(argv[1]) : 30;
vector<int> minp(n + 1, 0), pr; long long k = 0; for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; pr.push_back(i); printf("质数 %d\n", i); } for (int p : pr) { if ((long long)i * p > n) break; minp[i * p] = p; k++; bool brk = (i % p == 0); printf("划 %d = %d × %d%s 累计 %lld\n", i * p, i, p, brk ? " [break]" : " ", k); if (brk) break; } } printf("收尾 质数 %d 个 划掉 %lld 次 合数 %d 个\n", (int)pr.size(), k, n - (int)pr.size() - 1); printf("★ 划掉的次数和合数个数%s —— 每个合数正好被它的最小质因子划掉一次。\n", k == (long long)(n - (int)pr.size() - 1) ? "**完全相等**" : "⚠ 对不上"); return 0;}点「运行 ▶」看结果
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 章那条「题面多问一句兜的是随机数据碰不到的那个边界」的另一张脸: 这一次不是题面的问题,是生成器的问题。
// ✗ 错误版本:那句 break **站在标记之前**//// ```// if (i % p == 0) break; ← ✗ 先 break// minp[i * p] = p; ← 于是这一次该划的没划// ```//// 靠什么现形:i 的最小质因子 p 那一次被跳过了 ——// 而 `i·p`(p = minp[i])**只有这一次机会被划到**(那是它唯一的 (i,p) 分解)。// ⇒ 4、8、9、12、16… 这些「最小质因子的平方能整除它」的数会漏网。// ★ 和 wrongNoBreak 正好是一对:**一句话,站前面站后面,错的方向正相反。**#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 if (i % p == 0) break; // ✗ 先 break 了 minp[i * p] = p; // ✗ 于是这一次该划的没划 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:`minp[i * p] = p` 写成了 `minp[i * p] = minp[i]`(想当然)//// 「i·p 的最小质因子,不就是 i 的最小质因子吗?」—— **不是。**// 内层循环里 p 是从小到大取的,而 break 保证了 **p ≤ minp[i]**,// 所以 i·p 的最小质因子是 **p**,不是 minp[i]。//// 靠什么现形:只要有一次 **p < minp[i]** 就错(比如 i = 9、p = 2 时 i·p = 18,// 最小质因子是 2 而不是 3)。⇒ 要询问**含小质因子的合数**。// ⚠ 而第一行「质数个数」和第三行「最大质数」它都是对的 —— **只有第二行错**。#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = minp[i]; // ✗ 想当然:i·p 的最小质因子是 p,不是 minp[i] if (i % p == 0) break; // ★★ 全章的灵魂 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:埃氏筛内层写成 `j < n`(少划了 n 本身)//// 靠什么现形:★ **n 本身是合数,而且它的最小质因子的平方 ≤ n** —— 那时候 n 没被划掉,// 于是它被当成质数,第一行的计数多 1、第三行的「最大质数」直接错成 n。// ⚠ n 是奇数还是偶数、是质数还是合数,**随机取一个 n 就有一半机会碰上** ——// 可如果生成器顺手只取偶数 n,或者只取一两个固定的 n,这个 bug 就永远见不到。// ★ 「区间的右端点少一格」是这本书里出现次数最多的一类 bug(第 6、23、38 章都有)。#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;bool comp[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) if (!comp[i]) for (long long j = (long long)i * i; j < n; j += i) // ✗ 少了一个等号,n 自己没划到 comp[j] = true;
vector<int> primes; for (int i = 2; i <= n; i++) if (!comp[i]) primes.push_back(i);
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; int mp = 0; if (x > 1) { mp = x; // ⚠ 求最小质因子只能再试除一遍 for (int p : primes) { if ((long long)p * p > x) break; if (x % p == 0) { mp = p; break; } } } out += to_string(mp); out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:试除写成 `i * i < x`(漏掉了完全平方数)//// 本来该是 `i * i <= x`。少一个等号,**x 正好是某个质数的平方时就判错了**:// 4、9、25、49、121… 会被当成质数。//// 靠什么现形:★ 询问里要出现 **质数的平方**(或者 n 足够大时第一行的计数会多出来一截)。// ⚠ 随机询问撞到 4/9/25 的概率同样极低 ⇒ 生成器要**专门塞小数和平方数**。#include <bits/stdc++.h>using namespace std;
/** x 的最小质因子;x = 1 时返回 0 */int minPrimeFactor(int x) { if (x == 1) return 0; for (int i = 2; (long long)i * i < x; i++) // ✗ 少了一个等号 if (x % i == 0) return i; return x; // 没被整除过 ⇒ 它自己是质数}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
int cnt = 0, mx = 0; for (int x = 2; x <= n; x++) if (minPrimeFactor(x) == x) { cnt++; mx = x; }
string out = to_string(cnt); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(minPrimeFactor(x)); out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(mx); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:**x = 1 时输出 1**(把 1 当成了「自己的最小质因子」)//// 题面写死了「x = 1 时输出 0」,因为 **1 没有质因子**(它也不是质数)。// 把 `minp[1]` 填成 1 是最自然不过的手滑 —— 而且它**只影响这一种询问**:// 第一行的质数个数、第三行的最大质数,它一个字都不错。//// 靠什么现形:★ 询问里必须出现 **x = 1**。// ⚠ 而 1 ≤ x ≤ n、n 到 10⁷ 时,随机询问撞到 1 的概率是**一千万分之一** ——// ⇒ 生成器必须**专门往询问里塞 1**。这是这一章「顺手写法」的现场。// ★ 0 和 1 是数学题里永远要单独想一遍的两个数(第 40 章立的那条:算法越短,// 风险越往「取模、溢出、负数、0 和 1」上转移)。#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
minp[1] = 1; // ✗ 题面规定 x = 1 要输出 0 for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = p; // ★ p 就是 i*p 的最小质因子 if (i % p == 0) break; // ★★ 全章的灵魂 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(minp[x]); // ✗ 忘了 x = 1 那条特例 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:**第三行没判「一个质数都没有」的情况**//// 题面允许 `n = 1` —— 这时候 1..n 里一个质数都没有,第三行该输出 0。// `primes.back()` 在空表上是**未定义行为**:跑出来的那个数是内存里的垃圾。//// 靠什么现形:★ 生成器必须造出 **n = 1**。// ⚠ 而「顺手」写的生成器多半是 `n = rnd(2, 50)` —— 因为「n ≥ 2 才有质数嘛」。// ★ 那正是第 27~40 章那条「顺手写法会悄悄给数据加一条题目里没有的性质」的第十三次:// 这次加的性质是「**n 至少是 2**」。#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = p; // ★ p 就是 i*p 的最小质因子 if (i % p == 0) break; // ★★ 全章的灵魂 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.back()); // ✗ 没判空:n = 1 时这里是未定义行为 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
输入 1 3
1 1 1
正解 0
0 0 0
0
wrongEmpty (直接崩掉 —— primes.back() 在空表上是未定义行为)⚠ 而它在上面那组 n = 30 的数据上输出和正解一模一样。
★ 一个 bug 只在一个特定的输入上现形,而那个输入随机撞不到 —— 这就是接下来那张表要解决的问题。
// 正解 —— 线性筛(欧拉筛):**每个合数只被它的最小质因子筛掉一次**//// ============ ★ 关键一步:那句 break 站在哪儿 ============//// ```cpp// for (int i = 2; i <= n; i++) {// if (!minp[i]) { minp[i] = i; primes.push_back(i); } // i 没被划掉 ⇒ 它是质数// for (int p : primes) {// if ((long long)i * p > n) break;// minp[i * p] = p; // ★ 用 p 去划掉 i*p,而 p 就是 i*p 的最小质因子// if (i % p == 0) break; // ★★ 全章的灵魂,就是这一句// }// }// ```//// ★ **为什么那句 `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 时,p ≤ i 的最小质因子,// 所以 **p 一定是 i·p 的最小质因子**。而一个合数的最小质因子只有一个、// 对应的 i = c/p 也只有一个 ⇒ **(i, p) 这一对是唯一的**,所以只会被划一次。// 而那句 break 干的正是「一旦 p 整除 i,就不许再往大的质数走」——// 再走下去,划的那个 i·p' 的最小质因子就不是 p' 了(是 p),就重复了。//// ⇒ **总的划掉次数 = 合数个数 = O(n)。这就是「线性」两个字的全部含义。**//// ⚠ 对比埃氏筛:它对每个质数 p 把 p²、p²+p、… 全划一遍,// 一个合数有几个不同的质因子就被划几次 ⇒ **O(n log log n)**。// 两者答案完全一样,差的只是次数 —— 这一章又是一个「对拍看不见慢」的现场。//// ============ ★ 顺手白送的东西:最小质因子 ============//// `minp[x]` 在筛的过程中天然就填好了,一分钱不用多花。// 有了它,**分解质因数就从 O(√x) 掉到 O(log x)**(一路除最小质因子)——// 题面第二问要的正是它。//// 复杂度 O(n)。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = p; // ★ p 就是 i*p 的最小质因子 if (i % p == 0) break; // ★★ 全章的灵魂 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}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 轮 |
⚠ 同一批 300 轮里还跟着跑了三份根本不是错的:erat(埃氏筛)、slowErat(从 2i 起划)、
noBreak(线性筛拿掉那句 break)。它们的答案全对,0 / 300,一次都不该被抓到 ——
所以它们不在上面那张表里,上面那张表只放「写错的」。
★ 那为什么还要陪跑?因为这正好把对拍的边界画了出来: 对拍只验答案,验不了快慢。 这三份和正解差的是「划了多少次、跑了多久」, 那是第 10 步那张次数 / 耗时表的活 —— 而那张表会告诉你一件更反常识的事。
| 档位 | 相对档位 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 步)。 要论筛质数本身的速度,在
n = 10⁷这个规模上,埃氏筛(从 i·i 起)是这台机器上最好的选择。
(第 39 章那条「两把尺子会当场打架」的第二次现场 —— 那一章是 1.6 倍 vs 30 倍,这一章是 1 倍 vs 2 倍。)
⚠⚠ 上面那句结论的主语是 n = 10⁷,而它会翻面 —— 这是 2026-09-02 写
P3912 的解析时量出来的,那道题的 n 是 10⁸:
| 秒表比值(每格 3 次取中位数) | n = 10⁷ |
n = 3 × 10⁷ |
n = 10⁸ |
|---|---|---|---|
| 埃氏筛(i·i) ÷ 线性筛 | 0.89(埃氏筛小赢) | 1.79 | ★ 2.27(线性筛赢) |
| 埃氏筛(i·i) ÷ 奇数+位压缩 | 2.30 | 4.76 | ★ 5.98 |
★★★ 第一行跨过了 1,拐点就在
10⁷和3 × 10⁷之间。 理由是上面那句「缓存全命中」自己失效了:n = 10⁸的那张表有 95 MB, 装不进任何一级缓存 —— 埃氏筛「扫得连续」的好处,被「为每个质数反复扫一张放不下的表」吃光了, 于是「划的次数少」这件事重新变成主角。⇒ ★★ 所以这一节的结论要连主语一起说: 「线性筛并不更快」说的是「表还待得进缓存」的那一段
n。 ★ 而两边真正的赢家是把表缩小(只存奇数 + 一位一个数,6.0 MB)—— 它划的次数比线性筛还多 2.2%,秒表却快 2.63 倍。
⚠⚠ 上面第 ② 点(「同样的次数,代价可以差一倍」)的主语也是 n = 10⁷ ——
它在 10⁸ 上也塌了(这是写 P3383 的解析时量的):
n = 10⁸,都要顺手收集素数表 |
划的次数 | ÷ 线性筛的耗时 |
|---|---|---|
埃氏筛(i·i 起) |
242 570 204 | 2.32 |
| 线性筛,忘了 break | 242 570 204(★ 一模一样,第 5 步证过) | 2.41 |
★★★ 在
10⁷上这两个是 0.04 秒 vs 0.09 秒(差一倍), 到10⁸上只差 4% —— 缓存这件事把这一节的两条结论同时改写了: 表一旦大过缓存,「扫得连不连续」和「划了多少次」这两个差别一起被内存往返淹掉。 ⇒ ★★ 每一句「谁比谁快」都要带上规模这个主语。
// ⚠⚠ 这不是错误版本 —— 而这件事**是我预判错了、实测纠过来的,值得单独记一笔**。//// 它把线段筛里那句 `if (i % p == 0) break;` 整个删掉了 ——// 也就是 OI 圈子里最有名的「线性筛写错」。我动笔前的预判是:// 「没有 break,`minp[i*p]` 会被更大的质数覆盖,于是最小质因子那一行会错。」//// ⚠ **实测:答案和正解逐字节相同(300 轮 0 / 300)。** 原因也说得清:// 外层 i 是**从小到大**跑的,而同一个合数 c 会被好几对 (i, p) 写到(c = i·p)。// **最后一次写入的是 i 最大的那一对,也就是 p 最小的那一对** ——// 而那个最小的 p 正好就是 c 的最小质因子。**它自己把自己纠正回来了。**//// ⇒ ★★ 所以「忘了 break」的真正后果**只有一个:慢**。// 每个合数被划的次数从 1 变成「它有几个不同的质因子」,退回埃氏筛那一档 ——// **而这件事对拍原理上一个字都看不见**(第 36 章那个第四盲区,这一章的第三份现场)。// ⇒ 尺子只能换成「数划了多少次」:count.cpp 数的就是这个。//// ★ 这一条值得记成一句话:**「这样写会错」和「这样写会慢」,得分清楚。**// OI 圈里流传的说法把它归成了前者,实测是后者。#include <bits/stdc++.h>using namespace std;
const int MAXN = 10000005;int minp[MAXN]; // minp[x]:x 的最小质因子(0 = 还没被划到)vector<int> primes;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
for (int i = 2; i <= n; i++) { if (!minp[i]) { minp[i] = i; primes.push_back(i); } // 没被划掉 ⇒ 质数 for (int p : primes) { if ((long long)i * p > n) break; // ⚠ i*p 会超 int,要用 long long 判 minp[i * p] = p; // ★ p 就是 i*p 的最小质因子 // ⚠ 这里少了一句 if (i % p == 0) break; —— 只影响次数,不影响答案 } }
string out = to_string((int)primes.size()); out += '\n'; for (int i = 0; i < m; i++) { int x; cin >> x; out += to_string(x == 1 ? 0 : minp[x]); // ⚠ 题面规定 x = 1 输出 0 out += (i == m - 1 ? '\n' : ' '); } if (m == 0) out += '\n'; out += to_string(primes.empty() ? 0 : primes.back()); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
⚠ 顺带一件必须写下来的事:我动笔前以为「忘了 break」会让 minp[] 记错。实测没有。
原因是外层 i 从小到大跑,同一个合数会被好几对 (i, p) 写到,
而最后一次写入的是 i 最大的那一对,也就是 p 最小的那一对 —— 正好是最小质因子。
它自己把自己纠正回来了。
★ 「这样写会错」和「这样写会慢」,得分清楚。 OI 圈里流传的说法把它归成了前者;实测是后者。
11自测
- 洛谷 P3383 【模板】线性筛素数解析 → —— 本章那道题的原题。默写 6 行线性筛,那句 break 保证了什么,先说出来再交
- 洛谷 P1075 [NOIP 2012 普及组] 质因数分解解析 → —— n ≤ 2×10⁹ 且是两个质数之积 —— 筛不动,只能试除,而且试到 √n 就够。本章第 3 步那句「为什么只要试到 √x」在这儿是唯一的解法来源
- 洛谷 P1217 [USACO1.5] 回文质数解析 → —— ★ 上界 10⁸,直接筛会爆内存。正解要反过来:先造回文数再判质数(偶数位的回文数必被 11 整除,可以整段跳过)。练的是「筛不是万能的」
- 洛谷 P1865 A % B Problem解析 → —— 筛 + 前缀和:cnt[i] 记 1..i 有几个质数,区间一减就出来。第 6 章那把尺子在这儿原样再用一次。⚠ 越界要输出 Crossing the line
- 洛谷 P3912 素数个数解析 → —— ★ n 到 10⁸。本章第 10 步那个实测就是为这道题准备的 —— ⚠ 但那张表量的是 n = 10⁷,到 10⁸ 上「线性筛反而赢 2.27 倍」(95 MB 的表装不进缓存);⇒ 交之前先想清楚你要用哪一种筛,以及为什么
- 那句
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 章的分治。
而取模的那些坑(乘法溢出、负数取模)会单独讲。