6 条题解
-
0
递归写法遍历会有可能MLE(内存溢出),请解释原因
你好!这是一个非常深刻且在竞赛中至关重要的问题。你的代码逻辑是完全正确的,但在某些极端的测试数据下,它确实有可能导致 MLE (Memory Limit Exceeded) 或 RE (Runtime Error),而这背后的元凶就是——递归栈溢出。
1. 什么情况下会 MLE / RE (栈溢出)?
首先,要明确一点,这里的 MLE 和我们通常说的“数组开太大”导致的 MLE 不太一样。递归调用函数时,系统会使用一个叫做**“调用栈 (Call Stack)”的内存区域来存储函数的状态(如参数、局部变量、返回地址等)。这个栈的大小是有限的**。
当递归的深度过大时,调用栈就会被耗尽,导致“栈溢出 (Stack Overflow)”。
在不同的操作系统和评测姬上,栈溢出可能会表现为:
- RE (Runtime Error):最常见,程序因为非法内存访问而崩溃。
- MLE (Memory Limit Exceeded):如果评测系统将栈空间也算入总内存使用,并且栈空间在耗尽前超过了内存限制。
对于你的代码,什么情况会导致递归深度过大?
想象一下,如果测试数据构造出一棵极度不平衡的树,特别是一条长链:
1 -> 2 -> 3 -> 4 -> ... -> n当你的
preorder(1)被调用时,它会调用preorder(2),然后preorder(2)调用preorder(3)……一直调用到preorder(n)。这时,你的调用栈里会同时存在
n个preorder函数的调用帧 (Call Frame):[ preorder(n) ] <--- 栈顶 [ preorder(n-1) ] [ ... ] [ preorder(2) ] [ preorder(1) ] <--- 栈底如果
n的值很大(比如 或 ),而系统默认的栈空间不够大,就会导致栈溢出。对于这道题,,递归深度最多 26 层,是绝对安全的。但如果题目数据范围改成 ,你的代码就非常危险了。
2. 应该怎么改造?—— “非递归”实现
要从根本上解决栈溢出的问题,我们就必须把递归算法改造成迭代 (Iterative) 的,也就是非递归的。
核心思想是:用我们自己手动管理的栈(
std::stack)来模拟系统的调用栈。下面以前序遍历为例,讲解如何改造。
a. 前序遍历 (根-左-右) 的非递归改造
- 创建一个
std::stack<int>。 - 先把根节点
root压入栈。 - 当栈不为空时,循环:
a. 弹出栈顶节点
curr。 b. 访问(打印)curr。 c. 关键:先把curr的右孩子压入栈(如果存在)。 d. 再把curr的左孩子压入栈(如果存在)。
为什么先压右再压左? 因为栈是“后进先出 (LIFO)”的。我们后压入的左孩子会先被弹出处理,这恰好符合“根-左-右”的顺序。
b. 中序遍历 (左-根-右) 的非递归改造
中序遍历稍微复杂一点,因为它需要先一路向左走到尽头。
- 创建一个
std::stack<int>。 - 创建一个指针(或索引)
curr指向根节点。 - 当
curr不为空或栈不为空时,循环: a. 如果curr不为空: i. 把curr压入栈。 ii.curr移动到它的左孩子 (curr = tree[curr].l)。 b. 如果curr为空(说明左边走到头了): i. 从栈顶弹出一个节点,记为node。 ii. 访问(打印)node。 iii.curr移动到node的右孩子 (curr = tree[node].r),准备处理右子树。
c. 后序遍历 (左-右-根) 的非递归改造
后序遍历是最复杂的,因为它要求在左右子树都访问完之后才能访问根。
- 技巧一(双栈法):
- 用一个栈
s1,按前序遍历的逻辑(根-右-左)遍历,但不是打印,而是把弹出的节点压入另一个栈s2。 - 遍历结束后,
s2中节点的顺序就是后序遍历的逆序。依次弹出s2并打印即可。
- 用一个栈
- 技巧二(单栈法 + 标记):
- 用一个栈,一个指针
curr,一个变量last_visited记录上一个访问的节点。 - 逻辑比较复杂,需要判断当前栈顶节点的右孩子是否已经被访问过,来决定是深入右子树还是访问当前节点。
- 用一个栈,一个指针
3. 改造后的代码示例
这里给出前序和中序的非递归改造代码,后序通常用递归写最简单,非递归实现较为复杂,但在竞赛中不常用。
#include <iostream> #include <vector> #include <stack> using namespace std; struct Node { char data; int l; int r; }; vector<Node> tree; // 非递归前序遍历 void preorder_iterative(int root) { if (root == 0) return; stack<int> s; s.push(root); while (!s.empty()) { int current_index = s.top(); s.pop(); cout << tree[current_index].data; // 先压右孩子,再压左孩子 if (tree[current_index].r != 0) { s.push(tree[current_index].r); } if (tree[current_index].l != 0) { s.push(tree[current_index].l); } } } // 非递归中序遍历 void inorder_iterative(int root) { stack<int> s; int current_index = root; while (current_index != 0 || !s.empty()) { // 一路向左,把路径上的节点都压入栈 while (current_index != 0) { s.push(current_index); current_index = tree[current_index].l; } // 左边到头了,从栈里弹出一个节点并访问 current_index = s.top(); s.pop(); cout << tree[current_index].data; // 转向右子树 current_index = tree[current_index].r; } } // 后序遍历(递归版,因为非递归版复杂且不常用) void postorder_recursive(int index) { if (index == 0) return; postorder_recursive(tree[index].l); postorder_recursive(tree[index].r); cout << tree[index].data; } int main() { // ... (main函数与你的原版完全一样) int n; cin >> n; tree.resize(n + 1); for (int i = 1; i <= n; i++) { char data; int l, r; cin >> data >> l >> r; tree[i].data = data; tree[i].l = l; tree[i].r = r; } int root = 1; preorder_iterative(root); cout << endl; inorder_iterative(root); cout << endl; postorder_recursive(root); // 保持递归 cout << endl; return 0; }总结与教练建议
-
何时用递归?
- 当题目数据范围明确保证递归深度不大时(通常是几十到几千,取决于栈大小)。
- 当算法逻辑用递归表达极其自然,而非递归实现非常复杂时(如大多数树形DP、后序遍历)。
- 你的原始代码对于 的题目是完美且最优的选择,因为它最简洁、最清晰。
-
何时必须用非递归?
- 当题目数据范围可能导致很深的递归链时(如 的链状图)。
- 在一些需要手动控制遍历过程的复杂算法中。
-
竞赛中的“潜规则”:
- Windows 系统下的默认栈空间通常较小(约 1-2MB),容易栈溢出。
- Linux 系统下的默认栈空间较大(通常 8MB 或更多),相对安全一些。大多数在线评测系统 (OJ) 是 Linux。
- 在一些比赛中,可以通过编译指令或代码来手动扩栈,但这不通用。
- 最稳妥的办法:看到 且图可能退化成链时,就要有意识地考虑非递归实现,或者使用 BFS 替代 DFS。
-
0
1、
这个题解里故意留有几个小错误,请自测解决; 2、这里用了递归实现,如果树的高度很深则有可能爆栈内存MLE。(不过因为是log复杂度所以一般树的高度都不会太深)#include <bits/stdc++.h> using namespace std; strut Node{ char data; int l; int r; } vector<Node> tree; void preorder(int index){ if(index==0){ return; } cout<<tree[index].data; preorder(tree[index].l); preorder(tree[index].r); } void postorder(int index){ if(index==0){ return; } postorder(tree[index].l); postorder(tree[index].r); cout<<tree[index].data; } int inorder(int index){ if(index==0){ return; } inorder(tree[index].l); cout<<tree[index].data; inorder(tree[index].r); } int main(){ int n; cin>>n; tree.resize(n+1); for(int i=1;i<=n;i++){ char data; int l,r; cin>>data>>l>>r; tree[i].data=data; tree[i].l=l; tree[i].r=r; } int root=1; preorder(root); cout<<endl; inorder(root); cout<<endl; postorder(root); cout<<endl; return 0; } -
0
能用数组下标i, 2i+1的这种线性数组表示法吗?
绝佳的问题!你提到的这种用
arr[i]的父子关系是arr[2*i]和arr[2*i+1]的方法,是一种非常重要且高效的存储方式,但它有一个极其严格的使用前提。作为你的教练,我来为你彻底剖析这种方法的原理、适用场景以及为什么不能用于解决你当前这道题。
1. 这种表示法叫什么?
这种方法通常被称为二叉树的顺序存储结构或线性数组表示法。它最经典的应用就是堆 (Heap) 数据结构。
规则 (以1-based索引为例):
- 节点
i的左孩子是2 * i - 节点
i的右孩子是2 * i + 1 - 节点
i的父节点是i / 2(整除)
(以0-based索引为例):
- 节点
i的左孩子是2 * i + 1 - 节点
i的右孩子是2 * i + 2 - 节点
i的父节点是(i - 1) / 2(整除)
2. 它什么时候才能用?
这种表示法能够成立的唯一前提是:这棵树必须是一棵完全二叉树 (Complete Binary Tree)。
什么是完全二叉树? 一棵二叉树,如果除了最后一层外,其他各层都是满的,并且最后一层的节点都从左到右连续排列,中间没有空缺,那么它就是一棵完全二叉树。
为什么必须是完全二叉树? 因为
2*i这个公式是隐式地定义了树的结构。它假设了树的形态是完美且无缝填充的。如果你在数组中存储一棵完全二叉树,它会恰好填满数组的前n个位置,没有任何空间浪费。3. 为什么不能用在你这道题上?
你这道题的输入格式是
字符 左孩子序号 右孩子序号,它定义的是一棵任意结构的普通二叉树,它几乎不可能是完全二叉树。让我们用你的样例来尝试一下,看看会发生什么灾难性的后果。
样例树的结构:
1(F) / \ 2(C) 3(E) / \ \ 4(A) 5(D) 6(G) / 7(B)尝试用线性数组存储: 我们开一个足够大的
char数组tree_array,并把根节点1(F)放在tree_array[1]。-
tree_array[1] = 'F'。它的孩子是2(C)和3(E)。根据公式,它们应该在[2]和[3]。tree_array[2] = 'C'tree_array[3] = 'E'- 到目前为止,看起来还行。
-
看节点
2(C),它在[2]。它的孩子是4(A)和5(D)。根据公式,它们应该在[4]和[5]。tree_array[4] = 'A'tree_array[5] = 'D'- 也还行。
-
看节点
3(E),它在[3]。它的左孩子是0(空),右孩子是6(G)。- 根据公式,它的孩子应该在
[6]和[7]。 - 所以
tree_array[6] = 'G',而tree_array[7]必须空着!
- 根据公式,它的孩子应该在
-
灾难来了! 看节点
5(D),它在[5]。它的左孩子是7(B),右孩子是0(空)。- 根据公式,它的孩子应该在
[10]和[11]。 - 所以我们必须把
'B'放到tree_array[10]! - 数组状态:
[F, C, E, A, D, G, 空, 空, 空, B, ...]
- 根据公式,它的孩子应该在
你发现了什么问题吗?
- 巨大的空间浪费:为了存储节点
7(B),我们不得不把数组开到至少 10 的大小,而中间的[7],[8],[9]位置都是空的,被白白浪费了。 - 结构不匹配:输入明确告诉我们
7号节点是B,但在我们的数组里,它却被放到了第10个位置。输入给出的节点编号和数组下标完全对不上了!
如果这棵树再“偏”一点,比如是一条长链
1 -> 2 -> 3 -> ... -> n,那么节点n会被存储在数组的2^(n-1)的位置,空间复杂度会变成指数级!4. 总结与教练建议
存储方法 适用场景 优点 缺点 vector<struct>或 平行数组通用,适用于任何树或图结构 结构灵活,空间利用率高,忠实反映输入 需要额外空间存储左右孩子的索引 线性数组 ( 2*i)仅限完全二叉树 空间极致紧凑 (对完全二叉树而言),无需存孩子索引,父子关系计算快 对非完全二叉树会造成巨大空间浪费,不通用 给你的建议:
当你看到题目是通过显式的父子关系(比如
u v表示边,或者parent[i])来定义一棵树时,你应该立即放弃使用线性数组2*i的表示法。请务去使用
vector<struct>或者 平行数组,因为只有这两种方法才能忠实地、不浪费空间地表示题目给出的任意树结构。线性数组
2*i这种方法,请把它和你脑海中的 “堆” 或者 “完全二叉树” 这两个词绑定在一起。 - 节点
-
0
能用数组+下标的表示法存储吗?
当然可以!你问到的“数组+下标”的表示法,是一种非常经典且在 C 语言时代非常主流的存储方式,我们通常称之为**“平行数组” (Parallel Arrays)** 或 “结构化数组” (Structure of Arrays, SoA)。
这种方法的核心思想是:不用一个结构体把节点的所有信息打包在一起,而是用多个独立的数组,每个数组存储一种信息。同一个下标
i在所有数组中都代表同一个节点。
1. 思路与数据结构变化
vector<struct>写法“平行数组”写法 说明 struct Node { char data; int left; int right; };char data[N];data[i]存节点i的值vector<Node> tree;int left_child[N];left_child[i]存节点i的左孩子索引int right_child[N];right_child[i]存节点i的右孩子索引这里的
N是一个预先定义的常量,比如const int MAXN = 30;,因为题目说了n <= 26。2. 代码实现的变化
a. 读入数据的变化
原来是:
tree[i].data = data; tree[i].left_child = left; tree[i].right_child = right;现在变成了:
data[i] = data_char; // 变量名改一下防止冲突 left_child[i] = left; right_child[i] = right;b. 遍历函数的变化
原来访问一个节点的属性是
tree[index].属性,现在是属性数组[index]。- 前序遍历
- 原:
cout << tree[index].data;-> 新:cout << data[index]; - 原:
pre_order(tree[index].left_child);-> 新:pre_order(left_child[index]); - 原:
pre_order(tree[index].right_child);-> 新:pre_order(right_child[index]);
- 原:
中序和后序遍历的修改方式完全相同。
3. 完整代码实现 (平行数组版)
#include <iostream> using namespace std; // 预定义最大节点数,比题目要求稍大一点是个好习惯 const int MAXN = 30; // 使用三个独立的数组来存储树的信息 char data[MAXN]; // data[i] 存储节点 i 的字符值 int left_child[MAXN]; // left_child[i] 存储节点 i 的左孩子索引 int right_child[MAXN]; // right_child[i] 存储节点 i 的右孩子索引 // 前序遍历 void pre_order(int index) { if (index == 0) { return; } // 1. 访问根节点 cout << data[index]; // 2. 递归遍历左子树 pre_order(left_child[index]); // 3. 递归遍历右子树 pre_order(right_child[index]); } // 中序遍历 void in_order(int index) { if (index == 0) { return; } // 1. 递归遍历左子树 in_order(left_child[index]); // 2. 访问根节点 cout << data[index]; // 3. 递归遍历右子树 in_order(right_child[index]); } // 后序遍历 void post_order(int index) { if (index == 0) { return; } // 1. 递归遍历左子树 post_order(left_child[index]); // 2. 递归遍历右子树 post_order(right_child[index]); // 3. 访问根节点 cout << data[index]; } int main() { // 优化IO ios::sync_with_stdio(false); cin.tie(0); int n; cin >> n; // 读入 n 行数据,填充三个数组 for (int i = 1; i <= n; i++) { char data_char; int left, right; cin >> data_char >> left >> right; data[i] = data_char; left_child[i] = left; right_child[i] = right; } int root_index = 1; // 执行并输出三种遍历结果 pre_order(root_index); cout << endl; in_order(root_index); cout << endl; post_order(root_index); cout << endl; return 0; }4. 两种方法的对比与教练建议
特性 vector<struct>(AoS)平行数组 (SoA) 教练点评 代码组织性 高 低 结构体将一个节点的全部信息封装在一起,逻辑清晰,符合面向对象思想。 可读性 中 tree[i].data比data[i]更能自解释其意。扩展性 低 如果要给节点增加新属性(如父节点、深度), struct只需加一个成员。平行数组需要新增一个完整的数组。内存管理 自动 手动 vector自动管理内存。数组需要预先定义大小,可能浪费空间或不够用。性能 理论上更好 理论上稍差 对于树的遍历,我们通常会连续访问一个节点的多个属性 ( data,left,right)。struct将这些数据在内存中紧凑排列,缓存命中率 (Cache Locality) 更高。但对于本题这种小数据量,性能差异可以完全忽略不计。给你的建议:
- 首选
vector<struct>:在现代 C++ 竞赛中,vector配合struct是表示图和树的主流且推荐的方法。它更清晰、更安全、更易于维护。 - 理解“平行数组”:平行数组是一种非常底层的思想,了解它有助于你理解计算机是如何组织数据的。在某些特定的、需要极致性能优化的场景(例如,你只需要遍历所有节点的
data属性,而完全不需要left/right),平行数组因其数据连续性可能反而更快。
总而言之,两种方法都能正确解决问题。但从代码工程和现代编程实践的角度看,
vector<struct>是更优的选择。 - 前序遍历
-
0
使用链表表示的题解
你好!这是一个非常好的问题。在算法竞赛中,使用
vector来存储图或树是一种灵活且现代的方法。对于这道二叉树遍历的题目,我们通常需要一个结构体来存储每个节点的信息,然后用vector来管理这些节点。下面我将分步讲解如何用
vector来实现,并给出完整的代码和注释。
1. 思路分析与数据结构设计
a. 如何表示一个节点?
一个二叉树节点需要存储三个核心信息:
- 节点的值:题目中是字符(如 'F', 'C' 等)。
- 左孩子的引用/索引:一个指向左孩子节点的“指针”。
- 右孩子的引用/索引:一个指向右孩子节点的“指针”。
我们可以定义一个
struct或class来表示它:struct Node { char data; // 节点的值 int left_child; // 左孩子的索引 int right_child; // 右孩子的索引 };这里的
left_child和right_child存储的是孩子节点在vector中的下标(索引)。如果题目给的序号是从 1 开始的,我们在代码里通常也习惯用 1-based 索引,这样和输入保持一致。如果为 0,则表示没有孩子。b. 如何表示整棵树?
既然每个节点的信息都打包好了,我们只需要一个容器来存放所有的节点。
std::vector就是完美的选择。vector<Node> tree;tree[i]就代表了编号为i的那个节点的所有信息。c. 如何处理输入?
题目的输入格式是:
字符 左孩子序号 右孩子序号。但这个输入的顺序是不固定的,比如第一行输入的可能是 2 号节点的信息,第二行是 5 号节点。我们需要一种方法,把字符和它的序号对应起来。
-
方法一(推荐):由于节点总数
n不大(<=26),我们可以直接开一个大小为n+1的vector。输入F 2 3,我们就知道这是 1 号节点的信息(因为它是第一个输入的),我们就把F存到tree[1].data,2存到tree[1].left_child,3存到tree[1].right_child。 -
方法二(更通用,但本题没必要):如果节点编号不是按顺序给的,可以用一个
std::map<char, int>来映射字符到节点编号。
本题中,输入
n行,第i行(从1开始计数)描述的就是编号为i的节点。所以我们可以简化处理。2. 遍历算法的实现
前、中、后序遍历都是基于递归(深度优先搜索)实现的。它们的区别仅仅在于访问根节点(打印
data)的时机。我们写一个
dfs函数,接收当前要遍历的节点索引index作为参数。-
递归基线 (Base Case):如果
index为 0,说明这个节点不存在(是空孩子),直接return。 -
前序遍历 (Pre-order):根 -> 左 -> 右
void pre_order(int index) { if (index == 0) return; cout << tree[index].data; // 1. 访问根 pre_order(tree[index].left_child); // 2. 递归左 pre_order(tree[index].right_child); // 3. 递归右 } -
中序遍历 (In-order):左 -> 根 -> 右
void in_order(int index) { if (index == 0) return; in_order(tree[index].left_child); // 1. 递归左 cout << tree[index].data; // 2. 访问根 in_order(tree[index].right_child); // 3. 递归右 } -
后序遍历 (Post-order):左 -> 右 -> 根
void post_order(int index) { if (index == 0) return; post_order(tree[index].left_child); // 1. 递归左 post_order(tree[index].right_child); // 2. 递归右 cout << tree[index].data; // 3. 访问根 }
3. 完整代码实现
下面是将上述思路整合起来的完整 C++ 代码。
#include <iostream> #include <vector> #include <string> using namespace std; // 定义二叉树节点结构体 struct Node { char data; // 节点存储的字符值 int left_child; // 左孩子的索引,0表示没有 int right_child; // 右孩子的索引,0表示没有 }; // 使用 vector 来存储整棵树,tree[i] 表示编号为 i 的节点 // 我们使用 1-based 索引,所以大小开 n+1 vector<Node> tree; // 前序遍历 void pre_order(int index) { // 递归出口:如果节点索引为0,表示是空节点 if (index == 0) { return; } // 1. 访问根节点 cout << tree[index].data; // 2. 递归遍历左子树 pre_order(tree[index].left_child); // 3. 递归遍历右子树 pre_order(tree[index].right_child); } // 中序遍历 void in_order(int index) { if (index == 0) { return; } // 1. 递归遍历左子树 in_order(tree[index].left_child); // 2. 访问根节点 cout << tree[index].data; // 3. 递归遍历右子树 in_order(tree[index].right_child); } // 后序遍历 void post_order(int index) { if (index == 0) { return; } // 1. 递归遍历左子树 post_order(tree[index].left_child); // 2. 递归遍历右子树 post_order(tree[index].right_child); // 3. 访问根节点 cout << tree[index].data; } int main() { // 优化IO ios::sync_with_stdio(false); cin.tie(0); int n; cin >> n; // 调整 vector 大小以容纳 n+1 个节点 (索引从1到n) tree.resize(n + 1); // 读入 n 行数据,构建树 // 题目的输入格式保证了第 i 次读入的是 i 号节点的信息 for (int i = 1; i <= n; i++) { char data; int left, right; cin >> data >> left >> right; tree[i].data = data; tree[i].left_child = left; tree[i].right_child = right; } // 题目没有明确说根节点是哪个,但通常二叉树问题默认第一个输入的节点(1号)为根 int root_index = 1; // 执行并输出三种遍历结果 pre_order(root_index); cout << endl; in_order(root_index); cout << endl; post_order(root_index); cout << endl; return 0; }教练划重点
- 索引 vs 指针:在 C++ 中,我们通常用索引(数组/
vector下标)来模拟指针,这比直接使用原生指针Node*更安全,也更容易管理内存。 - 0-based vs 1-based:竞赛中输入的编号通常是
1到n。代码内部也使用1到n的索引(即 1-based)可以减少很多+1或-1的转换,降低出错概率。代价是vector的 0 号位置被浪费了,但这通常无关紧要。 - 根节点的确定:这道题比较简单,默认 1 号就是根。在更复杂的题目中,你可能需要自己找根(比如,找到那个没有被任何其他节点当作孩子的节点)。
用
vector和struct来表示树是一种非常基础且重要的技能,一定要熟练掌握! -
0
C++ :
#include <iostream> using namespace std; typedef struct node{ char x; int l; int r; }node ; void preOrder(node * t,int n){ cout<<t[n].x; if(t[n].l==0&&t[n].r==0){ return ; } if(t[n].l!=0)preOrder(t,t[n].l); if(t[n].r!=0)preOrder(t,t[n].r); return ; } void inOrder(node * t,int n){ if(t[n].l!=0)inOrder(t,t[n].l); cout<<t[n].x; if(t[n].l==0&&t[n].r==0){ return ; } if(t[n].r!=0)inOrder(t,t[n].r); return ; } void postOrder(node * t,int n){ if(t[n].l!=0)postOrder(t,t[n].l); if(t[n].r!=0)postOrder(t,t[n].r); cout<<t[n].x; if(t[n].l==0&&t[n].r==0){ return ; } return ; } int main(){ int n; cin>>n; node * t = new node [n+1]; for(int i=1;i<=n;i++){ cin>>t[i].x>>t[i].l>>t[i].r; } //----------------ÏÈÐò±éÀú--------------- preOrder(t,1);cout<<endl; //----------------ÖÐÐò±éÀú--------------- inOrder(t,1);cout<<endl; //----------------ºóÐò±éÀú--------------- postOrder(t,1);cout<<endl; return 0; }
- 1
信息
- ID
- 2681
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- 递交数
- 1
- 已通过
- 1
- 上传者