2024 ICPC 网络预选赛 第 2 场
“你有一天会明白,构成我们身体的原子都来自于恒星的聚变反应,也终有一天它们会归于星辰,只要你的思绪存在,就可以跨越星河,永远璀璨”
0. 批斗
难度排序:F<<A=J=I<L=G=E<C=K=H
赛后批斗,很遗憾最后只开出了 \(6\) 题无缘晋级赛。
\(F\) 题签到。队友写的。一发过。
\(I\) 开赛就被我秒了,我尝试让队友抢一血,但是队友说会 \(F\) 和 \(J\) 所以放他先写完,反正没手速也抢不了一血。
于是我继续和另一个队友去讨论了 \(I\) 的正确性,讨论出来发现显然是对的。我们打算写 \(I\) 的时候已经过了四十多队了,由于机器下面的俩人没有代码能力,口述做法给了主码手让他实现,很快一发过了。(赛后我自己去实现甚至花了半个小时,而且这时候我还不困)
\(J\) 题我们队刷题少的人在这场比赛之前完全不会微扰法,还好队友会。我当时没看。队友一发过。
\(A\) 题是另一个队友秒了的,和我讨论了正确性后发现很对。但是他手速很慢,实现花了很久。也一发过了。
这时候队伍排名已经遥遥领先了。接下来才是惨败。
\(L\) 题队友猜了一个贪心,甚至我也觉得大概率是对的,但是错了。事后队友给了一个几乎正确的做法,我第一次检查感觉没问题,\(WA\) 了一发。很久以后我发现队友的期望多算了 \(1\) ,深入询问后,确定了他看错了题。
重新读题后更新了公式,但公式莫名其妙被约没了,这时候队友怀疑思路是错的,我提出了坚信这个思路是对的观点,然后独自去推公式了。
不久后推出了存在一个 \(v\) 满足左边的贡献为 \(\frac{1}{n} i\) ,右边为 \(1 + E(X)\) 。这时候队友也同时发现之前的公式被约没是因为期望又少算了 \(1\) ,再次检验后发现和我刚想到的公式一样,于是笃定是完全对的。
上面两个错误的观察卡了我们几乎两个多小时。当时我为了追罚时不想求导,劝队友上机三分秒掉,结果手速过慢写了十几分钟。实际上求个导找极点估计只要三五分钟。
\(G\) 题是我们卡 \(L\) 的时候队友写的。赛后观察了一下显然需要强制连胜才能导致游戏继续,考虑维护概率前缀暴力 \(dfs\) 模后的状态,容易检验正确性为每次只会让状态乘以 \(2\) 且最多有 \(\log n\) 层状态。因为去年多校写过这种题所以很容易想,如果先开 \(G\) 而不是 \(L\) 我们应该能半小时内想完+写完。
\(E\) 题一眼看过去完全没思路,询问队友后发现他的思路正确性显然。于是三个人讨论一下后发现维护奇偶染色是显然的,但是有两个人不会写代码,只能干瞪眼等主码手写。
但还是写 \(WA\) 了,于是大家都对着打印一直找 \(bug\) 。看比赛快要结束了,我提出 \(bfs\) 染色的部分是对的试图抢救一下,但是时间还剩二十分钟估计队友没精力听我说话。
虽然怀疑是路径维护出了问题,但是我当时自己也没学过路径维护的正确做法,加上队友的代码很难看不确定他是不是真的错了,也没敢强行让队友重写路径维护,而是寄托于主码手自己调。
最后还是没开出来。
赛后检查发现 \(E\) 几乎纯粹就是有人读错题了,错误的理解导致细节维护错了,当时没有一个人察觉,只对着打印干瞪眼了半天。
很遗憾晋级失败了,也不一定能成为外卡选手了。如果被宣告拿不到卡,就是有些人九月已经死了,十一月才闭掉眼睛。
前期的手速也没有任何作用,遇到 \(L\) 和 \(E\) 这种调不出 \(bug\) 的题,很无奈共最后总是会体现成被其他队伍题数碾压。
很遗憾会以这样的方式收场。
我们队手速快的人没有解决难题的能力,会做难题的人没有代码能力,或许一直是我们队伍的致命缺陷,暴露得如此彻底。
1.题解报告
F
题意:
给 \(a_0 = 1500\) ,询问第一个 \(i\) 使得 \(\sum_{i = 0}^{i} a_i \geq 4000\) 。
题解:
线性能过,那就秒了,没什么说的。
Code
int n; std::cin >> n;
std::vector<i64> a(n + 1);
a[0] = 1500;
int v = -1;
for (int i = 1; i <= n; i++) {
std::cin >> a[i];
a[i] += a[i - 1];
if (v == -1 && a[i] >= 4000) {
v = i;
}
}
std::cout << v << "\n";
A
题意:
给 \(n\) 支队伍,每支队伍能力值为 \(a_i \ (1 \leq i \leq n)\) ,属于学校 \(b_i \ (1 \leq i \leq n)\) 。有 \(k\) 场区域赛,第 $ \ (1 \leq i \leq k)$ 个赛区限制每个学校最多派遣 \(c_i\) 队。每支队伍最多选择两个赛区。
每支队伍不知道其他队伍的选赛区情况(哪怕是自己学校的),询问第 \(i, i = 1, 2, \cdots, n\) 支队伍在最优选择情况下的最坏的最高排名。
题解:
容易发现这是一个欺骗题,我们并不期望能够获得的排名率尽可能优,而是希望排名尽可能高。
于是最高排名一定会选择最小的赛区。直接 \(ban\) 掉 \(c\) 数组和可选两个赛区的操作。
维护出每个学校的队伍,假设有 \(m\) 个学校分别有 \(t_i \ (1 \leq i \leq m)\) 支队伍。
考虑最优选择下的最坏情况。
令 \(mn = \mathbf{min}\{c_i\}\) ,这个赛区最坏会被所有学校最强的 \(\mathbf{min}(mn, t_i)\) 支队伍选择。维护出这些队伍并进行一个升序排序,假设存储在 \(vec\) 。
考虑每个队伍,不妨是第 \(i \ (1 \leq i \leq n)\) 个。
- 这个队伍如果在这些强队中,只需 \(lower\_bound\) 出第一个不弱于它的队伍的位置 \(p\) ,这个队伍一定是它,于是它在 \(p\) 这个位置。
- 这个队伍如果不在这些强队中,考虑 \(lower\_bound\) 出第一个不弱于它的队伍的位置 \(p\) ,挤掉右边一个同校的队伍,最坏会让从 \(p\) 开始的队伍右移一位,于是它也在 \(p\) 这个位置。
答案是 \(|vec| - 1 - p + 1 = |vec| - p\) 。
Code
int n, k; std::cin >> n >> k;
const int INF = 1 << 30;
int mi = INF;
for (int i = 1; i <= k; i++) {
int x; std::cin >> x;
mi = std::min(mi, x);
}
std::map<std::string, std::vector<int> > sch;
std::vector<int> a(n + 1);
for (int i = 1; i <= n; i++) {
std::string s; int x;
std::cin >> x >> s;
sch[s].push_back(x);
a[i] = x;
}
std::vector<int> teams;
for (auto &t : sch) {
std::vector<int> vec = t.second;
std::sort(vec.begin(), vec.end(), std::greater<>());
for (int i = 0; i < std::min(mi, (int)vec.size()); i++) {
teams.push_back(vec[i]);
}
}
int tot = teams.size();
std::sort(teams.begin(), teams.end());
// for (auto x : teams) std::cout << x << " "; std::cout << "\n";
for (int i = 1; i <= n; i++) {
auto it = std::lower_bound(teams.begin(), teams.end(), a[i]);
int rk = it - teams.begin();
std::cout << tot - rk << "\n";
}
时间复杂度依赖于排序的 \(O(n \log n)\) 和容器调用的 \(O(n \log n)\) 。
J
题意:
给 \(n\) 个物品属性为 \(v_i, c_i, w_i\) 。可以用任意顺序堆叠它们。从上往下第 \(i\) 个物品体积会被压缩为 \(v_i - c_i \times (\sum_{j = 1}^{i - 1} w_j)\) ,询问这 \(n\) 个物品总共的最小体积。
数据保证物品不会被压缩成负体积。(哪怕真有这种情况也不影响做法)
题解:
对于寻求最优排列的问题,可以尝试考虑微扰法。
任选第个 \(i \ (i < n)\) 位的物品,有
考虑仅调换两个物品的顺序,它们的体积之和为
如果第一种情况更优,则应该满足
排序的微扰法正确性容易构造性证明:
从增量法构造偏序的角度考虑:若可以让任意相邻两个物品满足偏序,则任意两个物品满足偏序。
\(\square\)
存在两个相邻物品不具有偏序时,微扰法是失效的。
不难发现 \(\forall i \in [1, n - 1]\) 都和后一个位置满足偏序,于是按偏序 \(w_i \times c_j \geq w_j \times c_i\) 排序能获得最优偏序。
Code
int n; std::cin >> n;
std::vector<std::array<i64, 3> > a(n + 1);
for (int i = 1; i <= n; i++) {
i64 w, v, c; std::cin >> w >> v >> c;
a[i] = {w, v, c};
}
std::sort(a.begin() + 1, a.begin() + 1 + n, [&](std::array<i64, 3> A, std::array<i64, 3> B){
// w_1 / c_1 > w_2 / c_2 -> w_1 * c_2 > w_2 * c_1
return A[0] * B[2] > B[0] * A[2];
});
i64 sum = 0, pre = 0;
for (int i = 1; i <= n; i++) {
sum += a[i][1] - a[i][2] * pre;
pre += a[i][0];
}
std::cout << sum << "\n";
时间复杂度 \(O(n \log n)\) 。
I
题意:
给一个十进制的无符号 \(32\) 位正整数 \(n\) ,询问 \(n\) 能否构造成多项式:
如果能,输出一个多项式。
题解:
显然我们要构造一个相邻位不能同时为 \(0\) 的多项式。
首先传统进制多项式 \(\sum_{i = 0}^{n} a_i x^{i} \ (0 \leq a_i < x)\) 是唯一的。
一个经典的思路是:由于二进制与所给多项式的数位存在大量交集,且二进制一定可以构造出这个多项式。不妨只从二进制考虑微调构造出这个多项式。
考虑一个小学和初中常遇见的奥数公式:只注意 \(2^{k} - \sum_{i = l}^{k - 1} 2^{i} = 2^{l}\) 。
- 存在性显然。
- 每个数位只能取 \(\{-1, 0, 1\}\) 的时候具有唯一性,比如可以用几何法证明。
\(lowbit\) 左边的 \(??001??\) 都可以变成 \(??1-1-1??\) 。
\(lowbit\) 右边的 \(0\) 如果要改成 \(-1\) 需要更低位存在 \(1\) ,矛盾。
不妨通过 \(lowbit\) 判断是否有解,然后把 \(lowbit\) 左边的 \(0\) 全转成 \(-1\) 。然后问题解决了。
Code
const int M = 32;
void solve() {
int n; std::cin >> n;
if ((n >> 0 & 1) == 0 && (n >> 1 & 1) == 0) {
std::cout << "NO\n";
return;
}
std::cout << "YES\n";
std::vector<int> ans(32);
int O = __builtin_ctz(n & -n);
for (int i = O, j = O; i < M; i++) {
while (j < i) {
j++;
}
while (j + 1 < M && (n >> j + 1 & 1) == 0) {
j++;
}
if ((n >> j & 1) == 1) {
ans[j] = 1;
continue;
}
// i, j -> 100...0 -> -1 -1 -1 ... 1
ans[j] = 1;
for (int k = i; k < j; k++) {
ans[k] = -1;
}
// std::cout << i << " " << j << "\n";
i = j;
}
for (int i = 0; i < M; i++) {
std::cout << ans[i] << " \n"[(i + 1) % 8 == 0];
}
}
时间复杂度 \(O(Tw)\) 。注意输出格式为 \(\overline{a_{31}a_{30} \cdots a_{0}}\) 每行空格隔开 \(8\) 个共四行。
副机位刚启动,刚好点开了这题,发现十年前写过,然后直接确定了正确做法。
但队友选择了先跟榜写掉签到题。由于签到都被队友秒了所以拖到第三道题才写。
L
题意:
开局在 \([1, n]\) 随机一个 \(t\) ,每一秒执行一次以下两个操作之一
- 待机,\(t\) 会自然减少 \(1\) 。
- 让 \(t\) 在 \([1, n]\) 重新随机。
询问使用最优操作让 \(t\) 到 \(0\) 的最小时间期望,答案输出最简分数下的分子和分母。
题解:
设游戏开始到游戏结束的最优期望时间是 \(E(X)\) 。
根据期望的线性性考虑期望的和等于和的期望: \(E(X) = \sum_{i = 1}^{n} E(Y_i)\) (第 \(i\) 个点到 \(0\) 的期望)。
考虑 \(i \in [1, n]\) ,在 \(i\) 点走到 \(0\) 的随机变量取值 \(Y_i\) :
- 首先有 \(\frac{1}{n}\) 的概率出现,然后:
- 要么是直接花费 \(i\) 秒走到 \(0\) ,时间贡献是 \(i\) 。
- 要么是使用随机,对时间贡献是 \(1 + E(X)\) 。
- 有 \(1 - \frac{1}{n}\) 的概率不出现,不贡献时间。
于是有
由于 \(i\) 单调,\(1 + E(X)\) 为常数,一定存在一个 \(v\) 使得
不难证明(比如根据单调性+端点检验)一个宽松边界是 \(v \in [1, n]\) ,于是
若 \(E(X)\) 为最优则一定最小,显然 \(E(X)\) 在 \(v\) 的定义域上是对勾函数,于是在 \((0, +\infty]\) 存在极小值。
这时候可以掂量一下是自己求导求极值快还是写三分快(可能对于很多队伍来说写个三分只需要一分钟)。问题可以解决。
如果求导则有
在 \(\sqrt{2n}\) 的上下取整两个点进行函数比较即可得到极小值。
当时我错误地以为队友写三分会比我们推公式快,写着写着发现他不太会写,但当时已经没有决策冷静了,硬写了十几分钟才写出三分。
Code
i64 gcd(i64 a, i64 b) { return b ? gcd(b, a % b) : a; }
void solve() {
i64 n; std::cin >> n;
// E(X) = \frac{v - 1}{2} + \frac{n}{v} -> \sqrt{2v}
i64 v1 = std::sqrt(2 * n), v2 = std::sqrt(2 * n) + 1;
// std::cout << "v " << v1 << " " << v2 << "\n";
auto calc = [&] (i64 v) -> std::array<i64, 2> {
i64 O = v * v - v + 2LL * n, P = 2LL * v;
i64 g = gcd(O, P);
return {O / g, P / g};
};
std::array<i64, 2> F1 = calc(v1), F2 = calc(v2);
if (F1[0] * F2[1] < F2[0] * F1[1]) {
std::cout << F1[0] << " " << F1[1] << "\n";
} else {
std::cout << F2[0] << " " << F2[1] << "\n";
}
}
时间复杂度 \(O(\log n)\) ,需要求 \(O(1)\) 次 \(gcd\) 。如果三分只会增加一个 \(O(\log n)\) 。
最后说说我队如何唐掉的 \(L\) ,队友先猜了一发只待机是最优选择,当时看榜上过了七十队,刷新一下直接过了一百多队,于是示意冲了一发贪心,结果是错了。
后续我们大概宕机了半个小时,才意识到期望的和等于和的期望 + 每个位置在游戏开始时的地位是等价的这件事情。放在期末考试几乎都是送分题,前提是前晚着重复习过概率期望。但在竞赛里太少接触过纯期望了,都是马尔可夫链偏多,反应太慢菜是原罪。
再然后继续出了两个致命点:搞了半天推不出,发现有人读错题,再然后重读题,再然后改公式把公式改错了。于是卡了几乎两个小时。
G
题意:
一局游戏,\(Alice\) 开局有 \(x\) 个筹码,\(Bob\) 开局有 \(y\) 个筹码。 \(Alice\) 赢的概率是 \(p_0\) ,\(Bob\) 赢的概率是 \(p_1\) ,平局的概率是 \(1 - p_0 - p_1\) 。
- 如果有人赢了一局,则败者会减少胜者的筹码数量。若败者筹码数量因此 \(\leq 0\) ,这轮的胜者直接获得整局胜利。否则游戏立刻进入下一轮。
- 如果平局,游戏立刻进入下一轮。
给出 \(a_0, a_1, b\) 以表示 \(p_0 = \frac{a_0}{b}, p_1 = \frac{a_1}{b}\) 。
询问 \(Alice\) 能获得全局胜利的概率,答案模 \(998244353\) 。
题解:
因为平局会导致游戏直接进入下一轮,所以平局实际上是一个状态的自环。因为只考虑获胜的后继和失败的后继,所以自环是无效状态。
于是重定义胜败的概率,\(p_0 = \frac{a_0}{a_0 + a_1}\) \(p_1 = \frac{a_1}{a_0 + a_1}\) 。
分析状态:
- 若 \(x = y\) ,有 \(p_0\) 的概率 \(Alice\) 转移向胜态,\(p_1\) 的概率 \(Bob\) 转移向胜态。
- \(Alice\) 获得全局胜利的概率为,到达当前局面的概率 \(cur\) 乘以 \(p_0\) 。
- 若 \(x < y\) ,\(Alice\) 必须连赢后续 \(d = \lceil \frac{y - x}{x} \rceil\) 轮才能使游戏继续。游戏从当前状态继续的概率为 \(p_{0}^{d}\) ,\(y := y - d x\) 。
- 若 \(x > y\) ,\(Alice\) 必须连输后续 \(d = \lceil \frac{x - y}{y} \rceil\) 轮才能使游戏继续。游戏从当前状态继续的概率为 \(p_{1}^{d}\) ,\(x := x - d y\) 。
- 但凡 \(Alice\) 没有连输 \(d\) 次,都能获得全局胜利并终止游戏。\(Alice\) 在后续连续 \(d\) 次中存在胜利的概率,可以方便地用容斥计算,为 \(1\) 减去 \(Alice\) 达到当前状态的概率乘以连输 \(d\) 次的概率,这个概率为到达当前局面的概率 \(cur\) 乘以 \(p_1^{d}\) 。
尝试暴力搜索这些状态,按轮次的不独立性以乘法原理维护一个搜索树前缀的概率。
不难分析出每个状态要么 \(x\) 至少减半,要么 \(y\) 至少减半。最多会有 \(2 \log n\) 个状态。于是复杂度是对的。
或者可以注意到
- \(y := y - dx \Leftrightarrow y := y \bmod x\) 。
- \(x := x - dy \Leftrightarrow x := x \bmod y\) 。
这个状态递降速度等于辗转相除的递降速度,也可以认为状态个数是 \(O(\log n)\) 的。
Code
const int MOD = 998244353;
i64 ksm(i64 a, i64 n) {
i64 res = 1; a = (a + MOD) % MOD;
for(;n;n>>=1,a=a*a%MOD)if(n&1)res=res*a%MOD;
return res;
}
void solve() {
i64 x, y; std::cin >> x >> y;
i64 a0, a1, b; std::cin >> a0 >> a1 >> b;
b = a0 + a1;
i64 p0 = a0 * ksm(b, MOD - 2) % MOD;
i64 p1 = a1 * ksm(b, MOD - 2) % MOD;
i64 ans = 0;
std::function<void(i64, i64, i64)> dfs = [&] (i64 cx, i64 cy, i64 cur) {
// std::cout << cx << " " << cy << "\n";
if (cx == cy) {
ans = (ans + cur * p0) % MOD;
return;
} else if (cx < cy) {
i64 d = (cy + cx - 1) / cx - 1; // ceil( (cy - cx) / cx )
dfs(cx, cy - d * cx, cur * ksm(p0, d)) % MOD;);
} else { // cx > cy
i64 d = (cx + cy - 1) / cy - 1; // ceil( (cx - cy) / cy )
ans = ( ans + cur * (1 - ksm(p1, d) + MOD) ) % MOD;
dfs(cx - d * cy, cy, (cur * ksm(p1, d)) % MOD;);
}
};
dfs(x, y, 1);
std::cout << ans << "\n";
}
E
题意:
给 \(n\) 个点,\(m\) 条边,组成无向图,没有自环和重边。给一个定值 \(d\) 。
然后给一个 \(k\) ,有 \(k\) 个机器人在 \(s_1, s_2, \cdots, s_k\) 点。
- 你开始在 \(1\) 点,需要前往 \(n\) 点。如果可以做到,需要最快前往 \(n\) 点。
- \(k\) 个机器人的移动通路长度最远是 \(d\) 步,他们会随机游走。允许在原路径上撤回,会减少而不是增加步数的贡献。
- 你每走一步,所有机器人会同时走一步且一定会走一步。
- 你如果和机器人处于同一个点,视为你被击杀。
- 机器人和你同时到达 \(n\) 点,视为你被击杀。
- 如果你从 \(u\) 到 \(v\) ,机器人从 \(v\) 到 \(u\) ,不视作为你们相遇。
询问你是否一定可以到达 \(n\) 点?如果能,输出一条从 \(1\) 到 \(n\) 的路径。
\(2 \leq n \leq 2 \times 10^{5}, 1 \leq m \leq \mathbf{min}(\frac{n(n + 1)}{2}, 5) \times 10^{5}\)
\(2 \leq k \leq n - 2, 1 < s_k < n\) 。
题解:
首先,随机游走是欺骗性的,只需要考虑最坏情况。
注意你在某个位置能否遇到机器人,只需要考虑是否存在一个机器人有可能在当前时刻也到达这个位置。
首先注意所有机器人的移动半径都是一样的,于是可以用多源 \(bfs\) 进行染色,只需要关心 \(bfs\) 森林的深度。
如果存在机器人的移动半径不一样,则多源 \(bfs\) 算法无法确定森林中某个深度的节点,是由哪个机器人走的。这时候至少单源 \(bfs\) 是一种问题的解决办法,但复杂度的贡献会乘以源点个数。
根据任意机器人的移动半径相同,这里显然能多源 \(bfs\) 。
接下来分析问题。
假设机器人可以选择走或不走,怎么处理?
你的最优选一定是走最短路径,即沿着 \(bfs\) 边走。机器人的最优选一定是可走可不走。
那么从所有机器人开始做一个多源 \(bfs\) ,对他们可达的路径进行一个最早时间戳的染色。设任意节点 \(v\) 能被机器人最早到达的时间是 \(t[v]\) 。
如果你到达某个节点 \(v\) 的时间是 \(k\) ,假设 \(k < t[v]\) ,则一定是安全的,否则最坏情况一定能遇到机器人,你就结束游戏了。
于是你只需要 \(bfs\) 一条最短安全路径,路径上任意 \(v\) 满足 \(k < t[v]\) 。然后输出即可。
机器人一定会走,怎么处理?
如果你到达某个节点 \(v\) 的时间是 \(k\) ,假设 \(k > t[v]\) 。如果某个机器人能在 \(t[v]\) 到达 \(v\) ,则需要延迟时间为在 \(k\) 到达 \(t[v]\) 。
- 如果 \(k - t[v] \bmod 2 = 0\) ,机器人一定可以刚好到 \(v\) 。
- 如果 \(k - t[v] \bmod 2 = 1\) ,机器人不一定可以刚好到 \(v\) 。
看起来不好强行处理?
假设 \(t0[v]\) 为机器人最快奇数时间到达 \(v\) ,\(t1[v]\) 为机器人最快奇数时间到达 \(v\) 。那么问题可以解决。
- 如果 \(k \bmod 2 = 0 \wedge k \geq t0[v]\) ,机器人一定可以刚好到 \(v\) 。
- 如果 \(k \bmod 2 = 1 \wedge k \geq t1[v]\) ,机器人一定可以刚好到 \(v\) 。
如何处理 \(t0, t1\) ?
考虑:单层图上 \(bfs\) 的意义是,只有已染色的节点可以向相邻未染色的节点转移。
常考虑思路:不妨简单粗暴的考虑一个分层图,分成两层即可。考虑让这样连边(从左图变成右图),然后在分层图上多源 bfs 。这样做可以将原图基于染色时间奇偶拆分。于是发现问题可以被解决。
考虑在这个分层图上,从第 \(0\) 层开始多源 \(bfs\) 。显然,每个节点如果在第一层被染色,一定是机器人最快到达的偶数时间。如果在第二层被染色,一定是机器人最快到达的奇数时间。
新图的 \(n\) 和 \(m\) 的规模只是翻倍,复杂度不变。
实际上做多源 \(bfs\) 的时候并不必要真的建出分层图,大多时候只需要利用原图的连通性,通过状态空间限制进行 \(bfs\) 转移,以此维护需要的状态。比如
for (auto v : adj[u]) {
if (f[v][c ^ 1] != INF) { // 利用原图的连通性,实现分层的状态图转移
f[v][c ^ 1] = f[u][c] + 1;
}
}
对于任意一个节点 \(v\) ,你的最优选并不是最快到达,而是分别考虑在偶数时间最快到达和在奇数时间最快到达。所以你的最优选显然也是在分层图上走最短路。
由于图上 \(bfs\) 的路径是一棵树(多源 \(bfs\) 的路径是森林),只需记前驱 \(pre[u][x] = v\) ,表示第 \(x\) 层的 \(u\) 的前驱是第 \(x \oplus 1\) 层的 \(v\) 。若能 \(bfs\) 到 \(n\) 号点,复原路径即可。
终点可能任意一层的 \(n\) 号点,源点只能是第 \(0\) 层的 \(1\) 号点。
多源 \(bfs\) 的时空复杂度为 \(O(n + m)\) 。
Code
int n, m, d; std::cin >> n >> m >> d;
std::vector<std::vector<int> > adj(n + 1);
for (int i = 1; i <= m; i++) {
int u, v; std::cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
const int INF = 1 << 30;
std::queue<std::array<int, 2> > qe;
std::vector<std::array<int, 2> > t(n + 1, {INF, INF});
int k; std::cin >> k;
for (int i = 1; i <= k; i++) {
int x; std::cin >> x;
t[x][0] = 0;
qe.push({x, 0});
}
while (!qe.empty()) {
auto ft = qe.front();
int u = ft[0], c = ft[1];
qe.pop();
if (t[u][c] == d) {
continue;
}
for (auto v : adj[u]) {
if (t[v][c ^ 1] == INF) {
t[v][c ^ 1] = t[u][c] + 1;
qe.push({v, c ^ 1});
}
}
}
std::queue<std::array<int, 2> > qe_;
std::vector<std::array<int, 2> > t_(n + 1, {INF, INF});
std::vector<std::array<int, 2> > pre(n + 1);
qe_.push({1, 0});
t_[1][0] = 0;
while (!qe_.empty()) {
auto ft = qe_.front();
int u = ft[0], c = ft[1];
qe_.pop();
for (auto v : adj[u]) {
if (t_[v][c ^ 1] == INF) {
if (t[v][c ^ 1] == INF || t_[u][c] + 1 < t[v][c ^ 1]) {
t_[v][c ^ 1] = t_[u][c] + 1; // 这时候 t_[v][c ^ 1] 才更新,我在这 WA 了三小时
pre[v][c ^ 1] = u;
qe_.push({v, c ^ 1});
}
}
}
}
if (t_[n][0] == INF && t_[n][1] == INF) {
std::cout << -1 << "\n";
} else {
int x = 0;
if (t_[n][x] > t_[n][x ^ 1]) {
x ^= 1;
}
std::vector<int> road;
int y = n;
while (true) {
road.push_back(y);
if (y == 1 && x == 0) {
break;
}
y = pre[y][x];
x ^= 1;
}
std::cout << (int)road.size() - 1 << "\n";
std::reverse(road.begin(), road.end());
assert((int)road.size() - 1 >= 1);
for (int i = 0; i < (int)road.size(); i++) {
std::cout << road[i] << " \n"[i == (int)road.size() - 1];
}
}
然后这个题实际上我自己做,调了三个小时,你知道的,我不是主码手。我把在 bfs 转移的时候,把还未更新的状态用来判断是否转移合法了。去 QOJ 问了数据才找出 bug 。
这显然是维护 \(bfs\) 转移的问题写得少甚至没写过导致的。
C
经典板子题。\(kmp\) 上的自动机问题,太典了。
题意:
\(n\) 次操作,动态加入 \(s_i, a_i, b_i \ (1 \leq i \leq n)\) 。询问每次操作后的 \(\sum_{i = 1}^{n} \sum_{j = 1}^{i + z_i - 1} a_j b_i\) 。
题解:
注意若 \(n\) 是动态变大, \(\sum_{i = 1}^{n} \sum_{j = i}^{i + z_i - 1} a_j b_i\) 这种公式显然是可以通过作差法获得增量,以做到动态维护的。
举两个例子:
一
二
上述都只需要维护前缀和即可 \(O(1)\) 回答更新,而不需要重新计算。
题目中定义 \(z_i\) 为 \(s\) 和 \(s[i \cdots n]\) 的最长公共前缀 \(lcp\) 。且此处钦定 \(z_1 = n\) 而不是 \(0\) 。
现在问题为
比较动态的数组变化,需要展开观察动态的变化发生在哪里。
显然新加入的 \(a_n\) 贡献是最右的一条斜列。 \(a_n\) 的变少,取决于某个 \(z_i\) 的停止增长,那么在 \(i\) 这一位的贡献都是 \(0\) 。
分析 \(z\) 数组。随着 \(s_n\) 动态拼接在 \(s\) 之后,对于某个 \(i\) :
- 要么 \(z_i\) 的 \(lca\) 可以继续延长 \(1\) ,对应 \(z_i = n - i + 1\) 。
- 要么刚好被确定无法再延长,对应 \(z_i = n - i\) 。
- 要么早就被确定了无法再延长。
于是增量为
如果可以动态求 \(z\) 数组,只需维护前缀 \(pre = \sum_{i = 1}^{n} \cdot [z_i = n - i + 1]\) 。把 \(z_i \neq n - i + 1\) 的 \(b_i\) 贡献从 \(pre\) 中减去。时间复杂度之和是 \(O(n)\) 的。
如果要求静态 \(z\) 数组,显然是 \(exkmp\) ,但问题目前学术界还不存在动态 \(exkmp\) 。
因为 \(exkmp\) 不是自动机……但是 \(kmp\) 是自动机啊!
经典问题: 通过 \(kmp\) 的动态 \(border\) ,实现动态 \(z\) 数组。
以下的 \(kmp\) 理论基于 \(0 \ base\) 下标。
border
\(border\) :对于字符串 \(s[0 \cdots i]\) ,若满足 \(\forall k < i, s[0 \cdots k - 1] = s[i - k + 1 \cdots i]\) ,则 \(s[0 \cdots k - 1]\) 是 \(s[0 \cdots i]\) 的一个 \(border\) 。
失配数组
\(next[i]\) :
- 作为长度是 \(s[0 \cdots i]\) 的最大 \(border\) 的长度。
- 作为下标是 \(s[0 \cdots i]\) 的最大 \(border\) 的右一位。
- \(j = next[j])\) 反复执行,可以获得 \(s[0 \cdots i]\) 的所有 \(border\) 。根据定义是显然的。
考虑构建 \(kmp\) 的过程。先钦定 \(next[0] = 0\) ,\(\forall i \geq 1\) 的失配指针初 \(j\) 初始化为 \(next[i - 1]\) 。
考虑 \(kmp\) 动态加入字符串 \(s_{i}\) 时的处理过程:
若 \(s_{j} \neq s_{i}\) ,则代表 \(i\) 与 \(j\) 失配。
失配的操作:
- 反复缩小 \(border\) ,即 \(j = next[j]\) ,直到 \(j = 0 \vee s_{j} = s_{i}\) 。
- 若 \(s_{j} \neq s_{i}\) ,\(s_{i}\) 的最大 \(border\) 长度为 \(0\) ,即没有 \(border\) 。
- 若 \(s_{j} = s_{i}\) ,则 \(i\) 与 \(j\) 匹配。\(s[0 \sim i]\) 的最大 \(border\) 长度为 \(j + 1\) ,于是 \(next[i] = j + 1\) 。
失配的实际意义:
注意:\(kmp\) 的失配发生时,一定会导致 \(z_{i - 1 - j + 1} = i - 1\) 且 \(z_{i - 1 - j + 1} \neq i\) 。详细地说,\(kmp\) 的每次失配,对应某个 \(k\) ,使 \(z_{k}\) 由未确定转为确定。
- \(\forall k\) ,\(z_k\) 最多只会发生一次由未确定转为确定的状态。于是整个 \(kmp\) 的构建过程,最多出现 \(n\) 次失配。
- 这同时也是另一种证明 \(kmp\) 时间复杂度的方法:失配指针只会在失配发生时回跳,无法回跳或第一次匹配发生时停止。于是 \(kmp\) 时间复杂度是 \(O(n)\) 的,且常数 \(< 1\) 。
考虑加入 \(s_{i}\) 时,所有与 \(s_{i}\) 失配的位置 \(l\) , \(\forall z_{i - 1 - l + 1} = z_{i - l}\) 会由未确定转为被确定。
一个暴力的做法是寻找所有 \(s[0 \cdots i - 1]\) 的 \(border\) ,即尽可能回跳失配指针,可以找到所有 \(l\) 。但暴力找到的 \(border\) ,既存在匹配 \(i\) 的,也存在失配 \(i\) 的,不能保证时间复杂度。
考虑 \(kmp\) 的构建过程中,失配指针 \(j\) 的回跳过程:
- 失配指针回跳过程中:若 \(s_{j} \neq s_{i}\) , \(j\) 和 \(i\) 失配是显然的。
- 失配指针回跳结束后:
- 若 \(j\) 与 \(i\) 仍失配。
- 若 \(j\) 与 \(i\) 匹配。\(\forall l < j\) 若将导致 \(i\) 失配,一定也曾导致 \(j\) 失配。
定义 \(fails[i]\) 表示 \(i\) 的所有失配位。在 \(j\) 回跳时存储 \(j\) 。若最终 \(j\) 和 \(i\) 匹配,则暴力将 \(fails[j]\) 的元素转移到 \(fails[i]\) 。
考虑失配位转移的实现:
for (auto x : vec[k]) {
vec[i].push_back(x);
}
考虑暴力转移失配位的时空复杂度:
- 时间上,失配最多发生 \(n\) 次,时间复杂度总共是 \(O(n)\) 。
- 空间上,每次失配发生导致空间加 \(1\) ,空间复杂度总共是 \(O(n)\) 。
我们不需要真的维护出动态 \(z\) 数组。只需要确定动态的 \(\forall i, z_i \neq n - i + 1\) 是何时发生的,并把这些位置的贡献从 \(pre\) 中减去。
时空复杂度 \(O(n)\) 。处理字符串的自动机问题,下标用 \(0-base\) 会更方便。
然后不是很懂为什么这题要强制在线,可能是存在某些神奇做法可以离线秒了吧?
Code
int n; std::cin >> n;
const int MOD = n;
std::vector<int> nxt(n);
std::vector<int> s(n), a(n), b(n);
std::vector<std::vector<int> > fails(n);
i64 ans = 0, pre = 0;
for (int i = 0; i < n; i++) {
std::cin >> s[i] >> a[i] >> b[i];
s[i] = (s[i] + ans) % MOD;
pre += b[i];
if (i > 0) {
int j = nxt[i - 1];
while (j > 0 && s[j] != s[i]) {
fails[i].push_back(j);
j = nxt[j - 1];
}
if (s[j] == s[i]) {
for (auto x : fails[j]) {
fails[i].push_back(x);
}
j++;
} else {
fails[i].push_back(j);
}
nxt[i] = j;
}
for (auto x : fails[i]) {
pre -= b[i - x];
}
ans += pre * a[i];
std::cout << ans << "\n";
}
\(z\) 数组虽然可以用 \(kmp, exkmp\) 求,但 \(exkmp\) 的核心不仅仅只是 \(z\) 数组。并不意味着 \(exkmp\) 无用,比如扩展成 \(manacher\) 算法,且求 \(z\) 数组的常数更小。
K
经典的拆位分治 \(DP\) 。贡献用组合数和卷积转移。在 \(cf\) 中的评级很可能不超过 \(2400\) 。
题意:
给两个长度为 \(n\) 的序列 \(a, b\) 。一个二分图按如下方式生成:
二分图左侧和右侧各 \(n\) 个节点,左侧第 \(i \ (1 \leq i \leq n)\) 个节点和右侧第 \(j \ (1 \leq j \leq n)\) 个节点间存在一条边,当且仅当 \(a_{i} \oplus b_{j} \geq k\) 。
\(k\) 是给定的。
对 \(x = 1, 2, 3, \cdots, n\) ,计算二分图中大小为 \(x\) 的匹配的数量。结果对 \(998244353\) 取模。
\(1 \leq n \leq 200, 0 \leq k < 2^{60}, 0 \leq a_i, b_i < 2^{60}\) 。
简略题解:
这显然是维护多项式的、按位的、分治 \(dp\) 板子。
考虑某一位 \(d\) 。
如果这位上 \(k\) 是 \(1\) , 这一位能异或出 \(1\) 才可能存在答案。“可能”是因为高位相同,低位不一定依旧大于等于。
则把这一位能异或出 \(1\) 的 \(A\) 和 \(B\) 分两组往低位分治算。返回的多项式卷一下就是现在的答案了。
如果这位上 \(k\) 是 \(0\) ,这一位能异或出 \(1\) 一定存在答案,高位更大一定更大。这位能异或出 \(1\) 的 \(A, B\) 可以任选。
这一位能异或出 \(0\) 可能存在答案,这部分的 \(A, B\) 继续往低位分治算。
于是先去分治算可能的答案,用这两个多项式算某个匹配的贡献后可以知道消耗了多少元素。那么剩下的元素如果能匹配就都能任选,再用组合计数算一下贡献。
详细题解:
异或问题可以拆位处理,并且更高位决定更低位。分治 DP 是显然的。
分治方向通常都是从条件更严格的位置往更不严格的方向分治。
考虑从高位往低位枚举一个 \(d\) ,令 \(K = k \odot 2^{d}\) ,\(A_i = a_i \odot 2^{d}\) , \(B_i = b_i \odot 2^{d}\) 。
钦定忽略掉尾 \(0\) ,只讨论这一位的权值,。问题很轻易的就约束到了,元素集为 \(\{0, 1\}\) 的情况。
但这还不够方便,考虑一个更加细致的拆分。将 \(\forall A_i = 0\) 组成 \(A0\) ,\(\forall A_i = 1\) 组成 \(A1\) ,\(\forall B_i = 0\) 组成 \(B0\) ,\(\forall B_i = 1\) 组成 \(B1\) 。
若 \(K = 1\) 。
则 \(A0, B1\) 的任意元素和 \(A1, B0\) 的任意元素都有连边的可能,其他组合没有贡献。于是只分治计算 \((A0, B1, d - 1)\) 和 \((A1, B0, d - 1)\) 的答案。
让他们分别返回一个多项式 \(f, g\) 。表示二分图的 \([0, 1, 2, \cdots , |f| - 1]\) 匹配的数量,二分图的 \([0, 1, 2, \cdots, |g| - 1]\) 匹配的数量。
数量按组合方案数进行贡献,显然卷积 \(f \circ g\) 即是当前的多项式 \(F\) ,代表 \(\leq d\) 位的方案数。\(|F| = |f| + |g| - 1\) 。
若 \(K = 0\) 。
则 \(A0, B0\) 的任意元素 和 \(A1, B1\) 的任意元素都有连边的可能,需要分治算 \((A0, B0, d - 1)\) 和 \((A1, B1, d- 1)\) 。假设答案分别为 \(f, g\) 。
而 \(A0, B1\) 的任意元素 和 \(A1, B0\) 的任意元素一定可以连边,能直接在 \(d\) 位算出贡献。令 \(N = \mathbf{min}(|A0|, |B1|)\) , \(M = \mathbf{min}(|A1|, |B0|)\) 。
考虑当前的多项式 \(F\) 如何计算贡献:
\(A0,B0\) 如果配出了 \(i \ (i \leq |f| - 1)\) 对,\(A1,B1\) 如果配出了 \(j \ (j \leq |g| - 1)\) 对。
则 \(A0\) \(B0\) 各选了 \(i\) 个,\(A1\) \(B1\) 各选了 \(j\) 个。
\(A0, B1\) 还剩余的匹配数量为 \(k \leq \mathbf{min}(A0 - i, B1 - j)\) 。各从 \(A0 - i\) 和 \(B1 - j\) 中选出 \(k\) 个组合,其中一组钦定顺序,第二组全排列,即匹配的组合方案数。
\(A1, B0\) 还剩余的匹配数量为 \(l \leq \mathbf{min}(A1 - j, B0 - i)\) 。各从 \(A1 - j\) 和 \(B0 - i\) 中选出 \(l\) 个组合,其中一组钦定顺序,第二组全排列,即匹配的组合方案数。
于是
考虑组合贡献,最多有 \(\mathbf{min}(|A0|, |B0|) + \mathbf{min}(|A1|, |B1|) = \mathbf{min}(|A0| + |A1|, |B0| + |B1|) = \mathbf{min}(|A|, |B|)\) 个匹配,\(|F| = \mathbf{min}(|A|, |B|) + 1\) 。
这部分代码为
std::vector<i64> f = dfs(A0, B0, D - 1);
std::vector<i64> g = dfs(A1, B1, D - 1);
std::vector<i64> F(std::min(A.size(),B.size()) + 1);
for (int i = 0; i < f.size(); i++) {
for (int j = 0; j < g.size(); j++) {
for (int k = 0; k <= std::min(A0.size() - i, B1.size() - j); k++) {
for (int l = 0; l <= std::min(A1.size() - j, B0.size() - i); l++) {
F[i + j + k + l] = (F[i + j + k + l] + f[i] * g[j] % MOD *
Comb[A0.size() - i][k] % MOD * Comb[B1.size() - j][k] % MOD * fac[k] % MOD *
Comb[A1.size() - j][l] % MOD * Comb[B0.size() - i][l] % MOD * fac[l] % MOD ) % MOD;
}
}
}
}
注意这里 \(F\) 并不是单纯多项式卷积得到的多项式,而是多项式卷积混着组合贡献得到的多项式。
可以继续化简一下这个式子(只是化简,不能降低复杂度)。先将 \(\left ( \binom{A0 - i}{k} \times \binom{A1 - j}{k} \times k! \right ) \times \left ( \binom{A1 - j}{l} \times \binom{B0 - i}{l} \times l! \right )\) 的结果处理成卷积 \(h\) 。
于是 \(F = f \circ g \circ h\) ,容易发现 \(h\) 是根据 \(f, g\) 的贡献动态算出的,相当于二元静态卷积卷上一个动态卷积。而卷积优化算法只能处理动态卷积。
化简后,代码将更新成如下(本质不变)。
std::vector<i64> f = dfs(A0, B0, D - 1);
std::vector<i64> g = dfs(A1, B1, D - 1);
std::vector<i64> F(std::min(A.size(),B.size()) + 1);
for (int i = 0; i < f.size(); i++) {
for (int j = 0; j < g.size(); j++) {
std::vector<i64> h = calc(A0.size() - i, B1.size() - j) * calc(A1.size() - j, B0.size() - i);
for (int k = 0; k < h.size(); k++) {
F[i + j + k] = (F[i + j + k] + f[i] * g[j] % MOD * h[k]) % MOD;
}
}
}
考虑分治递归树的叶子如何处理,按位分治不需要在 \(d = 0\) 统计答案,因为 \(0\) 依然需要分治计算。
考虑 \(d = -1\) 时的 \((A, B, -1)\) ,这个状态递归到 \(d = -1\) 的答案相当于 \(A, B\) 在 \(d = 0\) 的组合贡献的答案。
注意如果递归传入了空序列,如果代码处理得不好很可能会触发 \(bug\) 。最好是出现这种情况直接返回一个向量 \(\{1\}\) 表示有 \(1\) 种方法实现 \(0\) 的匹配数(即不匹配)。
考虑如何分析复杂度?看似时间复杂度为 \(O(\log k \times n^{4})\) 。
考虑最坏是 \(60\) 次 \(O(n^{4})\) 的 \(4\) 重乘法,\(n = 200\) 的数据怎么通过?\(60 \times 200^{4}\) 过一秒,是我不会优化?还是出题人是傻逼?
都不是。先别急,这不是西安赛区。分析复杂度,\(4\) 重循环如果在最高位出现,最坏是 \((\frac{1}{2} n)^{4} = \frac{1}{16} n^{4}\) ,否则容易退化成 \(2\) 重循环的复杂度。
假设当前层的多项式大小为 \(n\) ,如果能继续递归向下,当前层的 \(4\) 重循环如果需要最坏,则下一层的多项式大小为 \(\frac{1}{2} n\) 。
考虑能在所有 \(60\) 层发生 \(4\) 重循环且每层都最坏,则时间贡献是
这个常数如果是无穷级数算出来也只是 \(\frac{1}{8}\) ,只有 \(60\) 位几乎等于 \(\frac{1}{16}\) ,所以 \(1\) 秒的时限是完全合理的。
要算卷积,下标用 \(0-base\) 会更方便。
Code
const int MOD = 998244353;
const int MAXN = 205;
i64 Comb[MAXN][MAXN], fac[MAXN];
void initC() {
Comb[0][0] = 1;
fac[0] = 1;
for (int i = 1; i < MAXN; i++) {
fac[i] = (fac[i - 1] * i) % MOD;
Comb[i][0] = Comb[i][i] = 1;
for (int j = 1; j < i; j++) {
Comb[i][j] = (Comb[i][j] + Comb[i - 1][j] + Comb[i - 1][j - 1]) % MOD;
}
}
}
std::vector<i64> operator * (std::vector<i64> f, std::vector<i64> g) {
std::vector<i64> F(f.size() + g.size() - 1);
for (int i = 0; i < f.size(); i++) {
for (int j = 0; j < g.size(); j++) {
F[i + j] = (F[i + j] + f[i] * g[j]) % MOD;
}
}
return F;
}
std::vector<i64> calc (int A, int B) {
std::vector<i64> F(std::min(A, B) + 1);
for (int i = 0; i <= std::min(A, B); i++) {
F[i] = (F[i] + Comb[A][i] * Comb[B][i] % MOD * fac[i]) % MOD;
}
return F;
};
void solve() {
int N; std::cin >> N;
i64 K; std::cin >> K;
std::vector<i64> a(N), b(N);
for (int i = 0; i < N; i++) {
std::cin >> a[i];
}
for (int i = 0; i < N; i++) {
std::cin >> b[i];
}
std::function<std::vector<i64>(std::vector<i64>, std::vector<i64>, int)>
dfs = [&] (std::vector<i64> A, std::vector<i64> B, int D) -> std::vector<i64> {
if (A.empty() || B.empty()) {
return {1};
}
auto div_to_parity = [&] (std::vector<i64> vec, std::vector<i64> &v0, std::vector<i64> &v1) {
for (int i = 0; i < vec.size(); i++) {
if (vec[i] >> D & 1) {
v1.push_back(vec[i]);
} else {
v0.push_back(vec[i]);
}
}
};
std::vector<i64> A0, A1;
div_to_parity(A, A0, A1);
std::vector<i64> B0, B1;
div_to_parity(B, B0, B1);
if (D == -1) {
return calc(A.size(), B.size());
}
if (K >> D & 1) {
return dfs(A0, B1, D - 1) * dfs(A1, B0, D - 1);
} else {
std::vector<i64> f = dfs(A0, B0, D - 1);
std::vector<i64> g = dfs(A1, B1, D - 1);
std::vector<i64> F(std::min(A.size(),B.size()) + 1);
for (int i = 0; i < f.size(); i++) {
for (int j = 0; j < g.size(); j++) {
std::vector<i64> h = calc(A0.size() - i, B1.size() - j) * calc(A1.size() - j, B0.size() - i);
for (int k = 0; k < h.size(); k++) {
F[i + j + k] = (F[i + j + k] + f[i] * g[j] % MOD * h[k]) % MOD;
}
}
}
return F;
}
};
int d = 60;
std::vector<i64> ans = dfs(a, b, d - 1);
for (int i = 1; i <= N; i++) {
std::cout << ans[i] << "\n";
}
}
时间复杂度 \(O(n^{4})\) ,常数大概为 \(\frac{1}{16}\) 。
似乎这题有一个经典的边卷边算的卷积 trick ,可以用 CDQ 分治跑得更快?感觉用了就上当了。
H
人均没注意到数据随机这句话(写在输入数据里怎么注意到……),榜没歪的话实际上只能算一道银牌题。
这题的 \(O(n^{2})\) 做法是十分常规的做法。普通的扫描线板子和凸壳 DP 板子,两个一套就十分常规。
然后由于数据随机完全有可能在上面这个基础上乱优化乱暴力一下就过了。
题意:
二维平面上有 \(n\) 个点 \(P_1, p_2, \cdots, p_n\) 。第 \(i\) 个点位置为 \((x_i, y_i)\) ,权值为 \(w_i\) 。
令 \(S(a, b) = \{p_i | x_i \leq a \wedge y_i \leq b \}\) 。考虑对参数 \((a, b, c)\) 的一个询问,这个询问为“是否存在一个 \(S(a, b)\) 的子集 \(T\) 满足 \((\sum_{p_i \in T} w_i) \bmod n = c\) ?”。让 \(query(a, b, c)\) 表示这个询问的结果,返回一个布尔值表示 \(True/False\) 。
你需要回答
\(2 \leq 5 \times n \leq 10^{5}, 1 \leq x_i, y_i, c \leq n\)
吐槽:
从模数和权值范围来看就知道出题人是个好人而且是有东西的,因为我们只需要开 \(64\) 位无符号整型而不需要取模,坐标也不需要离散化。
事后好像几乎查明了是 Claris 出的这题。
题解:
为了方便说明复杂度,这里把式子修改成
设第一维范围 \(n\) ,第二维范围 \(m\) ,点共 \(N\) ,点集价值的值域 \(M\) 。
出题人让所有数据范围同阶,有利于比赛,但并不利于补题时对算法时间复杂度分析的学习。
首先,条件反射地可以想到讲操作离线下来。
采取一个 \(vector<int> point[x]\) 存储第 \(x\) 行(也可以看成列)上的所有点,一般是存离线出来的点的 \(id\) 。存储完毕很多时候后可以对点排序方便操作。
问题处于扫描线的角度之下,会变得简单粗暴地容易处理。
扫描线 \(for \ x = 1 \to n\) ,然后暴力遍历或快速检查当前 \(point[x]\) 的坐标,这只需要 \(O(n + N)\) 的时间消耗。
如果题目故意卡处理:
- 如果值域很大就离散化。
- 如果限制了二维的 \(n \times m\) 的数据范围,就 \(for\) 更小的那维,或者转置坐标矩阵。
条件反射完了,再(先)分析一下题目。
如果能从几何意义角度观察,\((a, b)\) 的询问约束出的是一个前缀矩形区域。
最直观的是:若 \(query(a, b, c) = true\) ,那么 \(query(\geq a, \geq b, c) = true\) 。
考虑扫描线过程重的某个时刻的点空间。
- 对于任意一组点集 \(T\) ,若符合条件 \((\sum_{p_i \in T_k} w_i) \bmod = c\) 。只需考虑所有满足这个性质的所有 \(T\) 中,存在一个满足条件的点集 \(P_c\) 的凸壳顶点最低,后续加入的任意点如果比这个点更高,不会影响到 \(c\) 的贡献。称 \(P_c\) 为 \(c\) 的一个最优点集。
- 某个新点加入,可能存在 \(c\) 对应的最优点集 \(P_c\) 会发生改变。
于是定义 \(f_{k, c}\) 为已经加入了 \(k\) 个点,满足 \((\sum_{p_i \in T_k} w_i) \bmod = c\) 的点击的凸壳的最低顶点。
考虑如果当前加入了第 \(k\) 个点 \(p\) ,属性为 \(x, y, w\) 。
- \(p\) 可以不加入任意 \(P_c\) 。
- \(p\) 可以尝试加入任意 \(P_c\) 。则点集从 \(P\) 变为了 \(P_{'}\) ,权值从 \(c\) 变为了 \((c + w) \bmod n\) 。若 \(P_{(c + w) \bmod n}\) 能被更新成更优点集,则 \(f_{k, (c + w) \bmod n}\) 可以由 \(f_{k - 1, c}\) 转移。
显然转移依赖于凸壳顶点 \(\mathbf{max}(f_{k - 1, c}, y)\) 。于是状态转移方程为 \(\mathbf{checkmin}(f_{k, (c + w) \bmod M}, \mathbf{max}(f_{k - 1, c}, y))\) 。初始状态 \(f_{0, 0} = 0\) 。
这里只利用了概念上的凸壳的更新转移,并不需要真的维护凸壳进行更新转移。
考虑维护 \(f_{k, c}\) 的时间复杂度。显然每个点的加入会导致一次 \(O(M)\) 的转移,总时间复杂度是 \(O(N \times M)\) 。
考虑答案贡献的计算。若当前扫描线坐标为 \(a\) ,则先让扫描线线上的所有点更新完所有凸壳最小点。
- 若 \(a\) 上存在点,视为凸壳最小点一定被更新过。\(\forall c\) ,\(f_{a, c} \sim N\) 的每个点都存在一个 \(c\) 的贡献,共为 \(cost(c) = c \cdot (\sum_{i = 1}^{N} i - \sum_{i = 1}^{f_{a, c} - 1} i) = c \cdot (\binom{N + 1}{2} - \binom{f_{a, c}}{2})\) 。当前 \(a\) 坐标上的总贡献为 \(sum = \sum_{i = 0}^{M - 1} cost(i)\) 。
- 若 \(a\) 上不存在点,代表凸壳没有被更新过。答案只需要增加上一次凸壳更新时得到的总贡献 \(sum\) 。
时间复杂度为 \(O(N \times M + n)\) 。
另外扫面线遍历的时间复杂度为 \(O(N)\) 。
是否需要将第二维的点排序在这题不关键。可以注意到第二维的范围 \(m\) 被优化没了。
于是总的时间复杂度为 \(O(N + N \times M + N \times M + n) = O(N M + n)\) 。
代码如下:
The O(NM+n) solution
int n; std::cin >> n;
std::vector<std::vector<std::array<int, 2> > > point(n + 1);
for (int i = 1; i <= n; i++) {
int x, y, w; std::cin >> x >> y >> w;
point[x].push_back({y, w});
}
std::vector<int> f(n, n + 1);
f[0] = 0;
std::vector<int> g(n, n + 1);
u64 ans = 0, sum = 0;
for (int i = 1; i <= n; i++) {
for (auto yw : point[i]) {
int y = yw[0], w = yw[1];
g = f;
for (int c = 0; c <= n - 1; c++) {
int nxtc = c + w >= n ? c + w - n : c + w;
g[nxtc] = std::min(g[nxtc], std::max(f[c], y));
}
sum = 0;
for (int c = 0; c <= n - 1; c++) {
f[c] = g[c];
if (f[c] != n + 1) {
sum += u64(c) * (u64(n) * (n + 1) - u64(f[c]) * (f[c] - 1)) / 2;
}
}
}
ans += sum * i;
}
std::cout << ans << "\n";
但是这是通过不了数据的。
注意到输入数据描述里存在一行文字,“输入数据随机”。
让考虑一个看似莫名其妙的剪枝优化。
注意状态转移方程 \(f_{i, (c + w) \bmod M} = \mathbf{min}(f_{i, (c + w) \bmod M}, \mathbf{max}(f_{i - 1, c}, y))\) 。
若新加进来的第 \(k\) 个点 \(p\) 纵坐标为 \(y\) ,若 \(y \geq \mathbf{max}\{ f_{i - 1, c} | 0 \leq c < M \}\) ,则更新不了任何顶点最小的最优凸壳。
维护所有最有凸壳的顶点的最大值为 \(mx\) ,若 \(k \geq 2\) 且 \(y \geq mx\) ,则跳过这个点。
由于不确定什么时候第一个点会出现,可以先将 \(mx\) 赋为 \(M + 1\) ,需要更新凸壳顶点的时候再赋为 \(0\) 并更新最大值。
实现这个优化。神秘的事情发生了,然后它就过了?
接下来将会证明这个优化的时间复杂度期望为 \(T(M \ln M \ln N) = O(M \log M \log N)\) 。
先讨论一件事情:\(n\) 个球,每次等概率取出一次,放回。期望多少次全部取完?这是广为人知的“赠券收集者问题”,为 \(O(n \ln n)\) 次。
这个问题太过于经典了以至于我希望再进行一遍推导。
定义 \(E(X)\) 为全部取完的期望的次数。
这个问题直接用定义计算 \(E(X) = \sum_{i \in X} \mathcal{P}(X = i) \times i\) 比较困难。用期望的线性性计算也不容易。
考虑期望间的马尔可夫链转移(求第推式),不妨定义 \(f_{i}\) 为从“已经取了 \(i\) 个球”的状态到“取完”需要的期望次数。
于是我们已知 \(f_{n} = 0\) ,希望计算 \(E(X) = f_{0}\) 。
消耗一次操作后,\(f_{i}\) 有 \(\frac{i}{n}\) 的概率自环,\(1 - \frac{i}{n}\) 的概率转移向 \(f_{i + 1}\) 。
得到递推式后,这里不进行 DP 。累加法是一个简便常用的方法
观察这个 \(\sum_{i = 1}^{n} \frac{1}{n}\) ,虽然是一个更为极度的广为人知的结论,但是还是可以简述一下。
可以发现 \(\frac{d}{d n} \ln n = \frac{1}{n}\) ,对 \(\ln (n + 1) - \ln n\) 和 \(\ln n - \ln (n - 1)\) 累加做差能发现
于是 \(E(X) = f_{0} \approx n \ln n\) 。
考虑随机状态下,已经在平面中加入了 \(k\) 个点,总共会有 \(2^{k}\) 个子点集,这些点击的价值会在 \([0, M)\) 等概率分布。当子点集的数量达到 \(M \ln M\) 个,期望可以覆盖 \([0, M)\) 。\(\ln 2^{k} \approx k \approx \ln (M \ln M) = \ln M + \ln^{2} M = T(\ln M)\) 。
从 \(k = T(\ln M)\) 个点开始,纵坐标第 \(T(\ln M)\) 小的点,会是所有最优顶点最低的凸壳的最大值。
关于 \(k\) 个随机点中第 \(T(\ln M)\) 小的点的期望,是经典的“顺序统计量期望值”。
下述定理经典的,且会是直觉上显然的(但是证明需要生成函数爆算一下,我暂时不会,不妨暂时记一下):
对于 \([a, b]\) 中的随机 \(k\) 个点,询问将这 \(k\) 个点排序后,第 $r \ (r \leq k) $ 小的期望 \(E = a + (b - a) \frac{r}{k + 1}\) 。
于是 \(k\) 个点的第 \(T(\ln M)\) 小点的期望为 \(T(\frac{M \ln M}{k})\) 。在 \([0, M)\) 中随机一个点,小于它的概率为 \(\frac{\frac{M \ln M}{k}}{M} = \frac{\ln M}{k}\) 。
从期望第 \(\ln M\) 个点到第 \(N\) 个点,期望小于当前第 \(\ln M\) 小的点的期望个数为 \(A = \sum_{k = \ln M}^{N} \frac{\ln M}{k} = \ln M \sum_{k = \ln M}^{N} \frac{1}{k} \approx = \ln M \ln N\) 。
从期望第 \(1\) 个点到第 \((\ln M) - 1\) 个点,期望小于当前第 \(\ln M\) 小的点的期望个数为 \(\ln M\) 。
于是凸壳最多会被更新 \(T(\ln M \ln N + \ln M)\) 次。
于是时间复杂度为 \(O(M \log M \log N)\) 。
最终代码
Code
int n; std::cin >> n;
std::vector<std::vector<std::array<int, 2> > > point(n + 1);
for (int i = 1; i <= n; i++) {
int x, y, w; std::cin >> x >> y >> w;
point[x].push_back({y, w});
}
std::vector<int> f(n, n + 1);
f[0] = 0;
std::vector<int> g(n, n + 1);
u64 ans = 0, sum = 0;
for (int i = 1, mx = n + 1; i <= n; i++) {
for (auto yw : point[i]) if (yw[0] < mx) {
int y = yw[0], w = yw[1];
g = f;
for (int c = 0; c <= n - 1; c++) {
int nxtc = c + w >= n ? c + w - n : c + w;
g[nxtc] = std::min(g[nxtc], std::max(f[c], y));
}
sum = 0, mx = 0;
for (int c = 0; c <= n - 1; c++) {
f[c] = g[c];
mx = std::max(mx, f[c]);
if (f[c] != n + 1) {
sum += u64(c) * (u64(n) * (n + 1) - u64(f[c]) * (f[c] - 1)) / 2;
}
}
}
ans += sum * i;
}
std::cout << ans << "\n";
一些鬼故事
- 我多次误把 u64 写成 u32 ?是因为 u32 以前常用,而 u64 没用过
- defint int u64 肯定会慢。最坏可能慢一倍。
- 模 \(n\) 要么快速动态快速模,要么手调一下。动态模数最坏可能慢一倍。
- 终极鬼故事:第二段代码 \(0\) 优化。因为已经贡献了一个 \(O(n)\) 复杂度了,这个位置的 continue 无济于事。对不起我在这卡了一个小时。
// 1-st
if (y < mx) {
continue;
}
g = f; // vector g -> f
// 2-nd
g = f; // vector g -> f
if (y < mx) {
continue;
}