Day6
你提供的这段代码是欧拉筛(Euler Sieve / Linear Sieve)最标准、最精炼的写法,常用于算法竞赛。
这段代码之所以被称为“线性筛”,是因为它保证了范围内的每一个合数都被其“最小质因子”筛掉,且仅被筛掉一次。
下面我为你逐行深度解析这段代码的逻辑:
1. 初始化
memset(st, false, sizeof(st));
st[i]为false表示i是素数,为true表示i是合数。- 在多组测试数据的情况下,
memset是必须的。
2. 外层循环
for(int i = 2; i <= max_val; i ++) {
if(!st[i]) Pri[cnt ++] = i; // 如果没被筛掉,说明 i 是素数
- 从 2 遍历到
max_val。 - 如果
st[i]为false,说明在之前的循环里没有更小的质因子能把它筛掉,那它一定就是质数,加入Pri数组。
3. 内层循环(核心)
for(int j = 0; j < cnt && Pri[j] <= max_val / i; j ++) {
st[Pri[j] * i] = true; // 标记合数
j < cnt:遍历当前已经发现的所有质数。Pri[j] <= max_val / i:这是一种防溢出的写法,等价于Pri[j] * i <= max_val。这保证了我们要筛掉的合数在预定范围内。- 关键点:这里是用 “当前的数 \(i\)” × “某个质数 \(Pri[j]\)” 来构造一个合数。
4. 线性复杂度的精髓
if(i % Pri[j] == 0) break;
这是全篇最重要的逻辑。为什么这一行能保证 \(O(n)\)?
-
逻辑拆解:
- 如果
i % Pri[j] == 0,说明Pri[j]是i的最小质因子(因为质数是从小到大遍历的)。 - 那么对于下一个要处理的合数
Pri[j+1] * i:- 我们可以把
i写成 \(k \times Pri[j]\)。 - 所以
Pri[j+1] * i = Pri[j+1] * k * Pri[j]。 - 显然,这个新合数的最小质因子应该是
Pri[j],而不是Pri[j+1]。
- 我们可以把
- 结论:如果我们在此时不
break,那么Pri[j+1] * i就会被Pri[j+1]筛掉。但这违背了“每个数只被最小质因子筛掉”的原则(它应该在后面 \(i\) 变大到某个值时,被Pri[j]筛掉)。
- 如果
-
结果:由于每个数只被其最小质因子筛一次,整个双层循环的总运行次数严格等于
max_val,复杂度为 \(O(max\_val)\)。
举例运行过程:筛 30 以内的数
当 i = 4 时:
- 4 是合数(之前被 2 筛过了),不进入
if(!st[i])。 - 进入内层循环,此时质数表
Pri有{2, 3}。 j=0:Pri[0]=2。筛掉2 * 4 = 8。- 检查
4 % 2 == 0,成立!Break。- 思考:如果不 Break,下一步会筛掉
3 * 4 = 12。但 12 的最小质因子是 2,它应该在i = 6时被2 * 6 = 12筛掉。欧拉筛通过这个 break 避免了重复操作。
- 思考:如果不 Break,下一步会筛掉
当 i = 9 时:
- 9 是合数,进入内层循环,质数表
{2, 3, 5, 7}。 j=0:Pri[0]=2。筛掉2 * 9 = 18。j=1:Pri[1]=3。筛掉3 * 9 = 27。- 检查
9 % 3 == 0,成立!Break。- 注意:27 的最小质因子是 3,这里筛掉它是正确的。接下来的
5 * 9 = 45最小质因子也是 3,不该由质数 5 来筛。
- 注意:27 的最小质因子是 3,这里筛掉它是正确的。接下来的
优缺点总结
- 优点:
- 极速:理论上是最快的筛法。
- 多功能:在
break之前或之后,可以顺便处理积性函数(如欧拉函数 \(\phi(n)\)、莫比乌斯函数 \(\mu(n)\)、约数个数等),只需多开一个数组。
- 缺点:
- 空间消耗:需要一个大的
st数组和一个Pri数组。 - 局限性:如果 \(max\_val\) 超过 \(10^8\),内存压力会很大(但在大多数算法题中 \(10^7\) 是上限)。
- 空间消耗:需要一个大的
结论:你手里的这段代码是处理中大规模素数问题的“终极武器”。
这是一个非常棒的观察!理解了 \(i\) 的角色和这个等式,你就真正触及了欧拉筛(线性筛)的灵魂。
我们来拆解一下:
1. \(i\) 是什么?
在代码中,i 是外层循环的变量:
for(int i = 2; i <= max_val; i ++)
- 字面意思:它是一个从 2 到
max_val扫描的每一个整数。 - 算法角色:它扮演的是“倍数”(或者是“放大器”)的角色。
- 核心公式:欧拉筛的核心公式是:合数 = 质数 \(Pri[j]\) × 倍数 \(i\)。
在筛选过程中,每一个合数都被拆解成一个“最小质因子”和一个“倍数”。
比如,当 \(i=6\) 时,它会和质数表里的质数相乘,去“干掉” 12 (\(2 \times 6\))。
2. 为什么 \(i\) 能用 \(k \times Pri[j]\) 表示?
这源于代码中的那行判断条件:
if (i % Pri[j] == 0) break;
当你运行到这一行且条件成立时,根据取模运算的定义:
如果 i 除以 Pri[j] 的余数是 0,意味着 i 能被 Pri[j] 整除。
既然能整除,那么一定存在一个整数 \(k\),使得:
这就是你疑惑的来源。 这个 \(k\) 就是 \(i\) 除以 \(Pri[j]\) 的商。
3. 为什么要因为这个等式而 break?(深度逻辑)
这是最关键的部分。假设我们当前正在处理 i,并且已经发现了 \(i = k \times Pri[j]\)。
如果我们不 break,继续看质数表里的下一个质数 \(Pri[j+1]\)。
我们接下来会去标记这个合数:
现在,我们把 \(i = k \times Pri[j]\) 代入这个合数里:
我们整理一下:
发现了什么?
这个即将被标记的合数,它不仅有 \(Pri[j+1]\) 这个因子,它还有一个因子是 \(Pri[j]\)。
由于质数表是从小到大排列的,显然 \(Pri[j] < Pri[j+1]\)。
这意味着:
\(Pri[j+1] \times i\) 这个合数的“最小质因子”是 \(Pri[j]\),而不是 \(Pri[j+1]\)。
如果我们现在(在倍数为 \(i\) 的时候)用 \(Pri[j+1]\) 去筛掉它,它就是被“非最小质因子”筛掉了。等到后面,当倍数 \(i\) 变大到 \((Pri[j+1] \times k)\) 的时候,它又会被 \(Pri[j]\) 筛一次。
这就导致一个合数被筛了多次,算法就不再是线性的了。
4. 形象的例子:当 \(i = 6\) 时
- 质数表里有
Pri = {2, 3, 5, ...}。 - 当外层循环 \(i = 6\) 时:
- \(j=0, Pri[j]=2\):标记 \(2 \times 6 = 12\)。
- 检查 \(6 \% 2 == 0\)? 成立!。此时 \(i = 3 \times 2\)(这里的 \(k=3, Pri[j]=2\))。
- 触发 Break!
如果不 Break 会发生什么?
- 程序会继续 \(j=1, Pri[j]=3\)。
- 标记 \(3 \times 6 = 18\)。
- 问题来了:\(18\) 的最小质因子是 \(2\),不是 \(3\)。
- 如果你现在筛掉了 \(18\),那么以后当 \(i = 9\) 时,程序还会执行 \(2 \times 9 = 18\) 又筛一次。
- \(18\) 就被筛了两遍!
总结
- \(i\):是合数拆解后的那个“大头”(倍数)。
- \(i = k \times Pri[j]\):说明 \(Pri[j]\) 是 \(i\) 的因子,更是 \(i \times \text{后续质数}\) 的最小质因子。
- Break:是为了把“用最小质因子筛选”这个重任,留给后面更大的 \(i\)。这保证了 \(O(n)\) 的神速。

浙公网安备 33010602011771号