【算法基础篇】(三十九)数论之从质数判定到高效筛法:质数相关核心技能全解析


目录

​编辑

前言

一、质数的定义与直观判定

[1.1 质数与合数的概念](#1.1 质数与合数的概念)

[1.2 试除法的优化:从 O (n) 到 O (√n)](#1.2 试除法的优化:从 O (n) 到 O (√n))

[1.3 C++ 实现质数判定函数](#1.3 C++ 实现质数判定函数)

[1.4 实战例题:洛谷 P5736 质数筛](#1.4 实战例题:洛谷 P5736 质数筛)

[二、筛法入门:埃氏筛法(Eratosthenes Sieve)](#二、筛法入门:埃氏筛法(Eratosthenes Sieve))

[2.1 筛法的核心思想](#2.1 筛法的核心思想)

[2.2 埃氏筛法的基本实现](#2.2 埃氏筛法的基本实现)

[2.3 C++ 实现埃氏筛法](#2.3 C++ 实现埃氏筛法)

[2.4 埃氏筛法的时间复杂度与优化](#2.4 埃氏筛法的时间复杂度与优化)

[2.5 实战例题:洛谷 P3383 模板线性筛素数](#2.5 实战例题:洛谷 P3383 模板线性筛素数)

三、筛法巅峰:线性筛法(欧拉筛法)

[3.1 埃氏筛法的局限性](#3.1 埃氏筛法的局限性)

[3.2 线性筛法的核心原理](#3.2 线性筛法的核心原理)

[3.3 线性筛法的逻辑拆解](#3.3 线性筛法的逻辑拆解)

[3.4 C++ 实现线性筛法](#3.4 C++ 实现线性筛法)

[3.5 线性筛法的优势与应用场景](#3.5 线性筛法的优势与应用场景)

四、进阶实战:质数相关经典例题解析

[4.1 例题 1:洛谷 P1835 素数密度](#4.1 例题 1:洛谷 P1835 素数密度)

[4.2 例题 2:UVA543 Goldbach's Conjecture](#4.2 例题 2:UVA543 Goldbach's Conjecture)

五、质数相关算法的常见误区与优化技巧

[5.1 常见误区](#5.1 常见误区)

[5.2 优化技巧](#5.2 优化技巧)

总结


前言

在算法竞赛的数论板块中,质数相关问题始终是高频考点。无论是判断单个数字是否为质数,还是快速筛选出一定范围内的所有质数,这些基础技能不仅是解决复杂数论问题的基石,更是拉开竞赛分数差距的关键。本文将从质数的定义出发,深入剖析质数判定的优化思路,详解埃氏筛法与线性筛法的原理与实现,带大家彻底掌握这部分核心内容。下面就让我们正式开始吧!


一、质数的定义与直观判定

1.1 质数与合数的概念

质数(又称素数)是指大于 1 的自然数中,除了 1 和它本身以外不再有其他因数的数。与之相对的是合数,合数是指大于 1 且不是质数的自然数。特别规定:1 既不是质数也不是合数。

这个定义看似简单,但在实际判定时却藏着不少优化空间。比如判断一个数 x 是否为质数,最直观的思路是检查从 2 到 x-1 的所有整数是否能整除 x。但这样的暴力解法效率极低,当 x 达到 1e5 甚至更大时,必然会超时。

1.2 试除法的优化:从 O (n) 到 O (√n)

我们可以通过一个关键性质优化判定过程:如果 a 是 x 的约数,那么 x/a 也一定是 x 的约数。这意味着我们无需检查到 x-1,只需检查到√x 即可。因为如果 x 存在大于√x 的因数,那么它对应的另一个因数必然小于√x,在此之前就已经被检查过了。

举个例子,判断 100 是否为质数:√100=10,我们只需检查 2 到 10 之间的数。发现 2 能整除 100,直接判定 100 为合数,无需继续检查后续数字。

此外,在代码实现时,为了避免使用 sqrt 函数带来的精度问题,我们可以采用i <= x / i的写法,既保证了逻辑正确性,又提升了代码效率。

1.3 C++ 实现质数判定函数

cpp 复制代码
bool isprime(int x) {
    if (x <= 1) return false;  // 小于等于1的数直接排除
    // 试除法核心:枚举到√x即可
    for (int i = 2; i <= x / i; ++i) {
        if (x % i == 0) return false;  // 存在其他因数,不是质数
    }
    return true;  // 未找到其他因数,是质数
}

这个函数的时间复杂度为 O (√x),对于 1e9 以内的单个数字判定完全足够。比如判断 1e9+7 是否为质数,只需循环约 3e4 次,效率极高。

1.4 实战例题:洛谷 P5736 质数筛

题目链接:https://www.luogu.com.cn/problem/P5736

题目描述:输入 n 个不大于 1e5 的正整数,去除掉不是质数的数字,依次输出剩余的质数。

解题思路:对于每个输入的数字,调用上述 isprime 函数进行判定,将判定为质数的数字输出即可。

C++ 参考代码

cpp 复制代码
#include <iostream>
using namespace std;

bool isprime(int x) {
    if (x <= 1) return false;
    for (int i = 2; i <= x / i; ++i) {
        if (x % i == 0) return false;
    }
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        int x;
        cin >> x;
        if (isprime(x)) {
            cout << x << " ";
        }
    }
    cout << endl;
    return 0;
}

代码优化说明 :使用**ios::sync_with_stdio(false);cin.tie(nullptr);**关闭输入输出同步,提升大数据量下的读取速度,避免因输入输出耗时导致超时。

二、筛法入门:埃氏筛法(Eratosthenes Sieve)

2.1 筛法的核心思想

当需要找出 [1, n] 范围内的所有质数时,逐个判定的方法效率较低(时间复杂度 O (n√n))。筛法的核心思想是 "标记非质数":利用质数的倍数一定是合数的性质,从 2 开始,将每个质数的所有倍数标记为合数,未被标记的数即为质数。

2.2 埃氏筛法的基本实现

  1. 初始化一个布尔数组 st,st [i]表示数字 i 是否被标记为合数(初始值均为 false)。
  2. 从 2 开始遍历到 n:
    • 如果 st [i] 为 false,说明 i 是质数,将其记录下来。
    • 然后将 i 的所有倍数(从 ii 开始,而非 2 i)标记为 true。这里从 ii 开始是因为小于 i i 的倍数已经被更小的质数标记过了(比如 i=5 时,52=10 已被 2 标记,53=15 已被 3 标记,只需从 5*5=25 开始标记)。

2.3 C++ 实现埃氏筛法

cpp 复制代码
const int MAXN = 1e6 + 10;
bool st[MAXN];  // 标记是否为合数
int primes[MAXN], cnt;  // 存储质数,cnt为质数个数

void eratosthenes_sieve(int n) {
    memset(st, false, sizeof st);
    cnt = 0;
    for (int i = 2; i <= n; ++i) {
        if (!st[i]) {  // i是质数
            primes[++cnt] = i;
            // 从i*i开始标记倍数
            for (long long j = 1LL * i * i; j <= n; j += i) {
                st[j] = true;
            }
        }
    }
}

2.4 埃氏筛法的时间复杂度与优化

埃氏筛法的时间复杂度为O (n log log n),这个复杂度非常接近线性,对于 n=1e6 的范围,几乎可以瞬间完成筛选。

关键优化点

  • 使用 long long 类型的 j 避免溢出:当 i 接近√n 时,ii 可能超过 int 的范围(比如 i=1e5 时,ii=1e10,超过 int 的最大值 2e9),因此需要用 1LL 强制转换为长整型。
  • 数组初始化优化 :使用 memset函数快速初始化数组,效率大大高于循环赋值。

2.5 实战例题:洛谷 P3383 模板线性筛素数

题目链接:https://www.luogu.com.cn/problem/P3383

题目描述:给定范围 n 和 q 个询问,每次输出第 k 小的素数。

解题思路:先用埃氏筛法筛选出 [1, n] 范围内的所有质数,存储在 primes 数组中,然后直接响应每个询问。

C++ 参考代码

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

const int MAXN = 1e8 + 10;  // 根据题目n的范围调整
bool st[MAXN];
int primes[MAXN], cnt;

void eratosthenes_sieve(int n) {
    memset(st, false, sizeof st);
    cnt = 0;
    for (int i = 2; i <= n; ++i) {
        if (!st[i]) {
            primes[++cnt] = i;
            for (long long j = 1LL * i * i; j <= n; j += i) {
                st[j] = true;
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n, q;
    cin >> n >> q;
    eratosthenes_sieve(n);
    while (q--) {
        int k;
        cin >> k;
        cout << primes[k] << endl;
    }
    return 0;
}

注意事项:当 n 的范围较大(如 1e8)时,需要注意数组的内存占用。bool 类型数组每个元素占 1 字节,1e8+10 的数组约占 100MB,在竞赛允许的内存范围内。

三、筛法巅峰:线性筛法(欧拉筛法)

3.1 埃氏筛法的局限性

埃氏筛法虽然高效,但存在一个问题:某些合数会被多个质数重复标记。比如 12,会被 2 标记一次,又会被 3 标记一次。这虽然不影响最终结果,但造成了不必要的计算开销。

线性筛法(又称欧拉筛法)的核心创新点是:让每个合数只被其最小质因数标记一次,从而实现 O (n) 的线性时间复杂度,是竞赛中筛选质数的最优方法。

3.2 线性筛法的核心原理

  1. 同样使用 st 数组标记合数,primes 数组存储已找到的质数。
  2. 遍历每个数 i(从 2 到 n):
    • 如果 i 未被标记,说明 i 是质数,将其加入 primes 数组。
    • 遍历 primes 数组中的每个质数 p [j]
      • 计算ip [j],如果 i p [j] > n 则跳出循环。
      • 将**i*p [j]**标记为合数。
      • 关键步骤 :如果 i 能被 p [j] 整除,立即跳出循环。因为此时 p [j] 是 i 的最小质因数,也是 ip [j] 的最小质因数;如果继续遍历更大的质数,就会导致 ip [j] 被更大的质因数标记,违背 "只被最小质因数标记" 的原则。

3.3 线性筛法的逻辑拆解

我们以 i=6 为例,详细拆解线性筛法的执行过程:

  • primes 数组中已有 [2, 3, 5]。
  • 遍历 p [j]=2:6*2=12,标记 12 为合数;由于 6%2==0,立即跳出循环。
  • 这里 12 的最小质因数是 2,因此只被 2 标记一次,不会再被 3 标记(6*3=18,18 会在 i=9 时被 3 标记)。

再以 i=5(质数)为例:

  • 遍历 p [j]=2:5*2=10,标记 10(最小质因数 2)。
  • p [j]=3:5*3=15,标记 15(最小质因数 3)。
  • p [j]=5:5*5=25,标记 25(最小质因数 5)。
  • p [j]=7:5*7=35>n(假设 n=30),跳出循环。

通过这种方式,每个合数都只被其最小质因数标记一次,确保了线性时间复杂度。

3.4 C++ 实现线性筛法

cpp 复制代码
const int MAXN = 1e7 + 10;
bool st[MAXN];
int primes[MAXN], cnt;

void euler_sieve(int n) {
    memset(st, false, sizeof st);
    cnt = 0;
    for (int i = 2; i <= n; ++i) {
        if (!st[i]) {  // i是质数
            primes[++cnt] = i;
        }
        // 遍历所有已找到的质数,标记i*primes[j]为合数
        for (int j = 1; 1LL * i * primes[j] <= n; ++j) {
            st[i * primes[j]] = true;
            if (i % primes[j] == 0) {  // primes[j]是i的最小质因数
                break;
            }
        }
    }
}

3.5 线性筛法的优势与应用场景

线性筛法的时间复杂度严格为O (n),在 n=1e7 的范围内,其速度远快于埃氏筛法。更重要的是,线性筛法在筛选质数的同时,还能方便地求出每个数的最小质因数,这为后续的质因数分解、欧拉函数计算等操作提供了极大便利。

四、进阶实战:质数相关经典例题解析

4.1 例题 1:洛谷 P1835 素数密度

题目链接:https://www.luogu.com.cn/problem/P1835

题目描述:给定 L 和 R,计算区间 [L, R] 中素数的个数(1≤L≤R<2^31,R-L≤1e6)。

难点分析:R 的范围最大为 2^31-1,直接筛选 [1, R] 范围内的质数会导致数组过大(需要 2^31 字节,约 2GB),内存无法承受。

解题思路:利用 "区间筛法",核心思想是:

  1. 对于区间 [L, R] 中的数,其质因数一定不大于√R。因此先筛选出 [1, √R] 范围内的所有质数。
  2. 用这些质数去标记 [L, R] 范围内的倍数,未被标记的数即为质数。

C++ 参考代码

cpp 复制代码
#include <iostream>
#include <cstring>
#include <cmath>
using namespace std;

typedef long long LL;
const int MAXN = 1e6 + 10;
bool st_prime[MAXN];  // 标记[1, sqrt(R)]的质数
bool st_range[MAXN];  // 标记[L, R]的合数
int primes[MAXN], cnt;

// 筛选[1, n]的质数
void sieve(LL n) {
    memset(st_prime, false, sizeof st_prime);
    cnt = 0;
    for (LL i = 2; i <= n; ++i) {
        if (!st_prime[i]) {
            primes[++cnt] = i;
            for (LL j = i * i; j <= n; j += i) {
                st_prime[j] = true;
            }
        }
    }
}

int count_primes(LL L, LL R) {
    memset(st_range, false, sizeof st_range);
    // 用[1, sqrt(R)]的质数标记[L, R]的倍数
    for (int i = 1; i <= cnt; ++i) {
        LL p = primes[i];
        // 计算区间[L, R]中p的最小倍数:max(p*2, ceil(L/p)*p)
        LL start = max(2LL * p, (L + p - 1) / p * p);
        for (LL j = start; j <= R; j += p) {
            st_range[j - L] = true;  // 映射到数组索引
        }
    }
    // 统计未被标记的数(质数)
    int res = 0;
    for (LL i = L; i <= R; ++i) {
        if (i < 2) continue;  // 小于2的数不是质数
        if (!st_range[i - L]) {
            ++res;
        }
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    LL L, R;
    cin >> L >> R;
    LL sqrt_R = sqrt(R);
    sieve(sqrt_R);
    cout << count_primes(L, R) << endl;
    return 0;
}

代码解析

  • 由于 R-L≤1e6,st_range 数组只需开 1e6+10 的大小,内存占用仅 1MB 左右,完全可行。
  • 计算 start 时,使用**(L + p - 1) / p * p**可以快速求出大于等于 L 的最小 p 的倍数,避免了浮点数运算。

4.2 例题 2:UVA543 Goldbach's Conjecture

题目链接:https://www.luogu.com.cn/problem/UVA543

题目描述:验证哥德巴赫猜想:任意一个大于 4 的偶数都可以拆成两个奇质数之和。对于每个输入的偶数 n,输出 b-a 最大的一组解(a≤b,a 和 b 均为奇质数)。

解题思路

  1. 先用线性筛法筛选出 1e6 以内的所有质数(题目要求验证小于 1e6 的数)。
  2. 对于每个偶数 n,从最小的奇质数 3 开始遍历,检查 n-a 是否为奇质数。找到第一组满足条件的 (a, n-a) 即为 b-a 最大的解(因为 a 越小,b 越大,差值越大)。

C++ 参考代码

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

const int MAXN = 1e6 + 10;
bool st[MAXN];
int primes[MAXN], cnt;

void euler_sieve(int n) {
    memset(st, false, sizeof st);
    cnt = 0;
    for (int i = 2; i <= n; ++i) {
        if (!st[i]) {
            primes[++cnt] = i;
        }
        for (int j = 1; 1LL * i * primes[j] <= n; ++j) {
            st[i * primes[j]] = true;
            if (i % primes[j] == 0) {
                break;
            }
        }
    }
}

void solve(int n) {
    // 从最小的奇质数开始查找
    for (int i = 2; i <= cnt; ++i) {
        int a = primes[i];
        int b = n - a;
        if (b < a) continue;  // 保证a<=b
        if (!st[b]) {  // b是质数
            printf("%d = %d + %d\n", n, a, b);
            return;
        }
    }
    printf("Goldbach's conjecture is wrong.\n");
}

int main() {
    euler_sieve(1e6);  // 预处理1e6以内的质数
    int n;
    while (cin >> n && n) {
        solve(n);
    }
    return 0;
}

优化说明:预处理 1e6 以内的质数后,每个查询的时间复杂度为 O (π(√n))(π(x) 表示 x 以内的质数个数),效率极高,即使处理多组数据也能快速响应。

五、质数相关算法的常见误区与优化技巧

5.1 常见误区

  1. 整数溢出问题 :在计算**i*p [j]**或 p 的幂次时,容易超出 int 的范围,导致程序出错。解决方案是使用 long long 类型进行中间计算。
  2. 数组初始化错误:筛法中数组未初始化或初始化不完全,导致标记错误。建议使用 memset 函数进行初始化,注意 memset 按字节赋值,仅适用于 bool、char 数组或清零 int 数组。
  3. 边界条件处理不当:比如将 1 判定为质数、筛选时从 1 开始遍历、未处理 L=1 的情况等。需要严格遵循质数的定义,仔细处理边界。

5.2 优化技巧

  1. 内存优化:对于大范围筛选(如 1e8),可以使用 bitset 代替 bool 数组,将内存占用减少到原来的 1/8(bitset 中每个元素占 1 位)。
  2. 输入输出优化 :竞赛中大数据量的输入输出会占用大量时间,建议使用ios::sync_with_stdio(false); **cin.tie(nullptr);**或用 scanf/printf 代替 cin/cout
  3. 预处理优化:对于多组查询的题目,提前预处理出最大范围的质数和相关信息(如质因子、欧拉函数等),避免重复计算。

总结

质数判定与筛法是算法竞赛数论部分的基础,掌握这些技能不仅能解决直接的质数相关问题,还能为后续学习质因数分解、欧拉函数、同余方程等复杂数论内容打下坚实基础。

在实际竞赛中,质数相关问题往往会与其他数论知识结合考查,比如与欧拉函数结合的 GCD 计数问题、与中国剩余定理结合的模运算问题等。因此,除了掌握本文介绍的内容,还需要不断拓展知识面,多做综合性题目,提升知识的融会贯通能力。

最后,建议大家多动手实现代码,通过调试理解算法的核心逻辑,同时关注题目中的数据范围,选择合适的算法和优化方式。只有通过大量练习,才能在竞赛中快速准确地解决质数相关问题,拿到关键分数。

相关推荐
@小码农2 小时前
2025年12月 GESP认证 图形化编程 一级真题试卷(附答案)
开发语言·数据结构·算法
巧克力味的桃子2 小时前
让程序在读取到整数0时就终止循环
c语言·算法
算法与编程之美2 小时前
损失函数与分类精度的关系
人工智能·算法·机器学习·分类·数据挖掘
Pcr_C2 小时前
Qt事件循环深度解析与实战指南
开发语言·c++·qt·开源
汉克老师2 小时前
GESP2025年12月认证C++一级真题与解析(编程题2(手机电量显示))
c++·while循环·多分支结构
天呐草莓2 小时前
聚类(Clustering)算法
人工智能·python·算法·机器学习·数据挖掘·数据分析·聚类
m0_743106462 小时前
【基础回顾】针孔相机、深度、逆深度、与SfM的统一
人工智能·算法·计算机视觉·3d·几何学
炽烈小老头2 小时前
【每天学习一点算法 2025/12/30】最大子序和
学习·算法
Flash.kkl2 小时前
优选算法专题十八——BFS解决拓扑排序
算法·宽度优先