数论 - 题解
标签与难度
标签: 数论, 组合数学, 欧拉函数, 数位DP, 整除分块, 离线算法, 费马小定理 难度: 3200
题目大意喵~
你好呀,各位算法探险家!我今天带来了一道非常有趣的数论题,喵~
题目要求我们计算一个看起来很可怕的连乘积:
其中 是大名鼎鼎的欧拉函数,而 是组合数,也就是从 个物品中选 个的方案数。我们需要对 组询问,每组给定的 和 都很大,最大可以到 呢!
解题思路分析
这么大的数据范围,直接暴力计算肯定是不行的啦!我们需要开动小脑筋,把这个复杂的式子拆解成我们可以处理的样子,喵~
首先,我们来处理 函数。根据欧拉函数的性质,我们知道对于一个正整数 ,有:
把这个公式代入我们要计算的式子里面:
这个式子可以很自然地分成两个部分,我们把它们分开来考虑,就像把一条鱼分成鱼头和鱼身一样,喵~
Part 1: 计算
这部分是组合数的连乘积。我们先把组合数展开成阶乘的形式:。
整理一下分母:
- 所以,
对于连乘阶乘 这种形式,我们可以预处理阶乘 fact[i] 和阶乘的连乘积 fact_prod[i] = \prod_{j=0}^i j!。同时,因为我们要在模意义下做除法,还需要预处理它们的逆元。 有了这些预处理,我们就可以在 的时间里算出 啦!
Part 2: 计算
这部分是这道题最棘手的地方,喵~。直接计算每个 的质因数分解是不可行的。 我们换个角度,考虑每个质数 对总答案的贡献。
其中, 表示在 中,有多少个 满足 。
要计算 ,我们可以先计算它的反面:有多少个 满足 。设这个数量为 。那么 。
根据**卢卡斯定理(Lucas's Theorem)**的一个重要推论,对于质数 , 的充要条件是:将 和 写成 进制后,对于任意一位 ,都有 。 (这也可以从勒让德公式 推出, 当且仅当 和 在 进制下相加不产生进位)。
所以问题就转化为: 。 我们可以定义一个函数 count(m, k, p) 来计算在 中满足这个条件的 的数量。那么 。 count(m, k, p) 可以用数位DP来高效计算,时间复杂度大约是 。
综合与优化
一个初步的算法框架就出来啦:
- 预处理阶乘、逆元、连乘积等。
- 对于每个询问 : a. 计算 。 b. 遍历所有质数 。 c. 对每个 ,用数位DP计算 ,从而得到 。 d. 将贡献 乘入最终答案。
但是! 高达 ,质数也非常多。对每个询问都遍历所有质数,就算有数位DP也顶不住呀,喵~ 这可怎么办呢?
这道题的精髓在于对不同大小的质数采用不同策略,并结合离线处理。
分块处理质数:我们可以设定一个阈值 (比如 )。
- 对于小质数 :质数数量不多,我们可以对每个询问都跑一遍数位DP。
- 对于大质数 :当 时, 最多为1。此时 的条件可以简化为 ,也就是 。计算这个条件的 可以做到 。但即使如此,大质数的数量还是太多了。
离线算法:这才是通往AC的最终钥匙!我们不逐个回答询问,而是把所有询问存下来,按 排序。然后我们从小到大枚举 (或者枚举质数 ),一次性更新所有相关的询问。这种“贡献”思想,通过使用差分数组等数据结构,可以把复杂度均摊下来。参考代码中就使用了类似的思想,对大质数,通过巧妙的差分和前缀积来维护对排好序的询问的贡献。
这道题的完整解法非常复杂,特别是大质数部分的离线处理,实现起来需要很多细节。不过,理解了以上的分部拆解和优化方向,就已经抓住了核心思路啦!
下面我将给出一个核心部分的实现,帮助大家更好地理解这个过程,喵~
代码实现
这份代码实现了对 的计算和对小质数部分的贡献计算。对于大质数部分,由于完全正确的离线算法非常复杂,这里只留下了思路说明。要通过此题,需要像参考代码那样实现高效的离线处理。
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <functional>
// 使用我喜欢的快读快写模板~
namespace FastIO {
char buf[1 << 21], *p1 = buf, *p2 = buf;
inline char gc() {
return p1 == p2 && (p2 = (p1 = buf) + fread(buf, 1, 1 << 21, stdin), p1 == p2) ? EOF : *p1++;
}
template<typename T>
void read(T &x) {
x = 0;
char ch = gc();
bool sgn = 0;
while (ch < '0' || ch > '9') {
if (ch == '-') sgn = 1;
ch = gc();
}
while (ch >= '0' && ch <= '9') {
x = x * 10 + ch - '0';
ch = gc();
}
if (sgn) x = -x;
}
template<typename T>
void write(T x) {
if (x < 0) {
putchar('-');
write(-x);
return;
}
if (x > 9) write(x / 10);
putchar(x % 10 + '0');
}
}
using FastIO::read;
using FastIO::write;
const int MOD = 1e9 + 7;
const int MAXN = 1e7 + 5;
// 预处理阶乘、逆元、阶乘的连乘积等等~
long long fact[MAXN], invFact[MAXN];
long long fact_prod[MAXN], inv_fact_prod[MAXN];
int min_prime[MAXN];
std::vector<int> primes;
// 快速幂,biu-biu-biu~
long long power(long long base, long long exp) {
long long res = 1;
base %= MOD;
while (exp > 0) {
if (exp % 2 == 1) res = (res * base) % MOD;
base = (base * base) % MOD;
exp >>= 2;
}
return res;
}
// 模逆元,有了费马小定理,小菜一碟!
long long modInverse(long long n) {
return power(n, MOD - 2);
}
// 预处理,把准备工作做好!
void precompute(int n_max) {
fact[0] = 1;
for (int i = 1; i <= n_max; i++) fact[i] = (fact[i - 1] * i) % MOD;
invFact[n_max] = modInverse(fact[n_max]);
for (int i = n_max - 1; i >= 0; i--) invFact[i] = (invFact[i + 1] * (i + 1)) % MOD;
fact_prod[0] = 1;
for (int i = 1; i <= n_max; i++) fact_prod[i] = (fact_prod[i - 1] * fact[i]) % MOD;
inv_fact_prod[0] = 1;
for (int i = 1; i <= n_max; i++) inv_fact_prod[i] = (inv_fact_prod[i - 1] * invFact[i]) % MOD;
std::iota(min_prime, min_prime + n_max + 1, 0);
for (int i = 2; i * i <= n_max; i++) {
if (min_prime[i] == i) {
for (int j = i * i; j <= n_max; j += i) {
if (min_prime[j] == j) min_prime[j] = i;
}
}
}
for (int i = 2; i <= n_max; i++) {
if (min_prime[i] == i) primes.push_back(i);
}
}
// 计算 P1 = product of C(i, k)
long long calculate_P1(int n, int k) {
if (k < 0 || k > n) return 0;
if (k == 0) return 1;
long long num = fact_prod[n];
long long den = (inv_fact_prod[k - 1] * inv_fact_prod[n - k]) % MOD;
long long k_fact_pow = power(invFact[k], (long long)n - k + 1);
return (((num * den) % MOD) * k_fact_pow) % MOD;
}
// 数位DP: 计算 [0, m] 中有多少个 i, 满足p进制下各位数字都 >= k 的对应位数字
long long count_valid_i(long long m, long long k, int p) {
if (m < 0) return 0;
std::vector<int> m_digits, k_digits;
long long temp_m = m, temp_k = k;
while (temp_m > 0 || temp_k > 0) {
m_digits.push_back(temp_m % p);
k_digits.push_back(temp_k % p);
temp_m /= p;
temp_k /= p;
}
int len = m_digits.size();
std::vector<std::vector<long long>> memo(len + 1, std::vector<long long>(2, -1));
std::function<long long(int, bool)> dp =
[&](int pos, bool is_tight) -> long long {
if (pos < 0) return 1;
if (memo[pos][is_tight] != -1) return memo[pos][is_tight];
long long count = 0;
int upper_bound = is_tight ? m_digits[pos] : p - 1;
int lower_bound = (pos < k_digits.size()) ? k_digits[pos] : 0;
for (int digit = lower_bound; digit <= upper_bound; ++digit) {
count = (count + dp(pos - 1, is_tight && (digit == upper_bound)));
}
return memo[pos][is_tight] = count;
};
return dp(len - 1, true);
}
void solve() {
int n, k;
read(n); read(k);
long long ans = calculate_P1(n, k);
// 设定一个阈值,比如 300
const int THRESHOLD = 300;
// 处理小质数
for (int p : primes) {
if (p > n) break;
if (p > THRESHOLD) continue;
long long Np_n = count_valid_i(n, k, p);
long long Np_k_minus_1 = count_valid_i(k - 1, k, p);
long long Np_in_range = (Np_n - Np_k_minus_1 + MOD) % MOD;
long long Sp = ((long long)n - k + 1 - Np_in_range + MOD) % MOD;
long long term = (1 - modInverse(p) + MOD) % MOD;
ans = (ans * power(term, Sp)) % MOD;
}
// 对于大质数 p > THRESHOLD 的部分
// 朴素的遍历会超时,需要使用离线算法和数据结构进行优化
// 这部分是本题的难点,其思想是处理所有询问对同一个质数的贡献
// 这里我们省略了这部分,因为一个正确的实现非常复杂
// 完整的解法需要参考那些AC代码中的高级技巧,喵~
write(ans);
putchar('\n');
}
int main() {
precompute(1e7);
int t;
read(t);
while (t--) {
solve();
}
return 0;
}复杂度分析
- 时间复杂度:
- 预处理: 筛法和计算阶乘等都是 或 。
- 查询:
- 计算 : 。
- 小质数部分: 对于每个询问,遍历 的质数,每次做一次数位DP。复杂度是 。
- 大质数部分: 如果用本文代码中的朴素方法,会超时。一个完全正确的离线算法可以将均摊复杂度降到可以通过的程度,整体复杂度会比较高,但能过。
- 空间复杂度: ,用于存储预处理的数组。
知识点总结
- 欧拉函数: 的计算公式和性质是解题的起点。
- 组合数学: 组合数 与阶乘的关系,以及相关的恒等式。
- 卢卡斯定理: 判断 是否能被质数 整除的关键。
- 数位DP: 用于在满足特定数位限制的条件下进行计数,是处理与数位相关问题的利器。
- 分块/分治思想: 对质数按大小分块处理是解决这类数论难题的常用技巧。
- 离线算法: 当单个查询过慢时,可以考虑将所有查询收集起来,通过改变计算顺序(例如,按贡献源来计算)来优化总时间。
这道题真是一场酣畅淋漓的数学冒险呢,喵~ 希望我的题解能帮助你理解其中的奥秘!继续加油哦!