杜教筛简介
极难2杜教筛:快速计算积性函数前缀和的利器
1. 这是用来解决什么问题的?
想象你是一个数学侦探,想统计从1到N(比如N=10^12,超级大)中,有多少个数的莫比乌斯函数值为1、-1或0,或者计算所有数的欧拉函数值加起来是多少。这种“累加”操作叫前缀和,记作:
如果N很小(比如1000),我们可以一个一个算。但当N像天文数字(比如10^12)时,连最快的线性筛法都要花很长时间(因为线性筛要枚举每个数)。杜教筛是一种“聪明”的数学+编程技巧,它利用数论卷积和整除分块,把计算时间降到大约 ,也就是比直接算快得多。
生活类比:你想统计全校所有班级的人数总和。每个班级的人数你可能要一个一个数(线性)。但如果你知道每个年级的总人数(前缀和),然后把不同年级的人数加起来,就可能快很多。杜教筛就像用已知的“年级总人数”去快速推算“全校总人数”,而不需要把每个班都数完。
2. 杜教筛的核心思想——用卷积“拆解”问题
2.1 什么是狄利克雷卷积?
两个函数 和 的狄利克雷卷积定义为:
简单说,就是把 拆成两个因子相乘,然后分别看两个函数在这些因子上的值,再加起来。例如:
- 如果 (常函数),,那么 就是 的因数个数。
- 如果 ,(恒等函数,),那么 就是 的所有因数之和。
2.2 核心公式
杜教筛的关键是:我们想求 ,但直接求很难。于是我们构造一个简单易求的函数 ,让它们的卷积 的前缀和很容易算出来。然后利用下面这个关系:
这个公式怎么来的?从卷积的定义交换求和顺序:
把 的项单独拿出来,并假设 (大多数积性函数都满足),得到:
移项就得到杜教筛的核心公式:
这个公式右边递归地出现了 本身,而且 的值只有大约 种(整除分块)。我们只需要递归计算这些值,并配合记忆化(缓存结果),就能大大降低时间复杂度。
3. 如何选择函数 ?
选 就像“打配合”:要让 的前缀和很简单,同时 本身的前缀和也要容易算(因为公式里会用到 的累加,我们在整除分块时需要快速求一段 的和)。
常用配对:
| 目标 | 选 | 卷积 | 说明 | |
|---|---|---|---|---|
| (莫比乌斯函数) | 1 | (单位函数,只有) | 1 | 因为 |
| (欧拉函数) | 1 | (恒等函数) | 因为 | |
| 1 | (除数之和) | 需要另外求? | 通常不用杜教筛,直接用公式 |
例1:求Möbius函数前缀和
取 ,则 ,所以 。公式变为:
例2:求欧拉函数前缀和
取 ,则 ,。公式变为:
4. 整除分块——把递归的层数压下来
公式里需要对 从2到N求和。但直接循环 次就太慢了。但是注意到: 随着 变化,只有大约 个不同的值。比如 ,当 时 ,当 时也是1,所以从51到100这一大段,商都是1。我们只需要把这一整段一起处理。
具体做法:对于当前 ,设 ,那么下一个不同商的起点是 。在这一段内, 的和可以用前缀和快速求出(因为 的前缀和也容易算)。
生活类比:假设你要统计班级里每个人的数学分数。直接问每个人(循环N次)太慢。但如果分数是重复的(比如很多人考了90分),你可以先按分数分组,统计每组的人数,然后用“人数×分数”快速算出总分。整除分块就是按“商”分组。
5. 编程实现——计算莫比乌斯函数前缀和(完整代码)
我们要实现 。按照以下步骤:
- 先用线性筛预处理前 个数的 和它的前缀和( 通常取 左右)。
- 对于大于 的 ,使用杜教筛递归计算,并记忆化结果。
- 在递归中,利用整除分块快速求和。
5.1 C++ 完整代码(动态预处理上限)
#include <iostream>
#include <vector>
#include <unordered_map>
#include <cmath>
using namespace std;
// 全局变量,用于记忆化和前缀和
unordered_map<long long, long long> memo; // 记忆化 S_mu 的结果
vector<long long> mu_sum; // 预处理的前缀和(下标从0开始,mu_sum[i] = S_mu(i))
int limit; // 预处理的上限
// 线性筛预处理 mu 和前缀和,直到 up_to
void precompute(int up_to) {
vector<int> mu(up_to + 1, 0);
vector<bool> is_composite(up_to + 1, false);
vector<int> primes;
mu[1] = 1;
for (int i = 2; i <= up_to; i++) {
if (!is_composite[i]) {
primes.push_back(i);
mu[i] = -1;
}
for (size_t j = 0; j < primes.size() && i * primes[j] <= up_to; j++) {
int p = primes[j];
int x = i * p;
is_composite[x] = true;
if (i % p == 0) {
mu[x] = 0; // 有平方因子
break;
} else {
mu[x] = mu[i] * mu[p];
}
}
}
mu_sum.resize(up_to + 1);
mu_sum[0] = 0;
for (int i = 1; i <= up_to; i++) {
mu_sum[i] = mu_sum[i-1] + mu[i];
}
}
// 杜教筛求 S_mu(N)
long long S_mu(long long N) {
if (N <= limit) return mu_sum[N]; // 已经预处理过了
if (memo.count(N)) return memo[N]; // 记忆化
long long res = 1; // S_h(N) = 1,因为 h = epsilon
// 整除分块,d 从2开始
long long d = 2;
while (d <= N) {
long long q = N / d; // 商
long long next_d = N / q + 1; // 下一个不同的 d
// 这一段的 d 范围是 [d, next_d-1],它们的 floor(N/d) 都是 q
// 需要求和 g(d) = 1,所以块长度 = next_d - d
res -= (next_d - d) * S_mu(q);
d = next_d;
}
memo[N] = res;
return res;
}
int main() {
long long N;
cout << "请输入 N(例如 1000000 或 10000000000): ";
cin >> N;
// 预处理上限取 N^(2/3),但不要超过 1e7 以防内存爆炸
limit = (int)min((long long)1e7, (long long)pow(N, 2.0/3.0));
if (limit < 1) limit = 1;
precompute(limit);
cout << "S_mu(" << N << ") = " << S_mu(N) << endl;
return 0;
}
代码解释:
mu_sum[i]存储了从1到i的莫比乌斯函数值之和,方便直接查表。memo是一个哈希表,缓存大N的递归结果,避免重复计算。- 在
S_mu中,res初始为1(对应 ),然后减去所有 的贡献。 - 整除分块中,
next_d - d就是这一段的长度,乘以S_mu(q)(因为 )。
5.2 Python 完整代码
import math
import sys
sys.setrecursionlimit(10**6) # 防止递归深度太大
memo = {}
limit = 0
mu_sum = []
def precompute(up_to):
"""线性筛预处理 mu 和前缀和到 up_to"""
mu = [0] * (up_to + 1)
mu[1] = 1
primes = []
is_composite = [False] * (up_to + 1)
for i in range(2, up_to + 1):
if not is_composite[i]:
primes.append(i)
mu[i] = -1
for p in primes:
if i * p > up_to:
break
is_composite[i * p] = True
if i % p == 0:
mu[i * p] = 0
break
else:
mu[i * p] = mu[i] * mu[p]
# 计算前缀和
mu_sum.append(0) # 下标0占位
for i in range(1, up_to + 1):
mu_sum.append(mu_sum[-1] + mu[i])
return mu_sum
def S_mu(N):
"""杜教筛求莫比乌斯函数前缀和"""
if N <= limit:
return mu_sum[N]
if N in memo:
return memo[N]
res = 1 # S_h(N) = 1
d = 2
while d <= N:
q = N // d
next_d = N // q + 1
# 这一段的长度乘以 S_mu(q)
res -= (next_d - d) * S_mu(q)
d = next_d
memo[N] = res
return res
if __name__ == "__main__":
N = int(input("请输入 N:"))
# 预处理上限取 N^(2/3),但不超过 10^7
limit = min(int(1e7), int(N ** (2/3)))
if limit < 1:
limit = 1
mu_sum = precompute(limit)
print(f"S_mu({N}) = {S_mu(N)}")
6. 常见错误与避坑指南
- 遗忘记忆化:递归时如果不把结果存起来,会陷入指数级重复计算。必须用哈希表或数组缓存。
- 整型溢出:C++ 中如果 N 很大(如 10^12),
N * (N+1)可能超过 64 位?实际上 10^12 * 10^12 会溢出,但在计算 时通常用N * (N+1) / 2,中间结果要用long long,且要小心除以2的顺序。更好的是用N / 2 * (N+1)或强制类型转换。 - 预处理上限选择不当:上限太小会导致递归太多层,效率变差;上限太大可能内存超限。经验值是 左右,不超过 1e7 通常安全。
- 整除分块边界错误:
next_d = N / q + 1要确保处理完 d 到最后 N+1 时跳出循环。注意当q = 0时(d > N),循环应该结束。 - g(1) 不等于1:公式中假设 g(1)=1,如果 g(1)≠1,公式要稍作修改,但大多数常用积性函数 g(1)=1。
- 递归深度过大:Python 需要设置
sys.setrecursionlimit,C++ 使用循环或迭代,但递归深度一般不会太深(因为整除分块会快速缩小参数)。
7. 用杜教筛求欧拉函数前缀和
只要把代码中的 和 换一下就 OK。对于欧拉函数:
因此 S_phi(N) 的递归函数如下(C++ 风格):
long long S_phi(long long N) {
if (N <= limit) return phi_sum[N];
if (memo.count(N)) return memo[N];
long long res = N * (N + 1) / 2; // S_h(N) = N(N+1)/2
long long d = 2;
while (d <= N) {
long long q = N / d;
long long next_d = N / q + 1;
res -= (next_d - d) * S_phi(q);
d = next_d;
}
memo[N] = res;
return res;
}
只需要把线性筛部分改成筛 即可。
8. 总结与相关指引
杜教筛是竞赛中处理大范围积性函数前缀和的“杀手锏”。它的核心三步:
- 选一个好搭档 ,使得 和 的前缀和都容易算。
- 用核心公式递归:。
- 整除分块 + 记忆化 加速递归。
掌握了杜教筛,你就能轻松应付 高达 的前缀和问题。如果想进一步学习,可以看看以下方向:
- 莫比乌斯反演:杜教筛的基石之一,用于推导很多等价关系。
- 狄利克雷卷积:更多有趣的搭配(如 与 )。
- 数论分块(整除分块)的更多应用,比如快速求 。
- 欧拉函数与莫比乌斯函数的性质:理解它们的积性有助于构造 。
试一试:请写出杜教筛求 的代码?其实直接用公式,但你能用杜教筛验证公式吗?(提示:取 ,则 ,然后 用杜教筛求,最后解出 ——有点绕,但很有趣。)
希望这篇文章能帮你打开杜教筛的大门!
例题精讲
杜教筛主要用于计算哪种类型的值?
杜教筛要求待求前缀和的函数必须是积性函数。
以下代码是杜教筛求莫比乌斯函数前缀和的框架,请补充缺失部分。
unordered_map<int, int> mp;
int sum_mu(int n) {
if (n < N) return pre_mu[n];
if (mp.count(n)) return mp[n];
int res = 1;
for (int l = 2, r; l <= n; l = r + 1) {
r = n / (n / l);
res -= (r - l + 1) * sum_mu(___);
}
return mp[n] = res;
}杜教筛的时间复杂度通常可达到多少?
杜教筛中,需要预处理前n^(2/3)个函数值以优化递归计算。