#LC145. 二叉树的后序遍历
二叉树的后序遍历
你好,同学。欢迎来到二叉树遍历系列教程的“最终章”——二叉树的后序遍历。
在 NOI 竞赛中,后序遍历(Postorder Traversal)是三者中最具挑战性的,也是用途极广的。它是**树形动态规划(Tree DP)**的核心基础:在处理当前节点(根)之前,必须先收集并处理完左右子树的信息。
一、 题目描述 (NOI 风格)
【题目名称】 二叉树的后序遍历 (Binary Tree Postorder Traversal) 【时间限制】 1.0s 【内存限制】 256MB
【问题描述】 给定一棵包含 个节点的二叉树,请输出该二叉树的后序遍历序列。 后序遍历的顺序为:左子树 -> 右子树 -> 根节点。
由于后序遍历在迭代实现时逻辑最为复杂,本题要求你不仅掌握基础递归,还需深入理解显式栈的维护逻辑,以及 Morris 遍历在后序场景下的特殊变形。
【输入格式】 第一行包含一个整数 ,表示节点总数。 接下来 行,每行包含三个整数 ,分别表示编号为 () 的节点的权值、左孩子编号和右孩子编号。若无子节点则用 表示。 (注:根节点默认为编号 1 的节点。)
【输出格式】 输出一行,包含 个整数,表示后序遍历的结果,节点值之间用空格隔开。
【样例 1 输入】
3
1 -1 2
2 3 -1
3 -1 -1
【样例 1 输出】
3 2 1
【数据范围】
- 树中节点数目 范围是 。
- 。
- 进阶要求:掌握递归、迭代(显式栈)及 Morris 遍历。
二、 预备知识点
- 后序遍历定义:访问顺序为左右子树全部处理完后,再处理当前根。
- 状态回溯:在迭代中,需要区分“是从左子树回来”还是“是从右子树回来”。
- Morris 逆序处理:Morris 后序遍历需要对左子树的最右侧路径进行临时逆转输出,这是空间 的巅峰技巧。
三 : 启发式教学:草稿纸上的推理过程
后序遍历最难的地方在于:你两次路过根节点,但只有第二次路过时才能访问它。
1. 递归思维 (自底向上)
想象你在处理一个公司的财务报表:
- 你必须先收齐左部门的报告,
- 再收齐右部门的报告,
- 最后你才能汇总成总公司的报告(根节点处理)。
2. 迭代思维 (状态记录模型)
在迭代时,我们要用一个 prev 指针记录“上一个访问的节点”。
- 推演逻辑:
- 一路向左压栈,直到
curr为空。 - 看栈顶节点 。
- 如果 有右孩子,且我们不是刚从右孩子回来(即
T->right != prev),那么我们就不能访问 ,得先去处理右孩子。 - 否则,说明左右都处理完了,弹出 并访问,更新
prev = T。
- 一路向左压栈,直到
3. Morris 遍历 (镜像与反转)
Morris 后序遍历非常奇特:
- 当我们通过线索回到根节点时,说明左子树已经走完。
- 此时,我们逆序输出左子树最右侧路径上的所有节点。
- 最后,别忘了在循环结束后单独处理整棵树的最右侧路径。
四、 算法流程图 (伪代码逻辑)
1. 递归法逻辑
graph TD
A("递归开始 Postorder(u)") --> B{"u 为 -1?"}
B -- "是" --> C["直接返回"]
B -- "否" --> D["1. 递归左子树 Postorder(tree(u).l)"]
D --> E["2. 递归右子树 Postorder(tree(u).r)"]
E --> F["3. 访问并输出 tree(u).val"]
F --> G["返回"]
2. 迭代法 (显式栈+上一次访问标记) 逻辑
graph TD
S1("开始") --> S2["curr=root, prev=NULL, 准备栈 S"]
S2 --> S3{"curr != -1 OR !S.empty?"}
S3 -- "否" --> SEnd("结束")
S3 -- "是" --> S4{"curr != -1?"}
S4 -- "是 (向左钻)" --> S5["压栈 curr, curr=tree(curr).l"]
S5 --> S4
S4 -- "否 (触底回溯)" --> S6["取出栈顶 t (不弹出)"]
S6 --> S7{"tree(t).r != -1 AND tree(t).r != prev?"}
S7 -- "有右边且未处理" --> S8["curr = tree(t).r"]
S8 --> S3
S7 -- "右边已处理或无右边" --> S9["访问 t, prev=t, 弹出栈顶, curr=-1"]
S9 --> S3
五、 复杂度分析与优化建议
- 时间复杂度:。每个节点在递归/迭代中被访问次数恒定。
- 空间复杂度:
- 递归/迭代:。NOI 考场上如果树退化为链且 ,递归会爆栈。
- Morris:。后序的 Morris 需要额外的“逆序打印”逻辑,稍微复杂但依然保持 空间。
优化建议:
- 快速输出:后序遍历序列通常很长,NOI 中建议使用
printf或快读快写。 - 栈空间:如果不得不使用递归处理深树,可以使用
#pragma comment(linker, "/STACK:...")(取决于竞赛环境允许)或改写迭代。
六、 标准题解代码 (NOIP C++14 标准)
版本一:递归法
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 105;
struct Node { int val, l, r; } tree[MAXN];
void postorder(int u) {
if (u == -1) return;
postorder(tree[u].l);
postorder(tree[u].r);
cout << tree[u].val << " ";
}
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;
postorder(1);
return 0;
}
版本二:迭代法 (推荐,规避爆栈风险)
#include <iostream>
#include <stack>
using namespace std;
const int MAXN = 105;
struct Node { int val, l, r; } tree[MAXN];
void iterativePostorder(int root) {
if (root == -1) return;
stack<int> s;
int curr = root, prev = -1;
while (curr != -1 || !s.empty()) {
while (curr != -1) {
s.push(curr);
curr = tree[curr].l;
}
curr = s.top();
// 关键点:如果右子树为空,或者右子树刚刚被访问过
if (tree[curr].r == -1 || tree[curr].r == prev) {
cout << tree[curr].val << " ";
prev = curr;
s.pop();
curr = -1; // 强制下一轮从栈中取节点
} else {
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;
iterativePostorder(1);
return 0;
}
版本三:Morris 遍历 (空间 进阶版)
注:逻辑较为繁琐,包含逆转链表操作。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAXN = 105;
struct Node { int val, l, r; } tree[MAXN];
// 辅助函数:逆序打印左子树最右路径
void printReverse(int from, int to) {
vector<int> temp;
int curr = from;
while (curr != to) {
temp.push_back(tree[curr].val);
curr = tree[curr].r;
}
temp.push_back(tree[to].val);
reverse(temp.begin(), temp.end());
for (int v : temp) cout << v << " ";
}
void morrisPostorder(int root) {
if (root == -1) return;
int dummy = 0; // 建立虚拟节点
tree[dummy].l = root;
int curr = dummy;
while (curr != -1) {
if (tree[curr].l == -1) {
curr = tree[curr].r;
} else {
int pre = tree[curr].l;
while (tree[pre].r != -1 && tree[pre].r != curr) pre = tree[pre].r;
if (tree[pre].r == -1) {
tree[pre].r = curr;
curr = tree[curr].l;
} else {
tree[pre].r = -1;
printReverse(tree[curr].l, pre); // 关键:逆序打印
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;
morrisPostorder(1);
return 0;
}
七、 读题关键词总结
- “后序/末序”:暗示必须先处理完子问题。典型应用:计算子树大小、计算树的高度、销毁树的内存。
- “自底向上 (Bottom-up)”:后序遍历的本质思维。
- “镜像遍历”:有时可以通过“根-右-左”的顺序遍历后再反转结果,这比标准的后序迭代法更容易实现。
八、 数据生成器 (gen_post.cpp)
#include <iostream>
#include <fstream>
#include <vector>
#include <stack>
#include <algorithm>
#include <random>
#include <chrono>
using namespace std;
struct Node { int val, l, r; };
// 迭代法生成标准答案
vector<int> getPost(int n, const vector<Node>& tree) {
if (n <= 0) return {};
vector<int> res;
stack<int> s;
int curr = 1, prev = -1;
while (curr != -1 || !s.empty()) {
while (curr != -1) { s.push(curr); curr = tree[curr].l; }
curr = s.top();
if (tree[curr].r == -1 || tree[curr].r == prev) {
res.push_back(tree[curr].val);
prev = curr; s.pop(); curr = -1;
} else curr = tree[curr].r;
}
return res;
}
int main() {
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
for (int t = 1; t <= 10; ++t) {
int n = (t <= 2 ? t - 1 : (t * 10)); // 0, 1, 30...100
vector<Node> tree(n + 1, {0, -1, -1});
for (int i = 1; i <= n; ++i) tree[i].val = (int)(rng() % 201 - 100);
// 构造随机树
for (int i = 2; i <= n; ++i) {
int p = rng() % (i - 1) + 1;
if (tree[p].l == -1) tree[p].l = i;
else if (tree[p].r == -1) tree[p].r = i;
}
string in_f = to_string(t) + ".in", out_f = to_string(t) + ".out";
ofstream fin(in_f), fout(out_f);
fin << n << endl;
for (int i = 1; i <= n; ++i) fin << tree[i].val << " " << tree[i].l << " " << tree[i].r << endl;
vector<int> ans = getPost(n, tree);
for (int i = 0; i < ans.size(); ++i) fout << ans[i] << (i == ans.size() - 1 ? "" : " ");
fout << endl;
}
return 0;
}
教练寄语:后序遍历是通往树上高级算法(如 LCA 离线算法 Tarjan)的必经之路。理解 prev 指针在迭代中的作用,能让你对程序的执行状态有更深刻的把控。加油!