3673 字
9 分钟
HBCPC 复盘
2026-05-30

热身赛#

动态规划 — 能力闯关问题#

题目大意:初始有 ww 点能力值,xx 枚金币。依次闯过 nn 个关卡,每关包含课程考试两个阶段:

  • 学习课程需花费 cic_i 金币,获得 Δwi\Delta w_i 能力值提升
  • 考试得分 = 当前能力值

求在金币有限的前提下,最大化考试总得分。

赛时思路:用两个状态做滚动数组——选这门课 / 不选这门课,转移到下一门课。方向是对的,但当时没做出来。

问题分析

每关的决策影响后续所有关卡的收益,这也是赛时卡住的核心——能力值是动态变化的,朴素 DP 需要同时记录「当前能力值」和「剩余金币」,维度太高。

关键转化

总得分 = k=1n\sum_{k=1}^{n}(初始 ww + 第 kk 关之前所有已学课程的 Δw\Delta w 之和)

拆开来看,初始 ww 贡献固定为 w×nw \times n。而每个课程 Δwi\Delta w_i 的贡献取决于它被学完之后,后面还剩多少关考试——即每学一门课,它会在之后每一关都发挥作用。

因此第 ii 关的课程贡献 = Δwi×(ni)\Delta w_i \times (n - i)(ni)(n - i) 其实就是后缀和——第 ii 关之后还剩多少关考试,这门课的提升会在之后每一关都生效。

这就转化成了 01 背包问题

  • 物品:每个关卡一门课
  • 价值:Δwi×(ni)\Delta w_i \times (n - i)(后缀关卡数)
  • 重量:cic_i
  • 目标:背包容量 xx 内总价值最大

状态转移

// 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 背包 + 滚动数组),赛时卡住的点在于没有把「能力值提升」正确换算成「总得分增量」。正确做法是算 Δwi×\Delta w_i \times 剩余关卡数,而不是在 DP 里维护能力值这个维度。


正式赛#

交互题 — 二进制数中 1 的个数#

题目大意:交互器生成一个长度为 NN 的二进制串(N217N \le 2^{17})。你最多有 1717 次查询机会。每次查询选择一个下标集合 SS,交互器返回:

ans=cnt(S)×cnt(S)\text{ans} = \text{cnt}(S) \times \text{cnt}(\complement S)

其中 cnt(X)\text{cnt}(X) 表示集合 XX 中值为 11 的位数,S\complement SSS 的补集。

目标:求出整个二进制串中 11 的总数 KK

关键观察

设总共有 KK11,查询集合 SS 中有 xx11,补集中有 KxK-x11。交互器返回:

P=x(Kx)P = x \cdot (K - x)

这是一个关于 xx 的二次方程:x2Kx+P=0x^2 - Kx + P = 0,解得:

x=K±K24P2x = \frac{K \pm \sqrt{K^2 - 4P}}{2}

xx 要么是某个值 cc,要么是 KcK - c

查询策略 — 为什么二进制分组可行

NN 个位置从 00 开始编号。第 jj 次查询(0j160 \le j \le 16)选择所有「下标第 jj 位为 11」的位置:

查询 j: 所有满足 (i >> j) & 1 == 1 的位置 i

核心思想:二进制位的正交性

每个下标 ii 有一个唯一的 1717 位二进制表示。第 jj 位是 11 还是 00,决定了 ii 是否进入第 jj 次查询的集合。这就像傅里叶变换里的 sin\sincos\cos 基函数——它们是正交的:

不同二进制位之间的 0/10/1 标记天然构成了一组正交基。每一位的查询独立地刻画了全体 11 在该位上的分布特征,互不干扰。

换个角度理解:对于任意两个不同的下标 iii \neq i',它们的二进制表示至少有一位不同,即至少存在一次查询 jj,使得「ii 进入查询 jjii' 不进入」(或反之)。

这意味着:不存在两个下标在所有 1717 次查询里有完全相同的进出模式。每个位置的「1717 位查询签名」就是它自身编号的二进制表示,天然不重复。

这正是正交性的直观含义——每次查询在不同的「维度」上切分下标空间,1717 个维度互不重叠、互不包含,共同唯一确定了 2172^{17} 个位置。

信息论视角

这个问题本质上是一个信息传输问题。二进制串有 KK11,相当于要从 NN 个位置中恢复 KK 这个整数。KK 的取值范围是 [0,N][0, N],用二进制表示需要 log2(N+1)\log_2(N+1) 比特的信息。

每次查询返回一个乘积 Pj=cj(Kcj)P_j = c_j(K - c_j)。由于我们不知道 cjc_j,单次查询并不能直接传递 log2N\log_2 N 比特——但 PjP_j 本身是一个非负整数,且与 KKcjc_j 同时相关。在正确的分组策略下,每条查询提供了约 11 比特的有效信息(确定 cjc_j 在两个可能值中的哪一个),1717 条恰好传递 1717 比特,刚好覆盖 log2N17\log_2 N \le 17 的信息量上限。

而模余数分组之所以失败,从信息论看是因为查询之间存在互信息(mutual information)

I(Queryj;Queryj1)0I(\text{Query}_j; \text{Query}_{j-1}) \gg 0

嵌套结构导致后一条查询的信息大部分已被前一条包含,1717 次查询实际提供的独立信息远小于 1717 比特,不足以确定 KK。二进制分组则保证了:

I(Queryj;Queryk)0(jk)I(\text{Query}_j; \text{Query}_{k}) \approx 0 \quad (j \neq k)

查询之间的互信息接近于零,每次查询的 11 比特信息都是新鲜的——这就是正交性在信息论下的等价表述。

赛时为什么走了弯路

我当时用的策略不是按二进制位分组,而是按模余数分组:

第 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)
...

这个方案的问题在于:这些查询不是正交的。第 jj 次查询的集合并不能表示为「所有第 jj 位为 11 的位置」——它们之间存在大量重叠和包含关系,导致不同查询给出的方程冗余,无法独立提供信息。

具体来说,按模 2k2^k 分组时,第 kk 次查询的集合完整包含了第 k1k-1 次查询的集合,形成了嵌套结构。这意味着:

  • c0c1c2c_0 \subseteq c_1 \subseteq c_2 \subseteq \dots
  • 各次查询高度耦合,信息量远不如二进制分组

而二进制分组保证了 1717 次查询完全独立——每次查询给出的 Pj=cj(Kcj)P_j = c_j(K - c_j) 在信息论意义下是互不冗余的,这正是正交性的威力。

求解步骤(逐步详解)#

拿到 1717PjP_j 之后,怎么求出 KK?分三步走。


第一步:写出每条查询提供的方程

对于第 jj 次查询,设集合内恰有 cjc_j11,则:

Pj=cj(Kcj)(1)P_j = c_j \cdot (K - c_j) \tag{1}

注意:cjc_jKK 都是未知数。一条二次方程有两个未知数,无法直接解出。但 1717 条方程共享同一个 KK,这个公共约束就是突破口。


第二步:把每条方程改写成 cjc_j 关于 KK 的表达式

(1)(1) 整理为标准二次形式:

cj2Kcj+Pj=0c_j^2 - K \cdot c_j + P_j = 0

用求根公式解出 cjc_j

cj=K±K24Pj2(2)c_j = \frac{K \pm \sqrt{K^2 - 4P_j}}{2} \tag{2}

此时的未知数只剩 KK。但直接解这 1717 个联立方程很麻烦,更简单的做法是——枚举 KK,逐一验证。


第三步:枚举 KK,验证自洽性

KK 的范围是 [0,N][0, N]。对每个候选 KK,检查两条:

条件 AK24PjK^2 - 4P_j 必须是完全平方数。设 sj=K24Pjs_j = \sqrt{K^2 - 4P_j},则 sjs_j 必须是整数。

因为 cjc_j11 的个数,必须是整数。而根据 (2)(2)cjc_j 是整数当且仅当 K24Pj\sqrt{K^2 - 4P_j} 是整数。

条件 Bcj=(Ksj)/2c_j = (K - s_j)/2 必须在 [0,K][0, K] 内。

取减号那支就够了——另一支 (K+sj)/2(K + s_j)/2 其实就是 KcjK - c_j,是同一个值从补集视角看。

如果 1717 条方程全部通过,这个 KK 就是答案。


为什么枚举可行?

枚举量是 O(N)O(N),每条方程检查是 O(1)O(1),总计 O(17N)O(17N)。题目中 N2171.3×105N \le 2^{17} \approx 1.3 \times 10^517N2.2×10617N \approx 2.2 \times 10^6,完全在时限内。

可以加速P0P_0 的方程 c0(Kc0)=P0c_0(K - c_0) = P_0 已经大大限制了 KK——不是每个 KK 都能让 K24P0K^2 - 4P_0 成为完全平方。实际枚举时绝大多数 KK 在第一轮就因判别式不满足而被跳过。


代码对应关系

// 对每个候选 K
for (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;
}
}

直观理解

想象你手里有 1717 条「规则」,每条规则说「如果你告诉我总共有 KK11,我就能告诉你第 jj 位为 11 的那些位置里有多少个 11」。你挨个试 K=0,1,2,,NK = 0, 1, 2, \dots, N,看哪个 KK 能让所有 1717 条规则同时说得通。只有真正的 KK 能让所有方程同时给出整数解——这就是自洽性检验。

参考代码

#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;
}

注意:实际交互中 NN 可能较大,枚举 KK 时可以利用「P0=c0(Kc0)P_0 = c_0(K - c_0)c0c_0 为整数」直接缩小 KK 的候选范围,避免 O(N)O(N) 枚举。


奇技淫巧:随机单点查询#

除了正统的二进制分组,还有一种「非正统」做法——随机单点查询,赛场上确实有人靠运气过了这道题。

原理极其简单

每次随机选一个位置 ii,只查询这一个位置。设该位的值为 aia_i,交互器返回:

ans=ai(Kai)={0如果 ai=0K1如果 ai=1\text{ans} = a_i \cdot (K - a_i) = \begin{cases} 0 & \text{如果 } a_i = 0 \\ K-1 & \text{如果 } a_i = 1 \end{cases}
  • 如果返回 00:没抽中 11,再来一次。
  • 如果返回正数:恭喜,抽中了一个 11!此时 K=ans+1K = \text{ans} + 1,直接出答案。
// 随机抽奖
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;
}
}

期望分析

KK11 均匀分布在 NN 个位置中,每次抽中 11 的概率是 p=K/Np = K/N。在 1717 次机会内至少抽中一次的概率为:

P成功=1(1K/N)17P_{\text{成功}} = 1 - (1 - K/N)^{17}
  • KN/4K \ge N/4:概率 1(0.75)1799.3%\ge 1 - (0.75)^{17} \approx 99.3\%
  • K=N/10K = N/10:概率 1(0.9)1783%\approx 1 - (0.9)^{17} \approx 83\%
  • K=1K = 1:概率 17/N\approx 17/N,基本看命

换句话说,只要 11 不是太稀疏,这招靠的是大数定律+运气,不需要任何数学推导。

严肃评价:这不是正确做法,不值得提倡。但它确实生动地说明了一件事——交互题中,单点查询本身就是最直接的信息获取方式,正是「查询一个集合」这个更一般的形式把问题变复杂了,才有了二进制分组的用武之地。


E. 电梯#

题目大意:大楼有 nn 层,第 11 到第 nn 层。建 mm 个电梯,每个电梯必须停第 11 层和第 nn 层,中间可选停。要求:对于任意不同的两层 x,yx,y,存在一个电梯使得 x,yx,y 可以直达(中间不经停任何其它楼层)。求 mm 的最小值并构造。

约束n1000n \le 1000,总停次数 2×106\le 2 \times 10^6


问题转化:每个电梯 = 一条从 11nn 的严格递增路径,路径的每条边(相邻停靠对)就是可直达的楼层对。我们需要用 mm 条路径覆盖 KnK_n 的全部 C(n,2)C(n,2) 条边。


下界分析

每条电梯恰好有一条首边 (1,a2)(1, a_2) 和一条末边 (ak1,n)(a_{k-1}, n)。要覆盖所有涉及 11 的楼层对 (1,2),(1,3),,(1,n)(1,2),(1,3),\dots ,(1,n),至少需要 n1n-1 个不同的电梯(每个只能贡献一条首边)。

同理,要覆盖所有 (2,n),(3,n),,(n1,n)(2,n),(3,n),\dots ,(n-1,n),至少 n1n-1 条末边。但 (1,n)(1,n) 兼为首边和末边,可被同一个电梯覆盖。所以:

mn1m \ge n - 1

但这只是「首末边覆盖」的下界。还剩内部对 (i,j)(i,j) 满足 2i<jn12 \le i < j \le n-1ji2j-i \ge 2,这些必须被某条电梯的中间边覆盖。

内部对共 C(n2,2)C(n-2, 2) 个,每个电梯至多贡献 n3n-3 条中间边,这给出了更强的下界:

mn1+2C(n2,2)n3m \ge n - 1 + \left\lceil \frac{2 \cdot C(n-2, 2)}{n-3} \right\rceil

实际最小值更接近 n24\left\lfloor \dfrac{n^2}{4} \right\rfloor(验证了小 nn)。


nn 验证

nnmminm_{\min}n2/4\lfloor n^2/4 \rfloor
211
322
444
566

构造方法(以 n=5,m=6n=5,m=6 为例):

每部电梯可以理解为一个「分段方案」,它将 [1,n][1,n] 切成若干子区间:

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₅ 完成 (1,n)(1,n)。E₂-E₄ 和 E₆ 覆盖跨 gap ≥ 2 的对。

每部电梯的首边在 (1,)(1,\cdot ) 集合中、末边在 (,n)(\cdot ,n) 集合中各认领一个任务;中间段则负责跨层直达。

本人更喜欢的做法 — 贪心 DFS

核心思路非常直觉:从 11 出发,每步找一个「还没被覆盖直达关系」的楼层跳过去,直到连到 nn,一部电梯生成完毕。如果还有未覆盖的楼层对,就再生成一部新电梯。重复直到所有对都被覆盖。

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 出发,贪心构建一部电梯直到 n
vector<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";
}
}

运行示例 (n=4n=4):

4
1 2 3 4
1 3 4
1 4
1 2 4

贪心 DFS 不保证 mm 最小(复杂度 O(n3)O(n^3) 内跑完 n1000n \le 1000),但它直观、好写、可读性强,赛场上能快速拿到一个可行解。顺带提一句,我在考场上思路是没问题的,就是码力不太够,缺少将思路转换为代码的能力,解决这个问题必须多阅读代码,多敲代码。


总结

考点说明
构造 + 覆盖把「直达」条件转化为路径覆盖 KnK_n
下界首边/末边 + 中间对数,给出 mm \ge 推导
小规模推理n1000n \le 1000,枚举推导或递归构造均可

二分答案 — 雪糕生产线#

这一题意难平。开赛时看到很多人都迅速过了,心想这题应该很简单,结果罚时两发,遗憾没有省一。

题意:有 nn 台机器,第 ii 台生产一根雪糕需要 tit_i 分钟。问生产 mm 根雪糕至少需要多少分钟。

本来思路(错):算速度——拿 1/ti1/t_i 表示第 ii 台机器每分钟能做多少根,然后累加求总速度。但这里有浮点陷阱:假如两台机器各每分钟做 0.50.5 根,0.5+0.5=10.5+0.5=1,看起来一分钟能做一根。但实际上每台机器独立工作 1 分钟,第一根雪糕在哪台都没完成(1<ti1 < t_i),一根都出不来。

正解:二分时间 TT,检验在 TT 分钟内能否生产 m\ge m 根。第 ii 台机器的产量 = T/ti\lfloor T / t_i \rfloor(整数除法)。

#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;
}
考点说明
二分答案最优值问题 → 判定问题,O(logmax)O(\log \text{max})
整数二分ll 防溢出,右界设为 1e18
浮点陷阱1/ti\sum 1/t_i 的整数部分 \neq 实际能完成的根数

总结#

  • 做得好的:热身赛 DP 题虽然没出,但赛后很快找准了”后缀和”转化 + 01 背包的正确框架;交互题的正交性理解到位
  • 需要改进的
    • 「思路 → 代码」的翻译能力不足,思路有了但写出来就有细节 bug
    • 简单题心态不稳,看别人秒过就急着交,罚时不值
    • 二分答案这种基础题应该零失误,边界条件和数据类型多检查
  • 下一步计划:赛后定期打 CF 保持手感,重点练交互题和构造题的类型积累 以上就是本次比赛中对我而言有帮助的题目,其他的题目还没来得及看故而不进行分析
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

HBCPC 复盘
https://caoyue.xin/posts/hbcpc-review/
作者
Colton/曹越
发布于
2026-05-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录