二叉树后序遍历:从递归到非递归的优雅实现

二叉树后序遍历:从递归到非递归的优雅实现

Bilibili同步视频

二叉树后序遍历:从递归到非递归的优雅实现

在数据结构的学习中,二叉树的遍历是绕不开的核心知识点,而后序遍历 作为三大基础遍历之一,因根节点的输出顺序后置,在实现上尤其是非递归实现上,有着独特的思维魅力。递归实现后序遍历简洁易懂,但其底层依赖系统栈,在实际开发中可能受栈深度限制;非递归实现则需要我们手动模拟系统栈的工作过程,是对二叉树遍历逻辑和栈结构应用的深度考验。本文将从后序遍历的基本概念出发,先讲解简洁的递归实现,再深入剖析如何通过双栈模拟将递归转为非递归,带你彻底吃透二叉树后序遍历的精髓✨。

一、二叉树三大基础遍历:根节点定乾坤

二叉树的前序、中序、后序遍历,本质上是根据根节点的输出位置 来定义的,左右子树的遍历始终遵循"先左后右"的原则,这是理解所有遍历的核心前提。对于任意一棵二叉树(或子树),其节点都可分为根节点(root)、左子树(left)、右子树(right) 三部分,三大遍历的规则如下:

  • 前序遍历:根 → 左 → 右(根节点最先输出)

  • 中序遍历:左 → 根 → 右(根节点中间输出)

  • 后序遍历:左 → 右 → 根(根节点最后输出)💡

后序遍历的关键在于:必须先完整遍历左子树和右子树,才能输出根节点。哪怕是一棵最小的子树,也要严格遵循这个规则,这也是后序遍历非递归实现的难点所在------需要精准判断左右子树是否遍历完成。

举个例子:直观理解后序遍历

假设存在一棵二叉树,根节点为1,左子树根为2,右子树根为3;2的左子树为4、右子树为5;3的左子树为6,6的左子树为7、右子树为8(树结构如下):

Plain 复制代码
        1
      /   \
     2     3
    / \   /
   4   5 6
       / \
      7   8

按照左→右→根的规则,其遍历过程为:

  1. 遍历根1的左子树2:先遍历2的左子树4(无后代,直接输出4)→ 遍历2的右子树5(无后代,直接输出5)→ 输出根2 → 左子树2遍历结果:4 5 2

  2. 遍历根1的右子树3:先遍历3的左子树6 → 遍历6的左子树7(输出7)→ 遍历6的右子树8(输出8)→ 输出根6 → 输出根3 → 右子树3遍历结果:7 8 6 3

  3. 最后输出根节点1

最终后序遍历结果:4 5 2 7 8 6 3 1

对应的中序遍历(左→根→右)结果为:4 2 5 1 7 6 8 3,大家可以对比体会根节点位置的差异。

二、递归实现后序遍历:一行核心逻辑的优雅

递归是实现二叉树遍历最简洁的方式,其核心思想是分治+递归调用,将一棵大树的遍历拆解为无数棵小树的遍历,直到遇到空节点(递归终止条件)。后序遍历的递归逻辑完全贴合"左→右→根"的规则,代码量极少,可读性拉满🚀。

1. 算法原理

递归实现后序遍历的三步法:

  1. 终止条件:如果当前节点为空,直接返回,无需遍历;

  2. 递归左子树:递归调用后序遍历函数,处理当前节点的左子树;

  3. 递归右子树:递归调用后序遍历函数,处理当前节点的右子树;

  4. 输出根节点:左右子树均处理完成后,输出当前节点的值。

2. cpp代码实现

首先定义二叉树的节点结构,这是所有二叉树操作的基础:

cpp 复制代码
// 二叉树节点定义
struct TreeNode {
    int val;        // 节点值
    TreeNode *left; // 左子树指针
    TreeNode *right;// 右子树指针
    // 构造函数
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

后序遍历的递归实现(含结果收集,方便查看):

cpp 复制代码
#include <vector>
using namespace std;

// 递归实现后序遍历
void postorderRecur(TreeNode* root, vector<int>& res) {
    // 终止条件:空节点直接返回
    if (root == nullptr) return;
    postorderRecur(root->left, res);  // 1. 递归遍历左子树
    postorderRecur(root->right, res); // 2. 递归遍历右子树
    res.push_back(root->val);         // 3. 输出根节点(加入结果集)
}

// 对外调用接口
vector<int> postorderTraversal(TreeNode* root) {
    vector<int> res;
    postorderRecur(root, res);
    return res;
}

3. 代码解析

  • 递归函数postorderRecur接收两个参数:当前遍历的节点root和结果集res(引用传递,避免拷贝);

  • 终止条件if (root == nullptr) return是递归的"出口",防止访问空指针;

  • 代码执行顺序严格遵循"左→右→根",是后序遍历规则的直接翻译;

  • 对外接口postorderTraversal初始化结果集,调用递归函数并返回结果,符合工程开发的代码规范。

4. 递归的本质:系统栈的自动压栈/弹栈

递归代码看似简洁,但其底层依赖系统栈完成自动的压栈和弹栈操作:

  1. 每一次递归调用,都会将当前节点的信息(局部变量、程序执行位置)压入系统栈;

  2. 当遇到空节点时,递归终止,触发弹栈,回到上一层节点的执行位置;

  3. 当一个节点的左右子树均遍历完成后,该节点的信息从栈中弹出,完成根节点的输出。

以节点2(左子树4、右子树5)为例,系统栈的操作过程:

Plain 复制代码
压入节点2 → 压入节点4(无左右子树)→ 弹出节点4(输出4)→ 压入节点5(无左右子树)→ 弹出节点5(输出5)→ 弹出节点2(输出2)

递归的底层是栈,这也是我们非递归实现的核心思路------手动模拟系统栈的工作过程

三、非递归实现后序遍历:双栈模拟系统栈的精髓

递归虽好,但存在栈溢出风险 (当二叉树深度极大时,系统栈的容量不足以支撑多次压栈)。非递归实现后序遍历的核心是手动用栈模拟系统栈 ,而最通用、最易理解的方法是双栈法:一个栈存储二叉树节点(记为S1),另一个栈记录节点的遍历状态(记为S2,程序状态栈)💻。

1. 双栈法的核心原理

双栈的分工明确,解决了非递归遍历的核心痛点:如何判断节点的左右子树是否遍历完成

  • 节点栈S1:存储需要处理的二叉树节点,模拟系统栈中存储的节点信息;

  • 状态栈S2 :存储每个节点的遍历状态码,用0/1/2表示节点的三个处理阶段,模拟系统栈中记录的程序执行位置

状态码定义(核心!):

  • 0:当前节点待处理左子树 → 接下来需要递归压入该节点的左子树;

  • 1:当前节点左子树处理完成,待处理右子树 → 接下来需要递归压入该节点的右子树;

  • 2:当前节点左右子树均处理完成 → 接下来输出该节点的值,并将节点从S1中弹出。

整个非递归过程,就是不断根据S2的状态码,对S1中的节点进行"处理左子树→处理右子树→输出节点"的循环,直到S1为空(所有节点处理完成)。

2. 算法执行步骤(附图形解析)

以本文第一部分的二叉树(根1,左2右3)为例,结合图形讲解双栈法的执行步骤,初始化:将根节点1压入S1,状态码0压入S2。

Plain 复制代码
S1:[1]  S2:[0]  结果集:[]

步骤1:处理状态码0(待处理左子树)

当S2栈顶为0时,执行以下操作:

  1. 弹出S2的栈顶状态码0;

  2. 将当前节点的状态码改为1,重新压入S2(标记左子树待处理,下一步处理右子树);

  3. 如果当前节点的左子树不为空,将左子树节点压入S1,状态码0压入S2。

对节点1的操作

S2弹出0 → 压入1 → 节点1的左子树2非空 → S1压入2,S2压入0。

Plain 复制代码
S1:[1,2]  S2:[1,0]  结果集:[]

图形示意

Plain 复制代码
        1(状态1)
      /
     2(状态0)  3(未处理)
    / \
   4   5

步骤2:循环处理状态码0,直到左子树为空

对S1栈顶的节点2(状态0)重复步骤1:

S2弹出0 → 压入1 → 节点2的左子树4非空 → S1压入4,S2压入0。

Plain 复制代码
S1:[1,2,4]  S2:[1,1,0]  结果集:[]

对S1栈顶的节点4(状态0)重复步骤1:

S2弹出0 → 压入1 → 节点4的左子树为空,不压入新节点。

Plain 复制代码
S1:[1,2,4]  S2:[1,1,1]  结果集:[]

步骤3:处理状态码1(待处理右子树)

当S2栈顶为1时,执行以下操作:

  1. 弹出S2的栈顶状态码1;

  2. 将当前节点的状态码改为2,重新压入S2(标记左右子树均待处理完成,下一步输出节点);

  3. 如果当前节点的右子树不为空,将右子树节点压入S1,状态码0压入S2。

对节点4的操作

S2弹出1 → 压入2 → 节点4的右子树为空,不压入新节点。

Plain 复制代码
S1:[1,2,4]  S2:[1,1,2]  结果集:[]

步骤4:处理状态码2(输出节点)

当S2栈顶为2时,执行以下操作:

  1. 弹出S2的栈顶状态码2;

  2. 将S1栈顶的节点值加入结果集(输出节点);

  3. 弹出S1的栈顶节点(该节点处理完成,无需再保留)。

对节点4的操作

S2弹出2 → 结果集加入4 → S1弹出4。

Plain 复制代码
S1:[1,2]  S2:[1,1]  结果集:[4]

这一步完成了第一个节点的输出,与递归遍历的结果一致!

步骤5:循环执行上述步骤,直到S1为空

后续将依次处理节点2的右子树5、节点2、节点3的左子树6、节点6的左右子树7/8、节点6、节点3,最后处理根节点1,最终结果集为[4,5,2,7,8,6,3,1],与递归结果完全一致。

核心规律 :状态码的转换始终遵循0→1→2,一个节点只有完成状态码的全转换,才能被输出,这完美贴合了后序遍历"左→右→根"的规则。

3. cpp代码实现(双栈法)

结合上述原理,编写可直接运行的非递归后序遍历代码,包含空树判断 (工程开发的边界条件)和结果收集

cpp 复制代码
#include <vector>
#include <stack>
using namespace std;

// 二叉树节点定义(与递归部分一致)
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

// 非递归实现后序遍历:双栈法
vector<int> postorderTraversal(TreeNode* root) {
    vector<int> res;
    // 边界条件:空树直接返回空结果
    if (root == nullptr) return res;
    stack<TreeNode*> s1; // 节点栈:存储二叉树节点
    stack<int> s2;       // 状态栈:存储0/1/2状态码
    // 初始化:根节点入栈s1,初始状态0入栈s2
    s1.push(root);
    s2.push(0);

    while (!s1.empty()) { // 节点栈非空则继续处理
        int status = s2.top(); // 获取当前节点的状态码
        s2.pop();              // 弹出状态码
        TreeNode* cur = s1.top(); // 获取当前处理的节点

        if (status == 0) { // 状态0:处理左子树
            s2.push(1);    // 状态码转为1,重新入栈
            // 左子树非空则压入s1,状态0
            if (cur->left != nullptr) {
                s1.push(cur->left);
                s2.push(0);
            }
        } else if (status == 1) { // 状态1:处理右子树
            s2.push(2);    // 状态码转为2,重新入栈
            // 右子树非空则压入s1,状态0
            if (cur->right != nullptr) {
                s1.push(cur->right);
                s2.push(0);
            }
        } else if (status == 2) { // 状态2:输出节点
            res.push_back(cur->val); // 节点值加入结果集
            s1.pop();                // 节点处理完成,弹出s1
        }
    }
    return res;
}

// 测试主函数(可选)
#include <iostream>
int main() {
    // 构建示例二叉树
    TreeNode* root = new TreeNode(1);
    root->left = new TreeNode(2);
    root->right = new TreeNode(3);
    root->left->left = new TreeNode(4);
    root->left->right = new TreeNode(5);
    root->right->left = new TreeNode(6);
    root->right->left->left = new TreeNode(7);
    root->right->left->right = new TreeNode(8);

    // 非递归后序遍历
    vector<int> res = postorderTraversal(root);
    // 输出结果
    cout << "后序遍历结果:";
    for (int num : res) {
        cout << num << " ";
    }
    cout << endl; // 输出:4 5 2 7 8 6 3 1

    // 释放节点内存(略,工程开发需注意)
    return 0;
}

4. 代码深度解析

  1. 边界条件处理if (root == nullptr) return res; 避免空指针访问,是所有树操作的必写步骤;

  2. 双栈初始化:根节点入S1,状态0入S2,代表从根节点开始,先处理左子树;

  3. 循环条件!s1.empty() 只要节点栈非空,说明还有节点未处理,继续循环;

  4. 状态码处理分支

    • status=0:优先处理左子树,将状态码改为1后重新入栈,保证后续能处理右子树;

    • status=1:左子树处理完成,处理右子树,将状态码改为2后重新入栈,保证后续能输出节点;

    • status=2:左右子树均处理完成,输出节点并弹出,该节点的生命周期结束;

  5. 节点访问安全 :只有当左右子树非空时,才将其压入S1,避免空节点入栈导致的无效处理。

5. 双栈法的通用性

本文的双栈法并非仅适用于后序遍历,而是递归程序转非递归程序的通用技巧

  • 任意递归程序,都可以拆分为"多个执行阶段",用状态栈记录当前执行到的阶段;

  • 数据栈存储递归的局部变量(本文中为二叉树节点),状态栈记录程序执行位置;

  • 即使递归程序中有多个参数/局部变量,只需将其封装为"参数包"压入数据栈即可,状态栈的逻辑保持不变。

这一技巧不仅适用于二叉树遍历,还可用于斐波那契数列、阶乘、深度优先搜索(DFS)等所有递归场景,掌握后可轻松实现各类递归程序的非递归改造🌟。

四、递归与非递归实现的对比分析

实现方式 代码复杂度 可读性 运行效率 栈依赖 适用场景
递归 极低 极高 一般(有函数调用开销) 系统栈 二叉树深度较小、追求开发效率
非递归(双栈法) 中等 中等(理解状态码后易读) 较高(无函数调用开销) 手动栈 二叉树深度极大、追求运行效率、避免栈溢出
核心结论
  • 日常开发中,若二叉树深度可控,递归实现是首选,代码简洁、易维护;

  • 底层开发、高性能要求场景,或二叉树深度极大时,非递归实现更优,双栈法是通用且易实现的选择。

五、总结

二叉树的后序遍历,核心是左→右→根 的遍历规则,而递归与非递归实现的本质,都是栈的应用(系统栈 vs 手动栈)。

  1. 递归实现是规则的"直接翻译",利用分治思想将大问题拆解为小问题,代码简洁到极致,是理解后序遍历的基础;

  2. 非递归的双栈法,通过节点栈+状态栈模拟系统栈的工作过程,用0/1/2状态码精准标记节点的处理阶段,完美解决了"如何判断左右子树是否遍历完成"的痛点,且是递归转非递归的通用技巧;

  3. 掌握后序遍历的实现,不仅能吃透二叉树的遍历逻辑,还能加深对栈结构和递归本质的理解,为后续学习二叉树的层序遍历、深度优先搜索、广度优先搜索打下坚实基础。

二叉树的遍历是数据结构的入门知识点,但"知其然,更要知其所以然"------从递归到非递归的推导过程,正是锻炼逻辑思维和编程能力的关键。希望本文能带你彻底吃透后序遍历,在面对二叉树相关问题时,能做到游刃有余💪!

拓展思考 :除了双栈法,后序遍历还可以用单栈法前序遍历反转法实现,大家可以尝试基于本文的思路进行探索,欢迎在评论区交流~

相关推荐
炽烈小老头2 小时前
【每天学习一点算法 2026/04/10】Excel表列序号
学习·算法
亚马逊云开发者2 小时前
GameLift Servers DDoS防护实战:Player Gateway + Ping Beacons延迟优化 + C++ SDK集成
c++·gateway·ddos
宝贝儿好2 小时前
【LLM】第一章:分词算法BPE、WordPiece、Unigram、分词工具jieba
人工智能·python·深度学习·神经网络·算法·语言模型·自然语言处理
渡我白衣2 小时前
运筹帷幄——在线学习与实时预测系统
人工智能·深度学习·神经网络·学习·算法·机器学习·caffe
colus_SEU2 小时前
SVM 的终极视角:合页损失函数 (Hinge Loss) 与正则化
算法·机器学习·支持向量机
汀、人工智能2 小时前
[特殊字符] 第71课:爬楼梯
数据结构·算法·数据库架构·图论·bfs·爬楼梯
MicroTech20252 小时前
微算法科技(NASDAQ :MLGO)量子启发式算法与CNN、Transformer结合,实现端到端彩色图像分割
科技·算法·启发式算法
X journey2 小时前
机器学习进阶(14):交叉验证
人工智能·算法·机器学习
lolo大魔王2 小时前
Go语言的循环语句、判断语句、通道选择语句
开发语言·算法·golang