2 条题解
-
0
作为教练,我为你准备了一个高效、稳健的数据生成器。它采用 C++14 标准,能够生成 10 组涵盖各种形态(空树、满二叉树、左斜链、右斜链、随机树)的测试数据,并自动生成标准答案。
为了确保生成器不会因为数据深度过大而爆栈,生成
.out文件的过程采用了**迭代法(手动模拟栈)**来获取前序遍历序列。C++ 数据生成器 (gen.cpp)
#include <iostream> #include <vector> #include <fstream> #include <stack> #include <algorithm> #include <random> #include <string> using namespace std; // 节点结构体 struct Node { int val; int l, r; }; // --------------------------------------------------------- // 核心:使用迭代法(非递归)获取前序遍历并生成答案 // --------------------------------------------------------- void solve_and_write(int n, const vector<Node>& tree, string out_name) { ofstream fout(out_name); if (n <= 0) { fout.close(); return; } vector<int> preorder_vals; stack<int> s; s.push(0); // 根节点固定为 0 // 迭代前序遍历:根 -> 左 -> 右 while (!s.empty()) { int u = s.top(); s.pop(); preorder_vals.push_back(tree[u].val); // 先压右子节点,再压左子节点,这样出栈顺序就是左先右后 if (tree[u].r != -1) s.push(tree[u].r); if (tree[u].l != -1) s.push(tree[u].l); } // 写入答案文件 for (int i = 0; i < (int)preorder_vals.size(); ++i) { fout << preorder_vals[i] << (i == (int)preorder_vals.size() - 1 ? "" : " "); } fout << endl; fout.close(); } // --------------------------------------------------------- // 各种形态的树生成函数 // --------------------------------------------------------- void make_test_case(int id, int n, int type) { string in_name = to_string(id) + ".in"; string out_name = to_string(id) + ".out"; ofstream fin(in_name); if (n == 0) { fin << 0 << endl; fin.close(); solve_and_write(0, {}, out_name); return; } vector<Node> tree(n, {0, -1, -1}); mt19937 rng(id + 2025); uniform_int_distribution<int> val_dist(-100, 100); for (int i = 0; i < n; ++i) tree[i].val = val_dist(rng); if (type == 1) { // 极端左斜链 (需要 Morris 遍历或迭代处理) 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 if (id == 3) { // 手动模拟示例 1 tree.clear(); tree.resize(6); tree[0] = {1, 1, 2}; tree[1] = {2, 3, 4}; tree[2] = {5, -1, 5}; tree[3] = {3, -1, -1}; tree[4] = {4, -1, -1}; tree[5] = {6, -1, -1}; } else { // 随机二叉树 (保证连通且不超度) vector<int> slots; // 记录还有空位的节点 slots.push_back(0); for (int i = 1; i < n; ++i) { int idx = uniform_int_distribution<int>(0, (int)slots.size() - 1)(rng); int p = slots[idx]; 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; slots.erase(slots.begin() + idx); } else { tree[p].r = i; slots.erase(slots.begin() + idx); } slots.push_back(i); } } // 写入输入文件 fin << n << "\n"; for (int i = 0; i < n; ++i) { fin << tree[i].val << " " << tree[i].l << " " << tree[i].r << "\n"; } fin.close(); // 生成标准答案 solve_and_write(n, tree, out_name); cout << "Case " << id << " [N=" << n << "] generated." << endl; } int main() { // 测试点分布设计 make_test_case(1, 0, 0); // 边界:空树 make_test_case(2, 1, 0); // 边界:单节点 make_test_case(3, 6, 0); // 示例:样例 1 结构 make_test_case(4, 100, 1); // 极端:左斜 make_test_case(5, 100, 2); // 极端:右斜 make_test_case(6, 1023, 3); // 满二叉树 (N=2^10-1) make_test_case(7, 2000, 0); // 随机:上限规模 1 make_test_case(8, 2000, 1); // 随机:左斜上限 (测试性能) make_test_case(9, 2000, 0); // 随机:上限规模 2 make_test_case(10, 2000, 0);// 随机:上限规模 3 return 0; }
生成器说明及辅导建议
1. 复杂度与性能
- 生成时间:对于 的数据规模,整个程序生成 10 个测试点只需要几十毫秒。
- 非递归安全性:在生成
.out时,我们使用了std::stack进行迭代遍历。这确保了即便生成 层的长链(如type=1),生成器本身也不会出现内存错误或爆栈。 - 文件大小:单组数据
.in约为 ,10 组数据总计约 ,远小于 2MB,方便上传。
2. 测试点设计的深度
- 测试点 4 (左斜长链):这是对原地修改算法最严苛的考验。如果选手的代码逻辑中没有正确处理“寻找左子树最右节点”的边界,或者在挪动左子树时没有清空
L[curr],这个点必错。 - 测试点 6 (满二叉树):测试算法在处理大量分叉节点时的正确性。
3. 教练提醒
- 输入读取:在读入数据时,请提醒学生注意题目中“根节点固定为 0”的条件,直接从
dfs(0)或curr = 0开始即可。 - Morris 思想:虽然 不大,但如果能写出 空间复杂度的原地修改法,是衡量一名 NOI 选手是否具备底层优化意识的重要标志。
你可以直接编译运行此程序。它会自动在当前目录下生成
1.in/out到10.in/out。祝你的 OJ 建设和辅导工作顺利! -
0
作为教练,我将为你展示从“空间换时间”的辅助容器法,过渡到经典的反向后序递归,最后达到竞赛巅峰的 Morris 启发式原地修改法。
版本一:辅助容器法(最稳妥的暴力,空间 )
思路分析: 这是考场上最不容易出错的写法。
- 先进行一次标准的前序遍历,按访问顺序把节点的编号存入一个
std::vector。 - 遍历该容器,强行修改每个节点的指针:左孩子设为
-1,右孩子指向容器中的下一个节点。
- 时间复杂度:。
- 空间复杂度:,用于存储节点序列。
#include <iostream> #include <vector> using namespace std; const int MAXN = 2005; int Val[MAXN], L[MAXN], R[MAXN]; vector<int> nodes; // 标准前序遍历:根 -> 左 -> 右 void preorder(int u) { if (u == -1) return; nodes.push_back(u); preorder(L[u]); preorder(R[u]); } int main() { int n; if (!(cin >> n) || n == 0) return 0; for (int i = 0; i < n; ++i) { cin >> Val[i] >> L[i] >> R[i]; } preorder(0); // 根据前序序列重构树结构 for (int i = 0; i < (int)nodes.size() - 1; ++i) { int curr = nodes[i]; int next = nodes[i + 1]; L[curr] = -1; // 左子针始终为 -1 R[curr] = next; // 右子针指向序列下一个 } // 最后一个节点的左右都要为空 if (!nodes.empty()) { int last = nodes.back(); L[last] = -1; R[last] = -1; } // 按展开后的链表顺序输出 int curr = 0; while (curr != -1) { cout << Val[curr] << (R[curr] == -1 ? "" : " "); curr = R[curr]; } cout << endl; return 0; }
版本二:反向后序递归(巧妙的原地修改,空间 )
思路分析: 如果我们按“右 -> 左 -> 根”的顺序遍历(即前序遍历的完全反向),我们可以维护一个
last变量,记录上一个处理完的节点。 对于当前节点,直接将其右孩子指向last,左孩子设为-1。- 时间复杂度:。
- 空间复杂度:,取决于树的高度(递归栈)。
#include <iostream> using namespace std; const int MAXN = 2005; int Val[MAXN], L[MAXN], R[MAXN]; int last_node = -1; // 记录上一个被线性化的节点 // 反向后序遍历:右 -> 左 -> 根 void rev_postorder(int u) { if (u == -1) return; // 先处理右子树,再处理左子树 rev_postorder(R[u]); rev_postorder(L[u]); // 【核心操作】修改当前节点的指向 R[u] = last_node; L[u] = -1; // 更新 last_node 为当前节点,供它的前序前驱节点连接 last_node = u; } int main() { int n; if (!(cin >> n) || n == 0) return 0; for (int i = 0; i < n; ++i) { cin >> Val[i] >> L[i] >> R[i]; } rev_postorder(0); // 输出 int curr = 0; while (curr != -1) { cout << Val[curr] << (R[curr] == -1 ? "" : " "); curr = R[curr]; } return 0; }
版本三:最优原地修改法(Morris 思路,空间 )
思路分析: 这是面试和竞赛中的最优解。 对于每一个节点
curr:- 如果它有左子树,那么其左子树中“最右边”的节点(即左子树前序遍历的最后一个节点),一定是
curr原本右孩子的前驱。 - 我们把
curr的右子树整体接到左子树最右节点的右边。 - 把
curr的左子树挪到右边,左边置空。
- 时间复杂度:。虽然有嵌套循环,但每条边最多被访问两次(找前驱一次,移动一次)。
- 空间复杂度:。
#include <cstdio> #include <algorithm> using namespace std; const int MAXN = 2005; int Val[MAXN], L[MAXN], R[MAXN]; int main() { int n; if (scanf("%d", &n) != 1 || n <= 0) return 0; for (int i = 0; i < n; ++i) { scanf("%d %d %d", &Val[i], &L[i], &R[i]); } int curr = 0; // 从根节点开始 while (curr != -1) { if (L[curr] != -1) { // 1. 找到左子树中最右边的节点 int pre = L[curr]; while (R[pre] != -1) { pre = R[pre]; } // 2. 将 curr 的原右子树接到这个最右节点的右边 R[pre] = R[curr]; // 3. 将左子树整个移动到右边 R[curr] = L[curr]; L[curr] = -1; // 【易错点】左子树必须清空 } // 4. 继续处理下一个右节点(可能是原来的左孩子,也可能是接上来的右孩子) curr = R[curr]; } // 输出展开后的结果 curr = 0; while (curr != -1) { printf("%d%c", Val[curr], (R[curr] == -1 ? '\n' : ' ')); curr = R[curr]; } return 0; }
复杂度分析思考过程
-
时间复杂度思考:
- 在版本三中,外层
while遍历所有节点。内层寻找“最右节点”的while看起来会增加负担。 - 但从边的角度看:每条边在寻找
pre时被访问一次,在移动子树时被处理一次。因此总操作次数是 。 - 建议:在竞赛中,当看到这种“寻找前驱节点并修改指针”的题目,优先思考 Morris 遍历风格的迭代。
- 在版本三中,外层
-
空间复杂度思考:
- 版本一需要 。
- 版本二需要 。如果树退化成一条链,,同样可能爆栈。
- 版本三仅使用
curr和pre两个变量。 - 建议:NOIP 竞赛中, 级别使用递归通常安全,但如果 ,请务必使用版本三的迭代写法。
时间复杂度优化建议
- 减少数据拷贝:在版本一中,
vector的存储和随后的二次遍历带来了额外的常数开销。 - 指针式操作:版本三直接在原有的
L和R数组上进行逻辑搬运,缓存命中率最高。 - I/O 优化:使用
scanf/printf。
读题关键词总结
- “前序遍历”:确定了链接的逻辑顺序。
- “原地修改 / 额外空间”:核心考点。看到它,就要想到不能开新数组,要利用树本身的指针。
- “左子指针为 null”:完成操作后的结构约束。不要忘记
L[curr] = -1。 - “单链表”:暗示了结构已经由树退化为链。
希望这个由浅入深的分析能帮你透彻理解树的线性化变换!加油!
- 先进行一次标准的前序遍历,按访问顺序把节点的编号存入一个
- 1
信息
- ID
- 19417
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者