「联合省选 2020 B 卷」做题记录
「联合省选 2020 B 卷」做题记录
卡牌游戏
思维题,贪心
考虑刻画一次操作带来的收益。记 ,即 的前缀和。可以发现,如果在某个时刻选择第 ()张卡牌进行操作,得到的收益是恒定的 ,和之前如何操作无关。所以选择所有满足 的位置 (不包括第一个位置)进行操作就是最优的。
幸运数字
枚举,离散化
做这道题时,一开始我陷入了一个错误的方向,就是直接考虑如何最大化收益。假设没有奖励条件的限制,只考虑选择一堆 异或起来,使得异或和最大,这可以用线性基来做。但我们很难把奖励条件的限制加入进来。
这时候就要考虑换一个方向了!不妨看看第一个部分分:当值域很小时,可以枚举所有数字(显然,枚举 之内的数字就行),对每个数字在 时间内计算选择它时的奖励额度。进一步,我们实际上无需对每个数字都花费 的时间计算选择它时的奖励额度:使用递推的思想,从小到大枚举数字,动态地更新奖励额度,可以把总时间复杂度降到 。(其中 代表 的值域大小)
想到这步以后正解也近在咫尺了:显然有很多数字的奖励额度是相同的,所以并不需要枚举 个数字。离散化以后只有 个有用的数字。不过,只离散化 这些数字是错误的,容易找出反例。正确的做法是,除了把上述数离散化,还要把形如 和 的数也离散化。除此之外为了处理答案为 的情况,还要把 也离散化。
冰火战士
数据结构
写了三天,总共交了 16 发,战绩可查。
先观察一些性质。
首先,由于只有当一方战士的能量值全部耗尽时战斗才会结束,所以战士出战的顺序其实无关紧要。又因为双方消耗的能量值相等,所以消耗的总能量值是能量值较小一方的二倍。设在温度 下冰系战士出战的能量值总和为 ,火系战士为 ,则消耗的总能量值为 。(需要满足 且 。)
把双方战士按自身温度从低到高排序,可以发现冰系战士中能够出战的形成一段前缀,而火系战士中能够出战的构成一段后缀。那么 单调增而 单调减。因此存在某个温度 ,使得 时 而 时 。在第一种情况时有 且 在此条件下最大,第二种情况时有 且 在此条件下最大,因此分这两种情况讨论即可。
将询问离线,把温度离散化并据此建立树状数组,树状数组上某个位置 的权值代表温度为 的冰/火系战士的能量之和。(一开始看到有加入/删除的操作,我只想到了平衡树,而没有想到可以离线后使用树状数组。如果用平衡树,肯定会由于常数太大而过不了,并且代码也远远比树状数组难写。)设不同的温度的个数为 ,则我们能在 的时间内查询一段温度区间内冰/火系战士的能量总和,因而也就能查询某温度下出战的冰/火系的能量总和。
现在通过二分找出分界点 。由于温度是离散的,所以这个地方的细节比较多,需要想清楚再写。具体而言,第一次二分先找出最后满足 的 :
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;
}
然后,设 表示满足 (注意这里可以取等,否则考虑的情况不全)的第一个位置。但 并不一定是第二种情况的答案,因为可能不满足温度最大。所以要求的实际上是 ,满足 是最后一个使得 的位置。
要求出 ,显然可以直接再二分一次。但另一种更好的方法是复用之前求出的 ,因为 的下一个位置 肯定满足 ,所以直接令 即可。但这里有个问题,就是不一定存在满足 且 的 ,这时候还能直接令 吗?实际上是可以的,分两种情况讨论:
-
不存在 使得 和 同时非 :
(注意,虽然图中两条曲线看起来有交,并且在交点处两者同时非 ,但由于函数是离散的,所以它们实际上不存在交点)
这种情况下,无论什么温度,双方都不能开战。由于此时一定有 ,所以直接判掉这种情况即可。
-
存在 使得 和 同时非 ,但不存在 满足 且 :
这种情况下一定有 ,所以可以令 。
综上所述,我们就在没有再次二分的情况下得到了 。记 ,再二分一次得到最大的 使得 即可。
在树状数组上查询的时间复杂度为 ,二分的时间复杂度也为 ,总时间复杂度为 。在常数较小的时候可以通过。(根据试验,如果用二分找到 ,则每次询问要二分三次,此时只能得到 60 pts,但如果 不是用二分找的,则每次询问只用二分两次,可以得到 100 pts。)
要继续优化,可以使用树状数组二分的技巧。实际上与其说是二分,它更像是倍增。在树状数组中,点 维护是长为 区间 。以二分 为例,初始时 ,从 到 倒序枚举 ,每次尝试令 加上 ,判断扩展以后 是否成立,如果成立就扩展,否则撤回。这里的要点在于,用 记录当前的 ,扩展时,不用在树状数组上查询,而是直接令 即可。(其中 是树状数组中的数组。)这是因为,由于我们倒序枚举 ,所以一定有 ,因此 维护的就是 的信息。这就就成功把单次二分的时间复杂度降到了 ,总时间复杂度降为 。
参考代码(部分变量名称可能不同):
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
约定:记 代表信号站构成的排列;。
本题的关键在于发现花费是可以拆的。也就是说。假设序列 中存在相邻的数对 ,那么产生如下的费用:
换一种表述方法,把费用都摊到 上:每有一个 数对,则在 处产生 (若 )或 (若 )的费用;每有一个 数对,则在 处产生 (若 )或 (若 )的费用。那么总费用就是所有站点的费用之和。
上述观察是进一步解决问题的基础。
看到 很小,自然想到状压。设 表示把集合 ()中的信号站放到前 个位置时,它们产生的最小花费。(正因为我们把费用拆开了,我们才能在排列未完全确定的情况下,能够计算出一部分信号站的贡献)转移时,枚举 之外的一个信号站 ,转移到 这个状态。实际上这相当于要求:已知前 个位置的信号站集合为 ,在第 (下面称为 )个位置放第 个信号站,要能求出 的花费。观察花费的式子,我们发现这是可以求的,因为一个信号站的花费只与在它之前和之后的信号站集合有关,而顺序是无所谓的。
记 表示 序列中相邻数对 的个数,则有如下转移方程:
状态数为 ,转移时间复杂度为 ,总时间复杂度为 。
考虑优化。对于给定的 和 ,则 的系数为 ,记为 。转移的瓶颈主要在于求出这个系数,如何优化?实际上它可以递推求出:设 是 内的任意一个元素,那么如果已知 ,则 容易在 时间内求出:重新计算 和 数对的贡献即可。为了方便,不妨选择 中最小的元素作为 ,代码实现上有 。(这里用的是填表法)这样我们就把时间复杂度降为 ,但空间复杂度升为 。
现在时间复杂度已经足够低,但计算发现 个 int
的空间占用约为 736 MB,超过了空间限制,所以还要优化空间占用。由于无论是 还是 的转移,都是将一个大小为 的集合转移到大小为 的集合,所以如果按集合的大小从小到大转移,同一时间最多只有 个有用的状态,这是开得下的。
必须同时转移 和 ,这样才能保证 和 转移的进度是一样的。值得一提的是, 用刷表法转移比较方便,而上文中 使用了填表法转移(也就是 从 转移而来),如果同时转移,必须把二者统一起来。不妨考虑把 的转移改成刷表法。也就是说,对于集合 ,它转移到状态 ,需要满足 。(实质上我们是在要求每个状态只能从之前的 个状态转移而来,这样才能保证正确的时间复杂度。实际上把 的一个的状态转移多次也不会导致答案错误,但会增加时间开销)可以这么写代码:
for(int i = 1; i <= m; i++) {
if(get(s, i)) break; // 如果s的某一位为1,则退出循环
int t = s | (1 << (i - 1));
// 转移到g(t, *)
}
代码实现上,可以使用滚动数组或者队列。使用队列转移的过程类似 bfs。具体而言,把长为 的数组 记为一个状态,放到队列中转移即可。
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);
}
}
总结:如果题目给出的式子不好直接入手,要想到拆贡献!除此之外本题的代码实现用到了许多二进制的技巧,值得学习。
消息传递
点分治
点分治板子题。
设当前的分治中心为 ,先计算出 中所有点到 的距离,并用桶记录每种距离的个数。然后依次处理每棵子树。具体而言,对于子树 ,处理它时先在桶中减去它的贡献,这时桶内记录的就是 的距离。然后枚举 在 内的询问,并累加答案即可。处理完 后记得把它的贡献加回来。
由于每次分治时,每个节点和询问都只会被访问 次,而如果每次选择重心作为分治中心,分治的次数为 ,所以总时间复杂度为 。
丁香之路
前面的路,以后再来探索吧!
总结
game 和 lucky 都比较简单,冷静思考就能想出来。
icefire 是比较传统的数据结构题。一开始问题的转化是很容易的,转化之后很明显要用数据结构优化。但我还是 ds 题做的太少,一开始居然以为要用平衡树。这道题的实现还比较考验代码细节,我写了好几天才调出来。总而言之,对于这种没什么思维难度的 ds 题,还是要尽量拿下的。
transfer 是有一定思维难度的 dp。根据数据范围很容易猜到这是状压 dp,但我 dp 也很菜,没有想出什么可用的状态设计。实际上根据位置设计状态应该还是比较常见的,有了这个方向以后自然回去尝试拆贡献,拆完贡献以后直接 dp 就有 60 分,后面的优化也都比较常规。
message 纯板子。
lilac 不会。感觉比较思维。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】