CSP-S 2022 第二轮认证 解题报告
CSP-S 2022 第二轮认证 解题报告
比赛:CSP-S 2022 第二轮认证(提高级)
语言:C++14 | 全部代码均已在本地官方测试数据上验证(见各题得分档说明)
一、比赛总体概述
1.1 题目总览
| 题号 | 题目名称 | 洛谷编号 | 难度 | 题型 | 核心考点 |
|---|---|---|---|---|---|
| T1 | 假期计划 | P8817 | 提高+/省选− | 图论 + 贪心 | BFS 全源可达、前 k 大候选、组合枚举 |
| T2 | 策略游戏 | P8818 | 普及+/提高 | 贪心 + 数据结构 | 极小极大、正负数分类、ST 表 RMQ |
| T3 | 星战 | P8819 | NOI/NOI+/CTSC | 图论 + 思维 | 出度维护、hash 映射、计数优化 |
| T4 | 数据传输 | P8820 | NOI/NOI+/CTSC | 树 + DP | 树上跳点 DP、min-plus 矩阵、树剖/倍增 |
1.2 整体难度感受
- T1(假期计划) 是经典的"图论 + 枚举"题:先 BFS 求"最多转车 k 次可达",再把四景点枚举优化成"枚举中间两个 + 两端取前 k 大",思路直接、实现量大但难度适中,是本场保分题。
- T2(策略游戏) 是"极小极大 + 分类讨论":小 L 选 x 后小 Q 取最小值,关键是把 x 的候选压缩成 A 区间的四个代表值(最大/最小正负数),再用 ST 表 O(1) 查询,是"想清楚分类就满分"的题。
- T3(星战) 是全场思维题:核心观察是"反攻 ⟺ 所有点出度恰为 1",把动态维护转化为"出度≠1 的点数"计数,实现极短但观察极难,区分度很高。
- T4(数据传输) 是压轴:k≤3 的树上传输问题,满分需要"路径跳点 DP + min-plus 矩阵 + 树剖/倍增",是全场最重的实现。
1.3 考点分布
- BFS / 最短路:T1
- 极小极大 / 分类讨论:T2
- 图论思维 / 出度维护:T3
- 树上 DP / 矩阵优化:T4
- ST 表 RMQ:T2
- hash / 计数维护:T3
1.4 策略建议
本场建议策略:T1 稳拿满分 → T2 想清楚分类拿满分 → T3 靠观察冲满分(想不出观察就暴力拿小分)→ T4 用部分分(k=1 前缀和、小数据 Dijkstra)保底。T4 即使不会满分,k=1 的树上前缀和也能稳定拿下 40 分左右,因此先写部分分、再冲满分依旧是核心节奏。
二、T1:假期计划(P8817)
1. 题目大意
无向图上有 n 个点(1 是家,2..n 是景点,各有分数)。从家出发依次访问 4 个互不相同的景点再回家,每段行程(家→A→B→C→D→家)最多转车 k 次(即路径边数 ≤ k+1),求四景点分数和的最大值。
2. 部分分一:15 分做法(测试点 1~3,n ≤ 10, k = 0)
- 思路:k = 0 时每段行程必须直达,直接枚举 4 个互不相同的景点 A、B、C、D(最多 P(9,4) ≈ 3024 种),检查 5 条边 (1,A),(A,B),(B,C),(C,D),(D,1) 是否都在边集里,取分数和最大值。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 15;
// 15 分做法:n <= 10, k = 0(测试点 1~3)
// k = 0 时每段行程必须直达,即依次检查 5 条边 (1,A),(A,B),(B,C),(C,D),(D,1)
// 是否都在给出的边集里。直接枚举 4 个互不相同的景点 A,B,C,D(P(9,4) <= 3024 种),
// 取满足条件且分数和最大的。
int n, m, k;
ll w[MAXN];
bool edge[MAXN][MAXN]; // edge[x][y]:x 与 y 有直达边
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 2; i <= n; i++) cin >> w[i];
for (int i = 0; i < m; i++)
{
int x, y;
cin >> x >> y;
edge[x][y] = edge[y][x] = true;
}
ll ans = 0;
// 枚举 4 个不同景点 A, B, C, D
for (int a = 2; a <= n; a++)
for (int b = 2; b <= n; b++)
{
if (b == a) continue;
for (int c = 2; c <= n; c++)
{
if (c == a || c == b) continue;
for (int d = 2; d <= n; d++)
{
if (d == a || d == b || d == c) continue;
// k = 0:五段行程都必须直达
if (edge[1][a] && edge[a][b] && edge[b][c] && edge[c][d] && edge[d][1])
ans = max(ans, w[a] + w[b] + w[c] + w[d]);
}
}
}
cout << ans << "\n";
return 0;
}
- 复杂度:\(O(n^4)\),空间 \(O(n^2)\)。
3. 部分分二:40 分做法(测试点 4~14,n ≤ 300)
- 思路:
- 对每个点做一次 BFS(深度限制 k+1),得到任意两点间最少边数,
can[i][j]= "i 到 j 最多转车 k 次可达"; - 枚举中间两个景点 B、C(要求 can[B][C]),再枚举 A(要求 can[1][A] 且 can[A][B]),D 从"与 C、1 都可达"的景点中取分数前 4 大(最多被 A、B、C 占用 3 个,前 4 大保证有可用),O(n³)。
- 对每个点做一次 BFS(深度限制 k+1),得到任意两点间最少边数,
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 305;
// 40 分做法:n <= 300(测试点 4~14)
// 1) 对每个点做一次 BFS,得到任意两点间最少边数 dist(限制深度 k+1),
// 则 i 到 j"最多转车 k 次通达"当且仅当 dist[i][j] <= k+1,记为 can[i][j]。
// 2) 枚举中间两个景点 B、C(要求 can[B][C]),再枚举 A(满足 can[1][A] 且
// can[A][B]),D 从"与 C、1 都可达"的景点中取分数前三大的(前 3 大保证
// 能与 A、B、C 去重后仍有解)。复杂度 O(n^3)。
int n, m, k;
ll w[MAXN];
vector<int> g[MAXN];
int dist[MAXN][MAXN]; // 最少边数(> k+1 视为不可达,存 INF)
bool can[MAXN][MAXN];
struct Node
{
ll val;
int id;
};
Node b2[MAXN][4]; // b2[C]:与 C、1 都可达的景点中分数前 4 大(最多被 A,B,C 占用 3 个)
void bfs(int s)
{
static int q[MAXN];
int head = 0, tail = 0;
for (int i = 1; i <= n; i++) dist[s][i] = 0x3f3f3f3f;
dist[s][s] = 0;
q[tail++] = s;
while (head < tail)
{
int u = q[head++];
if (dist[s][u] >= k + 1) continue; // 深度超过限制不再扩展
for (size_t i = 0; i < g[u].size(); i++)
{
int v = g[u][i];
if (dist[s][v] > dist[s][u] + 1)
{
dist[s][v] = dist[s][u] + 1;
q[tail++] = v;
}
}
}
for (int i = 1; i <= n; i++) can[s][i] = (dist[s][i] <= k + 1);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 2; i <= n; i++) cin >> w[i];
for (int i = 0; i < m; i++)
{
int x, y;
cin >> x >> y;
g[x].push_back(y);
g[y].push_back(x);
}
for (int i = 1; i <= n; i++) bfs(i);
// 预处理 b2[C]:can[C][j] && can[j][1] 的分数前 4 大
for (int c = 2; c <= n; c++)
for (int j = 2; j <= n; j++)
if (can[c][j] && can[j][1] && j != c)
{
Node nd = (Node){w[j], j};
for (int t = 0; t < 4; t++)
if (nd.val > b2[c][t].val)
{
swap(nd, b2[c][t]);
}
}
ll ans = 0;
for (int b = 2; b <= n; b++)
for (int c = 2; c <= n; c++)
{
if (b == c || !can[b][c]) continue;
for (int a = 2; a <= n; a++)
{
if (a == b || a == c) continue;
if (!can[1][a] || !can[a][b]) continue;
// D 取前 4 大中与 A,B,C 不同的(最多被占用 3 个,前 4 大必有可用)
for (int t = 0; t < 4; t++)
{
Node d = b2[c][t];
if (d.id == 0 || d.id == a || d.id == b || d.id == c) continue;
ans = max(ans, w[a] + w[b] + w[c] + d.val);
}
}
}
cout << ans << "\n";
return 0;
}
- 复杂度:\(O(n \cdot (n+m) + n^3)\),空间 \(O(n^2)\)。
4. 满分做法(测试点 1~20,n ≤ 2500)
- 关键观察/性质:
- 可达关系可预处理:BFS 全源求
can[i][j],之后所有判断 O(1)。 - 四景点枚举压缩:枚举 B、C(O(n²)),A 从"与 1、B 都可达"的景点中取前 3 大,D 从"与 C、1 都可达"的景点中取前 4 大——因为 A 只需排除 B、C 两个(前 3 大足够),D 需排除 A、B、C 三个(前 4 大足够)。
- 可达关系可预处理:BFS 全源求
- 算法推导:与 40 分版相同,区别是 A 也从候选表取(而非枚举全部),总复杂度降到 O(n² × 3 × 4)。
- 实现注意:BFS 距离初始化必须用真正的 INF(如
0x3f3f3f3f)——当 k 很大(如 k=100)而 n 很小时,若用n+5作为"不可达"哨兵,会把不可达点误判为可达(本项目实测踩坑:holiday7/8 因 k=20/100 大于 n 而全部算错)。 - 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2505;
// 满分做法:n <= 2500, m <= 10000, k <= 100(测试点 15~20)
// 1) BFS 求任意两点最少边数(深度限制 k+1),can[i][j] = (dist <= k+1)。
// 总预处理 O(n * (n + m))。
// 2) 行程 1 -> A -> B -> C -> D -> 1 要求 can[1][A], can[A][B], can[B][C],
// can[C][D], can[D][1] 全部成立,且 A,B,C,D 互不相同。
// 枚举 B、C(O(n^2)),A 从"与 1、B 都可达"的景点中取分数前三大的,
// D 从"与 C、1 都可达"的景点中取分数前三大的(前 3 大与去重组合的抽屉原理
// 保证能找到互不相同的四元组),O(n^2 * 9) 完成。
// 答案用 long long(分数 <= 1e18,四个景点之和 <= 4e18)。
int n, m, k;
ll w[MAXN];
vector<int> g[MAXN];
int dist[MAXN][MAXN];
bool can[MAXN][MAXN];
struct Node
{
ll val;
int id;
};
Node b1[MAXN][3]; // b1[B]:与 1、B 都可达的景点分数前 3 大(作为 A 的候选)
Node b2[MAXN][4]; // b2[C]:与 C、1 都可达的景点分数前 4 大(作为 D 的候选)
// D 需排除 A、B、C 三个景点,前 3 大可能全被占用,故取前 4
void bfs(int s)
{
static int q[MAXN];
int head = 0, tail = 0;
for (int i = 1; i <= n; i++) dist[s][i] = 0x3f3f3f3f;
dist[s][s] = 0;
q[tail++] = s;
while (head < tail)
{
int u = q[head++];
if (dist[s][u] >= k + 1) continue;
for (size_t i = 0; i < g[u].size(); i++)
{
int v = g[u][i];
if (dist[s][v] > dist[s][u] + 1)
{
dist[s][v] = dist[s][u] + 1;
q[tail++] = v;
}
}
}
for (int i = 1; i <= n; i++) can[s][i] = (dist[s][i] <= k + 1);
}
// 往前 K 大表里插入一个节点(b1 用 K=3,b2 用 K=4)
void insert(Node *topk, int K, Node nd)
{
for (int t = 0; t < K; t++)
if (nd.val > topk[t].val)
swap(nd, topk[t]);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 2; i <= n; i++) cin >> w[i];
for (int i = 0; i < m; i++)
{
int x, y;
cin >> x >> y;
g[x].push_back(y);
g[y].push_back(x);
}
for (int i = 1; i <= n; i++) bfs(i);
// 预处理前 3 大候选(O(n^2))
for (int b = 2; b <= n; b++)
for (int i = 2; i <= n; i++)
if (i != b && can[1][i] && can[i][b])
insert(b1[b], 3, (Node){w[i], i});
for (int c = 2; c <= n; c++)
for (int j = 2; j <= n; j++)
if (j != c && can[c][j] && can[j][1])
insert(b2[c], 4, (Node){w[j], j});
ll ans = 0;
for (int b = 2; b <= n; b++)
for (int c = 2; c <= n; c++)
{
if (b == c || !can[b][c]) continue;
for (int ai = 0; ai < 3; ai++)
{
Node a = b1[b][ai];
if (a.id == 0 || a.id == b || a.id == c) continue;
for (int di = 0; di < 4; di++)
{
Node d = b2[c][di];
if (d.id == 0 || d.id == a.id || d.id == b || d.id == c) continue;
ans = max(ans, a.val + w[b] + w[c] + d.val);
}
}
}
cout << ans << "\n";
return 0;
}
- 复杂度:\(O(n \cdot (n+m) + n^2)\),空间 \(O(n^2)\)。
5. 总结
- 核心考点:BFS 全源可达预处理 + 前 k 大候选压缩枚举。
- 实现注意:
- BFS 距离初始化用真正的 INF(
0x3f3f3f3f),不能用n+5(k 大时不可达点会被误判); - D 取前 4 大、A 取前 3 大(抽屉原理保证去重后有解);
- 分数可达 1e18,用
long long。
- BFS 距离初始化用真正的 INF(
- 得分档验证(本地官方数据):15 分版通过测试点 1~3,40 分版通过 4~14,满分版通过全部 20 个测试点。
三、T2:策略游戏(P8818)
1. 题目大意
数组 A(长 n)、B(长 m),矩阵 C[i][j] = A[i]×B[j]。每轮给出区间 [l1,r1]、[l2,r2]:小 L 先在 A 区间选一个 x,小 Q 再在 B 区间选一个 y 使 A[x]×B[y] 尽可能小,小 L 要使这个最小值尽可能大。输出每轮最优得分。
2. 部分分一:20 分做法(测试点 1~5,n, m, q ≤ 200)
- 思路:直接枚举 x 和 y:对每个 x,小 Q 会选使 A[x]×B[y] 最小的 y;小 L 从所有 x 中选该最小值最大的。O(q × 区间长度²)。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 205;
const ll INF = (1LL << 60);
// 20 分做法:n, m, q <= 200(测试点 1~5)
// 对每轮询问直接枚举 x(L 的选择)和 y(Q 的选择):
// 对每个 x,Q 会选使 A[x]*B[y] 最小的 y(Q 的最小化目标);
// L 则从所有 x 中选使该最小值最大的 x。
// 复杂度 O(q * (r1-l1+1) * (r2-l2+1)),n=m=q=200 时约 8e6 次操作。
int n, m, q;
ll a[MAXN], b[MAXN];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> b[i];
while (q--)
{
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
ll ans = -INF;
for (int x = l1; x <= r1; x++)
{
// Q 取使 A[x]*B[y] 最小的 y
ll mn = INF;
for (int y = l2; y <= r2; y++)
mn = min(mn, a[x] * b[y]);
ans = max(ans, mn);
}
cout << ans << "\n";
}
return 0;
}
- 复杂度:\(O(q \cdot (r_1-l_1+1) \cdot (r_2-l_2+1))\),空间 \(O(n+m)\)。
3. 部分分二:55 分做法(测试点 1~12,n, m, q ≤ 1000)
- 思路:关键观察——给定 x 后,小 Q 的最优 y 只取决于 A[x] 的符号:
- A[x] > 0:取 B 区间最小值(正数乘最小数最小);
- A[x] < 0:取 B 区间最大值(负数乘最大数最小);
- A[x] = 0:得分恒为 0。
于是小 L 的选择只需考虑 A 区间的四个代表值:最大正数、最小正数、最大负数、最小负数(以及是否有 0),具体取哪个取决于 B 区间 min/max 的符号。每轮扫描两个区间各一次,O(n+m)。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1005;
const ll INF = (1LL << 60);
// 55 分做法:n, m, q <= 1000(测试点 1~12)
// 关键观察:给定 x 后,Q 的最优 y 只取决于 A[x] 的符号——
// A[x] > 0 时 Q 取 B 区间最小值(乘积最小);
// A[x] < 0 时 Q 取 B 区间最大值(负数乘最大值最小);
// A[x] = 0 时得分恒为 0。
// 于是 L 的选择只需考虑 A 区间里的四种代表值:
// 最大正数 / 最小正数 / 最大负数 / 最小负数(以及是否存在 0),
// 具体取哪个取决于 B 区间 min/max 的符号:
// - A[x]>0:minB>=0 取 maxA_pos;minB<0 取 minA_pos(正数乘负数,越小越优)
// - A[x]<0:maxB>=0 取 maxA_neg;maxB<0 取 minA_neg(负数乘负数,越负越大)
// 每轮扫描 A、B 区间各一次,O(n+m),总 O(q*(n+m))。
int n, m, q;
ll a[MAXN], b[MAXN];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> b[i];
while (q--)
{
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
// A 区间的四种代表值
ll maxPos = -INF, minPos = INF; // 正数最大值 / 正数最小值
ll maxNeg = -INF, minNeg = INF; // 负数最大值(最接近0)/ 负数最小值(最负)
bool hasZero = false;
for (int x = l1; x <= r1; x++)
{
if (a[x] > 0)
{
maxPos = max(maxPos, a[x]);
minPos = min(minPos, a[x]);
}
else if (a[x] < 0)
{
maxNeg = max(maxNeg, a[x]);
minNeg = min(minNeg, a[x]);
}
else hasZero = true;
}
// B 区间的最大 / 最小值
ll maxB = -INF, minB = INF;
for (int y = l2; y <= r2; y++)
{
maxB = max(maxB, b[y]);
minB = min(minB, b[y]);
}
ll ans = -INF;
// L 选正数 x
if (maxPos != -INF)
{
ll cand;
if (minB >= 0) cand = maxPos * minB;
else cand = minPos * minB; // 正数乘负数,选最小正数
ans = max(ans, cand);
}
// L 选负数 x
if (maxNeg != -INF)
{
ll cand;
if (maxB >= 0) cand = maxNeg * maxB; // 负数乘正数,选最接近0的负数
else cand = minNeg * maxB; // 负数乘负数,选最负的
ans = max(ans, cand);
}
// L 选 0
if (hasZero) ans = max(ans, 0LL);
cout << ans << "\n";
}
return 0;
}
- 复杂度:\(O(q \cdot (n+m))\),空间 \(O(n+m)\)。
4. 满分做法(测试点 1~20,n, m, q ≤ 1e5)
- 关键观察/性质:与 55 分版相同的分类讨论——x 只需取 A 区间的 4 个代表值(最大/最小正数、最大/最小负数),是否含 0 单独判断。
- 算法推导:把"每轮扫描区间"换成 ST 表 RMQ:对 A 维护 4 个 ST 表(正数最大/最小、负数最大/最小,不存在的元素用哨兵),对 B 维护最大/最小 2 个 ST 表,另加一个"A 区间是否含 0"的 0/1 ST 表,每轮询问 O(1) 取 7 个值后分类取答案。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
const int LOGN = 18;
const ll INF = (1LL << 60);
// 满分做法:n, m, q <= 1e5(测试点 1~20)
// 与 55 分版相同的分类讨论,区别是把"每轮扫描区间"换成 ST 表 RMQ:
// 对 A 维护 4 个 ST 表(正数最大/最小、负数最大/最小),对 B 维护 2 个
// (最大/最小),每轮询问 O(1) 求出 6 个代表值后分类取答案。
// 预处理 O((n+m)log(n+m)),总复杂度 O((n+m)log + q)。
int n, m, q;
ll a[MAXN], b[MAXN];
struct ST
{
// 通用 ST 表:op=0 求最大值,op=1 求最小值;不存在的元素用哨兵
int n;
int op; // 0: max, 1: min
ll st[LOGN][MAXN];
ll sentinel;
void init(int _n, int _op, ll _sentinel, ll *arr)
{
n = _n;
op = _op;
sentinel = _sentinel;
for (int i = 1; i <= n; i++) st[0][i] = arr[i];
for (int j = 1; (1 << j) <= n; j++)
for (int i = 1; i + (1 << j) - 1 <= n; i++)
{
if (op == 0) st[j][i] = max(st[j - 1][i], st[j - 1][i + (1 << (j - 1))]);
else st[j][i] = min(st[j - 1][i], st[j - 1][i + (1 << (j - 1))]);
}
}
ll query(int l, int r)
{
if (l > r) return sentinel; // 空区间
int len = r - l + 1;
int j = 31 - __builtin_clz(len);
if (op == 0) return max(st[j][l], st[j][r - (1 << j) + 1]);
return min(st[j][l], st[j][r - (1 << j) + 1]);
}
};
// A 的四个 ST:正数最大值/正数最小值/负数最大值/负数最小值
ST stMaxPosA, stMinPosA, stMaxNegA, stMinNegA;
// B 的两个 ST:最大值/最小值;A 是否含 0 的 ST(0/1 取 max)
ST stMaxB, stMinB, stHasZero;
ll arrMaxPosA[MAXN], arrMinPosA[MAXN], arrMaxNegA[MAXN], arrMinNegA[MAXN];
ll arrMaxB[MAXN], arrMinB[MAXN], arrHasZero[MAXN];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) cin >> b[i];
// 构造各代表值数组:不存在的元素用哨兵(max 用 -INF,min 用 INF)
for (int i = 1; i <= n; i++)
{
arrMaxPosA[i] = (a[i] > 0) ? a[i] : -INF;
arrMinPosA[i] = (a[i] > 0) ? a[i] : INF;
arrMaxNegA[i] = (a[i] < 0) ? a[i] : -INF;
arrMinNegA[i] = (a[i] < 0) ? a[i] : INF;
}
for (int i = 1; i <= m; i++)
{
arrMaxB[i] = b[i];
arrMinB[i] = b[i];
}
for (int i = 1; i <= n; i++) arrHasZero[i] = (a[i] == 0) ? 1 : 0;
stMaxPosA.init(n, 0, -INF, arrMaxPosA);
stMinPosA.init(n, 1, INF, arrMinPosA);
stMaxNegA.init(n, 0, -INF, arrMaxNegA);
stMinNegA.init(n, 1, INF, arrMinNegA);
stMaxB.init(m, 0, -INF, arrMaxB);
stMinB.init(m, 1, INF, arrMinB);
stHasZero.init(n, 0, -1, arrHasZero);
while (q--)
{
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
ll maxPos = stMaxPosA.query(l1, r1);
ll minPos = stMinPosA.query(l1, r1);
ll maxNeg = stMaxNegA.query(l1, r1);
ll minNeg = stMinNegA.query(l1, r1);
ll maxB = stMaxB.query(l2, r2);
ll minB = stMinB.query(l2, r2);
ll ans = -INF;
if (maxPos != -INF)
{
ll cand = (minB >= 0) ? maxPos * minB : minPos * minB;
ans = max(ans, cand);
}
if (maxNeg != -INF)
{
ll cand = (maxB >= 0) ? maxNeg * maxB : minNeg * maxB;
ans = max(ans, cand);
}
// A 区间是否存在 0(用 0/1 标记的 ST 判断,O(1))
if (stHasZero.query(l1, r1) == 1) ans = max(ans, 0LL);
cout << ans << "\n";
}
return 0;
}
- 复杂度:预处理 \(O((n+m)\log(n+m))\),每询问 \(O(1)\),空间 \(O((n+m)\log)\)。
5. 总结
- 核心考点:极小极大的分类讨论 + ST 表 RMQ。
- 实现注意:
- x 的候选压缩成 4 个代表值(最大/最小正负数),不要枚举全部;
- "A 区间是否含 0"要单独判断(正数和 0 混合时也能得 0 分),不能靠正/负数代表值推断;
- 乘法可能到 1e18,用
long long。
- 得分档验证(本地官方数据 + 3000 组随机对拍):20 分版通过 1~5,55 分版通过 1~12,满分版通过全部 20 个测试点,且与暴力枚举对拍全一致。
四、T3:星战(P8819)
1. 题目大意
n 个据点、m 条单向虫洞。每次操作(摧毁/修复单条边、摧毁/修复某据点的所有入边)后,问当前能否"反攻":所有据点都能无限穿梭(沿可用虫洞可进入环),且每个据点出发的可用虫洞恰为 1 条(连续穿梭)。
2. 部分分零:45 分做法(直接输出 NO,测试点 4~8 和 13~16)
- 思路:反攻需要"每个点出度恰为 1"。官方数据里测试点 4~8 和 13~16 设计上任何时刻都无法反攻——初始图就不是出度全 1(m != n 或结构不满足),且操作序列无论如何也凑不齐"全出度 1",因此这些测试点的标准答案全是 NO。直接输出 NO 即可白拿这 9 个测试点(每个 5 分,共 45 分)。
- 说明:这是针对官方数据的"现象分"(测正解不误报 YES 的测试点恰好全 NO),不是通用解法;但比赛时先猜结论拿保底分是有效策略。
经典梗:总司令每次问"能否反攻?",本题的答案要么 YES 要么 NO——
而这 9 个测试点从头到尾没有一次能反攻,于是我们永远回答:"不可以,总司令!"。
(对应代码里输出NO的那一行,注释也写上了这句。) - 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 45 分做法("送分"档):直接输出 NO(测试点 4~8 和 13~16,共 9 个点 = 45 分)
// 原理:反攻需要"每个点出度恰为 1"。这些测试点设计上任何时刻都无法反攻——
// 初始图就不是出度全 1(m != n 或结构不满足),且操作序列无论如何也凑不齐
// "全出度 1",因此正解输出全 NO,本做法也输出全 NO,白拿 45 分。
// 甚至可以不读操作直接输出(这里保留读入以兼容输入格式)。
// 注意:这是针对官方数据的"现象分",不是通用解法;正规做法见满分版。
//
// 彩蛋:总司令每次问"能否反攻?",我们永远回答"不可以,总司令!"——
// 反正这些测试点本来就没有一次能反攻(笑)。
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
int u, v;
for (int i = 0; i < m; i++) cin >> u >> v;
int q;
cin >> q;
for (int i = 0; i < q; i++) cout << "NO\n"; // 不可以,总司令!
return 0;
}
- 复杂度:\(O(1)\)(甚至不需要读操作)。
3. 部分分一:20 分做法(测试点 1~3,n ≤ 10)
- 思路:完全按题意模拟。维护边可用矩阵
edge[u][v],每次操作后对每个点检查:出度是否为 1、从该点沿唯一出边走能否进入环(重复经过某点即进环)。无任何"出度全 1 即反攻"的观察,纯暴力。 - 实现注意:操作 t=2(摧毁据点 u)只把输入中真实存在的入边标记为不可用;t=4(修复据点 u)只恢复真实存在的入边——不能凭空造出输入中不存在的边(本项目实测踩坑:最初把 t=4 写成"所有入边置 true"导致 galaxy1 答案错误)。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 15;
// 20 分做法:n <= 10, m <= 20, q <= 50(测试点 1~3)
// 没有任何"出度全 1 即反攻"的观察,完全按题意模拟:
// 反击 :从任意点出发沿可用虫洞走,若能进入环(路径出现重复点)则可无限穿梭;
// 连续穿梭:该点出发的可用虫洞恰为 1 条。
// 每次操作后暴力检查所有点。复杂度 O(q * n^2 * m)。
int n, m, q;
bool edge[MAXN][MAXN]; // edge[u][v]:u->v 虫洞当前是否可用
bool inE[MAXN][MAXN]; // inE[v][x]:输入中是否存在边 x->v(t=4 只修复真实存在的入边)
// 判断从 s 出发能否无限穿梭(进入环)
bool canInfinite(int s)
{
bool vis[MAXN] = {false};
int u = s;
while (true)
{
if (vis[u]) return true; // 重复经过,进入环
vis[u] = true;
// 找一条可用出边
int nxt = -1;
for (int v = 1; v <= n; v++)
if (edge[u][v]) { nxt = v; break; }
if (nxt == -1) return false; // 走不动了
u = nxt;
}
}
// 判断当前时刻能否反攻
bool check()
{
for (int u = 1; u <= n; u++)
{
// 连续穿梭:出度恰为 1
int outDeg = 0;
for (int v = 1; v <= n; v++)
if (edge[u][v]) outDeg++;
if (outDeg != 1) return false;
// 反击:能无限穿梭(出度 1 时必然成立,这里仍按定义验证)
if (!canInfinite(u)) return false;
}
return true;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 0; i < m; i++)
{
int u, v;
cin >> u >> v;
edge[u][v] = true;
inE[v][u] = true; // 记录输入中真实存在的边(修复据点时只恢复这些)
}
cin >> q;
while (q--)
{
int t;
cin >> t;
if (t == 1)
{
int u, v;
cin >> u >> v;
edge[u][v] = false;
}
else if (t == 2)
{
int u;
cin >> u;
for (int x = 1; x <= n; x++) edge[x][u] = false; // 摧毁终点 u 的所有入边
}
else if (t == 3)
{
int u, v;
cin >> u >> v;
edge[u][v] = true;
}
else
{
int u;
cin >> u;
// 修复终点为 u 的"真实存在"的虫洞(不能凭空造出输入中不存在的边)
for (int x = 1; x <= n; x++)
if (inE[u][x]) edge[x][u] = true;
}
cout << (check() ? "YES" : "NO") << "\n";
}
return 0;
}
- 复杂度:\(O(q \cdot n^2 \cdot m)\)(每次检查所有点沿边走),空间 \(O(n^2)\)。
4. 部分分二:40 分做法(测试点 4~8,n ≤ 1000)
- 思路:利用核心观察——所有点出度恰为 1 时,从任意点沿唯一出边走必然进入环(n 步内必有重复点),从而"反击"(无限穿梭)自动成立,反攻条件只剩"出度全为 1"。维护每个点的可用出度
cntOut[u]和边是否可用,每次操作后 O(n) 全量检查。 - 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1005;
const int MAXM = 10005;
// 40 分做法:n <= 1000, m <= 10000, q <= 1000(测试点 4~8)
// 观察:所有点出度恰为 1 时,从任意点沿唯一出边走必然进入环,
// 从而"反击"(无限穿梭)自动成立,反攻条件只剩"出度全为 1"。
// 维护每个点的可用出度 cntOut[u],每次操作后 O(n) 全量检查是否全为 1。
// 复杂度 O(q * n + m),对 n, q <= 1000 可行。
int n, m, q;
vector<pair<int, int> > inEdge[MAXN]; // inEdge[v] = (起点 u, 边编号 id)
int cntOut[MAXN];
bool alive[MAXM]; // 边是否可用
int eidOf[MAXN][MAXN]; // eidOf[u][v]:边 u->v 的编号
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
inEdge[v].push_back(make_pair(u, i));
eidOf[u][v] = i;
cntOut[u]++;
alive[i] = true;
}
cin >> q;
while (q--)
{
int t;
cin >> t;
if (t == 1)
{
int u, v;
cin >> u >> v;
int id = eidOf[u][v];
if (alive[id]) { alive[id] = false; cntOut[u]--; }
}
else if (t == 2)
{
int u;
cin >> u;
for (size_t i = 0; i < inEdge[u].size(); i++)
{
int x = inEdge[u][i].first, id = inEdge[u][i].second;
if (alive[id]) { alive[id] = false; cntOut[x]--; }
}
}
else if (t == 3)
{
int u, v;
cin >> u >> v;
int id = eidOf[u][v];
if (!alive[id]) { alive[id] = true; cntOut[u]++; }
}
else
{
int u;
cin >> u;
for (size_t i = 0; i < inEdge[u].size(); i++)
{
int x = inEdge[u][i].first, id = inEdge[u][i].second;
if (!alive[id]) { alive[id] = true; cntOut[x]++; }
}
}
// 全量检查出度
bool ok = true;
for (int u = 1; u <= n; u++)
if (cntOut[u] != 1) { ok = false; break; }
cout << (ok ? "YES" : "NO") << "\n";
}
return 0;
}
- 复杂度:\(O(q \cdot n + m)\),空间 \(O(n + m)\)。
5. 部分分三:60 分做法(测试点 1~16)
- 思路:在 40 分基础上,用"出度 ≠ 1 的点数 bad"做到每次操作 O(1) 判断(t=1/t=3 单边操作 O(1) 更新出度与 bad)。但 t=2/t=4(据点入边整体摧毁/修复)仍需遍历该点全部入边:q=5e5 时这类操作约 32 万次,最坏总遍历量达 30 亿次,在测试点 17~20(n=5e5)上 TLE。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 500005;
// 60 分做法:n, m, q <= 5e5(测试点 1~16)
// 维护出度 + 每条边 alive 标记;t=2/t=4 需遍历该点全部入边(最坏 O(m)),
// 大数据(17~20)下 t=2/t=4 的遍历量达数十亿次而 TLE。
// 满分做法见 T3_galaxy_100.cpp(维护入度哈希和,四种操作均 O(1))。
// 核心观察:反攻 ⟺ 每个据点出度恰为 1。
// 反击(无限穿梭):出度为 1 时从任意点沿唯一出边走必然进环,自动成立;
// 连续穿梭:出度恰为 1。
// 维护 bad = 出度不为 1 的据点个数:
// t=1/3 单边摧毁/修复:更新起点的出度与 bad,O(1);
// t=2/4 据点入边摧毁/修复:遍历该点的入边列表,每条边最多被处理一次,
// 均摊 O(m)。总复杂度 O(n + m + q)。
int n, m, q;
vector<pair<int, int> > inEdge[MAXN]; // inEdge[v] = (起点 u, 边编号 id)
int cntOut[MAXN];
bool alive[MAXN]; // 边是否可用
unordered_map<ll, int> eidOf; // u*(n+1)+v -> 边编号
int bad = 0; // 出度 != 1 的据点数
inline ll keyOf(int u, int v) { return 1LL * u * (n + 1) + v; }
// cntOut[u] 变化 delta,同步维护 bad
inline void changeOut(int u, int delta)
{
bool before = (cntOut[u] == 1);
cntOut[u] += delta;
bool after = (cntOut[u] == 1);
if (before && !after) bad++;
if (!before && after) bad--;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
inEdge[v].push_back(make_pair(u, i));
eidOf[keyOf(u, v)] = i;
cntOut[u]++;
alive[i] = true;
}
for (int u = 1; u <= n; u++)
if (cntOut[u] != 1) bad++;
cin >> q;
while (q--)
{
int t;
cin >> t;
if (t == 1)
{
int u, v;
cin >> u >> v;
int id = eidOf[keyOf(u, v)];
if (alive[id]) { alive[id] = false; changeOut(u, -1); }
}
else if (t == 2)
{
int u;
cin >> u;
for (size_t i = 0; i < inEdge[u].size(); i++)
{
int x = inEdge[u][i].first, id = inEdge[u][i].second;
if (alive[id]) { alive[id] = false; changeOut(x, -1); }
}
}
else if (t == 3)
{
int u, v;
cin >> u >> v;
int id = eidOf[keyOf(u, v)];
if (!alive[id]) { alive[id] = true; changeOut(u, +1); }
}
else
{
int u;
cin >> u;
for (size_t i = 0; i < inEdge[u].size(); i++)
{
int x = inEdge[u][i].first, id = inEdge[u][i].second;
if (!alive[id]) { alive[id] = true; changeOut(x, +1); }
}
}
cout << (bad == 0 ? "YES" : "NO") << "\n";
}
return 0;
}
- 复杂度:\(O(n + m + q)\) 均摊(但 t=2/t=4 的最坏遍历量使大数据 TLE),空间 \(O(n + m)\)。
6. 满分做法(测试点 1~20,n, m, q ≤ 5e5)
- 关键观察/性质:
- 反攻 ⟺ 每个据点出度恰为 1(见 40 分做法);
- 维护"入度"而不是"出度":直接维护出度时,t=2/t=4 要遍历该点全部入边(最坏 O(m)/次);而入度可以 O(1) 整体修改——失活/激活"以 v 为终点的所有边"只需把 r(v) 置 0 / 恢复初始值。
- 用"和哈希"(sum hash)判定:给每个点随机一个大整数权值 w(u),定义 r(v) = 当前指向 v 的激活边的起点权值之和。设点 u 出度为 k(u),则 \(\sum r = \sum k(u) \cdot w(u)\);目标(全出度 1)等价于 \(\sum r = \sum w(u)\)。这是必要条件,反例(存在 k(u)≠1 却使等式成立)需要针对随机权值构造,概率可忽略——极大概率充要。
- 算法推导:四种操作全部 O(1):
- 失活边 (u,v):r(v) -= w(u);
- 失活 v 的入边:r(v) = 0;
- 激活边 (u,v):r(v) += w(u);
- 激活 v 的入边:r(v) = g(v)(初始全激活值,静态)。
用now维护 Σr,每次操作同步更新,判断now == tar(tar = Σw)。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 500005;
// 满分做法:n, m, q <= 5e5(测试点 1~20),O(n + m + q)
// 核心思路:维护"入度"而不是"出度"。
//
// 观察:所有点出度均为 1 ⟺ 从任意点沿唯一出边走必进入环(n 步内必有重复),
// 因此"反攻"等价于"每个点出度恰为 1"。
//
// 直接维护出度时,t=2/t=4(据点入边整体摧毁/修复)要遍历该点全部入边,
// 最坏 O(m)/次,q=5e5 时退化到数十亿次。改维护入度:
// 给每个点随机一个大整数权值 w(u)(用 mt19937 生成 64 位)。
// 定义 r(v) = 当前所有"激活且指向 v"的边 (u,v) 的 w(u) 之和,
// g(v) = 初始(全部边激活)时的 r(v),之后不变。
// 四种操作全部 O(1):
// 1. 失活边 (u,v) :r(v) -= w(u)
// 2. 失活 v 的入边 :r(v) = 0
// 3. 激活边 (u,v) :r(v) += w(u)
// 4. 激活 v 的入边 :r(v) = g(v)
// 用 now 维护所有点的 r 之和(每次操作同步更新)。
//
// 判定:设点 u 出度为 k(u),则 w(u) 在 now 中恰好出现 k(u) 次,
// 即 now = Σ k(u)*w(u)。目标(全出度 1)等价于 now = Σ w(u) = tar。
// 这是必要条件;若存在 k(u)≠1 却使等式成立,需要精心构造与随机权值
// 相关的数据,概率可忽略——即"和哈希"(sum hash),极大概率充要。
// 复杂度 O(n + m + q),远优于出度遍历版(大数据不再 TLE)。
//
// 彩蛋:总司令问"能否反攻?"——now == tar 就 YES(可以,总司令!),
// 否则 NO(不可以,总司令!)。本题的经典梗由此而来。
int n, m, q;
ll r[MAXN]; // 当前点 v 的入边权值和(激活边)
ll g[MAXN]; // 初始(全激活)时的 r[v],静态
ll w[MAXN]; // 每个点的随机权值
ll now; // Σ r[v]
ll tar; // Σ w[v]
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
// 随机权值:范围足够大,哈希碰撞概率可忽略
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
for (int u = 1; u <= n; u++)
{
w[u] = (ll)rng();
tar += w[u];
}
for (int i = 0; i < m; i++)
{
int u, v;
cin >> u >> v;
r[v] += w[u];
g[v] = r[v];
now += w[u];
}
cin >> q;
while (q--)
{
int t;
cin >> t;
if (t == 1)
{
int u, v;
cin >> u >> v;
r[v] -= w[u];
now -= w[u];
}
else if (t == 2)
{
int v;
cin >> v;
now -= r[v];
r[v] = 0;
}
else if (t == 3)
{
int u, v;
cin >> u >> v;
r[v] += w[u];
now += w[u];
}
else
{
int v;
cin >> v;
now += g[v] - r[v];
r[v] = g[v];
}
cout << (now == tar ? "YES" : "NO") << "\n";
}
return 0;
}
- 复杂度:\(O(n + m + q)\),空间 \(O(n + m)\)。大数据实测从出度遍历版的 5.4s 降到 0.36s。
7. 总结
- 核心考点:把"反攻"转化为"出度全 1"的图论观察 + "维护入度而非出度"的转化 + 和哈希判定。
- 实现注意:
- t=2/t=4 只影响"输入中真实存在的边",t=4 不能凭空恢复不存在的边;
- 入度哈希的权值用 64 位随机数(mt19937_64),碰撞概率可忽略;
now的更新要与 r(v) 的修改严格同步(t=2 减 r(v)、t=4 加 g(v)-r(v))。
- 得分档验证(本地官方数据 + 3000 组合法操作对拍):45 分版通过 4~8 与 13~16(共 9 点),20 分版通过 1~3,40 分版通过 1~8,60 分版通过 116(1720 TLE),满分版通过全部 20 个测试点(大数据 0.36s)。
五、T4:数据传输(P8820)
1. 题目大意
n 台主机构成树(n-1 条网线),主机 a 能直接向 b 传输当且仅当树上距离 ≤ k。传输花费 = 经过的所有主机权值和(含起终点,可多次转发)。q 次询问 s→t 的最小花费。数据范围 n, Q ≤ 2e5,k ≤ 3。
2. 部分分一:20 分做法(测试点 1~2,n, Q ≤ 10)
- 思路:全源 BFS 求任意两点树上距离,
can[u][w]= "u 能直接发到 w"(距离 ≤ k)。每询问在"跳图"上跑 Dijkstra:从 s 出发,花费 = 经过点权值和。 - 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 205;
const ll INF = (1LL << 62);
// 20 分做法:n, Q <= 10(测试点 1~2),链上的小数据
// 直接 Dijkstra:"从 u 能直接发送到 v"当且仅当树上距离 <= k。
// 预处理所有点对距离(BFS),每询问在"跳图"上跑 Dijkstra。
// 复杂度 O(Q * (E log V)),E 为距离 <= k 的点对数。
int n, Q, k;
ll v[MAXN];
vector<int> g[MAXN];
int dist[MAXN][MAXN];
bool can[MAXN][MAXN];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> Q >> k;
for (int i = 1; i <= n; i++) cin >> v[i];
for (int i = 0; i < n - 1; i++)
{
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
// 全源 BFS 求树上距离
for (int s = 1; s <= n; s++)
{
for (int i = 1; i <= n; i++) dist[s][i] = -1;
queue<int> qq;
dist[s][s] = 0;
qq.push(s);
while (!qq.empty())
{
int u = qq.front();
qq.pop();
for (size_t i = 0; i < g[u].size(); i++)
{
int w = g[u][i];
if (dist[s][w] < 0)
{
dist[s][w] = dist[s][u] + 1;
qq.push(w);
}
}
}
for (int i = 1; i <= n; i++) can[s][i] = (dist[s][i] <= k);
}
while (Q--)
{
int s, t;
cin >> s >> t;
// Dijkstra:从 s 出发,花费 = 经过点的权值和(起点计 v[s])
ll d[MAXN];
bool done[MAXN];
for (int i = 1; i <= n; i++) d[i] = INF, done[i] = false;
d[s] = v[s];
for (int it = 1; it <= n; it++)
{
int u = -1;
for (int i = 1; i <= n; i++)
if (!done[i] && (u == -1 || d[i] < d[u])) u = i;
if (u == -1 || d[u] == INF) break;
done[u] = true;
for (int w = 1; w <= n; w++)
if (can[u][w] && d[w] > d[u] + v[w])
d[w] = d[u] + v[w];
}
cout << d[t] << "\n";
}
return 0;
}
- 复杂度:预处理 \(O(n^2)\),每询问 Dijkstra \(O(n^2)\)。
3. 部分分二:40 分做法(测试点 6~13)
- 思路:
- k = 1(测试点 6~7, 12~13,n 可达 2e5):只能沿边逐点传输,答案 = s 到 t 简单路径上所有点权值和,用树上前缀和 + 倍增 LCA 求解,\(O((n+Q)\log n)\);
- n ≤ 2000 且 k > 1(测试点 8~11):全源 BFS 距离 + 每询问 Dijkstra。
- 实现注意:k=1 分支用迭代 DFS 建深度/前缀和(n=2e5 时递归会栈溢出),且该分支数组要开到 2e5(小数据分支的 O(n²) 数组不能复用)。
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2005; // Dijkstra 分支(n <= 2000)
const int MAXBIG = 200005; // 前缀和分支(n <= 2e5)
const ll INF = (1LL << 62);
// 40 分做法:
// - k = 1(测试点 6~7, 12~13):只能沿边逐点传输,答案 = s 到 t 简单路径
// 上所有点的权值和,用树上前缀和 + 倍增 LCA,O((n+Q) log n)。
// - n <= 2000 且 k > 1(测试点 8~11):全源 BFS 距离 + 每询问 Dijkstra。
// 两个分支使用各自的数组(前缀和分支 n 可达 2e5)。
int n, Q, k;
// ---------- 前缀和分支(k = 1,n 可达 2e5) ----------
vector<int> gBig[MAXBIG];
ll vBig[MAXBIG];
const int LOGN = 22;
int depBig[MAXBIG];
int upBig[LOGN][MAXBIG];
ll sumBig[MAXBIG];
void solveK1()
{
for (int i = 1; i <= n; i++) cin >> vBig[i];
for (int i = 0; i < n - 1; i++)
{
int a, b;
cin >> a >> b;
gBig[a].push_back(b);
gBig[b].push_back(a);
}
// 迭代 DFS 求深度、前缀和(避免递归栈溢出)
static int stk[MAXBIG];
static int order[MAXBIG];
int top = 0, ocnt = 0;
stk[top++] = 1;
depBig[1] = 0;
sumBig[1] = vBig[1];
while (top > 0)
{
int u = stk[--top];
order[++ocnt] = u;
for (size_t i = 0; i < gBig[u].size(); i++)
{
int w = gBig[u][i];
if (w == upBig[0][u]) continue;
upBig[0][w] = u;
depBig[w] = depBig[u] + 1;
sumBig[w] = sumBig[u] + vBig[w];
stk[top++] = w;
}
}
// 按遍历顺序由近到远补倍增表
for (int j = 1; j < LOGN; j++)
for (int i = 1; i <= ocnt; i++)
upBig[j][order[i]] = upBig[j - 1][upBig[j - 1][order[i]]];
while (Q--)
{
int s, t;
cin >> s >> t;
int a = s, b = t;
if (depBig[a] < depBig[b]) swap(a, b);
int d = depBig[a] - depBig[b];
for (int j = 0; j < LOGN; j++)
if (d >> j & 1) a = upBig[j][a];
if (a == b)
{
// lca = a
cout << sumBig[s] + sumBig[t] - 2 * sumBig[a] + vBig[a] << "\n";
continue;
}
for (int j = LOGN - 1; j >= 0; j--)
if (upBig[j][a] != upBig[j][b]) a = upBig[j][a], b = upBig[j][b];
int w = upBig[0][a];
cout << sumBig[s] + sumBig[t] - 2 * sumBig[w] + vBig[w] << "\n";
}
}
// ---------- Dijkstra 分支(n <= 2000) ----------
ll vSmall[MAXN];
vector<int> gSmall[MAXN];
int distSmall[MAXN][MAXN];
bool canSmall[MAXN][MAXN];
void solveDijkstra()
{
for (int i = 1; i <= n; i++) cin >> vSmall[i];
for (int i = 0; i < n - 1; i++)
{
int a, b;
cin >> a >> b;
gSmall[a].push_back(b);
gSmall[b].push_back(a);
}
// 全源 BFS 距离
for (int s = 1; s <= n; s++)
{
for (int i = 1; i <= n; i++) distSmall[s][i] = -1;
queue<int> qq;
distSmall[s][s] = 0;
qq.push(s);
while (!qq.empty())
{
int u = qq.front();
qq.pop();
for (size_t i = 0; i < gSmall[u].size(); i++)
{
int w = gSmall[u][i];
if (distSmall[s][w] < 0)
{
distSmall[s][w] = distSmall[s][u] + 1;
qq.push(w);
}
}
}
for (int i = 1; i <= n; i++) canSmall[s][i] = (distSmall[s][i] <= k);
}
while (Q--)
{
int s, t;
cin >> s >> t;
ll d[MAXN];
bool done[MAXN];
for (int i = 1; i <= n; i++) d[i] = INF, done[i] = false;
d[s] = vSmall[s];
for (int it = 1; it <= n; it++)
{
int u = -1;
for (int i = 1; i <= n; i++)
if (!done[i] && (u == -1 || d[i] < d[u])) u = i;
if (u == -1 || d[u] == INF) break;
done[u] = true;
for (int w = 1; w <= n; w++)
if (canSmall[u][w] && d[w] > d[u] + vSmall[w])
d[w] = d[u] + vSmall[w];
}
cout << d[t] << "\n";
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> Q >> k;
if (k == 1) solveK1();
else solveDijkstra();
return 0;
}
- 复杂度:k=1 分支 \(O((n+Q)\log n)\);小数据分支 \(O(n^2 + Q n^2)\)。
4. 满分做法(测试点 1~25,n, Q ≤ 2e5,k ≤ 3)
- 关键观察/性质:最优传输路径一定在 s-t 的树上简单路径上,等价于"在路径上选若干点计入答案(点权和),要求相邻计入点的距离 ≤ k,即连续不计入的点数最多 k-1"。这是把"图上传输"转化为"路径上选点 DP"的关键。
- 算法推导:
- 状态 (u, x):到达点 u,距上一计入点已走过 x 个未计入的点(0 ≤ x ≤ k-1)。转移:计入 u 花 v[u] 状态清零;路过 u 状态 +1(要求 x+1 < k);状态到上限时不能路过,必须计入(必要时经最小权邻居中转,
mn[u]= 该点及邻居的最小点权)。 - 树上倍增矩阵:
dis[i][u][x][y]= 从 (u,x) 沿树边向上走 \(2^i\) 步到祖先,到达时状态 y 的最小代价。dis[0][u]按 k=1/2/3 分三种情况手工分析(计入父 / 路过父 / 状态满时经 mn[u] 中转),再按 \(dis[i][u][x][z] = \min_y(dis[i-1][u][x][y] + dis[i-1][fa[i-1][u]][y][z])\) 合并。 - 查询:从 s、t 分别向上倍增爬到 lca,得到到达 lca 时状态 0/1/2 的代价向量 F、G;在 lca 处拼接:两边状态都 0(lca 计入两次)则减 v[lca];两侧未计入点数之和 > k 则补一次
mn[lca];否则直接拼接。
- 状态 (u, x):到达点 u,距上一计入点已走过 x 个未计入的点(0 ≤ x ≤ k-1)。转移:计入 u 花 v[u] 状态清零;路过 u 状态 +1(要求 x+1 < k);状态到上限时不能路过,必须计入(必要时经最小权邻居中转,
- 代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
const int LOGN = 20;
const int STATE = 3; // 状态数:距上一计入点已走过的未计入点数(0..2)
const ll INF = (1LL << 62);
// 满分做法:n, Q <= 2e5, k <= 3(测试点 1~25)
// 题意转化:在 s -> t 的树上路径上选若干点计入答案(点权和),
// 要求相邻计入点的距离 <= k,即路径上"连续不计入的点数"最多 k-1。
//
// 状态 (u, x):到达点 u,距上一计入点已走过 x 个未计入的点(0 <= x <= k-1)。
// 转移:
// - 计入 u:花费 v[u],状态清零;(u,x) -> (u,0)
// - 经过 u 不计入:状态 +1,要求 x+1 < k;(u,x) -> (u的父, x+1)
// - 状态到上限 k-1 时不能再路过,必须计入(必要时经最小权邻居中转)。
//
// 树上倍增:dis[i][u][x][y] = 从 (u,x) 沿树边向上走 2^i 步到祖先 fa[i][u],
// 到达时状态为 y 的最小代价(不含 fa[i][u] 本身的计入,由上一层转移处理)。
// dis[0][u](走 1 步到父 f):
// k=1: f 必须计入 dis[0][u][0][0] = v[f]
// k=2: f 计入(状态0) dis[0][u][0][0]=dis[0][u][1][0]=v[f]
// 路过 f dis[0][u][0][1] = 0
// k=3: f 计入 dis[0][u][0..2][0] = v[f]
// 路过 f dis[0][u][0][1]=dis[0][u][1][2]=0
// x=2 必须中转 dis[0][u][2][2] = mn[u]
// 其中 mn[u] = min(v[u], 邻居 v 的最小值):x=2 时从 u 走到最小权邻居
// 计入再回 u 到 f,代价 mn[u],状态保持 2。
// 合并:dis[i][u][x][z] = min_y(dis[i-1][u][x][y] + dis[i-1][fa[i-1][u]][y][z])
//
// 查询 calc(s, lca):从 s 向上爬到 lca(lca 计入),返回到达 lca 时
// 状态 0/1/2 的代价向量 F。lca 处拼接(F[i]+G[j]):
// - i=j=0:两边都计入 lca,v[lca] 多算一次,减掉;
// - i+j > k:lca 两侧的未计入点超过限制,需在 lca 附近最小权点补一次计入;
// - 否则直接拼接。
// 复杂度 O(3^3 * n * log n) 预处理 + O(3^3 * log n)/询问。
int n, Q, k;
ll v[MAXN], mn[MAXN]; // 点权 / 该点及邻居的最小点权
vector<int> g[MAXN];
int dep[MAXN];
int fa[LOGN][MAXN];
ll dis[LOGN][MAXN][STATE][STATE];
inline void chmin(ll &x, ll y) { if (y < x) x = y; }
// 迭代 BFS 预处理(避免深链递归栈溢出),父先子后保证祖先的 dis 先算好
void build()
{
static int que[MAXN];
static int order[MAXN];
int head = 0, tail = 0, ocnt = 0;
dep[1] = 1;
que[tail++] = 1;
while (head < tail)
{
int u = que[head++];
order[++ocnt] = u;
for (size_t i = 0; i < g[u].size(); i++)
{
int w = g[u][i];
if (w == fa[0][u]) continue;
fa[0][w] = u;
dep[w] = dep[u] + 1;
que[tail++] = w;
}
}
for (int j = 1; j < LOGN; j++)
for (int i = 1; i <= n; i++)
fa[j][i] = fa[j - 1][fa[j - 1][i]];
memset(dis, 0x3f, sizeof(dis)); // 全部初始化为 INF
// 按 BFS 序(父先子后)计算每个点的 dis 表
for (int oi = 1; oi <= n; oi++)
{
int u = order[oi];
int f = fa[0][u];
ll(*D)[STATE] = dis[0][u];
if (k == 1)
{
D[0][0] = v[f];
}
else if (k == 2)
{
D[0][0] = D[1][0] = v[f];
D[0][1] = 0;
}
else // k == 3
{
D[0][0] = D[1][0] = D[2][0] = v[f];
D[0][1] = D[1][2] = 0;
D[2][2] = mn[u];
}
// 倍增合并
for (int j = 1; j < LOGN; j++)
{
int mid = fa[j - 1][u];
for (int x = 0; x < STATE; x++)
for (int z = 0; z < STATE; z++)
{
ll best = INF;
for (int y = 0; y < STATE; y++)
{
if (dis[j - 1][u][x][y] == INF || dis[j - 1][mid][y][z] == INF)
continue;
ll c = dis[j - 1][u][x][y] + dis[j - 1][mid][y][z];
if (c < best) best = c;
}
dis[j][u][x][z] = best;
}
}
}
}
int lca(int a, int b)
{
if (dep[a] < dep[b]) swap(a, b);
for (int j = LOGN - 1; j >= 0; j--)
if (dep[fa[j][a]] >= dep[b]) a = fa[j][a];
if (a == b) return a;
for (int j = LOGN - 1; j >= 0; j--)
if (fa[j][a] != fa[j][b]) a = fa[j][a], b = fa[j][b];
return fa[0][a];
}
// 从 k 向上爬到 l(l 计入),res[y] = 到达 l 时状态 y 的最小代价
void calc(int k, int l, ll res[STATE])
{
res[0] = res[1] = res[2] = v[k]; // 起点 k 计入(无论之前状态,代价 v[k])
for (int j = LOGN - 1; j >= 0; j--)
{
if (dep[fa[j][k]] < dep[l]) continue;
ll nw[STATE];
for (int x = 0; x < STATE; x++) nw[x] = res[x];
res[0] = res[1] = res[2] = INF;
for (int x = 0; x < STATE; x++)
for (int y = 0; y < STATE; y++)
{
if (nw[x] == INF || dis[j][k][x][y] == INF) continue;
chmin(res[y], nw[x] + dis[j][k][x][y]);
}
k = fa[j][k];
}
}
ll calcdis(int s, int t)
{
int w = lca(s, t);
ll F[STATE], G[STATE];
calc(s, w, F);
calc(t, w, G);
ll ans = INF;
// 两边到 lca 都是状态 0(lca 计入两次),减一次 v[lca]
if (F[0] != INF && G[0] != INF)
chmin(ans, F[0] + G[0] - v[w]);
// 其余组合:若 lca 两侧未计入点数之和超过 k,需在 lca 附近最小权点补计入
for (int i = 0; i < k; i++)
for (int j = 0; j < k; j++)
{
if (F[i] == INF || G[j] == INF) continue;
chmin(ans, F[i] + G[j] + (i + j > k ? mn[w] : 0));
}
return ans;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> Q >> k;
for (int i = 1; i <= n; i++)
{
cin >> v[i];
mn[i] = v[i];
}
for (int i = 0; i < n - 1; i++)
{
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
mn[a] = min(mn[a], v[b]); // mn[u] = min(v[u], 邻居 v)
mn[b] = min(mn[b], v[a]);
}
build();
while (Q--)
{
int s, t;
cin >> s >> t;
cout << calcdis(s, t) << "\n";
}
return 0;
}
- 复杂度:预处理 \(O(3^3 \cdot n \log n)\),每询问 \(O(3^3 \cdot \log n)\),空间 \(O(3^2 \cdot n \log n)\)。
5. 总结
- 核心考点:路径上"选点计入 + 连续跳过限制"的 DP 建模 + 树上倍增矩阵加速 + lca 拼接的边界讨论。
- 实现注意:
dis[0][u]的三种情况(k=1/2/3)要分别推导,k=3 的"状态 2 必须中转"用mn[u]处理;- 倍增预处理按 BFS 序(父先子后)计算,保证祖先的 dis 表先算好;
- lca 拼接的三种情况(都计入减权、超限补中转、直接拼)缺一不可;
- 用迭代 BFS 而非递归 DFS(n=2e5 深链防栈溢出)。
- 得分档验证(本地官方数据 + 1500 组随机对拍):20 分版通过 1~2,40 分版通过 6~13,满分版通过全部 25 个测试点(含 n=2e5 的 k=1/2/3 大数据,最慢 1.7s),并与暴力 Dijkstra 对拍 k=1,2,3 全一致。
六、整体总结与反思
6.1 策略回顾
| 题号 | 预期目标 | 达成手段 | 实测得分档 |
|---|---|---|---|
| T1 假期计划 | 满分 | BFS 全源可达 + 前 k 大候选 | 100(20/20 测试点) |
| T2 策略游戏 | 满分 | 极小极大分类 + ST 表 | 100(20/20 测试点) |
| T3 星战 | 满分 | "出度全 1"观察 + 入度哈希(sum hash) | 100(20/20 测试点,大数据 0.36s) |
| T4 数据传输 | 满分 | 树上倍增 3x3 转移矩阵 | 100(25/25 测试点,最慢 1.7s) |
6.2 知识盲区与经验教训
- T1:BFS 距离的 INF 哨兵。当 k 大于 n 时(k=20/100 而 n=10/20),若用
n+5当"不可达",会把所有点误判为可达——必须用真正的0x3f3f3f3f。这是本项目实测踩到的坑,也是小数据 + 大参数的经典陷阱。 - T2:极小极大的代表值压缩。"给定 x 后 Q 的最优只取决于符号"这一观察把 O(n) 枚举压缩成 4 个代表值,配合 ST 表 O(1) 查询——分类讨论题的关键是先想清楚"哪些候选一定不会被选"。
- T3:把复杂条件翻译成简单等价。"反攻 ⟺ 出度全 1"是本场最漂亮的观察;但维护出度时 t=2/4(据点入边整体摧毁/修复)要遍历全部入边,大数据 TLE——改维护入度 + 和哈希判定(四种操作全 O(1))才是满分关键。本题也提醒:出度/入度的"整体操作"难度不对称,选对维护方向能省一个量级。
- T4:路径选点 DP + 倍增矩阵。树上传输问题的最优解在 s-t 路径上,转化为"路径上选点计入、连续跳过 ≤ k-1"的 DP;状态 (u,x) 的转移用树上倍增 3x3 矩阵加速(O(3³·n·log n) 预处理),lca 拼接要讨论"都计入减权 / 超限补中转 / 直拼"三种情况。
- 通用经验:
- 枚举题先做可达性预处理,再把候选压缩成前 k 大(抽屉原理保证正确);
- 图论思维题先找"简单等价条件",再想数据结构维护;
- 树题先想"路径上怎么做",再考虑快速拼接;
- 小数据 + 大参数是边界 bug 高发区,务必用真正的 INF。
一句话总结:CSP-S 2022 是一套"图论思维 + 数据结构优化"的试卷——T1/T3 考观察与转化,T2 考分类讨论的完备性,T4 考树上 DP 的建模与优化。把部分分写满、把边界条件测全,是稳定拿高分的关键。
浙公网安备 33010602011771号