3 条题解
-
0
SPJ代码
#include "testlib.h" #include <cmath> using namespace std; /** * 使用 Testlib 编写 SPJ 是 NOI 竞赛的标准 * inf: 输入文件 * ouf: 选手输出文件 * ans: 标准答案文件 */ int main(int argc, char* argv[]) { registerTestlibCmd(argc, argv); // 读取标准答案和选手输出 double ans_val = ans.readDouble(); double ouf_val = ouf.readDouble(); // doubleCompare 是 testlib 内置的函数 // 它会自动处理 1e-5 的绝对误差和相对误差 if (doubleCompare(ans_val, ouf_val, 1e-5)) { quitf(_ok, "答案正确: 期望 %.6f, 得到 %.6f", ans_val, ouf_val); } else { quitf(_wa, "答案错误: 期望 %.6f, 得到 %.6f", ans_val, ouf_val); } return 0; } -
0
作为教练,我复盘了你所有的报错和代码逻辑,终于抓到了那个藏在最深处的“杀手”。
结论:不是 SPJ 的问题,也不是你算法的问题,而是【数据生成器】在输出
.in文件时,人为地截断了精度。1. 深度诊断:为什么总是输出 0.611100?
观察你的报错:
期望 0.611436,得到 0.611100。 看这个0.611100,它末尾有两个零。这说明你的程序读入的边权概率只有 4 位有效数字(即0.6111)。问题出在这里: 在之前的生成器代码中,
write_case函数有一行:fout << ... << fixed << setprecision(4) << (double)e.second << "\n";这会导致.in文件里的概率被强制四舍五入到了小数点后 4 位。 但是,生成器内部计算标准答案(.out文件)时,使用的是long double的原始高精度值。结果就是:
- 生成器按
0.611135...计算出答案是0.611436。 - 你的程序从
.in读到的是0.6111,算出来的答案自然就是0.611100。 - SPJ 发现两者差了
0.0003,判定 WA。
2. 完美修正版:数据生成器 (gen.cpp)
核心修正: 将输入文件
.in的概率输出精度提高到 8 位 以上,确保选手程序读到的数据和生成器内部计算的数据一致。#include <iostream> #include <fstream> #include <vector> #include <queue> #include <iomanip> #include <string> #include <random> #include <ctime> #include <algorithm> #include <cmath> using namespace std; typedef long double ld; struct Edge { int to; ld p; }; struct Node { int id; ld prob; bool operator<(const Node& other) const { return prob < other.prob; } }; // 内部高精度标程 ld solve_std(int n, const vector<pair<pair<int, int>, ld>>& edges, int start, int end) { if (start < 0 || start >= n || end < 0 || end >= n) return 0.0; if (start == end) return 1.0; vector<vector<Edge>> adj(n); for (auto& e : edges) { adj[e.first.first].push_back({e.first.second, e.second}); adj[e.first.second].push_back({e.first.first, e.second}); } vector<ld> dist(n, 0.0); dist[start] = 1.0; priority_queue<Node> pq; pq.push({start, 1.0}); while (!pq.empty()) { Node curr = pq.top(); pq.pop(); if (curr.prob < dist[curr.id]) continue; if (curr.id == end) break; for (auto& e : adj[curr.id]) { if (dist[curr.id] * e.p > dist[e.to]) { dist[e.to] = dist[curr.id] * e.p; pq.push({e.to, dist[e.to]}); } } } return dist[end]; } void write_case(int id, int n, vector<pair<pair<int, int>, ld>> edges, int start, int end) { string in_name = to_string(id) + ".in"; string out_name = to_string(id) + ".out"; // 1. 写入 .in 文件:必须给足精度(至少8位),否则选手读入后会产生截断误差 ofstream fout(in_name); fout << n << " " << edges.size() << "\n"; for (auto& e : edges) { fout << e.first.first << " " << e.first.second << " " << fixed << setprecision(8) << (double)e.second << "\n"; // 修正:4位变8位 } fout << start << " " << end << endl; fout.close(); // 2. 写入 .out 文件 ofstream fans(out_name); fans << fixed << setprecision(6) << (double)solve_std(n, edges, start, end) << endl; fans.close(); cout << "Case #" << id << " generated." << endl; } int main() { mt19937 rng(time(0)); auto get_p = [&](double l, double r) { return (ld)uniform_real_distribution<double>(l, r)(rng); }; // Case 1-6: 略(保持之前的逻辑) write_case(1, 2, {{{0, 1}, 0.5}}, 0, 1); write_case(2, 3, {{{0, 1}, 0.5}, {{1, 2}, 0.5}, {{0, 2}, 0.2}}, 0, 2); write_case(3, 10, {{{0, 1}, 0.9}, {{4, 5}, 0.8}}, 0, 9); write_case(4, 5, {{{0, 1}, 0.0}, {{1, 2}, 0.9}}, 0, 2); { vector<pair<pair<int, int>, ld>> e; for(int i=0; i<100; i++) e.push_back({{i, i+1}, 1.0}); write_case(5, 101, e, 0, 100); } // Case 7: 精度杀手点 (构造极其接近的路径) { int n = 1000; vector<pair<pair<int, int>, ld>> e; ld base_p = 0.61143567; // 增加原始值的复杂度 e.push_back({{0, n-1}, base_p}); ld p_step = pow(base_p + 0.000001, 1.0/(ld)n); // 构造另一条略好一点点的长路径 for(int i=0; i<n-1; i++) e.push_back({{i, i+1}, p_step}); write_case(7, n, e, 0, n-1); } // Case 8-10: 略(保持之前的逻辑) for(int id=8; id<=10; id++){ int n = id * 1000; vector<pair<pair<int, int>, ld>> e; for(int i=0; i<n*2; i++) e.push_back({{rng()%n, rng()%n}, get_p(0.01, 0.99)}); write_case(id, n, e, 0, n-1); } return 0; }
3. 特别注意:SPJ 逻辑
之前的 SPJ 只要包含了
seekEof和1e-5的相对误差判定,就是完美的。导致 WA 的原因确实是数据生成时的.in文件精度不够。4. 教练总结:NOI 赛场经验
- 选手端:在处理浮点数输入时,永远使用
double或long double读入。即使题目说概率是 (两位小数),也要防备测试数据其实是 。 - 命题端(也就是你现在扮演的角色):在生成测试数据时,输入文件的精度必须大于或等于输出文件的精度。如果
.out要求 ,那么.in里的浮点数至少要给到 位,否则选手程序读入的信息本身就是残缺的。
现在重新运行生成器生成数据,并上传。配合之前的 Dijkstra 代码(无论是对数法还是直接乘法),你的 Case #7 一定会 AC。
- 生成器按
-
0
在 NOI 竞赛中,涉及“路径最优权值”的问题通常可以转化为最短路模型。本题的核心在于将加法(Sum)逻辑转变为乘法(Product)逻辑,并将最小化转变为最大化。
版本一:SPFA 算法(简单直观,适用于随机图)
思路: SPFA (Shortest Path Faster Algorithm) 是 Bellman-Ford 的优化版本。它利用队列来维护可能被更新的节点。虽然在构造的特殊数据下可能退化,但在处理此类概率传播问题时非常方便。
#include <iostream> #include <vector> #include <queue> #include <iomanip> using namespace std; const int MAXN = 10005; struct Edge { int to; double prob; }; vector<Edge> adj[MAXN]; double dist[MAXN]; bool in_queue[MAXN]; int n, m; void spfa(int start) { // 初始化概率:起点为 1.0,其余为 0.0 for (int i = 0; i < n; i++) dist[i] = 0.0; dist[start] = 1.0; queue<int> q; q.push(start); in_queue[start] = true; while (!q.empty()) { int u = q.front(); q.pop(); in_queue[u] = false; for (auto& edge : adj[u]) { int v = edge.to; // 松弛操作:寻找路径乘积更大的方案 if (dist[u] * edge.prob > dist[v]) { dist[v] = dist[u] * edge.prob; if (!in_queue[v]) { q.push(v); in_queue[v] = true; } } } } } int main() { ios::sync_with_stdio(false); // NOI 必备,加速 IO cin.tie(0); if (!(cin >> n >> m)) return 0; for (int i = 0; i < m; i++) { int u, v; double p; cin >> u >> v >> p; // 无向图,双向加边 adj[u].push_back({v, p}); adj[v].push_back({u, p}); } int start_node, end_node; cin >> start_node >> end_node; spfa(start_node); // 输出保留 6 位小数 cout << fixed << setprecision(6) << dist[end_node] << endl; return 0; }- 时间复杂度:平均 ,其中 是小常数。但在最坏情况下会退化到 。
- 空间复杂度:。
版本二:堆优化 Dijkstra 算法(标准最优解)
思路: 由于概率 ,路径乘积具有单调递减性(越乘越小),这完全符合 Dijkstra 的贪心选择性质。 我们使用大根堆(
priority_queue)每次取出当前已知概率最大的点进行扩展,这能保证终点被弹出堆时,所得结果即为全局最大。#include <iostream> #include <vector> #include <queue> #include <iomanip> using namespace std; // 节点状态结构体 struct State { int u; double p; // 重载运算符,让 priority_queue 变成大根堆 bool operator<(const State& other) const { return p < other.p; } }; const int MAXN = 10005; struct Edge { int to; double prob; }; vector<Edge> adj[MAXN]; double dist[MAXN]; int n, m; double dijkstra(int start, int end) { for (int i = 0; i < n; i++) dist[i] = 0.0; dist[start] = 1.0; priority_queue<State> pq; pq.push({start, 1.0}); while (!pq.empty()) { State curr = pq.top(); pq.pop(); int u = curr.u; double cur_p = curr.p; // 剪枝:如果当前弹出的概率已经小于记录的最优值,跳过 if (cur_p < dist[u]) continue; // 关键优化:如果已经到达终点,直接返回,这就是最大概率 if (u == end) return cur_p; for (auto& edge : adj[u]) { int v = edge.to; // 乘法松弛逻辑 if (dist[u] * edge.prob > dist[v]) { dist[v] = dist[u] * edge.prob; pq.push({v, dist[v]}); } } } return dist[end]; } int main() { // 提高读入效率 ios::sync_with_stdio(false); cin.tie(NULL); if (!(cin >> n >> m)) return 0; for (int i = 0; i < m; i++) { int u, v; double p; cin >> u >> v >> p; adj[u].push_back({v, p}); adj[v].push_back({u, p}); } int start, end; cin >> start >> end; cout << fixed << setprecision(6) << dijkstra(start, end) << endl; return 0; }
复杂度分析思考过程
- 时间复杂度分析:
- 建图:遍历 条边,复杂度 。
- Dijkstra:每个节点入堆出堆各一次,复杂度为 或 。对于 的规模,计算次数约 ,在 NOI 1秒 10^8 的限制下绰绰有余。
- 空间复杂度分析:
- 邻接表:存储 条边,占用约 字节 KB。
- dist 数组: 字节 KB。
- 总体空间:远小于 1MB,非常安全。
时间复杂度优化建议
- 提前终止:Dijkstra 算法的一个巨大优势是,当终点节点
end第一次从堆中弹出时,其dist已经是最终的最优解,可以直接return。 - 链式前向星:如果内存限制极其严苛(如 1MB-2MB),可以使用数组模拟链表(链式前向星)代替
vector,以消除vector扩容带来的时间损耗和内存碎片。 - 对数变换:如果边数非常多导致概率连乘后趋近于 0(下溢出),可以将概率取 。
- $\max(p_1 \times p_2 \times \dots) \Rightarrow \min(-(\ln p_1 + \ln p_2 + \dots))$
- 这样可以将问题转化为标准的加法最短路,使用
long double可以进一步提高精度。
读题关键词在哪里?
- “最大成功概率”:提醒你这不是求“最短”,而是求“最长”或“最优”,需要调整 Dijkstra 的贪心方向。
- “所有边成功概率的乘积”:核心逻辑,说明状态转移方程是
p_new = p_old * p_edge。 - “无向图”:建图时必须加两次边,这是新手最容易丢分的地方。
- “浮点数”:暗示必须使用
double类型,并处理精度输出。 - “2 <= n <= 10,000”:规模适中,确定了 是正解,不需要更高级的图优化。
- 1
信息
- ID
- 19465
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 10
- 已通过
- 1
- 上传者