【LeetCode 204. 计数质数】从暴力枚举到埃拉托斯特尼筛法

题目描述

给定整数 n ,返回所有小于非负整数 n 的质数的数量。

复制代码
示例 1

输入:n = 10
输出:4
解释:小于 10 的质数一共有 4 个, 它们是 2, 3, 5, 7 。

示例 2:

输入:n = 0
输出:0

示例 3:

输入:n = 1
输出:0

提示:
0 <= n <= 5 * 10^6

解题思路演进

这道题是经典的数论基础题。根据数据范围 n <= 5 * 10^6,我们可以推导出不同算法的时间复杂度表现。

方法一:暴力枚举(会超时 TLE)

最直观的想法是:遍历从 2 到 n-1 的每一个数字 i,然后判断 i 是否为质数。判断质数的方法是尝试用 2 到 sqrt(i) 之间的数字去整除 i。

代码实现:

cpp 复制代码
class Solution {
public:
    bool isPrime(int x) {
        for (int i = 2; i * i <= x; i++) {
            if (x % i == 0) return false;
        }
        return true;
    }
    int countPrimes(int n) {
        int ans = 0;
        for (int i = 2; i < n; i++) {
            if (isPrime(i)) ans++;
        }
        return ans;
    }
};

复杂度分析:

  • 时间复杂度:O(N根号N)。当 N=5×106时,计算量达到十亿级别,在 LeetCode 上必定超时。
  • 空间复杂度:O1。

方法二:埃拉托斯特尼筛法(Sieve of Eratosthenes)

既然暴力法会超时,我们需要一种更高效的算法。埃拉托斯特尼筛法(简称埃氏筛)是一种古老且经典的质数筛选算法。

核心思想:

如果 x 是质数,那么 x 的倍数(2x, 3x, 4x...)一定不是质数。我们可以从 2 开始遍历,将当前数字的倍数全部标记为"合数"。遍历结束后,未被标记的数字就是质数。

在实现埃氏筛时,有一个极其重要的优化细节:内层循环从 i * i 开始,而不是 2 * i。

cpp 复制代码
for (int j = i * i; j < n; j += i) {
    isPrime[j] = false;
}

为什么可以从 i * i 开始?

假设当前遍历到的质数是 i。对于 i 的倍数 i * k:

  • 如果 k < i,那么 i * k 必然已经被比 i 更小的质数(比如 k 的某个质因数)筛选过了。
  • 例如:当 i = 5 时,5 * 2 = 10(已被 2 筛掉),5 * 3 = 15(已被 3 筛掉),5 * 4 = 20(已被 2 筛掉)。
  • 因此,为了避免重复标记(重复计算),我们从 i * i 开始标记即可,这是 i 的倍数中第一个尚未被更小质数标记的数字。

代码实现 (C++)

cpp 复制代码
class Solution {
public:
    int countPrimes(int n) {
        // 边界条件:小于等于 2 的数没有质数
        if (n <= 2) return 0;
        // 创建布尔数组,isPrime[i] 表示数字 i 是否为质数
        // 初始默认全部为 true (质数)
        vector<bool> isPrime(n, true);
        // 0 和 1 不是质数
        isPrime[0] = false;
        isPrime[1] = false;
        // 从 2 开始筛,只需要遍历到 sqrt(n) 即可
        for (int i = 2; i * i < n; i++) {
            if (isPrime[i]) {
                // 优化:从 i * i 开始标记,步长为 i
                for (int j = i * i; j < n; j += i) {
                    isPrime[j] = false;
                }
            }
        }
        // 统计所有标记为 true 的数字
        int count = 0;
        for (int i = 2; i < n; i++) {
            if (isPrime[i]) count++;

        }
        return count;
    }
};

复杂度分析:

  • 时间复杂度:ON loglogN 。这是埃氏筛的经典复杂度,非常接近于线性时间,对于 5×106的数据量可以轻松通过。
  • 空间复杂度:ON 。需要一个长度为 N的布尔数组来记录状态。由于 vector<bool> 在 C++ 中经过了位压缩优化,实际占用内存非常小。

进阶拓展:线性筛(欧拉筛)

虽然埃氏筛已经足够优秀,但在某些极端情况下,可能会提到线性筛(欧拉筛)。

埃氏筛的痛点:一个合数可能会被多个质数重复标记。例如 12,会被 2 标记一次(2 * 6),也会被 3 标记一次(3 * 4),存在冗余计算。

线性筛的核心思想:保证每个合数只会被它的最小质因数筛掉。这样时间复杂度可以降到严格的 O(N)。

线性筛代码示例:

cpp 复制代码
class Solution {
public:
    int countPrimes(int n) {
        vector<int> primes; // 存储已找到的质数
        vector<bool> isPrime(n, true); // 标记数组
        int ans = 0;
        for (int i = 2; i < n; i++) {
            if (isPrime[i]) {
                primes.push_back(i);
                ans++;
            }
            // 核心:用当前质数 primes[j] 去筛 i * primes[j]
            for (int j = 0; j < primes.size() && i * primes[j] < n; j++) {
                isPrime[i * primes[j]] = false;
                // 保证每个合数只被它的最小质因数筛掉
                if (i % primes[j] == 0) break;
            }
        }
        return ans;
    }
};
相关推荐
冻柠檬飞冰走茶3 小时前
字母移动游戏题解:贪心求最长可保留连续子串(附 C 实现)
c语言·开发语言·数据结构·算法·动态规划
雪落漂泊4 小时前
C++算法 二分查找篇(上)
开发语言·c++·算法
LabVIEW开发6 小时前
LabVIEW 连接板:28 个端子怎么排,优先级怎么定
java·算法·labview
严肃小白兔7 小时前
MATLAB 数值计算中的精度与误差问题:浮点运算、误差传播与数值稳定性
人工智能·算法·matlab
青少儿编程课堂8 小时前
蓝桥杯青少组图形化省考报名倒计时·全国青少年信息素养大赛·电子学会图形化考级:校园黑白棋人机对战解析
c++·python·算法·bfs·信息学竞赛
jktantan9 小时前
注解式脱敏:用 Jackson 的 ContextualSerializer 做字段级策略(一)
spring boot·后端·算法·spring cloud·系统架构
Dream it possible!1 天前
LeetCode 面试经典 150_多维动态规划_交错字符串(97_C++_中等)
算法·leetcode·面试·动态规划
心抵鹊1 天前
算法之双指针系列(一)
算法·leetcode
十二1 天前
【洛谷题解】P1083 [NOIP 2012 提高组] 借教室(二分答案 + 差分)
数据结构·c++·算法·蓝桥杯
小此方1 天前
[算法实战论:动态规划]回文串问题的dp解法,回文串问题的母题,以及以此为基础的派生题
算法·动态规划