5 条题解
-
0
满分解法(100 分)
只需关注两个端点
支付一枚金币会同时得到一段钥匙,后面又可以从已打开的许多宝箱中任选金币。直接记录已经支付过的金币集合,状态数会很大。
先看已打开宝箱的形状。初始为 。若当前已打开 ,支付其中的金币 ,由于 ,新获得的区间与 一定相交,打开后的范围恰为
所以已打开的宝箱始终构成一个连续区间。只要宝箱 和 都已打开,中间就全部打开了。
当 时,初始钥匙已经足够,答案为 。下面讨论 。
打开一个端点是最短路
建立一个概念上的有向图:对于每个 ,向每个 连一条权值为 的边。边 表示支付金币 ,从而能够打开宝箱 。记这个图上的最短距离为 ,不可达时为 。
一条从 到 的路径给出打开宝箱 的合法过程:按路径依次取得并支付金币,终点宝箱只需打开,不必支付它的金币。反过来,沿着“宝箱是由哪枚金币首次打开的”向前追溯,可以从任意打开 的过程提取这样一条路径。因此单个目标的最优代价就是最短距离。
但不能直接把到两个端点的最短距离相加:两条路径可能共用已经支付的金币。
例如 ,五个区间依次为 。打开左端点可以依次支付金币 ,打开右端点可以依次支付金币 。独立相加得到 ,而实际过程如下:
支付的金币 已打开的区间 累计支付 尚未支付 金币 同时帮助了两侧,应该只算一次。
找到两条路径共用到哪里
为了准确计算共享的金币,定义:
- :初始拥有宝箱 的钥匙,要求支付金币 ,最终打开宝箱 所需的最少金币数。
- :同样要求支付金币 ,但目标改为宝箱 。
这个“要求支付金币 ”很关键。即使 ,仍有 ;类似地 。这样,两条分支都包含分岔处的同一枚金币,可以统一扣除重复。
设最后一次负责取得宝箱 钥匙的金币为 ,必有 。先到达 ,再支付它,得到
同理,
两式均可在反图上用多源最短路求出:计算 时,把所有满足 的点距离设为 ,其余设为无穷;计算 时换成 。反图的一条边 存在,当且仅当 ,边权仍为 。
现在固定一个分岔点 。先从 到达它,再分别打开两个端点,金币数的上界为
最后的 扣掉两条分支都支付的金币 。若所选路径还存在别的重合,已经支付过的金币可以跳过,不会增加代价:它提供的钥匙已经全部拿到了。
为什么只考虑一个分岔点就足够?从任意最优操作过程中,给每个非初始宝箱记录首次打开它的金币编号作为父亲。父亲对应的宝箱一定更早打开,因此这些关系形成一棵以 为根的树。只保留通向宝箱 、 的两条根路径,它们具有一段公共前缀,并在最后一个公共宝箱 处分开;一个端点也可能就是 。
公共前缀可以用最短路径替代。两侧路径的代价分别不小于 、,并且共同支付的金币 只计一次。如果一个端点就是 ,仍将支付 的那一枚计入这条分支;它本来就必须为另一条非空分支支付。这给出了不超过原最优方案的上述表达式。
另一方面,每个表达式都能按前述路径组合成一个代价不超过它的合法方案。两边合起来,答案恰为
$$\operatorname{ans}(s)=\min_v\bigl(\delta(s,v)+a(v)+b(v)-1\bigr).$$只考虑 均有限的 。令 ,在反图上把各个 的初始距离设为 ,再做一次多源最短路,就同时得到了所有起点的答案。无有限距离的起点输出 。
不显式列出所有区间边
图最多有 条边,不能逐条存储。我们要支持的反图操作是:当点 的最短距离确定后,找出所有包含 的区间 ,尝试用距离加 更新 。
在编号轴 上建立一棵线段树。把每个区间 分解为 个线段树节点,在这些节点的列表中存下编号 。
点 属于某个线段树节点代表的区间,当且仅当该节点在 对应叶子到根的路径上。因此,当 从最小堆中有效弹出时,沿这条路径访问各节点,就能找到需要松弛的反向边。
还必须避免反复扫描同一个列表。某个线段树节点第一次被访问时,当前 是它所覆盖的点中,最早确定最短距离的一个。Dijkstra 按最终距离非降顺序弹出点,故以后该区间内的点距离都不会更小。列表中的每个目标都只接收“该距离加 ”的转移;这次松弛后,再扫描这个列表不可能得到更好的值。因此每次最短路运行中,每个列表只扫描一次。
代码用标记记录已经扫描的线段树节点。沿祖先链遇到已标记节点时可以直接停止:第一次处理这个节点时,更上方的祖先也已经处理过,或因更早的标记而停止,它们同样不会遗漏。三次最短路分别重新初始化标记,列表本身保持不变。
实现顺序与复杂度
先读入所有区间,建立线段树节点列表和每个编号对应的叶子。随后依次计算 、,并以有限的 初始化第三次最短路。最后按原输入顺序输出询问答案;不需要询问有序或互不相同。
代码中的
left/right保存区间,g保存线段树节点列表,leaf定位叶子,dijkstra执行一次反向多源搜索。初始距离不同,所以用最小堆维护处理次序;出堆时跳过已过期的距离快照。每个区间分解后共存储 个编号。一次搜索扫描每个列表一次,总计 次检查。对某个真实点 ,第一次遇到区间 中已确定的点时,就取得所有这类前驱中最小的距离;它们的边权都是 ,所以后续前驱不能再次改进 。因此每个点除初始入堆外至多再因一次改进入堆,堆操作总计 。
时间复杂度:,包括建表、三次最短路和输出。
空间复杂度:,主要为线段树节点列表;距离、标记和堆额外占用 空间。
AC 代码(C++20)
#include <bits/stdc++.h> using namespace std; using i64 = long long; void solve() { int n; cin >> n; vector<int> left(n + 1), right(n + 1); for (int i = 1; i <= n; i++) cin >> left[i] >> right[i]; vector<vector<int>> g(4 * n); vector<int> leaf(n + 1); auto build = [&](auto&& self, int p, int l, int r) -> void { if (l == r) { leaf[l] = p; return; } int mid = (l + r) / 2; self(self, p * 2, l, mid); self(self, p * 2 + 1, mid + 1, r); }; build(build, 1, 1, n); auto add = [&](auto&& self, int p, int l, int r, int u) -> void { if (left[u] <= l && r <= right[u]) { g[p].push_back(u); return; } int mid = (l + r) / 2; if (left[u] <= mid) self(self, p * 2, l, mid, u); if (right[u] > mid) self(self, p * 2 + 1, mid + 1, r, u); }; for (int i = 1; i <= n; i++) add(add, 1, 1, n, i); constexpr int inf = INT_MAX / 2; auto dijkstra = [&](vector<int> dist) { using state = pair<int, int>; priority_queue<state, vector<state>, greater<state>> pq; for (int i = 1; i <= n; i++) { if (dist[i] != inf) pq.push({dist[i], i}); } vector<char> used(4 * n); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d != dist[u]) continue; for (int p = leaf[u]; p && !used[p]; p /= 2) { used[p] = true; for (int v : g[p]) { int nd = d + 1; if (nd >= dist[v]) continue; dist[v] = nd; pq.push({nd, v}); } } } return dist; }; vector<int> a(n + 1, inf), b(n + 1, inf); for (int i = 1; i <= n; i++) { if (left[i] == 1) a[i] = 1; if (right[i] == n) b[i] = 1; } a = dijkstra(a); b = dijkstra(b); vector<int> dist(n + 1, inf); for (int i = 1; i <= n; i++) { if (a[i] != inf && b[i] != inf) dist[i] = a[i] + b[i] - 1; } dist = dijkstra(dist); int q; cin >> q; while (q--) { int s; cin >> s; cout << (n == 1 ? 0 : dist[s] == inf ? -1 : dist[s]) << '\n'; } } int main() { cin.tie(0)->sync_with_stdio(0); solve(); } -
0
直接建立全部区间边(通过子任务 2–4、6,共 50 分)
本方法利用 较小,覆盖第 组;当 时 ,也覆盖第 组。
两端目标与三次搜索
已打开的宝箱始终构成区间:每次购买的区间包含金币所在宝箱,必与旧区间相交。因此目标等价于打开宝箱 。 单独回答 。
在图中从 向所有 连单位边,表示支付金币 后打开 。记最短距离为 。只要求到达一个宝箱时,支付过程中的首次打开依赖链就是一条这样的路径,所以最小代价可用最短路计算。
为处理两条路径共享金币,定义 为从宝箱 出发、要求支付金币 后打开 的最小代价, 则以打开 为目标。最后打开 的金币必须满足 ,从而
$$a(v)=\min_{t:L_t=1}(\delta(v,t)+1),\qquad b(v)=\min_{t:R_t=n}(\delta(v,t)+1).$$把原图反向,分别将上述两类源点的初始距离设为 ,就能用两次最短路求出所有 。不可达值为无穷。要求支付首枚金币意味着 ,不能把它们设为零。
一个实际方案中,给每个宝箱记下首次打开它的父亲金币,得到一棵以起点为根的树。通向 的两条根路径有一段公共前缀,并在某个 处分开。公共前缀代价至少为 ,两个分支的代价至少为 ,但金币 只支付一次,所以
$$\operatorname{ans}(s)=\min_v\bigl(\delta(s,v)+a(v)+b(v)-1\bigr).$$等号另一方向也成立:先沿路径到 ,再执行两条分支,重复使用的金币无需再次支付,就能打开两个端点。若一个端点正好是 ,它对应的分支只计强制支付 的一枚金币,仍然由减一正确合并。
例如从宝箱 出发,其区间为 ,金币 的区间为 ,金币 的区间为 。到两端各需两枚,但金币 只用一次,总共三枚,这正是 。
令所有有限的 为初始距离,再在反图上做第三次最短路,就同时获得全部起点的答案。结果为无穷时输出 。
按区间逐条建立反向边
对每个 ,枚举 ,把反向边 加入邻接表,边权均为 。这样共存储 条边。第 组保证 ,能够直接存储和遍历。
三次搜索均采用最小堆 Dijkstra:所有有限的初始距离入堆;弹出最小距离快照,过期则跳过,否则沿邻接表松弛。初值可能不同,不能将所有源点直接放进普通 BFS 队列而忽略初始代价。
每次搜索中,每条边只在其起点的距离确定后扫描一次。由于边权统一为 ,某个点第一次收到来自已确定前驱的候选值时,这个前驱的距离已是所有前驱中最小的;后来的前驱不能再改进它。因此每个点除初始入堆外至多因改进入堆一次,堆操作为 。
时间复杂度:,包含建图、三次搜索和查询。
空间复杂度:。
本方法对任意合法区间仍在计算同一张图,没有截断过长区间;只是当 达到 且 很大时,建图的时间和内存无法满足限制,需要满分解的区间表示。
参考代码(C++20)
#include <bits/stdc++.h> using namespace std; using i64 = long long; void solve() { int n; cin >> n; vector<int> left(n + 1), right(n + 1); for (int i = 1; i <= n; i++) cin >> left[i] >> right[i]; vector<vector<int>> g(n + 1); for (int i = 1; i <= n; i++) { for (int j = left[i]; j <= right[i]; j++) g[j].push_back(i); } constexpr int inf = INT_MAX / 2; auto dijkstra = [&](vector<int> dist) { using state = pair<int, int>; priority_queue<state, vector<state>, greater<state>> pq; for (int i = 1; i <= n; i++) { if (dist[i] != inf) pq.push({dist[i], i}); } while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d != dist[u]) continue; for (int v : g[u]) { int nd = d + 1; if (nd >= dist[v]) continue; dist[v] = nd; pq.push({nd, v}); } } return dist; }; vector<int> a(n + 1, inf), b(n + 1, inf); for (int i = 1; i <= n; i++) { if (left[i] == 1) a[i] = 1; if (right[i] == n) b[i] = 1; } a = dijkstra(a); b = dijkstra(b); vector<int> dist(n + 1, inf); for (int i = 1; i <= n; i++) { if (a[i] != inf && b[i] != inf) dist[i] = a[i] + b[i] - 1; } dist = dijkstra(dist); int q; cin >> q; while (q--) { int s; cin >> s; cout << (n == 1 ? 0 : dist[s] == inf ? -1 : dist[s]) << '\n'; } } int main() { cin.tie(0)->sync_with_stdio(0); solve(); } -
0
稠密图上的三次最短路(通过子任务 2–4,共 35 分)
本方法覆盖 的第 组。
把两端目标合并
已打开的宝箱始终构成区间:每次购买的区间包含金币所在宝箱,必与旧区间相交。因此目标等价于打开宝箱 。 单独回答 。
在图中从 向所有 连单位边,表示支付金币 后打开 。记最短距离为 。只要求到达一个宝箱时,支付过程中的首次打开依赖链就是一条这样的路径,所以最小代价可用最短路计算。
为处理两条路径共享金币,定义 为从宝箱 出发、要求支付金币 后打开 的最小代价, 则以打开 为目标。最后打开 的金币必须满足 ,从而
$$a(v)=\min_{t:L_t=1}(\delta(v,t)+1),\qquad b(v)=\min_{t:R_t=n}(\delta(v,t)+1).$$把原图反向,分别将上述两类源点的初始距离设为 ,就能用两次最短路求出所有 。不可达值为无穷。要求支付首枚金币意味着 ,不能把它们设为零。
一个实际方案中,给每个宝箱记下首次打开它的父亲金币,得到一棵以起点为根的树。通向 的两条根路径有一段公共前缀,并在某个 处分开。公共前缀代价至少为 ,两个分支的代价至少为 ,但金币 只支付一次,所以
$$\operatorname{ans}(s)=\min_v\bigl(\delta(s,v)+a(v)+b(v)-1\bigr).$$等号另一方向也成立:先沿路径到 ,再执行两条分支,重复使用的金币无需再次支付,就能打开两个端点。若一个端点正好是 ,它对应的分支只计强制支付 的一枚金币,仍然由减一正确合并。
例如从宝箱 出发,其区间为 ,金币 的区间为 ,金币 的区间为 。到两端各需两枚,但金币 只用一次,总共三枚,这正是 。
令所有有限的 为初始距离,再在反图上做第三次最短路,就同时获得全部起点的答案。结果为无穷时输出 。
直接扫描所有邻居
反图中 当且仅当 。不必存边:确定点 后,枚举所有 检查这个不等式即可。
采用朴素 Dijkstra。每一轮扫描所有未确定的点,选择距离最小者;若不存在有限距离则结束。随后扫描全部 ,对合法反向边尝试用 更新 。边权非负,取出的最小距离不会再被未确定点改进,故这个顺序能正确确定最短路。三个搜索使用同一过程,只改变初始距离。
每次有 轮,每轮两次线性扫描,三次运行仍为二次复杂度;查询只需查表。无需针对每个询问重新运行,因此 可以达到 。
时间复杂度:。
空间复杂度:。代码只保存区间、距离和访问标记,没有存储二次规模的邻接矩阵。
参考代码(C++20)
#include <bits/stdc++.h> using namespace std; using i64 = long long; void solve() { int n; cin >> n; vector<int> left(n + 1), right(n + 1); for (int i = 1; i <= n; i++) cin >> left[i] >> right[i]; constexpr int inf = INT_MAX / 2; auto dijkstra = [&](vector<int> dist) { vector<char> used(n + 1); for (int k = 1; k <= n; k++) { int u = 0; for (int i = 1; i <= n; i++) { if (!used[i] && dist[i] < dist[u]) u = i; } if (!u) break; used[u] = true; for (int v = 1; v <= n; v++) { if (left[v] <= u && u <= right[v]) { dist[v] = min(dist[v], dist[u] + 1); } } } return dist; }; vector<int> a(n + 1, inf), b(n + 1, inf); for (int i = 1; i <= n; i++) { if (left[i] == 1) a[i] = 1; if (right[i] == n) b[i] = 1; } a = dijkstra(a); b = dijkstra(b); vector<int> dist(n + 1, inf); for (int i = 1; i <= n; i++) { if (a[i] != inf && b[i] != inf) dist[i] = a[i] + b[i] - 1; } dist = dijkstra(dist); int q; cin >> q; while (q--) { int s; cin >> s; cout << (n == 1 ? 0 : dist[s] == inf ? -1 : dist[s]) << '\n'; } } int main() { cin.tie(0)->sync_with_stdio(0); solve(); } -
0
枚举已打开区间的广度优先搜索(通过子任务 2–3,共 30 分)
本方法利用单次询问和较小的 ,覆盖 的第 组。
初始已打开 。若已打开 ,可以支付任意 的金币,变为
两个区间都包含 ,所以始终只需两个端点描述已打开集合。
记 为打开区间恰好是 时,最少已经支付的金币数。初值为 ,其他状态尚未访问。每个支付动作成本都是 ,因此用 BFS 求最短路:弹出 后枚举全部 ,若产生的 尚未访问,设置
并加入队列。首次弹出 时,其距离就是答案;队列耗尽仍未到达则无解。
虽然状态没有记录已经用过哪些金币,但不会错误地再次使用一枚金币扩展范围:已支付金币提供的整段钥匙早已取得,再支付它只能回到原状态,BFS 会跳过这个已访问状态。反之,一条让区间真正变大的转移所需金币一定尚未支付。状态因此保留了所有有用选择。
例如先打开 ,若金币 的区间为 ,就会以一次支付到达状态 ;之后可以分别尝试其中三枚金币,不能只保留某一个看起来扩展最远的选择。
所有可达区间都包含固定起点 ,最多有 个;即使全部访问,枚举金币的总次数也不超过 。这仍是三次复杂度,但不必扫描任何不包含 的状态。代码对重复起点缓存答案,其他询问重新运行 BFS,不缩减合法输入。
时间复杂度:,适用于本组单次询问;一般有 个不同起点时为 。
空间复杂度:。
参考代码(C++20)
#include <bits/stdc++.h> using namespace std; using i64 = long long; void solve() { int n; cin >> n; vector<int> left(n + 1), right(n + 1), ans(n + 1, -2); for (int i = 1; i <= n; i++) cin >> left[i] >> right[i]; auto bfs = [&](int s) { vector<vector<int>> dist(n + 1, vector<int>(n + 1, -1)); queue<pair<int, int>> q; dist[s][s] = 0; q.push({s, s}); while (!q.empty()) { auto [l, r] = q.front(); q.pop(); if (l == 1 && r == n) return dist[l][r]; for (int i = l; i <= r; i++) { int nl = min(l, left[i]), nr = max(r, right[i]); if (dist[nl][nr] != -1) continue; dist[nl][nr] = dist[l][r] + 1; q.push({nl, nr}); } } return -1; }; int q; cin >> q; while (q--) { int s; cin >> s; if (ans[s] == -2) ans[s] = bfs(s); cout << ans[s] << '\n'; } } int main() { cin.tie(0)->sync_with_stdio(0); solve(); } -
0
从最左端出发的区间贪心(通过子任务 1,共 5 分)
本方法保证所有询问 的第 组。
初始打开宝箱 。因为每个购买区间都包含其金币所在宝箱,已打开的范围始终为某个前缀 。在已经打开的宝箱中选择 最大的金币,一次操作能把这个前缀扩展到最远。
这个贪心不会错失后续选择:假设支付同样多枚金币后,贪心得到的前缀包含另一方案得到的前缀,那么另一方案下一次可选的金币也都在贪心的前缀内。贪心选择最大右端点后,新的前缀仍包含另一方案的结果。从初始前缀归纳,贪心每一步都能达到同样操作次数下最远的右边界,第一次覆盖全部时必然最优。
若所有可选金币的右端点都不超过当前右边界,则再也无法扩展,答案为 。 时无需支付。
不必每次重新扫描整个前缀。只扫描这次新打开的宝箱,维护右端点最大的金币;每个宝箱至多加入一次。
代码同时维护左端点最小的金币,采用“可以向左扩展时先向左,否则向右”的自然扩展策略。对于本组 ,向左扩展的分支永远不会出现,恰好就是上述最优贪心。一般起点没有这个保证:例如区间依次为 ,从 出发,优先向左会支付 ,而支付 只需三枚。因此本方法不声称覆盖一般询问。
同一个起点的结果缓存后直接复用。本组即使有许多重复的询问,也只需扩展一次。
时间复杂度:。一般输入中若有 个不同起点,代码的成本为 。
空间复杂度:。
参考代码(C++20)
#include <bits/stdc++.h> using namespace std; using i64 = long long; void solve() { int n; cin >> n; vector<int> left(n + 1), right(n + 1), ans(n + 1, -2); for (int i = 1; i <= n; i++) cin >> left[i] >> right[i]; auto calc = [&](int s) { int l = s, r = s, x = s, y = s, cnt = 0; auto add = [&](int i) { if (left[i] < left[x]) x = i; if (right[i] > right[y]) y = i; }; while (l > 1 || r < n) { int u = left[x] < l ? x : y; int nl = min(l, left[u]), nr = max(r, right[u]); if (nl == l && nr == r) return -1; for (int i = nl; i < l; i++) add(i); for (int i = r + 1; i <= nr; i++) add(i); l = nl; r = nr; cnt++; } return cnt; }; int q; cin >> q; while (q--) { int s; cin >> s; if (ans[s] == -2) ans[s] = calc(s); cout << ans[s] << '\n'; } } int main() { cin.tie(0)->sync_with_stdio(0); solve(); }
- 1
信息
- ID
- 2306
- 时间
- 2000ms
- 内存
- 512MiB
- 难度
- 9
- 标签
- (无)
- 递交数
- 41
- 已通过
- 6
- 上传者