SIEVE / LAB 返回匹配文章

SIEVE OF ERATOSTHENES · FROM ONE QUERY

埃氏筛法详解

判断一个正整数是否为素数,可以逐个试除;当查询数量增大时,判定之间互不共享的代价开始累积。 把“询问一个数”换成“标记一批合数”,素数表便在一次遍历中完成。

试除判定重复查询合数结构 倍数标记起点优化线性筛

01 · 基本方案

单个数字的试除判定

本章作用建立一个一定正确的判定方法,并确认它的代价。
主线知识

素数定义、因数成对、试除到 √x、单次判定的代价。

扩展知识

大数素性测试(Miller–Rabin)只在最后一章提及,不进入主线。

素数的定义:只有两个正因数的正整数

素数定义:大于 1 且只有 1 和自身两个正因数的正整数。通俗地说,素数就是“拆不开”的数:不能写成两个大于 1 的正整数相乘(1 本身除外)。判定一个数 x 是否为素数,最直接的方案是检查 2 到 x−1 之间是否存在 x 的因数:存在则 x 是合数,不存在则 x 是素数。这个方案直接对照定义,一定正确。

因数成对:试除为什么只需要到 √x

若 x = a × b 且 a ≤ b,则 a ≤ √x。否则 a 与 b 都大于 √x,乘积将大于 x。 因此 2 到 √x 之间没有因数,就等价于 x 没有任何因数。

13 只需要试 2、3,因为 4² = 16 已经大于 13。36 = 4 × 9,因子对 (4, 9) 中较小者 4 不超过 √36 = 6。

x因子对需要试到
13(1,13)2, 3
36(2,18),(3,12),(4,9),(6,6)2,3,4,5,6
97(1,97)2..9

单次判定的代价:O(√x)

试除次数与 √x 成正比。对单个数字,问题已经解决;x 在 10¹² 量级时,√x = 10⁶,仍然可以接受。

剩余问题当需要判断大量数字时,每次判定都从头试除,判定之间没有任何信息共享。

02 · 痛点分析

重复劳动:多个查询的代价

本章作用确认试除法在批量场景下暴露的重复,把优化目标从“单次更快”转向“批量共享”。
主线知识

批量查询时试除法的代价如何累积;本阶段不引入新数据结构,只改变看待问题的角度。

扩展知识

本节是视角转换,不产生可复用代码。

多个查询的代价:Q × √X

《素数伴侣》中,每个奇偶对都要查询“a + b 是否为素数”。n = 100 时最多 2500 对,和的最大值不超过 60000。

痛点证据 查询数量 × 单次试除,且结果互不共享。

试除到 √60000 ≈ 245,2500 次查询约 6×10⁵ 次取模,小数据下并不慢;但查询数放大到 10⁵、和的上限放大到 10⁶ 时,代价变成 Q × √X,乘积增长。更本质的问题是:判断 13 时算出的“2、3 不能整除 13”,无法帮助判断 17;而“16 能被 2 整除”这个事实,本可以在检查 2 的时候就批量写下来。

重复劳动的来源:判定之间无法共享信息

每次判定都独立试除:判断 13 时算出的“2、3 不能整除 13”,无法帮助判断 17;而“16 能被 2 整除”这个事实,本可以在检查 2 的时候就批量写下来。判定之间互不共享,是重复劳动的结构性来源。

合数的定义:某个更小数字的倍数

严谨定义:合数是大于 1、并且除了 1 和自身之外还存在其他正因数的正整数。等价地,一个正整数是合数,当且仅当它能写成两个大于 1 的正整数的乘积。4 = 2×2、6 = 2×3、9 = 3×3 都是合数;2、3、5、7、11 不是合数。

通俗理解:合数就是“能拆成两个更小的正整数相乘”的数。能拆,意味着它一定是某个更小数字的倍数:6 同时是 2 和 3 的倍数,15 同时是 3 和 5 的倍数。

作用:这个“一定是谁的倍数”的性质让批量标记成为可能——与其逐个问 x 是不是素数,不如一次性回答:哪些数是 2 的倍数、哪些数是 3 的倍数、哪些数是 5 的倍数……把所有合数标记完后,剩下的数字天然是素数。

批量标记:预计算整除关系,查询降为 O(1)

结论把整除关系预先批量计算,单次查询降为查表。它是否成立,取决于哪些数需要被选出来标记倍数,以及为什么每个合数都会被轮到。

03 · 独立章节

合数的最小质因子

本章作用倍数标记方案需要知道“从哪些数开始标记”,并保证“没有合数被漏掉”,这两点由合数的质因子结构回答。
主线知识

合数的乘法结构、最小质因子、为什么每个合数都能被某个较小素数整除。

扩展知识

唯一分解定理的完整证明留作扩展。

概念定位:为什么这里需要最小质因子

合数的结构:质因数分解

每个合数 x 都可以分解成若干个素数的乘积,例如 12 = 2 × 2 × 3。这些质因子中有一个最小的,称为 x 的最小质因子。

为什么一定存在这样的分解(存在性,强归纳证明):对 n > 1 归纳。n = 2 是素数,本身就是一个长度为 1 的分解。假设所有 1 < m < n 都已经能分解:若 n 是素数,分解就是 n 自己;若 n 是合数,由合数定义存在因数 a 满足 1 < a < n,令 b = n/a,则 1 < b < n;归纳假设给出 a、b 各自的素数分解,合并后就是 n 的分解。每一步要么直接结束,要么把数拆成两个更小的数,因此过程必然在有限步内停止。

为什么不计顺序时分解唯一(唯一性,算术基本定理):唯一性依赖欧几里得引理——素数 p 若整除乘积 ab,则 p 必整除 a 或 b。用这条引理从两列质因子中逐次消去相同的素数,可以证明任意两种分解都只差顺序。欧几里得引理可由 Bézout 恒等式(p 与 a 互素时存在整数 x、y 使 ax + py = 1)推出。

质因子集合有限且非空,因此其中必有最小值,这就是最小质因子。它还是后面“筛法不会漏掉合数”论证的起点。

完整证明独立成页

质因数分解的存在性、唯一性(欧几里得引理与 Bézout 恒等式)及最小质因子的全部证明与数值例子,见独立知识页: 合数与质因数分解:定义、性质与证明 →

x质因数分解最小质因子
42 × 22
62 × 32
93 × 33
122 × 2 × 32
255 × 55

合数的性质:最小质因子

x 是合数当且仅当存在素数 p < x 使得 p 整除 x。

最小质因子 p ≤ √x 设 x = p × m。若 m < p,则 m 含有小于 p 的质因子 q 且 q 整除 x,与 p 最小矛盾,故 m ≥ p;于是 x = p·m ≥ p²,即 p ≤ √x。等价地,若 x 的全部质因子都大于 √x,它们的乘积将大于 x。

每个合数 x都能被某个不超过 √x 的素数整除。

性质推论:合数必有不超过 √x 的质因子

结论“合数必有 ≤ √x 的质因子”是试除法收缩到 √x 的原因,也是筛法不会漏掉合数的保证;素数不存在任何小于自身的因子,因此永远不会被标记。

04 · 批量标记

埃氏筛:标记所有合数

本章作用把“逐个数询问”落实为“一次遍历标记”,并验证正确性与复杂度。
主线知识

埃氏筛的执行过程、正确性论证、复杂度来源。

扩展知识

从 i² 开始、只筛奇数、线性筛分别留到后续章节。

筛法规则:素数的倍数全是合数

建表[2, n] 全部记为“可能是素数”。

标记从 2 开始:若当前数 p 尚未被标记,则 p 是素数,把 p 的所有倍数标记为合数。

推进移动到下一个未标记的数,重复,直到遍历完整个范围。

执行演示:n = 30 的标记过程

轮次当前未标记数判定标记的倍数
12素数4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30
23素数6, 9, 12, 15, 18, 21, 24, 27, 30(大部分已标记)
34已标记,跳过
45素数10, 15, 20, 25, 30
56已标记,跳过
67素数14, 21, 28
78..10已标记,跳过
811素数范围内无新倍数
913, 17, 19, 23, 29素数范围内无新倍数

最终未标记的数是 2, 3, 5, 7, 11, 13, 17, 19, 23, 29,与素数表一致。

正确性论证:不漏与不误伤

不漏

任何合数 x 都有最小质因子 p ≤ √x;执行到 p 时,x 会被这一轮标记。

不误伤

素数没有小于自身的因子,不会被任何一轮标记。

复杂度分析:为什么是 O(n log log n)

标记总次数等于每个素数 p 贡献的 n/p 次:n/2 + n/3 + n/5 + …。素数倒数和约为 log log n,因此总标记次数 ≈ n log log n,空间 O(n)。

方案单次查询批量查询额外空间
试除法O(√x)O(Q·√X)O(1)
埃氏筛O(1) 查表预处理 O(n log log n)O(n)
剩余问题 重复标记与整表存储。

12 同时是 2 和 3 的倍数,被标记两次;重复不影响正确性,但影响常数。n = 10⁷ 时布尔表约 10 MB,n = 10⁹ 时不能直接开表。

剩余问题先消除一部分可预见的重复标记,再考虑能否做到“每个合数只标记一次”。

05 · 起点优化

起点优化:从 i² 开始标记

本章作用消除可预见的重复标记,压缩埃氏筛的常数。
主线知识

为什么 i 的倍数可以从 i² 开始,跳过了哪些重复工作。

扩展知识

本优化不改变复杂度阶,只压缩常数。

可预见的重复:更小的倍数已被更小的素数标记

问题 标记 i 的倍数时,2i 到 (i−1)i 有必要再标一次吗?

2i 是 2 的倍数,在 2 那一轮已经被标记;3i 是 3 的倍数,在 3 那一轮已经被标记。一般地,ki(k < i)含有一个小于 i 的质因子,该质因子所在的轮次必然先于 i 执行。

优化效果:从 i² 开始,常数变小

从 i² 开始标记,每个素数 i 节省 (i−1) 次标记;总标记次数从约 n log log n 变成约 n log log n − O(n),复杂度阶不变,常数变小。

重复标记仍然存在:12 > 3² 且含因子 2,在 3 的轮次仍会被标记。这说明“跳过小倍数”只去掉了部分重复。

检查结果合数含多个质因子时依然会被多次标记,需要更严格去重。

06 · 空间优化

空间优化:只保留奇数

本章作用处理整表存储问题,把空间和标记次数各减一半。
主线知识

除 2 外所有偶数都是合数,存储可以压缩一半。

扩展知识

奇偶压缩需要下标映射,属于常数级优化。

偶数的性质:2 是唯一的偶素数

2 是唯一的偶素数,其余偶数全部是合数,不需要占用数组位。只存奇数 3, 5, 7, …,用下标 i 映射到数值 2i+1(或 2i+3),标记时跳过所有偶数。

压缩方案:下标映射只存奇数

执行规模½ × 空间 · ½ × 标记

优化掉了:一半的存储空间和一半的标记次数。代码需要维护“下标 ↔ 数值”的映射,可读性略降;内存紧张、范围很大时收益明显。若还需要最小质因子表,压缩会带来额外访问换算,通常不划算。

适用条件内存受限时使用;需要最小质因子表的场景通常不采用奇偶压缩。

07 · 进阶加速

线性筛:每个合数只标记一次

本章作用解决重复标记,把总工作量压缩到 O(n)。
主线知识

埃氏筛的重复标记来源、线性筛的 break 条件、为什么每个合数恰好被最小质因子标记一次。

扩展知识

线性筛可顺带得到最小质因子表,用于质因数分解。

重复标记的来源:一个合数拥有多个质因子

问题 能否让每个合数恰好被标记一次?

埃氏筛中,12 被 2 和 3 各标记一次,18 被 2 和 3 各标记一次。重复来自“同一个合数被多个质因子各轮一次”。

线性筛方案:每个合数只交给最小质因子

维护一个已经筛出的素数表 primes。对每个 i 从 2 到 n,从小到大尝试 primes 中的素数 p,标记 i × p;一旦 p 整除 i,立即停止内层循环。

break 条件的证明:为什么恰好标记一次

任意合数 x 有唯一的最小质因子 p,记 i = x / p。此时 i 的所有质因子都不小于 p。当外层循环执行到 i 时,primes 中 p 之前的素数 q 都不整除 i,因此不会提前 break;逐个标记 i×q 后,轮到 p:x 被标记,且 p 整除 i,break。

任何更大的素数 p′ 不会标记 x:i × p′ 的最小质因子是 min(p′, i 的最小质因子)。若 p′ 较大,这个最小质因子来自 i,因此 i × p′ 应交给更小的组合标记,而不是 p′。

执行规模O(n)

内层总执行次数等于合数个数加素数个数,整体线性。每个合数恰好被“自身 ÷ 最小质因子”这一组合标记一次。

方案退出

埃氏筛已经以 O(n log log n) 解决“批量查素数”这一主要问题。线性筛是不同条件下的替代:需要严格线性复杂度,或需要最小质因子表时使用;只为查询素数表时,两者的实际差距通常不足以抵消线性筛稍复杂的逻辑。

优化效果每个合数恰好被其最小质因子标记一次,预处理总代价 O(n)。

08 · 模型迁移

应用:素数伴侣与质因数分解

本章作用把筛法放回具体问题,说明它在每个场景中变成什么、为什么更容易。
主线知识

素数伴侣的查表建图、最小质因子分解、区间筛。

扩展知识

筛选场景的识别顺序。

素数伴侣中的查表建图

问题变成:对每个奇偶对查询“a + b 是否为素数”,最多 2500 次,和上限 60000。筛表一次完成 O(n log log n) 预处理,此后每次查询是 O(1) 的布尔数组访问;建图逻辑从“每个组合现场试除”简化为“查表连边”。小数据下试除法也能通过,筛法的价值在于把查询成本与值的规模解耦,并让代码结构统一。

质因数分解中的最小质因子表

线性筛顺带记录每个数的最小质因子 lp[x]。分解 x 时反复执行 p = lp[x]、除尽 p,每步去掉一个质因子,总步数等于质因子个数(含重数),约 O(log x)。这在多个数分解、求欧拉函数 φ(1..n) 等场景中是标准工具。

区间筛:大范围窄区间的处理

当 R 很大(10⁹)但 R − L 很小(10⁶)时,不能直接开 [0, R] 的表。先用 √R 以内的素数筛出小表,再在 [L, R] 区间内用这些素数标记,空间只与区间长度有关。

筛法选型顺序

识别顺序

查询次数少 → 试除法;上界小且查询多 → 埃氏筛;内存紧张 → 奇偶压缩;需要线性或最小质因子表 → 线性筛;大范围窄区间 → 区间筛。

09 · 判断边界

筛法选型

本章作用汇总各方案的适用条件,避免在错误规模下使用错误工具。
主线知识

试除、埃氏筛、奇偶压缩、线性筛、区间筛、Miller–Rabin 的适用边界。

扩展知识

Miller–Rabin 只作名称提示,不展开。

各方案的适用边界

场景方案理由
单个或少量查询试除到 √x不需要预处理,常数最小
批量查询、上界 ≤ 10⁷埃氏筛一次预处理,查询 O(1)
内存受限奇偶压缩的埃氏筛空间减半
需要最小质因子表 / 严格线性线性筛每个合数只标记一次
R 大、R−L 小区间筛空间只与区间长度有关
单个超大随机数Miller–Rabin(扩展)试除与筛法都不适用

10 · 多语言实现

代码实现

本章作用把埃氏筛写成四种常见语言的可运行程序,输入 n、输出 [2, n] 中素数个数。
主线知识

Python、C、Java、C++ 四种实现,均从 i² 开始标记。

扩展知识

四种语言只在输入输出与内存管理上有差异,算法结构完全一致。

Python 实现

PYTHON埃氏筛O(n log log n)
import math

def sieve(n: int) -> list[bool]:
    is_prime = [True] * (n + 1)
    is_prime[0] = is_prime[1] = False
    for i in range(2, math.isqrt(n) + 1):
        if is_prime[i]:
            for j in range(i * i, n + 1, i):
                is_prime[j] = False
    return is_prime

n = int(input())
is_prime = sieve(n)
print(sum(is_prime[2:]))

C 实现

C埃氏筛O(n log log n)
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

bool *sieve(int n) {
    bool *is_prime = malloc((n + 1) * sizeof(bool));
    for (int i = 0; i <= n; ++i) is_prime[i] = true;
    if (n >= 0) is_prime[0] = false;
    if (n >= 1) is_prime[1] = false;
    for (int i = 2; i * i <= n; ++i)
        if (is_prime[i])
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
    return is_prime;
}

int main(void) {
    int n;
    scanf("%d", &n);
    bool *is_prime = sieve(n);
    int cnt = 0;
    for (int i = 2; i <= n; ++i) cnt += is_prime[i];
    printf("%d\n", cnt);
    free(is_prime);
    return 0;
}

Java 实现

JAVA埃氏筛O(n log log n)
import java.util.Arrays;
import java.util.Scanner;

public class Main {
    static boolean[] sieve(int n) {
        boolean[] is_prime = new boolean[n + 1];
        Arrays.fill(is_prime, true);
        if (n >= 0) is_prime[0] = false;
        if (n >= 1) is_prime[1] = false;
        for (int i = 2; i * i <= n; ++i)
            if (is_prime[i])
                for (int j = i * i; j <= n; j += i)
                    is_prime[j] = false;
        return is_prime;
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        boolean[] is_prime = sieve(n);
        int cnt = 0;
        for (int i = 2; i <= n; ++i)
            if (is_prime[i]) ++cnt;
        System.out.println(cnt);
    }
}

C++ 实现

C++埃氏筛O(n log log n)
#include <bits/stdc++.h>
using namespace std;

vector<bool> sieve(int n) {
    vector<bool> is_prime(n + 1, true);
    if (n >= 0) is_prime[0] = false;
    if (n >= 1) is_prime[1] = false;
    for (int i = 2; i * i <= n; ++i)
        if (is_prime[i])
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
    return is_prime;
}

int main() {
    int n;
    cin >> n;
    auto is_prime = sieve(n);
    int cnt = 0;
    for (int i = 2; i <= n; ++i) cnt += is_prime[i];
    cout << cnt << '\n';
    return 0;
}

11 · 参考

继续阅读

相关文章与资料

← 返回 Blog