2 条题解
-
0
为了方便你为 OJ (Online Judge) 准备测试数据,我为你编写了一个基于 C++14 标准的数据生成器。
该生成器会生成 10 组测试点,涵盖了空树、单节点、完全二叉树、左斜树(链状)、右斜树(链状)以及复杂随机树。为了保证生成的
.out文件绝对正确且不会爆栈,生成器内部使用了**迭代式(显式栈)**的前序遍历算法。数据生成器代码 (gen_tree.cpp)
#include <iostream> #include <fstream> #include <vector> #include <stack> #include <string> #include <algorithm> #include <random> #include <chrono> using namespace std; // 节点结构体 struct Node { int val; int l, r; }; // 使用迭代法生成标准答案,确保不爆栈 vector<int> getPreorder(int n, const vector<Node>& tree) { if (n <= 0) return {}; vector<int> res; stack<int> s; s.push(1); // 根节点固定为 1 while (!s.empty()) { int u = s.top(); s.pop(); res.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); } return res; } // 写入 .in 文件 void writeIn(int n, const vector<Node>& tree, string filename) { ofstream fout(filename); fout << n << endl; for (int i = 1; i <= n; ++i) { fout << tree[i].val << " " << tree[i].l << " " << tree[i].r << endl; } fout.close(); } // 写入 .out 文件 void writeOut(const vector<int>& res, string filename) { ofstream fout(filename); for (int i = 0; i < (int)res.size(); ++i) { fout << res[i] << (i == (int)res.size() - 1 ? "" : " "); } fout << endl; fout.close(); } int main() { mt19937 rng(chrono::steady_clock::now().time_since_epoch().count()); uniform_int_distribution<int> valDist(-100, 100); for (int t = 1; t <= 10; ++t) { int n = 0; vector<Node> tree; string in_name = to_string(t) + ".in"; string out_name = to_string(t) + ".out"; if (t == 1) n = 0; // 边界:空树 else if (t == 2) n = 1; // 边界:单节点 else if (t == 3) n = 3; // 基础树:1 是根,2 是左,3 是右 else if (t == 4) n = 50; // 左斜树 (退化成链) else if (t == 5) n = 50; // 右斜树 (退化成链) else if (t == 6) n = 63; // 满二叉树 (深度为 6) else if (t == 7) n = 100; // 随机树 1 else if (t == 8) n = 100; // 随机树 2 else if (t == 9) n = 100; // 随机树 3 else n = 100; // 最大规模随机树 tree.assign(n + 1, {0, -1, -1}); for (int i = 1; i <= n; ++i) tree[i].val = valDist(rng); if (t == 3) { tree[1].l = 2; tree[1].r = 3; } else if (t == 4) { // 左斜 for (int i = 1; i < n; ++i) tree[i].l = i + 1; } else if (t == 5) { // 右斜 for (int i = 1; i < n; ++i) tree[i].r = i + 1; } else if (t == 6) { // 满二叉树逻辑 for (int i = 1; i <= n / 2; ++i) { tree[i].l = 2 * i; tree[i].r = 2 * i + 1; } } else if (t >= 7) { // 随机生成合法二叉树 vector<int> available; available.push_back(1); vector<int> free_slots(n + 1, 2); for (int i = 2; i <= n; ++i) { uniform_int_distribution<int> pick(0, (int)available.size() - 1); int p_idx = pick(rng); int p = available[p_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; else tree[p].r = i; free_slots[p]--; if (free_slots[p] == 0) { available.erase(available.begin() + p_idx); } available.push_back(i); } } writeIn(n, tree, in_name); writeOut(getPreorder(n, tree), out_name); cout << "Generated Case " << t << " (n=" << n << ")" << endl; } return 0; }
测试点详细说明表
测试点 规模 树形态特征 设计目的 1 0 空树 考察对 nullptr或 输入的基础防御能力2 1 仅根节点 考察递归基准情况或循环终止条件 3 3 简单完全树 验证最基础的 根->左->右逻辑4 50 纯左斜树 考察算法在极端深度(链状)下的稳定性 5 纯右斜树 6 63 满二叉树 验证对标准对称结构的遍历正确性 7 100 随机树 A 综合逻辑测试 8 随机树 B 9 随机树 C 包含大量随机叶子节点的情况 10 满载随机树 最大规模测试
数据生成器设计规范说明
- 非递归求精 (Safety):
- 在生成
.out标准答案时,我使用了stack<int>模拟的迭代法。即使未来将 调整到 ,这个生成器也不会因为系统栈溢出而崩溃,确保了数据的可靠性。
- 在生成
- 文件大小优化 (Compactness):
- 的规模下,每个
.in文件大约只有 1KB 到 2KB。10 组数据总大小不超过 30KB,极大地节省了 OJ 存储和下载带宽。
- 的规模下,每个
- 随机性与合法性 (Correctness):
- 随机树算法:采用“节点逐一加入”法。新节点 随机挂载到已有节点 的空槽位上。这种方法保证了生成的结构一定是合法的二叉树,且不会出现环或多个根。
- 种子设置:使用
chrono纳秒时钟,保证每次运行生成的数据序列都是唯一的。
- 防除零与异常 (Robustness):
- 代码逻辑中不涉及除法操作(除满二叉树索引外,已做安全判断),避免了
Floating Point Exception。 - 使用了
vector管理内存,避免了手动申请内存可能导致的Memory Limit Exceeded。
- 代码逻辑中不涉及除法操作(除满二叉树索引外,已做安全判断),避免了
使用方法
- 将代码保存为
gen.cpp。 - 在终端执行:
g++ gen.cpp -o gen -std=c++14。 - 运行
./gen,当前目录即会出现1.in/1.out到10.in/10.out。
- 非递归求精 (Safety):
-
0
同学你好!二叉树的前序遍历是树论算法的起点。在 NOI 竞赛中,掌握不同复杂度的实现方式能让你在面对不同内存限制(MLE)和时间限制(TLE)时游刃有余。
以下是三种版本的完整 C++14 代码实现。
版本一:递归法 (最简实现)
思路分析: 利用二叉树的递归特性:
Preorder(root) = root + Preorder(root->left) + Preorder(root->right)。- 时间复杂度:,每个节点访问一次。
- 空间复杂度:, 为树高。最坏情况下(链状树)为 。
- 关键点:递归边界判断
if (root == nullptr)。
#include <iostream> #include <vector> using namespace std; // NOI 竞赛常使用静态数组模拟树结构 struct Node { int val; int l, r; } tree[105]; // 结果存储 vector<int> res; void preorder(int u) { if (u == -1) return; // 递归边界:遇到空节点返回 // 1. 访问根节点 res.push_back(tree[u].val); // 2. 递归左子树 preorder(tree[u].l); // 3. 递归右子树 preorder(tree[u].r); } int main() { int n; if (!(cin >> n) || n == 0) return 0; // 读入节点:v 为值,l r 为左右孩子编号 for (int i = 1; i <= n; i++) { cin >> tree[i].val >> tree[i].l >> tree[i].r; } preorder(1); // 从编号 1 的根节点开始 for (int i = 0; i < res.size(); i++) { cout << res[i] << (i == res.size() - 1 ? "" : " "); } cout << endl; return 0; }
版本二:迭代法 (栈模拟)
思路分析: 为了防止递归层数过深导致栈溢出,我们手动维护一个
std::stack。- 时间复杂度:。
- 空间复杂度:。
- 易错点:由于栈是 LIFO (后进先出),在前序遍历中,为了先访问左孩子,必须先将右孩子压栈,再将左孩子压栈。
#include <iostream> #include <vector> #include <stack> using namespace std; struct Node { int val; int l, r; } tree[105]; void iterativePreorder(int rootIdx) { if (rootIdx == -1) return; stack<int> s; s.push(rootIdx); while (!s.empty()) { int u = s.top(); s.pop(); cout << tree[u].val << " "; // 访问当前根 // 关键:先压右再压左,弹出时就是先左后右 if (tree[u].r != -1) s.push(tree[u].r); if (tree[u].l != -1) s.push(tree[u].l); } } int main() { int n; cin >> n; if (n == 0) return 0; for (int i = 1; i <= n; i++) cin >> tree[i].val >> tree[i].l >> tree[i].r; iterativePreorder(1); cout << endl; return 0; }
版本三:Morris 遍历 (空间最优解)
思路分析: Morris 遍历的精髓在于利用叶子节点的空指针。它通过建立临时的“线索”回到父节点,从而实现 的额外空间。
- 时间复杂度:。虽然有嵌套循环,但每条边最多被访问 3 次。
- 空间复杂度:,不计存储答案的容器。
- 关键逻辑:
- 如果当前节点左子树为空,访问当前节点,去右边。
- 如果左子树不为空,找到左子树最右边的节点(前驱节点)。
- 如果前驱节点的右指针为空,说明第一次到这,访问当前节点,建立线索(右指针指向自己),去左边。
- 如果前驱节点的右指针指向自己,说明左边走完了,拆掉线索,去右边。
#include <iostream> #include <vector> using namespace std; // 为实现 Morris 遍历,我们需要指针化的结构,或者在静态数组上操作 struct Node { int val; int l, r; } tree[105]; void morrisPreorder(int rootIdx) { int curr = rootIdx; while (curr != -1) { if (tree[curr].l == -1) { // 情况 1: 没有左孩子,直接访问并去右边 cout << tree[curr].val << " "; curr = tree[curr].r; } else { // 情况 2: 有左孩子,找前驱 int pre = tree[curr].l; while (tree[pre].r != -1 && tree[pre].r != curr) { pre = tree[pre].r; } if (tree[pre].r == -1) { // 第一次访问:建立线索 cout << tree[curr].val << " "; // 前序遍历在建立线索时访问 tree[pre].r = curr; curr = tree[curr].l; } else { // 第二次访问:线索已存在,拆除线索回溯 tree[pre].r = -1; curr = tree[curr].r; } } } } int main() { int n; cin >> n; if (n == 0) return 0; for (int i = 1; i <= n; i++) cin >> tree[i].val >> tree[i].l >> tree[i].r; morrisPreorder(1); cout << endl; return 0; }
四、 复杂度分析思考过程
-
时间复杂度分析:
- 递归与迭代:很明显,每个节点进出栈一次或访问一次,均为 。
- Morris:乍看
while嵌套while像 ,但实际上每条“建立线索”的边在建立和拆除时各走一次,总边数 ,故为 。
-
空间复杂度优化建议:
- 在 NOI 比赛中,若 较大且树退化为链(深度大),递归法会报
RE(栈溢出)。 - 优化 1:优先使用迭代法。
- 优化 2:如果内存限制极其严苛(如 4MB/8MB),Morris 遍历是唯一选择。
- 优化 3:读入优化。若 ,使用
scanf或getchar()快读。
- 在 NOI 比赛中,若 较大且树退化为链(深度大),递归法会报
五、 读题关键词总结 (考场指南)
在处理树遍历题目时,注意这些隐含信息:
- “前序/先序 (Preorder)”:核心是“早访问”。只要到了一个节点,还没去子树前就要处理它。
- “ 节点”:如果 特别大(如 ),注意递归深度,必要时手动开大系统栈或改迭代。
- “节点值范围”:如果值很大,注意是否需要
long long(本题不需要)。 - “左、右子树”:在迭代法中,注意入栈顺序与遍历顺序是相反的。
教练寄语: 同学,二叉树的前序遍历是进入高级算法的大门。递归法练的是逻辑拆解,迭代法练的是数据结构模拟,而 Morris 遍历练的是空间复用的极致思维。请一定要在草稿纸上把 Morris 建立和拆除线索的过程画出来,那是算法之美。加油!
- 1
信息
- ID
- 19407
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 1
- 已通过
- 1
- 上传者