ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

质数筛法与区间统计:从线性筛到前缀和的算法实践

质数筛法与区间统计:从线性筛到前缀和的算法实践 1. 项目概述一道关于质数的经典竞赛题剖析最近在整理过往的竞赛题目时重新审视了这道来自JZOJ一个知名的在线评测与竞赛平台的题目——“prime”。题目编号6825标注为“【2020.10.17提高组模拟】”这通常意味着它是一道在特定模拟赛中面向高水平选手提高组的题目。看到“prime”这个标题结合竞赛背景我们立刻能意识到核心一定围绕着质数这一基础而深邃的数论概念展开。这类题目从来不是简单地判断一个数是不是质数而是会结合质数的分布、性质设计出需要巧妙算法和数学洞察力才能解决的问题。它考察的不仅是编码能力更是对数论知识的灵活运用和优化思维。对于正在备战信息学竞赛的选手或是希望提升算法思维的朋友来说解构这类题目是极好的训练。今天我就和大家一起像拆解一个精密仪器一样把这道题目的设计思路、核心算法以及实现细节彻底讲透。2. 题目核心需求与数学模型抽象面对任何算法题第一步永远是彻底理解题意并将其转化为清晰的数学模型或计算目标。虽然我们无法看到原题的全部描述但根据标题“prime”、来源JZOJ提高组模拟以及常见的出题套路我们可以合理地推断并重构其典型的核心需求。2.1 典型问题场景推断在竞赛中以“prime”为名的题目其内核通常逃不出以下几类经典问题区间质数统计给定一个区间[L, R]求区间内质数的个数。当R很大比如1e12时需要用到素数筛的变种或数论分块。质因数分解相关给定一个数n求其质因数分解式、质因数个数、所有因数之和等。可能涉及Pollard-Rho大数分解算法。与质数相关的构造或判定例如给定一个规则构造一个序列或矩阵使得其满足某种与质数相关的性质如相邻数和为质数。基于质数性质的计数问题在某种组合或序列中统计满足特定条件条件与质数相关的方案数。这往往需要动态规划结合数论知识。结合“提高组模拟”的难度标签题目很可能综合了数论与算法优化。一个非常经典且符合“提高组”难度的模型是给定一个范围[1, N]求有多少个整数i使得i的某个函数值f(i)是质数或者与i相关的某个属性是质数。这里的f(i)可能很复杂例如i的因数个数、i的欧拉函数值等。为了本次讲解的具体性我们不妨设定一个具体的、足以涵盖常见考点的题目模型设f(x)表示正整数x的质因数个数重复的质因数计算多次。 例如f(12) f(2*2*3) 3f(7) 1。 给定T组询问每组询问给出两个整数L, R(1 ≤ L ≤ R ≤ 10^6,T ≤ 10^5)。 对于每组询问求有多少个整数x ∈ [L, R]满足f(x)是一个质数。这个模型综合了质数筛法、前缀和、质数判定和高效查询非常适合用来剖析此类题目的通用解法。2.2 数学模型建立与难点分析根据上述设定我们将问题分解为三个子任务预处理f(x)对于1 ≤ x ≤ NN10^6高效计算出每个x的质因数个数f(x)。质数判定判断每个f(x)是否为质数。区间查询快速回答任意区间[L, R]内满足f(x)是质数的x的个数。难点在于时间与空间效率N高达10^6T高达10^5。这意味着我们必须在O(N)或O(N log log N)的复杂度内完成预处理并且每次查询必须在O(1)或O(log N)内完成。暴力对每个数分解质因数或对每个区间重新计算是绝对不可行的。算法选择计算f(x)需要用到线性筛或埃氏筛的变种。质数判定需要对f(x)的值域进行预处理。区间查询需要用到前缀和技巧。注意这里我们假设了题目模型。实际解题时务必以官方题目描述为准。但下面的解决方案框架预处理前缀和筛法是解决此类区间统计问题的通用且核心的思路。3. 核心算法设计与原理详解针对我们设定的问题模型解决方案的核心是欧拉线性筛的灵活运用和前缀和的预处理。3.1 计算 f(x)质因数个数筛法计算1到N每个数的质因数个数最优算法是线性筛。线性筛不仅能用O(N)的时间筛出所有质数还能在筛的过程中顺带计算出许多积性函数的值f(x)质因数个数虽然不是积性函数但其变种最小质因子的幂次可以通过线性筛高效求得。更准确地说我们计算的是Omega(n)即n的全部质因数个数按重数计。我们可以在线性筛的过程中维护两个数组prime[]: 存储筛出的质数。min_prime_factor_power[n]: 存储n的最小质因子的指数。例如12 2^2 * 3其最小质因子是2指数为2。omega[n]: 存储n的质因数总个数f(n)。递推原理 设当前枚举的质数为p当前要更新的数为i * p。如果i % p 0说明p是i的最小质因子。min_prime_factor_power[i * p] min_prime_factor_power[i] 1omega[i * p] omega[i] 1因为只是给i的最小质因子指数加1质因数种类没变但总个数1如果i % p ! 0说明p是i * p的新质因子且是当前最小的。min_prime_factor_power[i * p] 1omega[i * p] omega[i] 1增加了一个新的质因子总个数1这样我们就能在O(N)的时间内计算出所有omega[n]即我们定义的f(n)。3.2 质数判定与结果标记得到f(n)后我们需要判断它是否为质数。f(n)的最大值是多少一个数n最多能有多少个质因数极端情况是n为2^k此时f(n)k。当n 10^6时2^20 10^6所以f(n)的最大值不会超过20。实际上对于10^6以内的数质因数个数最多约为7或8例如2*3*5*7*11*13*175105102*3*5*7*11*13*17*199699690已超过10^6。因此f(n)的值域非常小0~20左右。我们可以直接用一个布尔数组is_prime_small[0..20]通过简单的埃氏筛或直接硬编码预处理出这个范围内哪些数是质数。注意根据质数定义0和1不是质数。然后我们创建一个标记数组is_valid[n]如果omega[n]是质数则is_valid[n] 1否则为0。3.3 高效区间查询前缀和为了回答T次区间[L, R]的查询我们需要O(1)的查询速度。标准做法是使用前缀和。定义前缀和数组prefix_sum[n]表示[1, n]区间内满足条件的数的个数。 即prefix_sum[n] prefix_sum[n-1] is_valid[n]预处理这个数组的时间是O(N)。对于每次查询(L, R)答案就是ans prefix_sum[R] - prefix_sum[L-1]这样每次查询仅需两次数组访问时间复杂度O(1)。3.4 算法流程总览初始化定义常量MAXN 1000000。初始化线性筛所需数组is_prime_small数组。线性筛运行线性筛在筛出质数的同时计算出每个数i的omega[i]质因数个数。标记有效数遍历1到MAXN根据omega[i]和is_prime_small数组得到is_valid[i]。计算前缀和遍历1到MAXN计算prefix_sum[i]。处理查询对于每个(L, R)输出prefix_sum[R] - prefix_sum[L-1]。整个预处理复杂度O(N)查询复杂度O(1)总复杂度O(N T)完全满足题目设定的数据范围要求。4. 代码实现与关键细节剖析理论清晰后我们来看代码实现。这里使用 C 进行演示因为这是算法竞赛中最常用的语言。#include iostream #include vector #include cstring using namespace std; const int MAXN 1000000; // 根据题目数据范围设定 // 线性筛所需全局变量 vectorint primes; // 存储所有筛出的质数 bool is_composite[MAXN 5]; // 标记合数 int omega[MAXN 5]; // omega[i] 记录 i 的质因数个数 int min_pow[MAXN 5]; // min_pow[i] 记录 i 的最小质因子的指数 // 小范围质数判定 bool is_prime_small[25]; // omega[i] 的值域很小0~20足够 // 前缀和数组 int prefix_sum[MAXN 5]; // 初始化小范围质数表 void init_small_primes() { memset(is_prime_small, true, sizeof(is_prime_small)); is_prime_small[0] is_prime_small[1] false; // 0和1不是质数 for (int i 2; i 25; i) { if (is_prime_small[i]) { for (int j i * i; j 25; j i) { is_prime_small[j] false; } } } } // 线性筛并计算 omega[] void linear_sieve() { memset(is_composite, 0, sizeof(is_composite)); omega[1] 0; // 1没有质因数 min_pow[1] 0; for (int i 2; i MAXN; i) { if (!is_composite[i]) { // i是质数 primes.push_back(i); omega[i] 1; // 质数的质因数个数为1 min_pow[i] 1; // 最小质因子就是自己指数为1 } // 用已筛出的质数 primes[j] 去筛合数 i * primes[j] for (int j 0; j primes.size() i * primes[j] MAXN; j) { int num i * primes[j]; is_composite[num] true; if (i % primes[j] 0) { // primes[j] 是 i 的最小质因子 min_pow[num] min_pow[i] 1; omega[num] omega[i] 1; // 增加了一个重复的最小质因子 break; // 保证每个数只被其最小质因子筛掉 } else { // primes[j] 是 num 的新最小质因子 min_pow[num] 1; omega[num] omega[i] 1; // 增加了一个新的质因子 } } } } int main() { // 1. 初始化 init_small_primes(); linear_sieve(); // 2. 构建标记数组和前缀和 for (int i 1; i MAXN; i) { // 判断 omega[i] 是否为质数 bool valid (omega[i] 25) ? is_prime_small[omega[i]] : false; // 实际上omega[i]不可能25这里是为了代码健壮性 prefix_sum[i] prefix_sum[i - 1] (valid ? 1 : 0); } // 3. 模拟查询 (这里用样例演示实际应从输入读取 T, L, R) // 假设 T3查询为 [2,10], [10,20], [1,100] vectorpairint, int queries {{2, 10}, {10, 20}, {1, 100}}; for (auto q : queries) { int L q.first, R q.second; int ans prefix_sum[R] - prefix_sum[L - 1]; cout Query [ L , R ]: ans endl; } return 0; }关键细节剖析线性筛中的break这是线性筛保证O(N)复杂度的关键。当i % primes[j] 0时说明primes[j]已经是i的最小质因子。那么对于后续更大的质数primes[k] (kj)它不再是i * primes[k]的最小质因子。如果继续筛就会导致一个数被多个质因子筛除破坏线性复杂度。此处break保证了每个合数只被其最小质因子筛掉一次。omega数组的递推逻辑务必理解if (i % primes[j] 0)分支中的omega[num] omega[i] 1。因为primes[j]已是i的因子num相比i只是给这个最小质因子增加了一个指数质因数种类数不变但总个数加1。而在else分支primes[j]是全新的质因子所以质因数总个数也是加1。两个分支的omega更新公式一样但背后的含义不同。值域估算与数组大小我们根据MAXN10^6估算出omega[i]最大约为20因此is_prime_small数组开到25足够安全。这是一种重要的问题化简思维将“判断一个大范围的数是否为质数”转化为“判断一个很小范围内的数是否为质数”后者可以用简单方法暴力处理。前缀和的下标处理prefix_sum[R] - prefix_sum[L-1]是区间求和的经典公式。注意当L1时L-10因此prefix_sum数组从下标1开始使用prefix_sum[0]初始化为0是正确操作。5. 性能优化与边界情况处理虽然上述方案已经足够高效但在竞赛极端场景下我们还可以思考一些优化点和必须警惕的边界情况。5.1 内存与缓存优化我们的数组is_composite,omega,min_pow,prefix_sum都是MAXN5大小对于MAXN10^6int类型每个数组占用约4MB四个数组约16MB内存压力不大。但如果MAXN扩大到10^7内存占用将达到160MB需要留意。一种优化方法是is_composite数组可以用bitset来实现将内存占用减少到原来的1/8。omega和min_pow数组因为值域很小可以用short类型2字节存储。这些小技巧在内存限制严格的题目中可能起到关键作用。#include bitset bitsetMAXN5 is_composite; // 替代 bool 数组 short omega[MAXN5]; // 值域小用 short short min_pow[MAXN5];5.2 输入输出优化当T很大10^5甚至10^6时标准的cin/cout可能成为性能瓶颈。务必使用快速的输入输出方法。ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); // 或者使用 scanf/printf5.3 边界情况与陷阱L和R的范围题目是否保证1 ≤ L ≤ R ≤ N我们的前缀和公式依赖于这个假设。如果不保证需要在查询前进行判断或预处理prefix_sum[0] 0。f(1)的定义在我们的模型里f(1)表示1的质因数个数定义为0。0不是质数所以1不被计入答案。这符合数论惯例。但有些题目可能对1有特殊定义必须严格按照题目描述来。质数判断的范围我们只判断了omega[i] 20的情况。如果题目模型导致f(x)可能更大那么is_prime_small数组的范围需要相应扩大或者改用标准的O(sqrt(n))质数判定函数。务必根据题目数据范围确认f(x)的最大可能值。多组测试数据的内存初始化如果题目有多组完全独立的测试数据即每组数据N不同需要在每组数据开始前清空全局数组如primes.clear(),memset。但更常见的是单组数据、多次查询。我们的写法是针对后者的。5.4 算法扩展性思考我们以计算质因数个数f(x)为例。线性筛的强大之处在于它能计算很多积性函数。如果题目中的f(x)换成以下其他函数只需修改递推公式约数个数d(n)积性函数。维护min_powd(n) d(i) / (min_pow[i]1) * (min_pow[i]2)(当i % p 0)。约数和σ(n)积性函数。需要维护最小质因子的等比数列和。欧拉函数φ(n)积性函数。φ(p^k) p^k - p^(k-1)。掌握线性筛的模板和这些常见函数的递推关系是解决此类数论预处理问题的关键。6. 调试技巧与测试用例设计编写完代码后如何验证其正确性对于算法竞赛题系统性的测试至关重要。6.1 设计测试用例不要只依赖题目给的样例。自己构造一些有特点的小数据用暴力程序对拍。极小数据N1, T1, [1,1]。检查对1的处理是否正确。质数相关数据N10。手动计算哪些数的f(x)是质数。f(2)1(1不是质数) - 无效f(3)1- 无效f(4)2(2是质数) - 有效f(5)1- 无效f(6)2(2是质数) - 有效f(7)1- 无效f(8)3(3是质数) - 有效f(9)2- 有效f(10)2- 有效 有效数4,6,8,9,10。可以验证程序输出。边界数据N1000000查询[1, 1000000]。用暴力程序跑一个小范围的版本比如N10000进行对拍确保算法逻辑正确。随机数据生成大量随机的[L,R]查询用你的优化程序和暴力程序对比结果。6.2 编写对拍程序对拍是竞赛调试的黄金标准。你需要写一个绝对正确但可能很慢的暴力程序。// 暴力程序 brute_force.cpp #include iostream #include vector #include cmath using namespace std; // 暴力计算质因数个数 int count_prime_factors(int x) { int cnt 0; int temp x; for (int i 2; i * i temp; i) { while (temp % i 0) { cnt; temp / i; } } if (temp 1) cnt; return cnt; } // 暴力判断质数 bool is_prime_brute(int x) { if (x 1) return false; for (int i 2; i * i x; i) { if (x % i 0) return false; } return true; } int main() { int L, R; // 从文件或标准输入读取 L, R while (cin L R) { int ans 0; for (int i L; i R; i) { int f count_prime_factors(i); if (is_prime_brute(f)) { ans; } } cout ans endl; } return 0; }然后用脚本如 Bash 或 Python随机生成数据分别运行你的优化程序和对拍程序比较输出是否一致。6.3 常见错误排查数组越界确保所有数组大小是MAXN5或更大以应对边界情况。线性筛中i * primes[j] MAXN是循环条件。初始化错误omega[1]必须初始化为0。prefix_sum[0]必须初始化为0。质数判定错误确保你的is_prime_small数组正确处理了0和1不是质数的情况。整数溢出本题数据范围下不会溢出但如果涉及乘法如计算约数和要留意使用long long。7. 总结与举一反三通过这道“prime”题目我们实际上串联起了算法竞赛中一系列核心知识点数论基础质数、筛法线性筛、前缀和以及复杂度分析。无论题目的具体f(x)如何变化解决问题的框架往往是相通的分析函数性质f(x)能否快速计算是否是积性函数值域有多大选择预处理算法如果是全局预处理线性筛是首选如果值域大有特殊性质可能需要数论分块或更高级的筛法。设计查询结构区间查询立刻想到前缀和单点查询可能用预处理数组直接O(1)回答。注意细节与优化内存、输入输出、边界条件。这道题的价值不在于背下代码而在于掌握这种“预处理查询”的解题范式。当你遇到类似“求区间内满足某种数论性质的数的个数”的题目时这个思考流程就能为你提供清晰的路径。最后分享一个我自己的调试习惯在写完线性筛这样复杂的预处理部分后我会先输出前100个数的omega[i]和质数表与手算或已知结果对照确保筛法部分绝对正确。基础部分的正确性是所有后续工作的基石这块稳了整个程序就成功了一大半。
返回列表