Crying 与初中数学 - 题解
标签与难度
标签: 数论, 杜教筛, 莫比乌斯反演, 欧拉函数, 整除分块, 积性函数 难度: 2700
题目大意喵~
一位叫 Crying 的同学正在研究如何化简二次根式 ,呐。我们知道,任何正整数 都可以唯一地表示成 的形式,其中 是一个“无平方因子数”(square-free number),也就是说 的任何质因子的幂次都为 1。化简 的过程,就是找到这个最大的 ,使得 。我们把这对 记为 。
题目要求我们计算一个总和:对于从 到 的所有整数 ,分别求出它们的 ,然后计算 的值。由于 可能非常大(比如 ),结果需要对 取模,这正好是 C++ 中 unsigned long long 的自然溢出,非常方便喵~
举个栗子:
- 当 时,。所以 。
- 当 时,。所以 。
- 当 时,。 本身就是无平方因子数,所以 。
我们需要计算的就是 。
解题思路分析
这道题是纯纯的数论题,直接从 到 模拟肯定是不行的说。我们需要运用我的智慧,把这个求和式子变变变,变成一个可以快速计算的形式,喵~
总的求和式可以分成两个独立的部分:
我们分别来推导这两部分的计算方法,呐。
Part 1: 计算
我们换个角度来思考求和。与其遍历 ,不如我们遍历所有可能的 和 的值。 根据定义,,其中 是无平方因子数。我们用 来表示 ,用 来表示 。 那么求和可以写作:
我们可以先枚举 ,再统计满足条件的 的贡献:
令 ,表示 到之间无平方因子数的个数。那么上式就是:
根据莫比乌斯反演的一个经典结论,一个数 是无平方因子数等价于 。于是: 。 代入回去:
交换求和次序,令 ,我们先枚举 :
哇!内层的 是一个狄利克雷卷积的形式,即 ,其中 。而这个卷积正好等于欧拉函数 ! 所以,第一部分的和可以漂亮地化简为:
Part 2: 计算
同样地,我们对 进行变形:
令 ,表示 到之间所有无平方因子数之和。上式为:
我们同样可以利用莫比乌斯反演来处理 :
内层的 ,我们记作 。于是:
代回到 的式子中,并利用 :
和刚才一样,交换求和次序,令 :
内层的和 也是一个积性函数。我们把它记作 。 是 吗?不对,是 在 的取值。应该是 。 这个函数是积性函数,我们可以看它在质数幂次上的取值:。对于 , 。所以 对所有 成立。 所以,第二部分的和化简为:
合并与计算
现在我们把两部分合在一起:
这个式子可以通过整除分块(也叫数论分块)来优化。我们发现,对于一段连续的 , 的值是相同的。 我们可以将 分成若干个块 ,在每个块内 的值都等于 。 对于一个块 ,它的贡献是:
这就需要我们能快速计算 和 的前缀和,即 和 。
杜教筛登场!
对于比较大的 (比如 ),预处理前缀和是不现实的。这时就要用杜教筛了喵!杜教筛是一种在亚线性时间内计算积性函数前缀和的强大算法。
- 求 : 我们知道一个经典的狄利克雷卷积恒等式:,即 。 利用这个性质,可以推导出 的递推式:
- 求 : 对于 ,我们需要找到一个简单的函数 使得 的前缀和容易计算。 经过一番探索(推导过程有点小复杂呢喵~),我们发现 和函数 的狄利克雷卷积是单位函数 。即 。 证明:。 所以, 对所有 成立。 利用这个性质,可以推导出 的递推式:
这两个递推式都可以用整除分块和记忆化搜索来高效实现。我们先用线性筛预处理出一部分小范围(比如到 )的前缀和,对于更大的值就用杜教筛递归计算。
最终,我们的算法流程是:
- 线性筛预处理小范围内的 和 以及它们的前缀和。
- 实现两个杜教筛函数,分别计算 和 。
- 使用整除分块,遍历 的所有块 。
- 在每个块内,计算出块对应的常数值 和 。
- 调用杜教筛函数得到 ,从而计算出 和 。
- 累加每个块的贡献,得到最终答案,喵~
代码实现
这是我仔细分析和重构后的代码,希望能帮助你理解整个过程哦!
#include <iostream>
#include <vector>
#include <cmath>
#include <unordered_map>
// 使用 unsigned long long 来处理对 2^64 的取模
using ull = unsigned long long;
// 使用 __int128 来防止中间计算溢出,特别是平方和、立方和
using int128 = __int128;
// 预处理数组的最大值,N_max^(2/3) 是一个比较理想的取值,这里取 5*10^6
const int PRECOMPUTE_LIMIT = 5000000;
// N 是题目输入的最大值
ull N;
// 预处理数组
std::vector<int> primes;
bool is_prime[PRECOMPUTE_LIMIT + 1];
ull phi[PRECOMPUTE_LIMIT + 1];
ull g[PRECOMPUTE_LIMIT + 1]; // g(j) = sum_{d|j} mu(d)d^2
ull sum_phi[PRECOMPUTE_LIMIT + 1];
ull sum_g[PRECOMPUTE_LIMIT + 1];
// 杜教筛记忆化
std::unordered_map<ull, ull> memo_sum_phi;
std::unordered_map<ull, ull> memo_sum_g;
// 线性筛预处理 phi 和 g
void sieve(int limit) {
std::fill(is_prime, is_prime + limit + 1, true);
is_prime[0] = is_prime[1] = false;
phi[1] = 1;
g[1] = 1;
for (int i = 2; i <= limit; ++i) {
if (is_prime[i]) {
primes.push_back(i);
phi[i] = i - 1;
g[i] = 1 - (ull)i * i; // g(p) = 1 - p^2
}
for (int p : primes) {
if ((long long)i * p > limit) break;
is_prime[i * p] = false;
if (i % p == 0) {
phi[i * p] = phi[i] * p;
g[i * p] = g[i]; // g(p^k) = g(p) if i contains p
break;
} else {
phi[i * p] = phi[i] * (p - 1);
g[i * p] = g[i] * g[p]; // g is multiplicative
}
}
}
// 计算前缀和
sum_phi[0] = sum_g[0] = 0;
for (int i = 1; i <= limit; ++i) {
sum_phi[i] = sum_phi[i - 1] + phi[i];
sum_g[i] = sum_g[i - 1] + g[i];
}
}
// 计算 1^2 + 2^2 + ... + n^2
ull sum_sq(ull n) {
int128 N128 = n;
int128 res = N128 * (N128 + 1) * (2 * N128 + 1) / 6;
return (ull)res;
}
// 杜教筛计算 phi 的前缀和
ull get_sum_phi(ull n) {
if (n <= PRECOMPUTE_LIMIT) {
return sum_phi[n];
}
if (memo_sum_phi.count(n)) {
return memo_sum_phi[n];
}
// S_phi(n) = n(n+1)/2 - sum_{k=2 to n} S_phi(n/k)
int128 n128 = n;
ull res = (ull)(n128 * (n128 + 1) / 2);
for (ull l = 2, r; l <= n; l = r + 1) {
ull val = n / l;
r = n / val;
res -= (r - l + 1) * get_sum_phi(val);
}
return memo_sum_phi[n] = res;
}
// 杜教筛计算 g 的前缀和
ull get_sum_g(ull n) {
if (n <= PRECOMPUTE_LIMIT) {
return sum_g[n];
}
if (memo_sum_g.count(n)) {
return memo_sum_g[n];
}
// S_g(n) = n - sum_{k=2 to n} k^2 * S_g(n/k)
ull res = n;
for (ull l = 2, r; l <= n; l = r + 1) {
ull val = n / l;
r = n / val;
// sum_{k=l to r} k^2 = sum_sq(r) - sum_sq(l-1)
res -= (sum_sq(r) - sum_sq(l - 1)) * get_sum_g(val);
}
return memo_sum_g[n] = res;
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
sieve(PRECOMPUTE_LIMIT);
std::cin >> N;
ull total_sum_a = 0;
ull total_sum_b = 0;
ull sqrt_N = sqrt(N);
// 整除分块计算 S(N)
for (ull l = 1, r; l <= sqrt_N; l = r + 1) {
ull d = N / (l * l);
// 为了安全起见,用 sqrt(double) 计算 r
// double d_double = d;
// r = sqrt( (double)N / d_double );
// 或者直接用整数,因为 d = N/(l*l) <= N/l^2
r = sqrt(N / d);
// 计算 sum a_n 部分
total_sum_a += d * (get_sum_phi(r) - get_sum_phi(l - 1));
// 计算 sum b_n 部分
// T_d = d(d+1)/2
int128 d128 = d;
ull T_d = (ull)(d128 * (d128 + 1) / 2);
total_sum_b += T_d * (get_sum_g(r) - get_sum_g(l - 1));
}
std::cout << total_sum_a + total_sum_b << std::endl;
return 0;
}复杂度分析
时间复杂度: 或 。
- 线性筛:预处理部分复杂度是 ,其中 是
PRECOMPUTE_LIMIT。 - 杜教筛:若预处理到 ,杜教筛的复杂度是 。在这里,我们需要的最大前缀和是到 。若预处理到 ,则计算所有需要的前缀和总复杂度为 。我们的 比 (当 时为 )大得多,所以杜教筛部分会非常快。
- 主循环: 整除分块的循环次数大约是 次。每次循环内部调用杜教筛函数。由于有记忆化,每个需要的值只会被计算一次。
- 综上,总复杂度由预处理和杜教筛主导,大致可以认为是 。对于 ,复杂度是 ,完全可以在时限内完成。
- 线性筛:预处理部分复杂度是 ,其中 是
空间复杂度:
- 预处理数组占用了 的空间。
- 杜教筛的记忆化
unordered_map会存储大约 个状态,所以空间复杂度是 。
知识点总结
- 问题转化: 将复杂的求和问题通过改变求和对象(从 到 的组合)进行简化,是数论题中的常用技巧。
- 积性函数: 欧拉函数 和我们构造的 都是积性函数,这使得我们可以利用线性筛来预处理它们的值。
- 狄利克雷卷积: 这是解决数论问题的有力武器!本题中用到了 和 (即 )两个关键恒等式。
- 莫比乌斯反演: 它是推导 和 公式的核心,帮助我们将 "square-free" 这个条件转化为了与 函数相关的求和。
- 整除分块 (数论分块): 对于形如 的和式,通过将 值相同的 分成一块来计算,可以大大降低时间复杂度。本题中我们用到了它的变体 。
- 杜教筛: 当需要计算积性函数在远超预处理范围的前缀和时,杜教筛是标准的高效算法。它的核心思想是利用一个巧妙的递推关系,结合记忆化搜索和整除分块来加速计算。
希望这篇题解能帮你彻底搞懂这道有趣的数论题,喵~ 如果有任何疑问,随时可以再来问我哦!