阶段 8 · 数学 · 第 41 章普及组 J

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

怎么把「n 以内的质数」筛得又对又快。★ 关键一步是那句 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_m(1 ≤ 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 这时候一个质数都没有,第三行输出 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暴力:一个数一个数地审

brute.cpp标准答案:试除法(和「筛」是完全不同的思路)
// 标准答案 —— 试除法:一个数一个数地问「它是质数吗」
//
// 为什么它存在:
// ① 它是对拍里的标准答案,思路和「筛」**完全不同** ——
// 筛法是**从质数出发去划掉合数**,试除法是**对每个数单独审问一遍**
// (第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么试除只要试到 √x

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

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

4第一次掉头:埃氏筛

erat.cpp⚠ 不是 bug:答案和正解逐字节相同,只是划的次数多
// ⚠ 这不是错误版本 —— 埃拉托色尼筛(埃氏筛)。答案和正解**逐字节相同**,只是划的次数多。
//
// 想法比线性筛还要老实:**从 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 内层为什么能从 i² 开始

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

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

slowErat.cpp⚠ 也不是 bug:内层从 2i 起,白划一大堆 —— 答案照样一字不差
// ⚠ 这不是错误版本 —— 埃氏筛,但内层**从 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;
}
点一下即可编辑
输入(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五种做法并排,数划掉 / 试除的次数
// ★ 这一章的尺子:**数一数每个数被划掉了多少次**
//
// 为什么它存在:
// 试除 / 埃氏筛(从 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 到底保证了什么

fast.cpp正解:线性筛(欧拉筛)
// 正解 —— 线性筛(欧拉筛):**每个合数只被它的最小质因子筛掉一次**
//
// ============ ★ 关键一步:那句 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;
}
点一下即可编辑
输入(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·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★ 顺手白送的东西:最小质因子

★ 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 什么时候生效
// 动画的文字版 —— 线性筛的每一步:**谁被谁划掉了**
//
// 为什么它存在:动画是用 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 章那条「题面多问一句兜的是随机数据碰不到的那个边界」的另一张脸: 这一次不是题面的问题,是生成器的问题。

wrongBreakFirst.cpp✗ 那句 break 站在标记之前 —— 和忘了 break 正好是一对
// ✗ 错误版本:那句 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongMinp.cpp✗ minp[i*p] = minp[i](想当然)
// ✗ 错误版本:`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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongEnd.cpp✗ 埃氏筛内层写成 j < n —— n 自己没划到
// ✗ 错误版本:埃氏筛内层写成 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongSq.cpp✗ 试除写成 i*i < x —— 漏掉完全平方数
// ✗ 错误版本:试除写成 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongOne.cpp✗ x = 1 时输出 1(题面规定输出 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongEmpty.cpp✗ 第三行没判空 —— ⚠ 在 n = 30 上一个字都不错
// ✗ 错误版本:**第三行没判「一个质数都没有」的情况**
//
// 题面允许 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ n = 1 这一组,专门跑给你看
输入        1 3
            1 1 1

正解        0
            0 0 0
            0

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

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

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

对拍器
★ 这个生成器有七个旋钮,其中两个是「专门把某个数塞进去」—— 不塞的话,那两个 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 步那张次数 / 耗时表的活 —— 而那张表会告诉你一件更反常识的事。

★★ 生成器:一个旋钮的两头,正好是一个 bug 的坟墓和天堂
档位 相对档位 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 只取质数 → 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,靠运气等不来 —— 只能专门造。

★ 定档:一处「明显有代价」的改动,和一处「组合起来才成立」的改动
档位 内容 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,接近五倍。

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 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% —— 缓存这件事把这一节的两条结论同时改写了: 表一旦大过缓存,「扫得连不连续」和「划了多少次」这两个差别一起被内存往返淹掉。 ⇒ ★★ 每一句「谁比谁快」都要带上规模这个主语。

noBreak.cpp⚠ 不是 bug:忘了 break 只影响次数、不影响答案 —— 而这一点我预判错了
// ⚠⚠ 这不是错误版本 —— 而这件事**是我预判错了、实测纠过来的,值得单独记一笔**。
//
// 它把线段筛里那句 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

11自测

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

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