2 条题解
-
0
作为教练,我为你提供一个符合 NOI 竞赛标准的高性能数据生成器。
该生成器采用 C++14 编写,能够生成 10 组测试数据。为了确保数据的合法性,生成器采用 “先构树,再遍历” 的逻辑:先随机生成不同形态的二叉树,然后导出其前序和后序序列作为输入(
.in),最后通过导出该树的中序序列作为标准答案(.out)。C++ 数据生成器 (gen.cpp)
#include <iostream> #include <vector> #include <string> #include <fstream> #include <algorithm> #include <random> #include <stack> #include <ctime> using namespace std; // 定义树节点 struct Node { int val; int l, r; Node() : val(0), l(-1), r(-1) {} }; // 全局变量用于存储生成的树 vector<Node> tree; vector<int> pre_seq, post_seq, in_seq; // --- 遍历序列生成函数 --- // 虽然 N=1000 递归没问题,但为了稳健,使用 std::vector 模拟 void generate_sequences(int root) { if (root == -1) return; // 前序: 根 左 右 pre_seq.push_back(tree[root].val); generate_sequences(tree[root].l); // 中序: 左 根 右 (作为答案) in_seq.push_back(tree[root].val); generate_sequences(tree[root].r); // 后序: 左 右 根 post_seq.push_back(tree[root].val); } // --- 树构造逻辑 --- void make_tree(int n, int type, int seed) { tree.assign(n, Node()); mt19937 rng(seed); // 生成 [1, n] 的随机排列作为节点值 vector<int> vals(n); for (int i = 0; i < n; ++i) vals[i] = i + 1; shuffle(vals.begin(), vals.end(), rng); for (int i = 0; i < n; ++i) tree[i].val = vals[i]; if (n <= 1) return; if (type == 1) { // 极端左斜链 for (int i = 0; i < n - 1; ++i) tree[i].l = i + 1; } else if (type == 2) { // 极端右斜链 for (int i = 0; i < n - 1; ++i) tree[i].r = i + 1; } else if (type == 3) { // 满/完全二叉树结构 for (int i = 0; i < n; ++i) { int left = 2 * i + 1; int right = 2 * i + 2; if (left < n) tree[i].l = left; if (right < n) tree[i].r = right; } } else { // 随机二叉树 // 使用父亲选择法,保证连通且不超度数 for (int i = 1; i < n; ++i) { int p = uniform_int_distribution<int>(0, i - 1)(rng); // 如果该父节点已满,向后寻找 while (tree[p].l != -1 && tree[p].r != -1) { p = (p + 1) % i; } if (tree[p].l == -1 && tree[p].r == -1) { if (rng() % 2) tree[p].l = i; else tree[p].r = i; } else if (tree[p].l == -1) { tree[p].l = i; } else { tree[p].r = i; } } } } // --- 主生成流程 --- void generate(int id, int n, int type) { string in_name = to_string(id) + ".in"; string out_name = to_string(id) + ".out"; ofstream fin(in_name); ofstream fout(out_name); // 重置序列 pre_seq.clear(); post_seq.clear(); in_seq.clear(); // 1. 构造树并生成序列 make_tree(n, type, id + 2025); generate_sequences(0); // 2. 写入 .in 文件 fin << n << "\n"; for (int i = 0; i < n; ++i) fin << pre_seq[i] << (i == n - 1 ? "" : " "); fin << "\n"; for (int i = 0; i < n; ++i) fin << post_seq[i] << (i == n - 1 ? "" : " "); fin << "\n"; // 3. 写入 .out 文件 (输出中序) for (int i = 0; i < n; ++i) fout << in_seq[i] << (i == n - 1 ? "" : " "); fout << "\n"; fin.close(); fout.close(); cout << "Test Case " << id << " [n=" << n << ", type=" << type << "] finished." << endl; } int main() { // 1-2: 边界测试 (单节点, 二节点) generate(1, 1, 0); generate(2, 2, 4); // 3-4: 极端链状 (测试递归深度和逻辑) generate(3, 1000, 1); // 左斜 generate(4, 1000, 2); // 右斜 // 5-6: 结构化树 (满二叉树) generate(5, 511, 3); generate(6, 1023, 3); // 7-10: 大规模随机树 for (int i = 7; i <= 10; ++i) { generate(i, 1000, 4); } return 0; }
数据说明及优化分析
-
文件大小控制:
- 本题目节点上限设为 (NOI 练习常见量级)。
- 每个
.in文件包含两行长度为 的序列,.out文件包含一行。 - 每个整数平均 4 字节,全套 10 组数据总大小约为 120KB 左右,远低于 2MB。
-
生成逻辑的剪枝与效率:
- 树构造:在生成随机树时,采用了
(p+1)%i的平滑寻址方式,避免了在父节点度数已满时的死循环,保证构造时间复杂度为 最坏,实际接近 。对于 而言,生成速度极快。 - 异常处理:所有随机数生成均使用
std::mt19937配合分布器,不存在除以零的可能。
- 树构造:在生成随机树时,采用了
-
覆盖性设计:
- Case 3 & 4 (斜链):这是前序+后序构造最容易出错的地方,尤其是当节点只有一个孩子时,区间计算的偏移量极易写错。
- Case 5 & 6 (完全二叉树):测试程序在平衡状态下的递归逻辑。
- Case 7-10 (随机树):通过随机化左、右孩子的分配,全面覆盖各种非对称结构。
辅导建议:
- 关于“任意一个”解:由于前序+后序不能唯一确定树,选手的答案可能与生成器的
.out不同但依然合法。在真实的 OJ 评测中,这道题通常需要配置 Special Judge (SPJ)。 - SPJ 逻辑提示:SPJ 应读取选手的输出构造出一棵树,然后检查这棵树的前序和后序遍历是否与输入一致。
- 选手注意:提醒学生注意 的特判,以及在递归中计算
left_size时不要越界。
你可以直接编译运行该生成器,它会自动在当前目录下产生所有测试点。祝你的 OJ 建设顺利!
-
-
0
作为教练,我将带你从线性搜索递归版(逻辑最直观)过渡到哈希优化版(竞赛最优复杂度)。
这道题的精髓在于:在前序序列中,根节点后面如果还有元素,那个元素必定是左子树的根;而在后序序列中,我们可以通过这个“左子树根”的位置,确定整个左子树的成员数量。
版本一:基础递归版(线性搜索 )
思路分析: 在每一次递归中,我们通过
for循环在后序序列中寻找左子树根节点的位置。- 时间复杂度:。总共 个节点,每个节点作为根都会扫描一次后序数组。
- 空间复杂度:。用于存储树结构和递归栈。
- 评价:对于 的数据量(原题范围),这个算法绰绰有余;对于 NOI 级别的 以上,表现也尚可。
#include <iostream> #include <vector> using namespace std; const int MAXN = 1005; // NOI 风格:使用静态数组存储树 struct Node { int l, r, val; } tree[MAXN]; int pre[MAXN], post[MAXN]; int node_cnt = 0; // 节点计数器 // preL, preR: 当前子树在前序序列中的区间 // postL, postR: 当前子树在后序序列中的区间 int build(int preL, int preR, int postL, int postR) { if (preL > preR) return -1; // 递归边界:空树 int u = node_cnt++; tree[u].val = pre[preL]; // 如果当前只有一个节点,说明是叶子,直接返回 if (preL == preR) { tree[u].l = tree[u].r = -1; return u; } // 【核心逻辑】前序序列中,根节点后的第一个元素是左子树的根 int left_root_val = pre[preL + 1]; // 在后序序列中寻找左子树根的位置,从而确定左子树大小 int idx = postL; while (idx < postR && post[idx] != left_root_val) { idx++; } int left_size = idx - postL + 1; // 左子树的节点个数 // 递归构造左右子树 // 左子树前序:跳过当前的根,取 left_size 个 // 左子树后序:从 postL 开始到 idx tree[u].l = build(preL + 1, preL + left_size, postL, idx); // 右子树前序:跳过根和左子树部分 // 右子树后序:左子树后到根之前 tree[u].r = build(preL + left_size + 1, preR, idx + 1, postR - 1); return u; } // 题目要求输出中序遍历 void inorder(int u, int n) { static int cnt = 0; if (u == -1) return; inorder(tree[u].l, n); cout << tree[u].val << (++cnt == n ? "" : " "); inorder(tree[u].r, n); } int main() { int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> pre[i]; for (int i = 0; i < n; i++) cin >> post[i]; int root_id = build(0, n - 1, 0, n - 1); inorder(root_id, n); cout << endl; return 0; }
版本二:索引优化版(竞赛标准 )
思路分析: 为了消除内层循环,我们可以预处理后序序列,用一个数组
pos记录每个值在后序序列中的下标。- 时间复杂度:。预处理 ,递归中寻找位置 。
- 空间复杂度:。
- 优化点:使用数组
pos代替哈希表unordered_map,在竞赛中常数更小,速度更快。
#include <iostream> using namespace std; const int MAXN = 1005; int pre[MAXN], post[MAXN]; int pos[MAXN]; // 记录 post[i] 的值在哪个下标 int node_val[MAXN], L[MAXN], R[MAXN]; int node_ptr = 0; int build(int preL, int preR, int postL, int postR) { if (preL > preR) return -1; int u = node_ptr++; node_val[u] = pre[preL]; // 如果是叶子,直接返回 if (preL == preR) { L[u] = R[u] = -1; return u; } // 通过预处理好的 pos 数组,$O(1)$ 时间获取左子树根的位置 int left_root_val = pre[preL + 1]; int idx = pos[left_root_val]; int left_size = idx - postL + 1; L[u] = build(preL + 1, preL + left_size, postL, idx); R[u] = build(preL + left_size + 1, preR, idx + 1, postR - 1); return u; } void inorder_print(int u, int n) { static int cnt = 0; if (u == -1) return; inorder_print(L[u], n); cout << node_val[u] << (++cnt == n ? "" : " "); inorder_print(R[u], n); } int main() { // NOI 技巧:加速 I/O ios::sync_with_stdio(false); cin.tie(0); int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> pre[i]; for (int i = 0; i < n; i++) { cin >> post[i]; pos[post[i]] = i; // 记录位置 } int root = build(0, n - 1, 0, n - 1); inorder_print(root, n); cout << endl; return 0; }
复杂度分析思考过程
-
时间复杂度思考:
- 在还原树的过程中,每一个节点都恰好被作为根节点处理一次。
- 关键操作是“确定子树分界点”。在 版本中,这个操作是不稳定的。
- 通过预处理
pos数组,我们将寻找分界点的代价降到了常数。总复杂度 。
-
空间复杂度思考:
- 存储空间:我们需要存储树的结构(左、右孩子和值),需要 的空间。
- 递归栈空间:取决于树的高度。最坏情况(树退化为链)为 。在 NOI 竞赛中,通常系统栈为 256MB 或更大,对于 绝对安全。
-
时间复杂度优化建议:
- 避免使用
std::map:在节点值为 时,数组映射总是比哈希表快。 - 静态化存储:在 C++ 中,直接开数组(如
L[MAXN])通常比vector或指针对象性能更优,且更不容易产生内存碎片。
- 避免使用
读题关键词与易错点(教练总结)
- “任意一个” (Any):
- 这是本题最大的陷阱。由于没有中序遍历,当一个节点只有一个子节点时,我们无法分辨它是左还是右。
- 我们的代码默认将
pre[preL + 1]识别为左子树根,也就是在这种情况下,我们默认它是左孩子。
- “左子树规模” (Size):
- 切割区间的核心是
size。 - 记住公式:左子树在后序序列中结束于
idx(左子树根所在的位置),所以大小是idx - postL + 1。
- 切割区间的核心是
- 边界条件
preL == preR:- 在尝试读取
pre[preL + 1]之前,必须判断当前节点是否为叶子节点,否则会发生数组越界。
- 在尝试读取
练习建议: 既然学会了前序+后序,请立刻复习一下“前序+中序”和“中序+后序”。你会发现,中序遍历才是唯一确定树形态的“定海神针”。没有了中序,树的形态就会产生歧义。加油!
- 1
信息
- ID
- 19421
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 0
- 上传者