「联合省选 2020 B 卷」做题记录
「联合省选 2020 B 卷」做题记录
卡牌游戏
思维题,贪心
考虑刻画一次操作带来的收益。记 \(s_{i} = \sum_{j = 1}^{i} a_{j}\) ,即 \(a\) 的前缀和。可以发现,如果在某个时刻选择第 \(i\)(\(i > 1\))张卡牌进行操作,得到的收益是恒定的 \(s_{i}\),和之前如何操作无关。所以选择所有满足 \(s_{i} > 0\) 的位置 \(i\)(不包括第一个位置)进行操作就是最优的。
幸运数字
枚举,离散化
做这道题时,一开始我陷入了一个错误的方向,就是直接考虑如何最大化收益。假设没有奖励条件的限制,只考虑选择一堆 \(w\) 异或起来,使得异或和最大,这可以用线性基来做。但我们很难把奖励条件的限制加入进来。
这时候就要考虑换一个方向了!不妨看看第一个部分分:当值域很小时,可以枚举所有数字(显然,枚举 \([\min \{L, A, B\} - 1, \max \{R, A, B\} + 1]\) 之内的数字就行),对每个数字在 \(O(n)\) 时间内计算选择它时的奖励额度。进一步,我们实际上无需对每个数字都花费 \(O(n)\) 的时间计算选择它时的奖励额度:使用递推的思想,从小到大枚举数字,动态地更新奖励额度,可以把总时间复杂度降到 \(O(V)\)。(其中 \(V\) 代表 \(L, R, A, B\) 的值域大小)
想到这步以后正解也近在咫尺了:显然有很多数字的奖励额度是相同的,所以并不需要枚举 \(O(V)\) 个数字。离散化以后只有 \(O(n)\) 个有用的数字。不过,只离散化 \(L, R, A, B\) 这些数字是错误的,容易找出反例。正确的做法是,除了把上述数离散化,还要把形如 \(x - 1\) 和 \(x + 1\) 的数也离散化。除此之外为了处理答案为 \(0\) 的情况,还要把 \(0\) 也离散化。
冰火战士
数据结构
写了三天,总共交了 16 发,战绩可查。
先观察一些性质。
首先,由于只有当一方战士的能量值全部耗尽时战斗才会结束,所以战士出战的顺序其实无关紧要。又因为双方消耗的能量值相等,所以消耗的总能量值是能量值较小一方的二倍。设在温度 \(k\) 下冰系战士出战的能量值总和为 \(f(k)\),火系战士为 \(g(k)\),则消耗的总能量值为 \(F(k) = 2\min(f(k), g(k))\)。(需要满足 \(f(k) > 0\) 且 \(g(k) > 0\)。)
把双方战士按自身温度从低到高排序,可以发现冰系战士中能够出战的形成一段前缀,而火系战士中能够出战的构成一段后缀。那么 \(f(k)\) 单调增而 \(g(k)\) 单调减。因此存在某个温度 \(p\),使得 \(k \le p\) 时 \(f(k) < g(k)\) 而 \(k > p\) 时 \(f(k) \ge g(k)\)。在第一种情况时有 \(\min(f(k), g(k)) = f(k)\) 且 \(f(k)\) 在此条件下最大,第二种情况时有 \(\min(f(k), g(k)) = g(k)\) 且 \(g(k)\) 在此条件下最大,因此分这两种情况讨论即可。
将询问离线,把温度离散化并据此建立树状数组,树状数组上某个位置 \(x\) 的权值代表温度为 \(x\) 的冰/火系战士的能量之和。(一开始看到有加入/删除的操作,我只想到了平衡树,而没有想到可以离线后使用树状数组。如果用平衡树,肯定会由于常数太大而过不了,并且代码也远远比树状数组难写。)设不同的温度的个数为 \(N\),则我们能在 \(O(\log N)\) 的时间内查询一段温度区间内冰/火系战士的能量总和,因而也就能查询某温度下出战的冰/火系的能量总和。
现在通过二分找出分界点 \(p\)。由于温度是离散的,所以这个地方的细节比较多,需要想清楚再写。具体而言,第一次二分先找出最后满足 \(f(p) < g(p)\) 的 \(p\):
int lo = 1, hi = Q, p = -1;
while(lo <= hi) {
int mid = (lo + hi) >> 1;
int pre = tr[0].query(1, mid), suf = tr[1].query(mid, Q);
if(pre < suf) p = mid, lo = mid + 1;
else hi = mid - 1;
}
然后,设 \(q'\) 表示满足 \(g(q') \le f(q')\)(注意这里可以取等,否则考虑的情况不全)的第一个位置。但 \(q'\) 并不一定是第二种情况的答案,因为可能不满足温度最大。所以要求的实际上是 \(q\),满足 \(q\) 是最后一个使得 \(g(q) = g(q')\) 的位置。
要求出 \(q'\),显然可以直接再二分一次。但另一种更好的方法是复用之前求出的 \(p\),因为 \(p\) 的下一个位置 \(p + 1\) 肯定满足 \(f(p + 1) \ge g(p + 1)\),所以直接令 \(q' \gets p + 1\) 即可。但这里有个问题,就是不一定存在满足 \(f(p) < g(p)\) 且 \(f(p) \neq 0\) 的 \(p\),这时候还能直接令 \(q' \gets p + 1\) 吗?实际上是可以的,分两种情况讨论:
-
不存在 \(k\) 使得 \(f(k)\) 和 \(g(k)\) 同时非 \(0\):
(注意,虽然图中两条曲线看起来有交,并且在交点处两者同时非 \(0\),但由于函数是离散的,所以它们实际上不存在交点)
这种情况下,无论什么温度,双方都不能开战。由于此时一定有 \(g(p + 1) = 0\),所以直接判掉这种情况即可。
-
存在 \(k\) 使得 \(f(k)\) 和 \(g(k)\) 同时非 \(0\),但不存在 \(p\) 满足 \(f(p) < g(p)\) 且 \(f(p) \neq 0\):
这种情况下一定有 \(g(p + 1) > 0\),所以可以令 \(q' \gets p + 1\)。
综上所述,我们就在没有再次二分的情况下得到了 \(q'\)。记 \(x = g(q')\),再二分一次得到最大的 \(q\) 使得 \(g(q) = g(q')\) 即可。
在树状数组上查询的时间复杂度为 \(O(\log N)\),二分的时间复杂度也为 \(O(\log N)\),总时间复杂度为 \(O(Q \log^{2} N)\)。在常数较小的时候可以通过。(根据试验,如果用二分找到 \(q'\),则每次询问要二分三次,此时只能得到 60 pts,但如果 \(q'\) 不是用二分找的,则每次询问只用二分两次,可以得到 100 pts。)
要继续优化,可以使用树状数组二分的技巧。实际上与其说是二分,它更像是倍增。在树状数组中,点 \(x\) 维护是长为 \(\operatorname{lowbit}(x)\) 区间 \([x - \operatorname{lowbit}(x) + 1, x]\)。以二分 \(p\) 为例,初始时 \(p = 0\),从 \(\log_{2}N\) 到 \(0\) 倒序枚举 \(i\),每次尝试令 \(p\) 加上 \(2^{i}\),判断扩展以后 \(f(p + 2^{i}) < g(p + 2^{i})\) 是否成立,如果成立就扩展,否则撤回。这里的要点在于,用 \(pre\)记录当前的 \(f(p)\),扩展时,不用在树状数组上查询,而是直接令 \(pre \gets pre + c(p + 2^{i})\) 即可。(其中 \(c\) 是树状数组中的数组。)这是因为,由于我们倒序枚举 \(i\),所以一定有 \(\operatorname{lowbit}(p + 2^{i}) = 2^{i}\),因此 \(c(p + 2^{i})\) 维护的就是 \([p + 1, p + 2^{i}]\) 的信息。这就就成功把单次二分的时间复杂度降到了 \(O(\log N)\),总时间复杂度降为 \(O(Q \log N)\)。
参考代码(部分变量名称可能不同):
int pre = 0, suf = tr[1].query(1, V), p = 0;
for(int i = __lg(V), lst = 0; i >= 0; i--) {
p += 1 << i;
if(p <= V && pre + tr[0][p] < suf - tr[1][p] - lst + sum[p]) {
pre += tr[0][p], suf = suf - tr[1][p] - lst + sum[p];
lst = sum[p];
} else {
p -= 1 << i;
}
}
信号传递
状压 dp
约定:记 \(p\) 代表信号站构成的排列;\(U = [1, n] \cap \mathbb{Z}\)。
本题的关键在于发现花费是可以拆的。也就是说。假设序列 \(S\) 中存在相邻的数对 \((x, y)\),那么产生如下的费用:
换一种表述方法,把费用都摊到 \(x\) 上:每有一个 \((x, y)\) 数对,则在 \(x\) 处产生 \(-p_{x}\)(若 \(p_{y} \ge p_{x}\))或 \(k \cdot p_{x}\)(若 \(p_{y} < p_{x}\))的费用;每有一个 \((y, x)\) 数对,则在 \(x\) 处产生 \(p_{x}\)(若 \(p_{y} \le p_{x}\))或 \(k \cdot p_{x}\)(若 \(p_{y} > p_{x}\))的费用。那么总费用就是所有站点的费用之和。
上述观察是进一步解决问题的基础。
看到 \(m\) 很小,自然想到状压。设 \(f(s)\) 表示把集合 \(s\)(\(s \subseteq U\))中的信号站放到前 \(|s|\) 个位置时,它们产生的最小花费。(正因为我们把费用拆开了,我们才能在排列未完全确定的情况下,能够计算出一部分信号站的贡献)转移时,枚举 \(s\) 之外的一个信号站 \(i\),转移到 \(f(s \cup \{i\})\) 这个状态。实际上这相当于要求:已知前 \(|s|\) 个位置的信号站集合为 \(s\),在第 \((|s| + 1)\)(下面称为 \(p\))个位置放第 \(i\) 个信号站,要能求出 \(i\) 的花费。观察花费的式子,我们发现这是可以求的,因为一个信号站的花费只与在它之前和之后的信号站集合有关,而顺序是无所谓的。
记 \(c(x, y)\) 表示 \(S\) 序列中相邻数对 \((x, y)\) 的个数,则有如下转移方程:
状态数为 \(O(2^{m})\),转移时间复杂度为 \(O(m^{2})\),总时间复杂度为 \(O(n + 2^{m}m^{2})\)。
考虑优化。对于给定的 \(s\) 和 \(i\),则 \(p\) 的系数为 \(\sum_{j \in S}( c(i, j) \cdot k + c(j, i)) + \sum_{j \not\in S \and j \neq i} (-c(i, j) + c(j, i) \cdot k)\),记为 \(g(s, i)\)。转移的瓶颈主要在于求出这个系数,如何优化?实际上它可以递推求出:设 \(j\) 是 \(s\) 内的任意一个元素,那么如果已知 \(g(s \setminus \{j\}, i)\),则 \(g(s, i)\) 容易在 \(O(1)\) 时间内求出:重新计算 \((i, j)\) 和 \((j, i)\) 数对的贡献即可。为了方便,不妨选择 \(s\) 中最小的元素作为 \(j\),代码实现上有 \(j = \operatorname{lowbit}(s)\)。(这里用的是填表法)这样我们就把时间复杂度降为 \(O(n + 2^{m}m)\),但空间复杂度升为 \(O(2^{m}m)\)。
现在时间复杂度已经足够低,但计算发现 \(2^{m}m\) 个 int
的空间占用约为 736 MB,超过了空间限制,所以还要优化空间占用。由于无论是 \(g\) 还是 \(f\) 的转移,都是将一个大小为 \(|s|\) 的集合转移到大小为 \(|s| + 1\) 的集合,所以如果按集合的大小从小到大转移,同一时间最多只有 \(\left(\dbinom{23}{11} + \dbinom{23}{12}\right)\) 个有用的状态,这是开得下的。
必须同时转移 \(f\) 和 \(g\),这样才能保证 \(f\) 和 \(g\) 转移的进度是一样的。值得一提的是,\(f\) 用刷表法转移比较方便,而上文中 \(g\) 使用了填表法转移(也就是 \(g(s, i)\) 从 \(g(s \setminus {\operatorname{lowbit}(s)}, i)\) 转移而来),如果同时转移,必须把二者统一起来。不妨考虑把 \(g\) 的转移改成刷表法。也就是说,对于集合 \(s\),它转移到状态 \(t\),需要满足 \(t - \operatorname{lowbit}(t) = s\)。(实质上我们是在要求每个状态只能从之前的 \(1\) 个状态转移而来,这样才能保证正确的时间复杂度。实际上把 \(g\) 的一个的状态转移多次也不会导致答案错误,但会增加时间开销)可以这么写代码:
for(int i = 1; i <= m; i++) {
if(get(s, i)) break; // 如果s的某一位为1,则退出循环
int t = s | (1 << (i - 1));
// 转移到g(t, *)
}
代码实现上,可以使用滚动数组或者队列。使用队列转移的过程类似 bfs。具体而言,把长为 \(m\) 的数组 \(g(s, \ast)\) 记为一个状态,放到队列中转移即可。
Code
st = (1 << m);
State s0(0);
for(int i = 1; i <= m; i++) {
for(int j = 1; j <= m; j++) {
if(i == j) continue;
s0.g[i] += -cnt[i][j] + K * cnt[j][i];
}
}
f.resize(st, INF), f[0] = 0;
while(!que.empty()) {
auto [s, g] = que.front();
que.pop();
int pos = __builtin_popcount(s) + 1;
// update f
for(int t = (st - 1) ^ s; t; t -= lb(t)) {
// s -> s cup {i}
int i = __builtin_ctz(t) + 1, news = s + lb(t);
chmin(f[news], f[s] + pos * g[i]);
}
// update g
for(int i = 1; i <= m; i++) {
if(get(s, i)) break;
// make sure that s = t - lb(t)
int t = s | (1 << (i - 1));
State nxt(t);
for(int _t = (st - 1) ^ t; _t; _t -= lb(_t)) {
int j = __builtin_ctz(_t) + 1;
nxt.g[j] = g[j] - (-cnt[j][i] + K * cnt[i][j]) + (K * cnt[j][i] + cnt[i][j]);
}
que.push(nxt);
}
}
总结:如果题目给出的式子不好直接入手,要想到拆贡献!除此之外本题的代码实现用到了许多二进制的技巧,值得学习。
消息传递
点分治
点分治板子题。
设当前的分治中心为 \(u\),先计算出 \(T(u)\) 中所有点到 \(u\) 的距离,并用桶记录每种距离的个数。然后依次处理每棵子树。具体而言,对于子树 \(v\),处理它时先在桶中减去它的贡献,这时桶内记录的就是 \(T(u) \setminus T(v)\) 的距离。然后枚举 \(x\) 在 \(T(v)\) 内的询问,并累加答案即可。处理完 \(T(v)\) 后记得把它的贡献加回来。
由于每次分治时,每个节点和询问都只会被访问 \(O(1)\) 次,而如果每次选择重心作为分治中心,分治的次数为 \(O(\log n)\),所以总时间复杂度为 \(O((n + m) \log n)\)。
丁香之路
前面的路,以后再来探索吧!
总结
game 和 lucky 都比较简单,冷静思考就能想出来。
icefire 是比较传统的数据结构题。一开始问题的转化是很容易的,转化之后很明显要用数据结构优化。但我还是 ds 题做的太少,一开始居然以为要用平衡树。这道题的实现还比较考验代码细节,我写了好几天才调出来。总而言之,对于这种没什么思维难度的 ds 题,还是要尽量拿下的。
transfer 是有一定思维难度的 dp。根据数据范围很容易猜到这是状压 dp,但我 dp 也很菜,没有想出什么可用的状态设计。实际上根据位置设计状态应该还是比较常见的,有了这个方向以后自然回去尝试拆贡献,拆完贡献以后直接 dp 就有 60 分,后面的优化也都比较常规。
message 纯板子。
lilac 不会。感觉比较思维。