2 条题解
-
0
为了方便你在 OJ(Online Judge)上创建测试数据,我为你编写了一个完整的数据生成器。该程序会自动生成符合 NOI 竞赛要求的
1.in~10.in以及对应的1.out~10.out文件。数据生成逻辑设计
- 覆盖范围:
- 测试点 1-2:边界情况()。
- 测试点 3-4:特殊形态(星形树、极深链形树)。
- 测试点 5-6:完全多叉树(平衡结构)。
- 测试点 7-10:大规模随机树(),严格控制树高 。
- 安全性:
- 使用
std::stack进行非递归遍历生成标准答案,防止生成器自身爆栈。 - 使用
std::mt19937提高随机性。 - 严格遵循 的限制,确保
.in文件大小适中。
- 使用
C++ 数据生成器代码 (gen.cpp)
#include <iostream> #include <fstream> #include <vector> #include <string> #include <random> #include <algorithm> #include <stack> #include <ctime> using namespace std; // 题目约束 const int MAXN = 10000; const int MAXVAL = 10000; const int MAXHEIGHT = 1000; struct Node { int val; vector<int> children; }; // 使用非递归方式生成前序遍历序列 (标准答案) vector<int> get_preorder(int n, const vector<Node>& tree) { vector<int> res; if (n == 0) return res; stack<int> s; s.push(0); // 根节点固定为0 while (!s.empty()) { int u = s.top(); s.pop(); res.push_back(tree[u].val); // 为了前序遍历 (从左到右),栈需要反向压入子节点 for (int i = (int)tree[u].children.size() - 1; i >= 0; --i) { s.push(tree[u].children[i]); } } return res; } // 生成树结构的核心逻辑 void generate_test_case(int case_num, int n, int type) { string in_name = to_string(case_num) + ".in"; string out_name = to_string(case_num) + ".out"; ofstream fin(in_name); ofstream fout(out_name); if (n == 0) { fin << 0 << endl; return; } vector<Node> tree(n); vector<int> depth(n, 0); mt19937 rng(time(0) + case_num); // 1. 生成节点值 for (int i = 0; i < n; ++i) { tree[i].val = rng() % (MAXVAL + 1); } // 2. 构造树结构 (确保 0 为根) depth[0] = 1; for (int i = 1; i < n; ++i) { int p; if (type == 1) { // 星形:所有人都连根 p = 0; } else if (type == 2) { // 链形:i 连 i-1 (需受 MAXHEIGHT 约束) p = (i < MAXHEIGHT) ? i - 1 : rng() % MAXHEIGHT; } else { // 随机树 // 启发式:在深度允许的范围内随机选父节点 do { p = rng() % i; } while (depth[p] >= MAXHEIGHT); } tree[p].children.push_back(i); depth[i] = depth[p] + 1; } // 3. 输出 .in 文件 fin << n << endl; for (int i = 0; i < n; ++i) { fin << tree[i].val << " " << tree[i].children.size(); for (int child : tree[i].children) { fin << " " << child; } fin << "\n"; } // 4. 计算并输出 .out 文件 (使用非递归前序遍历) vector<int> ans = get_preorder(n, tree); for (int i = 0; i < (int)ans.size(); ++i) { fout << ans[i] << (i == (int)ans.size() - 1 ? "" : " "); } fout << endl; fin.close(); fout.close(); cout << "Case " << case_num << " finished. n=" << n << endl; } int main() { // 1-2: 边界 generate_test_case(1, 0, 0); generate_test_case(2, 1, 0); // 3-4: 特殊形态 generate_test_case(3, 100, 1); // 星形 generate_test_case(4, 1000, 2); // 链形 // 5-6: 中等随机 generate_test_case(5, 500, 0); generate_test_case(6, 2000, 0); // 7-10: 大规模 (N=10000) for (int i = 7; i <= 10; ++i) { generate_test_case(i, 10000, 0); } return 0; }
数据说明与分析
1. 时间复杂度分析 (生成器)
- 构树过程:对于每个测试点,我们只遍历一次节点 。由于加入了
while(depth[p] >= MAXHEIGHT)检查,在最坏随机情况下,寻找父节点的时间也接近常数(因为合法的父节点占大多数)。 - 答案生成:使用栈进行非递归遍历,每个节点进出栈各一次,时间复杂度为 。
- 总计:生成 10 个测试点的时间复杂度为 ,在 的量级下,生成速度在毫秒级,不会超时。
2. 空间复杂度分析
- 内存占用:主要消耗在存储
vector<Node>,每个节点存其子节点编号。总边数为 ,空间复杂度为 。对于 ,内存占用约几百 KB。 - 文件大小控制:
- 每个节点在
.in文件中大约占10~20字节(数值+空格)。 - 节点对应的单个文件约
200KB。 - 10 个测试点总大小约
2MB左右,完全符合 OJ 系统的上传要求。
- 每个节点在
3. 读题关键词与易错点提示 (OJ 创建者视角)
- 节点编号:此生成器严格遵循 0 为根节点,且子节点按插入顺序(即输入顺序)决定前序遍历的“左到右”顺序。
- 空树处理:
n=0是 NOI 常见的“杀手”边界,请确保你的 OJ 判题脚本能正确处理空行。 - 格式要求:样例输出末尾通常不建议留多余空格。此代码中通过
(i == size-1 ? "" : " ")逻辑处理了行末空格。
如何使用
- 将上述代码保存为
gen.cpp。 - 使用
g++ gen.cpp -o gen -std=c++14编译。 - 运行
./gen,当前目录下即生成1.in~10.out。
- 覆盖范围:
-
0
在 NOI 竞赛中,处理树结构题目通常有两种主流写法:递归(DFS) 和 迭代(手动维护栈)。对于 的数据规模,两者在时间效率上差异不大,但在极端情况下(如树退化成链),递归可能导致系统栈溢出,而迭代法更为稳健。
以下是按照从基础到进阶的完整代码实现。
版本一:基础递归 DFS 版(NOI 赛场最常用)
思路思考: 这是最直观的解法。利用函数调用栈,先处理当前节点,再递归处理子节点。
- 优点:代码极其简洁,逻辑清晰。
- 缺点:如果树的深度很大(如 以上且没有系统栈保护),可能触发
Segmentation Fault。
#include <iostream> #include <vector> #include <algorithm> using namespace std; // 常量定义,根据题目规模设定 const int MAXN = 10005; // 定义 N 叉树结构 struct Node { int val; vector<int> children; } tree[MAXN]; vector<int> result; // 递归函数:前序遍历 void dfs(int u) { // 1. 记录当前节点值 (根) result.push_back(tree[u].val); // 2. 依次递归遍历每一个子节点 (从左到右) for (int child_id : tree[u].children) { dfs(child_id); } } int main() { // 加速 I/O ios::sync_with_stdio(false); cin.tie(0); int n; if (!(cin >> n)) return 0; if (n == 0) return 0; // 读入树结构 // 假设输入格式:节点编号 0 到 n-1,每行:值 子节点数 子节点1... for (int i = 0; i < n; ++i) { int id, k, val; cin >> val >> k; tree[i].val = val; for (int j = 0; j < k; ++j) { int child; cin >> child; tree[i].children.push_back(child); } } // 从根节点(编号0)开始 DFS dfs(0); // 输出结果 for (int i = 0; i < result.size(); ++i) { cout << result[i] << (i == result.size() - 1 ? "" : " "); } cout << endl; return 0; }
版本二:迭代优化版(手动模拟栈)
思路思考: 为了解决递归深度限制问题,我们手动维护一个
std::stack。- 难点:前序遍历顺序是“根 -> 左子 -> 右子”。但在栈中,先进入的后出来。
- 策略:为了让左边的子节点先被弹出处理,我们需要将子节点按 从右往左 的顺序压入栈中。
#include <iostream> #include <vector> #include <stack> #include <algorithm> using namespace std; const int MAXN = 10005; struct Node { int val; vector<int> children; } tree[MAXN]; void iterativePreorder(int root) { if (root < 0) return; stack<int> s; s.push(root); vector<int> result; // 预分配内存,减少 vector 扩容开销 result.reserve(MAXN); while (!s.empty()) { int u = s.top(); s.pop(); // 访问当前节点 result.push_back(tree[u].val); // 关键点:反向压栈! // 因为栈是后进先出,为了让左孩子先出栈,必须先压入右孩子 for (int i = (int)tree[u].children.size() - 1; i >= 0; --i) { s.push(tree[u].children[i]); } } for (int i = 0; i < result.size(); ++i) { cout << result[i] << (i == result.size() - 1 ? "" : " "); } cout << endl; } int main() { // 省略与版本一相同的读入代码... // 此处直接调用 iterativePreorder(0); return 0; }
复杂度分析思考过程
1. 时间复杂度:
- 分析:无论递归还是迭代,每个节点都恰好进栈/出栈一次(或被递归函数访问一次)。
- 子节点遍历:虽然每个节点有多个子节点,但所有节点的子节点总数加起来等于 (树的边数)。
- 结论:总操作次数与节点数 成线性关系。
2. 空间复杂度:
- 存储空间:邻接表
vector<int> children存储了 条边,占用 。 - 辅助空间:
- 递归版:系统栈深度最坏为 。
- 迭代版:手动维护的栈
stack<int>在最坏情况下(所有节点都是根的孩子)存储 个节点,占用 。
时间复杂度优化建议 (NOI 竞赛技巧)
-
快速 I/O: 对于 的题目,
cin/cout即使加了tie(0)也可能比scanf/printf慢。在 NOI 中建议使用:inline int read() { int x=0,f=1;char ch=getchar(); while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();} while(ch>='0'&&ch<='9'){x=x*10+ch-'0';ch=getchar();} return x*f; } -
避免
std::vector频繁拷贝: 在for (int child_id : tree[u].children)中使用引用&虽在处理int时提升不明显,但如果处理复杂对象,应养成const auto&的习惯。 -
链式前向星: 如果题目对空间限制极其苛刻(例如只有 16MB),
std::vector的内存扩容机制(通常是 1.5 到 2 倍倍增)可能会浪费空间。此时可以改用链式前向星存图:int head[MAXN], nxt[MAXN], to[MAXN], cnt; void add_edge(int u, int v) { to[++cnt] = v; nxt[cnt] = head[u]; head[u] = cnt; } // 注意:链式前向星遍历出来的是子节点的倒序, // 若要严格“从左到右”,需先将子节点存下来反转或调整加边顺序。
总结:读题关键词与陷阱
- “前序遍历”:看到它就要想到
根 -> 子的顺序。 - “N 叉树”:意味着子节点数量不固定,代码中必须有遍历子节点列表的循环。
- “从左到右”:这是一个非常关键的细节。如果使用栈实现,必须反向压栈;如果使用链式前向星,由于其“头插法”特性,遍历出的顺序默认是反向的,需要额外注意。
- 数据规模: 很小,但如果 ,一定要把递归改为迭代,或者使用
std::thread扩充系统栈,否则极易爆栈。
- 1
信息
- ID
- 19411
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者