[COCI2015-2016#6] PAROVI | 互质覆盖 题解
前言
不能在同一个坑上栽第三次!
题意简述
\(1 \sim n\) 数轴,你可以使用若干条线段 \([l, r]\) 来覆盖,其中要满足 \(\gcd(l, r) = 1\)。问你能够完全覆盖数轴的方案数,对 \(M\) 取模。
\(2 \leq n \leq 10^4\),\(2 \leq M \leq 10^9+7\)。不保证 \(M\) 为质数。
有趣的事实
正解可以做到 \(\mathcal{O}(n^2)\),尽管原题 \(n \leq 20\)。原题固定模数,这里动态模数为了卡打表。
题目分析
我们有理论 \(\mathcal{O}(n^2 \log n)\) 的暴力找出 \(n\) 以内互质的数对,但是太劣了。
可以证明,值域在 \(n\) 以内,互质的数对个数为 \(\dfrac{3}{\pi^2}n^2\) 级别,我还以为不多呢。
我们知道 \(\varphi(n) \sim \dfrac{n}{\zeta(2)}\),但是作者太菜了,不会证明。所以数对个数为 \(\sum \limits _ {i = 1} ^ n \varphi(i) = \Theta\Big(\dfrac{n^2}{\zeta(2)}\Big)\)。
据说欧拉又解决了 \(\zeta(2) = \dfrac{\pi^2}{6}\),但是作者还是太菜了,不会证明。所以综合考虑高斯求和里 \(\frac{1}{2}\) 的常数,互质的数对个数为 \(\dfrac{3}{\pi^2}n^2\) 级别。
明明只有 \(\mathcal{O}(n^2)\) 个数对,我们能不能减少冗余计算?答案是肯定的。我们考虑辗转相减法求 \(\gcd\) 的逆过程,从 \((1, 1)\) 开始,每次从 \((a, b)\) 走向 \((a, a+b), (b, a+b)\),可以证明,这样能够不重不漏找到所有互质对。注意 \((1, 1)\) 可能重复统计的细节。
#include <cstdio>
#include <utility>
const int N = 10010;
int n, head = 1, tail;
std::pair<int, int> Q[N * N];
signed main() {
n = 10000;
Q[++tail] = { 1, 1 }, ++head;
Q[++tail] = { 1, 2 };
while (head <= tail) {
auto &[x, y] = Q[head++];
if (x + y > n) continue;
Q[++tail] = { y, x + y };
Q[++tail] = { x, x + y };
}
printf("%d", tail);
return 0;
}
于是,从 \(3.75\) 秒优化至 \(0.16\) 秒,我们找出了所有可供使用的线段。下一步就是考虑用这些线段计算答案了。
计算肯定是通过 DP 的方式。阶段显然是当前决策到了第几条线段,但是状态是什么?也就是说怎么刻画一个局面?使用「已经完全覆盖了 \(1 \sim j\)」作为状态合适吗?不太好思考,我们不妨从别的角度来思考问题。
对于线段覆盖类问题,我们一般按照某一端点进行排序,左右端点是本质相同的,这里我们不妨按照左端点从小到大考虑。这对我们的状态设计有什么帮助呢?我们发现,当我们按照左端点排过序后决策,一个最终可能合法的局面,在任意时刻都覆盖了数轴的一段连续前缀。这样考虑,如果当前选择了某一条线段,和上一次之间留有空隙,那么这个空隙在之后都不会被填上,因为之后的线段的左端点不小于当前线段。所以可以使用这种状态。
状态设计好后,转移方程应该信手拈来了。我们再明确一下,记 \(f_{i,j}\) 表示决策了前 \(i\) 条线段,覆盖了数轴 \([1, j]\) 的前缀,方案数为多少。从 \(f_{i-1,j'}\) 转移到 \(f_{i,j}\),若不选择当前线段 \([l_i,r_i]\),则继承 \(i - 1\) 的方案数,即初始 \(f_{i,j}=f_{i-1,j}\),接下来考虑选择这条线段的转移。
- \(j' < l_i\)。
正如上文所言,不能选择当前线段。 - \(j' \in [l_i, r_i)\):
拼上当前线段后,覆盖的前缀增长为 \([1, r_i]\),故 \(f_{i,r_i} \gets f_{i,r_i}+\sum\limits_{j=l_i}^{r_i-1}f_{i-1,j}\)。 - \(j' \geq r_i\):
当前线段选择后,不会改变覆盖的前缀,故 \(f_{i,j}\gets f_{i,j}+f_{i-1,j}\)。
边界 \(f_{0,1} = 1\),答案为 \(f_{k, n}\),其中 \(k = \sum\limits_{i=1}^n\varphi(i)\),即线段条数。
已经能够单次 \(\mathcal{O}(n)\) 转移了,但是这样时间复杂度是 \(\mathcal{O}(n^3)\) 的。滚一滚不做赘述,我们来观察以下代码:
for (int i = 1; i <= n; ++i) {
for (int j = i + 1; j <= n; ++j)
if (cop[i][j]) { // i and j are coprimes
for (int o = j; o <= n; ++o) f[o] = add(f[o], f[o]);
for (int o = i; o < j; ++o) f[j] = add(f[j], f[o]);
}
}
我们发现,这就是一个后缀区间乘 \(2\)、区间求和、单点加操作。有人的 DNA 已经动了,开始线段树模式了。千万不要数据结构学傻了,这么优美的形式,当然要使用优雅地解决方法呀。
倘若不看别的,但看这个乘 \(2\) 操作,我们完全可以一次性扫一遍,只需要扫的时候,维护当前需要乘多少次 \(2\) 即可。
但看这个区间求和,也可以线性扫一遍,同时维护 \([i, j)\) 的 \(f\) 的和即可。
两者,显然可以融合。唯一要小心处理的是 \(f_j\) 先自乘 \(2\),再加。维护方法很多,给出我的方式,可能有些笨:
for (int i = 1; i <= n; ++i) {
int sum = 0;
for (int j = n; j >= i; --j) toadd(sum, f[j]);
for (int j = n; j > i; --j) {
tosub(sum, f[j]);
if (cop[i][j])
toadd(f[j], f[j]), toadd(f[j], sum);
}
for (int j = i + 1, tag = 1; j <= n; ++j) {
tomul(f[j], tag);
if (cop[i][j]) toadd(tag, tag);
}
}
于是,我们可以以严格 \(\mathcal{O}(n^2)\) 的时间复杂度解决此问题。
代码
#include <cstdio>
#include <utility>
const short N = 10010;
short n;
int mod;
int head = 1, tail;
std::pair<short, short> Q[N * N];
bool cop[N][N];
int f[N];
inline void toadd(int& a, const int& b) {
(a += b) >= mod && (a -= mod);
}
inline void tosub(int& a, const int& b) {
(a -= b) < 0 && (a += mod);
}
inline void tomul(int& a, const int& b) {
a = 1ll * a * b % mod;
}
signed main() {
scanf("%hd%d", &n, &mod);
Q[++tail] = { 1, 1 }, ++head;
Q[++tail] = { 1, 2 };
while (head <= tail) {
auto &[x, y] = Q[head++];
if (x + y > n) continue;
Q[++tail] = { x, x + y };
Q[++tail] = { y, x + y };
}
for (int i = 1; i <= tail; ++i)
cop[Q[i].first][Q[i].second] = true;
f[1] = 1;
for (int i = 1; i <= n; ++i) {
int sum = 0;
for (int j = n; j >= i; --j) toadd(sum, f[j]);
for (int j = n; j > i; --j) {
tosub(sum, f[j]);
if (cop[i][j])
toadd(f[j], f[j]), toadd(f[j], sum);
}
for (int j = i + 1, tag = 1; j <= n; ++j) {
tomul(f[j], tag);
if (cop[i][j]) toadd(tag, tag);
}
}
printf("%d", f[n]);
return 0;
}
Generator
if (argc < 4) {
cout << "Usage: " << args[0] << " NMAX MODMAX FORCEMODE SEED" << endl;
return 0;
}
if (string(args[3]) != "true" && string(args[3]) != "false") {
cout << "FORCEMODE should be 'true' or 'false'" << endl;
return 0;
}
int NMAX = stoi(string(args[1]));
int MODMAX = stoi(string(args[2]));
bool FORCEMODE = string(args[3]) == "true";
int SEED = stoi(string(args[4]));
mt19937 rand_num(SEED);
static auto rand = [&] (int l, int r) -> int {
uniform_int_distribution<int> dist(l, r);
return dist(rand_num);
};
cout << (FORCEMODE ? NMAX : rand(max(2, NMAX - 234), NMAX)) << ' ' << (FORCEMODE ? MODMAX : rand(max(2, MODMAX - 234), MODMAX)) << endl;
数据范围
const int NMAX[50] = {
5, 8, 13, 18, 20,
50, 60, 80, 90, 100,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500,
1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000,
5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000, 5000,
10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000
};
const int MODMAX[50] = {
1000000000, 1000000000, 1000000000, 1000000000, 1000000000,
2, 20, 200, 2000, 20000,
114, 514, 19, 19, 810, 10000, 10000, 10000, 10000, 10000,
100000, 100000, 1000000, 10000000, 100000000, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007,
1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007,
1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007, 1000000007,
};
const bool FORCEMODE[50] = {
true, true, true, true, true,
true, true, true, true, true,
false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, true
};
反思
线段覆盖类问题,这种处理方法是套路的。做完这题可以尝试 [USACO20FEB] Help Yourself P。
本文作者:XuYueming,转载请注明原文链接:https://www.cnblogs.com/XuYueming/p/18559166。
若未作特殊说明,本作品采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。