2 条题解
-
0
作为教练,我为你准备了一个针对“从前序与中序遍历构造二叉树”的数据生成器。
在 NOI 竞赛中, 是一个适中的规模。这个生成器能够构造随机树、完全二叉树、左斜树、右斜树等多种形态,并自动根据生成的树产生合法的
preorder和inorder输入,同时计算出标准答案的postorder。C++ 数据生成器 (gen.cpp)
#include <iostream> #include <vector> #include <algorithm> #include <random> #include <fstream> #include <string> #include <stack> #include <map> using namespace std; // --- 树的结构定义 --- struct Node { int val; int l, r; Node() : val(0), l(-1), r(-1) {} }; // --- 全局变量,用于存储生成的树和遍历序列 --- vector<Node> tree; vector<int> pre_seq, in_seq, post_seq; const int OFFSET = 3000; // --- 辅助函数:通过 DFS 获取各种遍历序列 --- // 虽然 N=3000 递归没问题,但为了稳健,这里展示标准的递归写法 void get_traversals(int u) { if (u == -1) return; pre_seq.push_back(tree[u].val); // 前序 get_traversals(tree[u].l); in_seq.push_back(tree[u].val); // 中序 get_traversals(tree[u].r); post_seq.push_back(tree[u].val); // 后序 (答案) } // --- 标准答案逻辑 (Solver) --- // 这部分代码将被集成到生成器中,用于产生 .out 文件 int pos_map[6005]; struct ResultNode { int l, r; } sol_tree[3005]; int sol_val[3005]; int solve_build(int& cur_pre, int inL, int inR, const vector<int>& pre, const vector<int>& in) { if (inL > inR) return -1; int root_val = pre[cur_pre++]; int u = cur_pre - 1; sol_val[u] = root_val; int k = pos_map[root_val + OFFSET]; sol_tree[u].l = solve_build(cur_pre, inL, k - 1, pre, in); sol_tree[u].r = solve_build(cur_pre, k + 1, inR, pre, in); return u; } void get_sol_post(int u, vector<int>& res) { if (u == -1) return; get_sol_post(sol_tree[u].l, res); get_sol_post(sol_tree[u].r, res); res.push_back(sol_val[u]); } // --- 数据生成主逻辑 --- void make_data(int id, int n, string type) { string in_file = to_string(id) + ".in"; string out_file = to_string(id) + ".out"; ofstream fin(in_file); ofstream fout(out_file); // 1. 生成不重复的节点值 vector<int> vals(6001); for(int i = 0; i <= 6000; ++i) vals[i] = i - 3000; mt19937 rng(id + 12345); shuffle(vals.begin(), vals.end(), rng); tree.clear(); tree.resize(n); for(int i = 0; i < n; ++i) tree[i].val = vals[i]; // 2. 根据类型构造树结构 if (type == "left") { // 左斜树 for(int i = 0; i < n - 1; ++i) tree[i].l = i + 1; } else if (type == "right") { // 右斜树 for(int i = 0; i < n - 1; ++i) tree[i].r = i + 1; } else if (type == "full") { // 类似完全二叉树 for(int i = 0; i < n; ++i) { if (2 * i + 1 < n) tree[i].l = 2 * i + 1; if (2 * i + 2 < n) tree[i].r = 2 * i + 2; } } 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; } } // 3. 生成输入序列 pre_seq.clear(); in_seq.clear(); post_seq.clear(); get_traversals(0); 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 << in_seq[i] << (i == n - 1 ? "" : " "); fin << "\n"; // 4. 调用标程逻辑生成输出序列 for(int i = 0; i < 6005; ++i) pos_map[i] = 0; for(int i = 0; i < n; ++i) pos_map[in_seq[i] + OFFSET] = i; int cur_pre = 0; int sol_root = solve_build(cur_pre, 0, n - 1, pre_seq, in_seq); vector<int> sol_res; get_sol_post(sol_root, sol_res); for(int i = 0; i < n; ++i) fout << sol_res[i] << (i == n - 1 ? "" : " "); fout << "\n"; fin.close(); fout.close(); cout << "Test Case " << id << " [N=" << n << ", Type=" << type << "] Generated." << endl; } int main() { make_data(1, 1, "random"); // 边界 1 make_data(2, 2, "random"); // 边界 2 make_data(3, 5, "full"); // 样例规模 make_data(4, 100, "left"); // 小规模左斜 make_data(5, 3000, "left"); // 极限左斜 (卡递归) make_data(6, 3000, "right"); // 极限右斜 make_data(7, 3000, "full"); // 满二叉树 make_data(8, 3000, "random");// 随机 1 make_data(9, 3000, "random");// 随机 2 make_data(10, 3000, "random");// 随机 3 return 0; }
数据说明及优化分析
-
区分度分析:
- Test 5 & 6 (Skewed Trees):这两个测试点是“递归杀手”。如果选手的代码在处理区间切分时逻辑不严谨,或者使用了常数很大的方法,在这两个点会非常危险。
- Test 7 (Full Tree):测试算法在处理高度为 的树时的表现,通常这是最快的情况。
- Test 8-10 (Random):测试普遍性能,节点值分布在整个 区间。
-
文件大小控制:
- 对于 ,每一行大约有 3000 个整数,每个整数平均 4 字节(含空格),一行大约 。
- 每个
.in文件约 ,.out文件约 。 - 总计 10 组数据总大小约为 400KB 左右,远低于 2MB 的要求。
-
时间复杂度优化:
- 生成器端:构建树的过程使用了简单的父节点查找法,复杂度 在 下只需数毫秒。
- 标程端:使用了索引加速 ( 算法),生成 10 组数据总耗时不超过 0.5s。
-
鲁棒性 (Robustness):
- 避免除零:代码中不涉及除法。
- 防爆栈:虽然 很难爆掉现代评测机的栈(通常是 8MB-512MB),但如果在本地 Windows 环境下使用默认栈大小编译,请确保 不要超过 。
教练提示(OJ 创建建议):
- 在上传题目时,可以将 Test 5 设为高分值,以筛选出那些对空间和递归效率有深刻理解的选手。
- 输出要求最后一行没有多余空格,生成器代码已通过
(i == n - 1 ? "" : " ")逻辑确保了输出的严谨性。
-
-
0
作为你的教练,我将带你从最基础的线性搜索递归版开始,逐步优化到竞赛级的哈希/索引加速版。
在还原二叉树时,核心逻辑是:利用前序序列确定“根是谁”,利用中序序列确定“左子树有哪些,右子树有哪些”。
版本一:基础递归版(线性搜索 )
思路分析: 这是最符合直觉的写法。在每一层递归中,我们都在中序遍历序列中“傻傻地”从头到尾找根节点的位置。
- 时间复杂度分析:每个节点作为根节点都会进入一次递归(共 次)。每次寻找中序索引需要 。总复杂度 。
- 空间复杂度分析:,用于存储树结构和递归系统栈。
- 评价:对于 的数据量, 约为 ,在 1s 限制内勉强能过,但在 时会 TLE。
#include <iostream> #include <vector> using namespace std; const int MAXN = 3005; struct Node { int val; int l, r; } tree[MAXN]; int preorder[MAXN], inorder[MAXN]; int node_cnt = 0; // 用于分配静态数组下标 // 简单递归还原 // preL, preR: 当前子树在前序序列中的范围 // inL, inR: 当前子树在中序序列中的范围 int build(int preL, int preR, int inL, int inR) { if (preL > preR) return -1; // 递归边界:区间为空 int root_val = preorder[preL]; int u = node_cnt++; // 分配一个新节点编号 tree[u].val = root_val; // 【关键点】在线性搜索中序序列中的根节点位置 int k = inL; while (k <= inR && inorder[k] != root_val) { k++; } int left_size = k - inL; // 左子树节点个数 // 【易错点】区间的计算:前序序列中,根后面紧跟 left_size 个左子树节点 tree[u].l = build(preL + 1, preL + left_size, inL, k - 1); tree[u].r = build(preL + left_size + 1, preR, k + 1, inR); return u; } // 后序遍历输出,用于验证 void postOrder(int u) { if (u == -1) return; postOrder(tree[u].l); postOrder(tree[u].r); cout << tree[u].val << " "; } int main() { int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> preorder[i]; for (int i = 0; i < n; i++) cin >> inorder[i]; int root_id = build(0, n - 1, 0, n - 1); postOrder(root_id); cout << endl; return 0; }
版本二:索引优化版(竞赛标准 )
思路分析: 为了消除 中的那个 ,我们可以预处理中序序列。由于节点值唯一,我们可以用一个数组
pos记录每个值在中序序列中的下标。- 时间复杂度分析:预处理 ,递归过程每个节点 ,总计 。
- 空间复杂度分析:。
- 注意:节点值包含负数,数组下标不能为负,我们需要加上一个
OFFSET(偏移量)。
#include <iostream> using namespace std; const int MAXN = 3005; const int OFFSET = 3000; // 节点值最小-3000,加上3000转为非负 struct Node { int val, l, r; } tree[MAXN]; int preorder[MAXN], inorder[MAXN]; int pos[6005]; // 存储中序遍历值对应的下标 int node_cnt = 0; // 优化后的递归构建 int build(int preL, int preR, int inL, int inR) { if (preL > preR) return -1; int root_val = preorder[preL]; int u = node_cnt++; tree[u].val = root_val; // 【性能优化】通过预处理的数组直接 $O(1)$ 获取位置 int k = pos[root_val + OFFSET]; int left_size = k - inL; tree[u].l = build(preL + 1, preL + left_size, inL, k - 1); tree[u].r = build(preL + left_size + 1, preR, k + 1, inR); return u; } void postOrder(int u, int n) { static int cnt = 0; if (u == -1) return; postOrder(tree[u].l, n); postOrder(tree[u].r, n); cout << tree[u].val << (++cnt == n ? "" : " "); } int main() { // 关闭同步流提高效率 ios::sync_with_stdio(false); cin.tie(0); int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> preorder[i]; for (int i = 0; i < n; i++) { cin >> inorder[i]; // 【关键点】预处理索引映射 pos[inorder[i] + OFFSET] = i; } int root_id = build(0, n - 1, 0, n - 1); postOrder(root_id, n); cout << endl; return 0; }
复杂度分析思考过程
-
时间复杂度优化建议:
- 从 到 :这是通过“空间换时间”的典型策略。在 OI 中,如果发现递归内部有循环搜索,第一时间想能不能用哈希表(
unordered_map)或索引数组预处理。 - 递归开销:对于 ,系统栈完全没问题。如果 ,需要考虑手动模拟栈实现非递归构建,或者通过
ulimit -s unlimited增加栈空间。
- 从 到 :这是通过“空间换时间”的典型策略。在 OI 中,如果发现递归内部有循环搜索,第一时间想能不能用哈希表(
-
空间复杂度思考:
- 本题使用了静态数组存储树,空间利用率极高。
pos数组的大小由节点值的范围决定( 个位置),而不是节点个数。如果节点值范围达到 ,则必须换用std::unordered_map。
读题关键词与易错点(教练总结)
- “根在哪里?”:前序序列的
preL位置。 - “左右子树规模”:由中序中根的位置
k决定。左子树规模size = k - inL。 - “边界控制”:递归还原中最容易错的是
preL + 1到preL + size这种下标计算。口诀:前序中左子树紧跟根后,右子树在左子树后。 - “节点值不重复”:这是此算法生效的唯一前提。如果题目没写这一句,说明不能唯一确定一棵树。
练习建议: 既然学会了前序+中序,你可以立刻尝试一下“中序+后序”。唯一的区别是:后序的根在序列的最后一个,且在切割前序序列时,你需要先确定右子树的范围。逻辑大同小异,快去练练吧!加油!
- 1
信息
- ID
- 19419
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者