[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}\),接下来考虑选择这条线段的转移。

  1. \(j' < l_i\)
    正如上文所言,不能选择当前线段。
  2. \(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}\)
  3. \(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

posted @ 2024-11-21 21:56  XuYueming  阅读(1)  评论(0编辑  收藏  举报