热身赛
动态规划 — 能力闯关问题
题目大意:初始有 点能力值, 枚金币。依次闯过 个关卡,每关包含课程和考试两个阶段:
- 学习课程需花费 金币,获得 能力值提升
- 考试得分 = 当前能力值
求在金币有限的前提下,最大化考试总得分。
赛时思路:用两个状态做滚动数组——选这门课 / 不选这门课,转移到下一门课。方向是对的,但当时没做出来。
问题分析:
每关的决策影响后续所有关卡的收益,这也是赛时卡住的核心——能力值是动态变化的,朴素 DP 需要同时记录「当前能力值」和「剩余金币」,维度太高。
关键转化:
总得分 = (初始 + 第 关之前所有已学课程的 之和)
拆开来看,初始 贡献固定为 。而每个课程 的贡献取决于它被学完之后,后面还剩多少关考试——即每学一门课,它会在之后每一关都发挥作用。
因此第 关的课程贡献 = 。 其实就是后缀和——第 关之后还剩多少关考试,这门课的提升会在之后每一关都生效。
这就转化成了 01 背包问题:
- 物品:每个关卡一门课
- 价值:(后缀关卡数)
- 重量:
- 目标:背包容量 内总价值最大
状态转移:
// dp[j] = 剩余 j 金币时的最大额外得分int dp[x + 1] = {0};
for (int i = 0; i < n; i++) { int value = delta_w[i] * (n - i); // 后续关卡数 int cost = c[i]; for (int j = x; j >= cost; j--) { dp[j] = max(dp[j], dp[j - cost] + value); }}
int ans = w * n + dp[x]; // 基础分 + 额外分反思:思路本身是对的(01 背包 + 滚动数组),赛时卡住的点在于没有把「能力值提升」正确换算成「总得分增量」。正确做法是算 剩余关卡数,而不是在 DP 里维护能力值这个维度。
正式赛
交互题 — 二进制数中 1 的个数
题目大意:交互器生成一个长度为 的二进制串()。你最多有 次查询机会。每次查询选择一个下标集合 ,交互器返回:
其中 表示集合 中值为 的位数, 是 的补集。
目标:求出整个二进制串中 的总数 。
关键观察:
设总共有 个 ,查询集合 中有 个 ,补集中有 个 。交互器返回:
这是一个关于 的二次方程:,解得:
即 要么是某个值 ,要么是 。
查询策略 — 为什么二进制分组可行:
将 个位置从 开始编号。第 次查询()选择所有「下标第 位为 」的位置:
查询 j: 所有满足 (i >> j) & 1 == 1 的位置 i核心思想:二进制位的正交性。
每个下标 有一个唯一的 位二进制表示。第 位是 还是 ,决定了 是否进入第 次查询的集合。这就像傅里叶变换里的 和 基函数——它们是正交的:
不同二进制位之间的 标记天然构成了一组正交基。每一位的查询独立地刻画了全体 在该位上的分布特征,互不干扰。
换个角度理解:对于任意两个不同的下标 ,它们的二进制表示至少有一位不同,即至少存在一次查询 ,使得「 进入查询 而 不进入」(或反之)。
这意味着:不存在两个下标在所有 次查询里有完全相同的进出模式。每个位置的「 位查询签名」就是它自身编号的二进制表示,天然不重复。
这正是正交性的直观含义——每次查询在不同的「维度」上切分下标空间, 个维度互不重叠、互不包含,共同唯一确定了 个位置。
信息论视角:
这个问题本质上是一个信息传输问题。二进制串有 个 ,相当于要从 个位置中恢复 这个整数。 的取值范围是 ,用二进制表示需要 比特的信息。
每次查询返回一个乘积 。由于我们不知道 ,单次查询并不能直接传递 比特——但 本身是一个非负整数,且与 和 同时相关。在正确的分组策略下,每条查询提供了约 比特的有效信息(确定 在两个可能值中的哪一个), 条恰好传递 比特,刚好覆盖 的信息量上限。
而模余数分组之所以失败,从信息论看是因为查询之间存在互信息(mutual information):
嵌套结构导致后一条查询的信息大部分已被前一条包含, 次查询实际提供的独立信息远小于 比特,不足以确定 。二进制分组则保证了:
查询之间的互信息接近于零,每次查询的 比特信息都是新鲜的——这就是正交性在信息论下的等价表述。
赛时为什么走了弯路:
我当时用的策略不是按二进制位分组,而是按模余数分组:
第 0 次查询: i % 2 == 1 (除以 2 余 1)第 1 次查询: i % 4 == 1 或 2 (除以 4 余 1,2)第 2 次查询: i % 8 ∈ {1,2,3,4} (除以 8 余 1,2,3,4)...这个方案的问题在于:这些查询不是正交的。第 次查询的集合并不能表示为「所有第 位为 的位置」——它们之间存在大量重叠和包含关系,导致不同查询给出的方程冗余,无法独立提供信息。
具体来说,按模 分组时,第 次查询的集合完整包含了第 次查询的集合,形成了嵌套结构。这意味着:
- 各次查询高度耦合,信息量远不如二进制分组
而二进制分组保证了 次查询完全独立——每次查询给出的 在信息论意义下是互不冗余的,这正是正交性的威力。
求解步骤(逐步详解)
拿到 个 之后,怎么求出 ?分三步走。
第一步:写出每条查询提供的方程
对于第 次查询,设集合内恰有 个 ,则:
注意: 和 都是未知数。一条二次方程有两个未知数,无法直接解出。但 条方程共享同一个 ,这个公共约束就是突破口。
第二步:把每条方程改写成 关于 的表达式
将 整理为标准二次形式:
用求根公式解出 :
此时的未知数只剩 。但直接解这 个联立方程很麻烦,更简单的做法是——枚举 ,逐一验证。
第三步:枚举 ,验证自洽性
的范围是 。对每个候选 ,检查两条:
条件 A: 必须是完全平方数。设 ,则 必须是整数。
因为 是 的个数,必须是整数。而根据 , 是整数当且仅当 是整数。
条件 B: 必须在 内。
取减号那支就够了——另一支 其实就是 ,是同一个值从补集视角看。
如果 条方程全部通过,这个 就是答案。
为什么枚举可行?
枚举量是 ,每条方程检查是 ,总计 。题目中 ,,完全在时限内。
可以加速: 的方程 已经大大限制了 ——不是每个 都能让 成为完全平方。实际枚举时绝大多数 在第一轮就因判别式不满足而被跳过。
代码对应关系:
// 对每个候选 Kfor (int K = 0; K <= n; K++) { bool ok = true; for (int j = 0; j < 17; j++) { // 条件 A:判别式是完全平方 long long disc = 1LL * K * K - 4 * P[j]; if (disc < 0) { ok = false; break; } // 不含实根 long long sq = sqrt(disc); if (sq * sq != disc) { ok = false; break; } // 不是完全平方
// 条件 B:c_j 在合法范围内 long long c = (K - sq) / 2; if (c < 0 || c > K) { ok = false; break; } } if (ok) { cout << "! " << K << endl; // ← 找到答案 return 0; }}直观理解:
想象你手里有 条「规则」,每条规则说「如果你告诉我总共有 个 ,我就能告诉你第 位为 的那些位置里有多少个 」。你挨个试 ,看哪个 能让所有 条规则同时说得通。只有真正的 能让所有方程同时给出整数解——这就是自洽性检验。
参考代码:
#include <bits/stdc++.h>using namespace std;
int query(const vector<int>& s) { if (s.empty()) return 0; cout << "? " << s.size(); for (int x : s) cout << " " << x; cout << endl; int ans; cin >> ans; return ans;}
int main() { int n; cin >> n; // 二进制串长度
// 17 次查询:按二进制位分组 vector<long long> P(17); for (int j = 0; j < 17; j++) { vector<int> s; for (int i = 0; i < n; i++) { if ((i >> j) & 1) s.push_back(i); } P[j] = query(s); }
// 枚举 K(总共有 K 个 1),验证所有方程是否自洽 for (int K = 0; K <= n; K++) { bool ok = true; for (int j = 0; j < 17; j++) { long long disc = 1LL * K * K - 4 * P[j]; // 判别式 if (disc < 0) { ok = false; break; } long long sq = sqrt(disc); if (sq * sq != disc) { ok = false; break; } // c 必须是整数且在 [0, K] 范围内 long long c = (K - sq) / 2; if (c < 0 || c > K || c * (K - c) != P[j]) { ok = false; break; } } if (ok) { cout << "! " << K << endl; return 0; } } return 0;}注意:实际交互中 可能较大,枚举 时可以利用「 且 为整数」直接缩小 的候选范围,避免 枚举。
奇技淫巧:随机单点查询
除了正统的二进制分组,还有一种「非正统」做法——随机单点查询,赛场上确实有人靠运气过了这道题。
原理极其简单:
每次随机选一个位置 ,只查询这一个位置。设该位的值为 ,交互器返回:
- 如果返回 :没抽中 ,再来一次。
- 如果返回正数:恭喜,抽中了一个 !此时 ,直接出答案。
// 随机抽奖mt19937 rng(random_device{}());for (int t = 0; t < 17; t++) { int i = uniform_int_distribution<>(0, n - 1)(rng); int ans = query({i}); // 只查这一个位置 if (ans > 0) { cout << "! " << ans + 1 << endl; return 0; }}期望分析:
设 个 均匀分布在 个位置中,每次抽中 的概率是 。在 次机会内至少抽中一次的概率为:
- 若 :概率
- 若 :概率
- 若 :概率 ,基本看命
换句话说,只要 不是太稀疏,这招靠的是大数定律+运气,不需要任何数学推导。
严肃评价:这不是正确做法,不值得提倡。但它确实生动地说明了一件事——交互题中,单点查询本身就是最直接的信息获取方式,正是「查询一个集合」这个更一般的形式把问题变复杂了,才有了二进制分组的用武之地。
E. 电梯
题目大意:大楼有 层,第 到第 层。建 个电梯,每个电梯必须停第 层和第 层,中间可选停。要求:对于任意不同的两层 ,存在一个电梯使得 可以直达(中间不经停任何其它楼层)。求 的最小值并构造。
约束:,总停次数 。
问题转化:每个电梯 = 一条从 到 的严格递增路径,路径的每条边(相邻停靠对)就是可直达的楼层对。我们需要用 条路径覆盖 的全部 条边。
下界分析:
每条电梯恰好有一条首边 和一条末边 。要覆盖所有涉及 的楼层对 ,至少需要 个不同的电梯(每个只能贡献一条首边)。
同理,要覆盖所有 ,至少 条末边。但 兼为首边和末边,可被同一个电梯覆盖。所以:
但这只是「首末边覆盖」的下界。还剩内部对 满足 且 ,这些必须被某条电梯的中间边覆盖。
内部对共 个,每个电梯至多贡献 条中间边,这给出了更强的下界:
实际最小值更接近 (验证了小 )。
小 验证:
| 2 | 1 | 1 |
| 3 | 2 | 2 |
| 4 | 4 | 4 |
| 5 | 6 | 6 |
构造方法(以 为例):
每部电梯可以理解为一个「分段方案」,它将 切成若干子区间:
E₁: 1 → 2 → 3 → 4 → 5 首边(1,2), 内部(2,3)(3,4), 末边(4,5)E₂: 1 → 3 → 5 首边(1,3), 末边(3,5)E₃: 1 → 2 → 4 → 5 首边(1,2), 内部(2,4), 末边(4,5)E₄: 1 → 4 → 5 首边(1,4), 末边(4,5)E₅: 1 → 5 首边(1,5) = 末边E₆: 1 → 2 → 5 首边(1,2), 末边(2,5)E₁ 完成所有「相邻层」的覆盖(gap=1),E₅ 完成 。E₂-E₄ 和 E₆ 覆盖跨 gap ≥ 2 的对。
每部电梯的首边在 集合中、末边在 集合中各认领一个任务;中间段则负责跨层直达。
本人更喜欢的做法 — 贪心 DFS:
核心思路非常直觉:从 出发,每步找一个「还没被覆盖直达关系」的楼层跳过去,直到连到 ,一部电梯生成完毕。如果还有未覆盖的楼层对,就再生成一部新电梯。重复直到所有对都被覆盖。
while (还有未覆盖的楼层对): cur = 1, path = [1] while (cur != n): nxt = 第一个 (cur, nxt) 还没被覆盖的楼层 如果不存在 → nxt = n (退化为直达 n) 标记 (cur, nxt) 已覆盖 path.push_back(nxt) cur = nxt ans.push_back(path) ← 一部电梯完成完整代码:
#include <bits/stdc++.h>using namespace std;
const int N = 1010;int n;bool covered[N][N]; // (i,j) 是否已被某部电梯覆盖vector<vector<int>> ans; // 所有电梯
// 从 1 出发,贪心构建一部电梯直到 nvector<int> build_elevator() { vector<int> path; path.push_back(1); int cur = 1;
while (cur != n) { int nxt = -1; for (int j = cur + 1; j <= n; j++) { if (!covered[cur][j]) { nxt = j; break; } } if (nxt == -1) nxt = n; // 退化为直达 covered[cur][nxt] = covered[nxt][cur] = true; path.push_back(nxt); cur = nxt; } return path;}
int main() { cin >> n;
// 只要还有未覆盖的对,就继续建新电梯 while (true) { bool all_covered = true; for (int i = 1; i <= n && all_covered; i++) for (int j = i + 1; j <= n; j++) if (!covered[i][j]) { all_covered = false; break; } if (all_covered) break;
ans.push_back(build_elevator()); }
cout << ans.size() << "\n"; for (auto& e : ans) { for (size_t i = 0; i < e.size(); i++) cout << (i ? " " : "") << e[i]; cout << "\n"; }}运行示例 ():
41 2 3 41 3 41 41 2 4贪心 DFS 不保证 最小(复杂度 内跑完 ),但它直观、好写、可读性强,赛场上能快速拿到一个可行解。顺带提一句,我在考场上思路是没问题的,就是码力不太够,缺少将思路转换为代码的能力,解决这个问题必须多阅读代码,多敲代码。
总结:
| 考点 | 说明 |
|---|---|
| 构造 + 覆盖 | 把「直达」条件转化为路径覆盖 |
| 下界 | 首边/末边 + 中间对数,给出 推导 |
| 小规模推理 | ,枚举推导或递归构造均可 |
二分答案 — 雪糕生产线
这一题意难平。开赛时看到很多人都迅速过了,心想这题应该很简单,结果罚时两发,遗憾没有省一。
题意:有 台机器,第 台生产一根雪糕需要 分钟。问生产 根雪糕至少需要多少分钟。
本来思路(错):算速度——拿 表示第 台机器每分钟能做多少根,然后累加求总速度。但这里有浮点陷阱:假如两台机器各每分钟做 根,,看起来一分钟能做一根。但实际上每台机器独立工作 1 分钟,第一根雪糕在哪台都没完成(),一根都出不来。
正解:二分时间 ,检验在 分钟内能否生产 根。第 台机器的产量 = (整数除法)。
#include <bits/stdc++.h>using namespace std;typedef long long ll;
const int N = 1e6 + 5;int n, m;int t[N];
// 检查在 k 分钟内能否生产 m 根bool check(ll k) { ll cnt = 0; for (int i = 0; i < n; i++) { cnt += k / t[i]; if (cnt >= m) return true; // 提前剪枝,防爆 long long } return cnt >= m;}
int main() { cin >> n >> m; for (int i = 0; i < n; i++) cin >> t[i];
ll l = 0, r = 1e18, ans = -1; // r 开大:m 根 × 最慢机器 while (l <= r) { ll mid = l + (r - l) / 2; if (check(mid)) { ans = mid; r = mid - 1; } else { l = mid + 1; } } cout << ans << "\n"; return 0;}| 考点 | 说明 |
|---|---|
| 二分答案 | 最优值问题 → 判定问题, |
| 整数二分 | 用 ll 防溢出,右界设为 1e18 |
| 浮点陷阱 | 的整数部分 实际能完成的根数 |
总结
- 做得好的:热身赛 DP 题虽然没出,但赛后很快找准了”后缀和”转化 + 01 背包的正确框架;交互题的正交性理解到位
- 需要改进的:
- 「思路 → 代码」的翻译能力不足,思路有了但写出来就有细节 bug
- 简单题心态不稳,看别人秒过就急着交,罚时不值
- 二分答案这种基础题应该零失误,边界条件和数据类型多检查
- 下一步计划:赛后定期打 CF 保持手感,重点练交互题和构造题的类型积累 以上就是本次比赛中对我而言有帮助的题目,其他的题目还没来得及看故而不进行分析
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
