质数距离:区间筛法实战与埃氏筛优化详解
2026/8/22 11:17:25 网站建设 项目流程

1. 从一道题说起:为什么“质数距离”是筛法的经典试金石?

最近在带新人刷题,又遇到了这道经典的“质数距离”。题目本身不难理解:给定一个区间[L, R],要求你找出这个区间内相邻质数之间差值最大和最小的两对。LR的范围可以非常大,比如1 <= L < R <= 2^31-1,但区间长度R-L通常被限制在1,000,000以内。这道题几乎出现在所有算法竞赛的质数筛法专题里,被无数人称为“筛素数模板题”。但在我看来,它远不止是模板。它是一块完美的试金石,能清晰地检验你是否真正理解了筛法(尤其是埃氏筛)的核心思想,以及你是否具备将算法思想灵活应用于非标准场景的能力。很多人背下了筛1e7以内素数的代码,但一遇到这道题就卡壳,问题往往出在“知其然,不知其所以然”。

这道题的难点在于,R的上限超过了2e9,我们无法直接创建一个大小为2e9的布尔数组来进行常规的筛法。内存和时间都不允许。然而,题目给了另一个关键约束:R - L <= 1e6。这个约束就是解题的钥匙,它引导我们走向一种经典的技巧——区间筛法。其核心思想是:既然我们无法标记所有到R的数,但我们可以只标记区间[L, R]内的合数。而标记一个区间内的合数,只需要用到小于等于sqrt(R)的所有质数作为筛子。因为任何一个合数n必然有一个不大于sqrt(n)的质因子。

所以,解题路线图就清晰了:

  1. 先用常规埃氏筛,筛出2sqrt(2^31-1)(约46340)之间的所有质数。这个范围很小,内存和时间都绰绰有余。
  2. 然后,利用这第一批质数作为“筛子”,去标记区间[L, R]内的所有合数。
  3. 最后,遍历处理后的区间[L, R],收集所有未被标记(即是质数)的数,并计算相邻质数的距离。

思路听起来很直接,但实操中的细节和坑点才是真正考验功力的地方。接下来,我们就一步步拆解,看看如何把教科书上的筛法,变成解决实际问题的利器。

2. 基石构建:高效筛出小质数列表

区间筛法的第一步,是获取我们需要的“筛子”——所有小于等于sqrt(R)的质数。对于R最大为2^31-1的情况,sqrt(R)约为46340。我们通常会将这个上界稍微取大一点,比如50000,以确保万无一失。

2.1 埃氏筛的经典实现与微优化

埃氏筛的原理大家应该都熟悉:从2开始,将每个质数的倍数全部标记为合数。一个最朴素的实现如下:

const int MAX_SIEVE = 50000; bool isPrime[MAX_SIEVE]; vector<int> primes; // 用于存储筛出来的小质数 void classicSieve() { fill(isPrime, isPrime + MAX_SIEVE, true); isPrime[0] = isPrime[1] = false; for (int i = 2; i < MAX_SIEVE; ++i) { if (isPrime[i]) { primes.push_back(i); for (int j = i * 2; j < MAX_SIEVE; j += i) { isPrime[j] = false; } } } }

这个实现没有问题,但可以进行两处非常有效的优化:

  1. 内层循环的起始点:在标记质数i的倍数时,我们从i * 2开始。但实际上,i * 2,i * 3, ...,i * (i-1)这些数,已经被比i更小的质数(比如2,3, ...)标记过了。因此,内层循环可以从i * i开始。这能减少大量重复操作。当i很大时,i*i可能超过MAX_SIEVE,所以需要加一个判断if ((long long)i * i < MAX_SIEVE)

  2. 外层循环的范围:我们只需要遍历到sqrt(MAX_SIEVE)即可。因为如果有一个数n是合数,那么它一定有一个不大于sqrt(n)的质因子。当我们用所有小于等于sqrt(MAX_SIEVE)的质数去筛过后,剩下未被标记的数就一定是质数。注意,这不影响我们收集所有质数。我们依然需要在i是质数时将其加入primes列表,即使i大于sqrt(MAX_SIEVE)

优化后的版本:

void optimizedSieve() { fill(isPrime, isPrime + MAX_SIEVE, true); isPrime[0] = isPrime[1] = false; for (int i = 2; i * i < MAX_SIEVE; ++i) { // 外层循环优化 if (isPrime[i]) { // 注意:质数 i 本身仍需加入列表 // primes.push_back(i); // 这里先不加入,见下文解释 for (int j = i * i; j < MAX_SIEVE; j += i) { // 内层循环优化 isPrime[j] = false; } } } // 统一收集所有质数 for (int i = 2; i < MAX_SIEVE; ++i) { if (isPrime[i]) { primes.push_back(i); } } }

这里有一个小细节:在外层优化循环(i*i < MAX_SIEVE)中,当i是质数时,我们没有立即将其加入primes。这是因为在这个循环里,我们只遍历到sqrt(MAX_SIEVE),会漏掉大于它的质数。所以更清晰的做法是:外层循环只负责“标记”合数,最后再用一个独立的循环从2MAX_SIEVE-1收集所有未被标记的数(即质数)到primes中。这样逻辑更清晰,也不容易出错。

注意MAX_SIEVE定义为50000时,i*ii=223时就接近50000了,所以优化后的外层循环次数很少,效率提升明显。

2.2 为什么不用欧拉筛(线性筛)?

很多同学会问,既然欧拉筛(线性筛)的时间复杂度是O(n),比埃氏筛的O(n log log n)更优,为什么这里不用欧拉筛?原因在于适用场景。

  • 欧拉筛的优势:在需要一次性获取从1N所有质数,并且N是固定的、内存可承受的情况下,欧拉筛因其线性复杂度而更优。它保证了每个合数只被其最小质因子筛掉一次。
  • 本题场景:我们只需要得到小于50000的质数列表。这个范围非常小,O(n log log n)O(n)的实际运行时间差异微乎其微,在毫秒级别。埃氏筛的代码更简短,更易于理解和记忆,在竞赛的快速编码中更有优势。此外,在接下来的区间筛法中,我们只是遍历primes列表来用每个质数进行筛选,这与筛法本身的复杂度无关。 因此,在这个特定的前置步骤中,埃氏筛的简洁性胜过了欧拉筛的微弱理论优势。当然,如果你对欧拉筛非常熟练,用它也完全没问题。

3. 核心战场:区间筛法的实现与边界处理

拿到了质数列表primes后,我们就要面对主战场:在[L, R]这个可能非常大的区间内标记出合数。这是整个算法最核心也最容易出错的部分。

3.1 偏移量映射:在连续区间上标记合数

由于LR很大,我们不能直接创建大小为R+1的数组。但我们知道R-L <= 1e6,所以我们可以创建一个大小为区间长度+1的布尔数组isPrimeSeg,其中isPrimeSeg[i]表示数字L + i是否是质数(true表示是质数,初始化时全部设为true)。 接下来的任务就是,对于primes中的每一个质数p,找到在区间[L, R]p的第一个倍数,然后将其及其后续倍数标记为合数(即isPrimeSeg[对应偏移量] = false)。

关键问题:如何找到大于等于L的第一个p的倍数? 设这个倍数为start。我们知道start必须满足start % p == 0start >= L。 一个常见的计算方法是:start = max((L + p - 1) / p * p, p * p);我们来拆解一下:

  1. (L + p - 1) / pceil(L / p),即L/p向上取整。再乘以p,就得到了大于等于L的第一个p的倍数。
  2. 但是,和之前埃氏筛的优化一样,从p*p开始标记才是高效的。因为小于p*pp的倍数已经被更小的质数筛过了。所以我们需要取max(ceil(L/p)*p, p*p)作为起始点。

这里有一个极其重要的坑:当p很大时,p * p可能会超过int的表示范围(2^31-1),导致溢出。在 C++ 中,溢出是未定义行为,可能得到负数,从而扰乱循环。因此,必须使用long long类型来进行这些乘法运算

3.2 详细步骤与代码实现

让我们把思路转化为代码,并仔细处理每一个边界。

#include <iostream> #include <vector> #include <cmath> #include <cstring> #include <climits> using namespace std; const int MAX_SMALL = 50000; // 筛小质数的上界 vector<int> primes; // 存储小质数 bool isPrimeSmall[MAX_SMALL]; // 1. 筛出小质数 void sieveSmall() { fill(isPrimeSmall, isPrimeSmall + MAX_SMALL, true); isPrimeSmall[0] = isPrimeSmall[1] = false; for (int i = 2; (long long)i * i < MAX_SMALL; ++i) { if (isPrimeSmall[i]) { for (int j = i * i; j < MAX_SMALL; j += i) { isPrimeSmall[j] = false; } } } primes.clear(); for (int i = 2; i < MAX_SMALL; ++i) { if (isPrimeSmall[i]) { primes.push_back(i); } } } // 2. 区间筛法主函数 void sieveSegment(long long L, long long R, vector<long long>& segPrimes) { if (L < 2) L = 2; // 1不是质数,从2开始考虑 int len = R - L + 1; vector<bool> isPrimeSeg(len, true); // 初始假设区间内所有数都是质数 for (int p : primes) { if ((long long)p * p > R) break; // 优化:如果p^2 > R,那么用p筛不会标记新区间内的任何数 // 找到大于等于L的第一个p的倍数 long long start = max((L + p - 1) / p * p, (long long)p * p); // 标记区间内的合数 for (long long j = start; j <= R; j += p) { isPrimeSeg[j - L] = false; // 通过偏移量映射到数组下标 } } // 收集区间内的质数 segPrimes.clear(); for (long long i = 0; i < len; ++i) { if (isPrimeSeg[i]) { segPrimes.push_back(L + i); } } } int main() { sieveSmall(); // 预处理小质数表 long long L, R; while (cin >> L >> R) { vector<long long> segPrimes; sieveSegment(L, R, segPrimes); if (segPrimes.size() < 2) { cout << "There are no adjacent primes." << endl; } else { long long minGap = LLONG_MAX, maxGap = 0; long long minL, minR, maxL, maxR; for (size_t i = 1; i < segPrimes.size(); ++i) { long long gap = segPrimes[i] - segPrimes[i-1]; if (gap < minGap) { minGap = gap; minL = segPrimes[i-1]; minR = segPrimes[i]; } if (gap > maxGap) { maxGap = gap; maxL = segPrimes[i-1]; maxR = segPrimes[i]; } } cout << minL << "," << minR << " are closest, " << maxL << "," << maxR << " are most distant." << endl; } } return 0; }

3.3 你必须跳过的坑:特殊情况的处理

  1. L可能为 1:质数定义从 2 开始。如果L为 1,我们需要在筛之前将其调整为 2,或者在筛之后手动将isPrimeSeg[1-L](如果L==1)设为false。上面的代码采用了前一种方式if (L < 2) L = 2;,更简洁。
  2. 质数p本身在区间内:当p大于等于L时,我们的start计算max(ceil(L/p)*p, p*p)可能会把p本身也标记为合数。例如,L=2, R=10, p=2ceil(2/2)*2 = 2p*p=4start = max(2, 4) = 4。这巧妙地跳过了2本身。如果L=10, R=20, p=3ceil(10/3)=4, 4*3=12p*p=9start=12,也跳过了3。所以这个max操作自动保护了作为筛子的质数p本身(只要p不小于L)。但如果p在区间[L, R]内,且p*p > R,那么p不会被任何更小的质数筛掉,它本身就是一个质数,会在最后收集阶段被正确加入segPrimes
  3. long long的全面使用L,R,start,j这些变量在计算和循环中都必须使用long longint的上限约2e9,而R最大可达2^31-1(约2.147e9),在计算p*pj+=p时极易溢出。
  4. 循环提前终止:在遍历primes时,一旦(long long)p * p > R,就可以立即break。因为对于任何大于sqrt(R)的质数p,它的最小倍数(p本身)已经大于R了(因为p > sqrt(R)p是质数,下一个倍数2p更大),它不可能筛掉区间[L, R]内的任何数。这是一个重要的性能优化。

4. 性能实测与复杂度分析

理解了原理和实现,我们还需要从理论上把握它的效率,并能在实际数据上验证。

4.1 时间复杂度拆解

整个算法分为两部分:

  1. 小质数筛选:筛出1MM = sqrt(MAX_R),约50000)的质数。使用优化后的埃氏筛,时间复杂度为O(M log log M)M=50000时,这个值非常小,几乎是常数时间。
  2. 区间筛选:对于[L, R]区间,长度为len = R-L+1 <= 1e6。对于每个小质数pp <= sqrt(R)),我们需要标记区间内p的倍数。区间内大约有len / pp的倍数。所以总操作数大约是len * (1/2 + 1/3 + 1/5 + ... + 1/p_max),其中p_max是小于等于sqrt(R)的最大质数。这个和近似于len * log log sqrt(R),由于len最大为1e6,且log log增长极慢,这部分操作在1e6量级也是完全可以接受的。

因此,整体算法对于单次查询是高效可行的。即使进行多次查询(L, R不同),小质数表也只需要预处理一次。

4.2 内存占用分析

内存消耗主要在两个布尔数组:

  • isPrimeSmall[MAX_SMALL]MAX_SMALL50000,布尔数组(或vector<bool>)约占用50,000 / 8 ≈ 6.25 KB,微不足道。
  • isPrimeSeg[len]len最大为1e6,使用vector<bool>优化后,约占用1e6 / 8 ≈ 125 KB。如果使用vector<char>bool数组(通常1字节每元素),则占用约1 MB。这在现代竞赛环境或普通PC上都是完全可接受的。

4.3 极端数据测试与调试

自己构造几组极端数据来测试代码的健壮性是非常好的习惯:

  • 最小区间L=2, R=3。区间内只有两个质数。检查程序是否能正确输出2,3 are closest, 2,3 are most distant.
  • 包含1的区间L=1, R=10。检查程序是否将1正确处理(不应视为质数)。
  • 大数区间L=999999000, R=1000000000。这个区间长度是1001。检查程序在p很大(接近sqrt(1e9)≈31623)时的计算是否正确,特别是p*p的溢出问题。
  • LR非常大且接近L=2147483647 - 1000, R=21474836472147483647本身是一个质数(梅森素数)。测试long long是否足以处理边界乘法(46340*46340仍在int范围内,但为了通用性,坚持用long long)。

在调试时,如果结果不对,可以尝试输出中间变量:

  • 在小质数筛完后,输出primes的前几个和最后几个,确认筛的范围和结果正确。
  • 在区间筛法中,对于前几个质数p,输出计算得到的start值,看是否合理。
  • 筛完后,输出segPrimes列表,与已知的质数表进行对比。

5. 举一反三:区间筛法的其他应用场景与变体

掌握了“质数距离”这道题,区间筛法这个工具就归你所有了。你可以用它来解决一系列类似问题。

5.1 统计区间内质数的个数

这是最直接的变体。题目可能只要求输出[L, R]内有多少个质数,而不需要具体是哪些。我们的算法完全适用,最后返回segPrimes.size()即可。甚至,如果我们不需要记录具体的质数,可以只维护一个计数器,在isPrimeSeg[i]true时加一。

5.2 求区间内质数的和

同样,在收集质数时累加即可。注意使用long long来存储和,因为质数的和可能很大。

5.3 结合其他数论函数

区间筛法可以扩展用于计算区间内每个数的某些数论函数值,例如:

  • 欧拉函数 φ(n):可以在筛法过程中,对于每个质数p及其倍数,应用公式进行递推计算。这需要更精巧的设计。
  • 莫比乌斯函数 μ(n):同样可以在筛法过程中确定每个数的 μ 值。
  • 区间内每个数的最小质因子/最大质因子:在标记合数时,可以记录是哪个质数p将其标记的,从而得到其最小质因子。

这类问题通常被称为“积性函数区间筛”,难度更高,但核心思想仍是利用小质数去批量处理大区间。

5.4 当区间长度非常大时(R-L > 1e7)

如果区间长度达到千万甚至上亿量级,我们的isPrimeSeg数组在内存上可能就有压力(1e8bool数组约12.5 MB,尚可;1e9则约125 MB,可能超出限制)。此时,可以考虑:

  1. 分段筛:将大区间[L, R]分成若干长度为BLOCK(例如1e6)的小段,每次只处理一小段。这样内存占用固定为O(BLOCK),但需要重复遍历小质数列表primes。由于primes很小,额外开销不大。
  2. 位压缩:使用vector<bool>或手动位运算(bitset)来压缩存储,可以将内存占用减少到原来的1/8

“质数距离”这道题之所以经典,正是因为它以一个清晰的问题,引出了区间筛法这一强大且实用的技巧。它告诉我们,算法竞赛中,死记硬背模板是行不通的。必须深刻理解算法背后的原理(为什么用sqrt(R)以内的质数?为什么从max(ceil(L/p)*p, p*p)开始?),并具备将经典算法适配到新场景的能力(如何将全局筛法映射到局部区间?)。解决它的过程,本身就是一次对筛法本质的深度复习和一次解决复杂问题能力的有效锻炼。下次再遇到需要处理大范围质数的问题,不妨先想想,能否用“区间”的视角来拆解它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询