6、二叉树遍历的非递归实现

递归遍历二叉树,代码确实简洁优雅。但我在实际项目中遇到过一个问题——当树深度达到几千层时,递归调用栈直接爆了。嗯,那次排查花了我不少时间。

所以,非递归遍历不是炫技,是刚需。今天我们就来聊聊三种遍历的非递归写法:前序、中序、后序。核心思想都一样——用栈模拟系统调用栈

为什么需要非递归?

递归的本质是函数调用,每次调用都会压栈。系统栈的大小是有限的,一般几MB。二叉树深度一大,递归就崩了。

非递归遍历,说白了就是自己管理一个栈。你控制入栈出栈,想怎么遍历都行。而且,非递归的效率通常更高——少了函数调用的开销。

核心结论:递归遍历是「系统帮你压栈」,非递归是「你自己压栈」。本质一样,但非递归更可控。

前序遍历非递归(栈实现)

前序遍历的顺序是:根 → 左 → 右。用栈实现时,我习惯先压右孩子,再压左孩子。为什么?因为栈是后进先出,先压右,左孩子才能先出栈。

看代码:

void preorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    
    stack<TreeNode*> stk;
    stk.push(root);
    
    while (!stk.empty()) {
        TreeNode* node = stk.top();
        stk.pop();
        
        printf("%d ", node->val);  // 访问根
        
        // 先压右,再压左
        if (node->right) stk.push(node->right);
        if (node->left) stk.push(node->left);
    }
}

逻辑很简单:每次弹出一个节点,访问它,然后把它的右孩子和左孩子依次入栈。这样左孩子就会在下一轮先被弹出。

我的习惯:写非递归前序时,我总会在心里默念「根左右,右先入」。这样不容易写反。

中序遍历非递归(栈实现)

中序遍历的顺序是:左 → 根 → 右。这个稍微绕一点。你不能直接访问根,得先把左子树处理完。

核心思路:一直往左走,把沿途节点全部入栈。走到空时,弹出栈顶访问,然后转向右子树。

void inorderTraversal(TreeNode* root) {
    stack<TreeNode*> stk;
    TreeNode* cur = root;
    
    while (cur != NULL || !stk.empty()) {
        // 一直往左走,入栈
        while (cur != NULL) {
            stk.push(cur);
            cur = cur->left;
        }
        
        // 弹出栈顶,访问
        cur = stk.top();
        stk.pop();
        printf("%d ", cur->val);
        
        // 转向右子树
        cur = cur->right;
    }
}

你想想看,这个while循环是不是很巧妙?外层循环控制整体流程,内层循环负责「一路向左」。我刚开始学的时候,总觉得这个写法很别扭,后来写多了才发现——这才是中序遍历的本质

我曾经踩过的坑:内层while结束后,忘记把cur置为cur->right,结果死循环了。记住:访问完根节点后,一定要转向右子树。

后序遍历非递归(双栈法)

后序遍历的顺序是:左 → 右 → 根。这是最麻烦的一个。因为你要等左右子树都访问完,才能访问根。

双栈法是我个人最喜欢的方式。思路很简单:

  • 第一个栈用来遍历,顺序是「根 → 右 → 左」
  • 第二个栈用来反转,得到「左 → 右 → 根」

说白了,就是先搞一个「根右左」的遍历,然后反转一下。

void postorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    
    stack<TreeNode*> stk1, stk2;
    stk1.push(root);
    
    while (!stk1.empty()) {
        TreeNode* node = stk1.top();
        stk1.pop();
        stk2.push(node);  // 先入栈2
        
        // 先压左,再压右(和前序相反)
        if (node->left) stk1.push(node->left);
        if (node->right) stk1.push(node->right);
    }
    
    // 输出栈2的内容
    while (!stk2.empty()) {
        printf("%d ", stk2.top()->val);
        stk2.pop();
    }
}

为什么这样能行?因为stk1的遍历顺序是「根右左」,入栈2的顺序也是「根右左」。但栈2是后进先出,所以出栈顺序就变成了「左右根」——正好是后序遍历。

后序遍历非递归(标记法)

双栈法虽然好理解,但用了两个栈,空间开销大。标记法只需要一个栈,但需要给每个节点加一个「是否已访问过」的标记。

void postorderTraversalWithFlag(TreeNode* root) {
    stack<pair<TreeNode*, int>> stk;
    TreeNode* cur = root;
    
    while (cur != NULL || !stk.empty()) {
        while (cur != NULL) {
            stk.push({cur, 0});  // 0表示左子树未处理完
            cur = cur->left;
        }
        
        auto& top = stk.top();
        cur = top.first;
        int flag = top.second;
        stk.pop();
        
        if (flag == 0) {
            // 左子树处理完了,但右子树还没处理
            stk.push({cur, 1});  // 标记为1,表示右子树已处理
            cur = cur->right;
        } else {
            // 左右子树都处理完了,访问根
            printf("%d ", cur->val);
            cur = NULL;  // 重要:置空,让外层循环继续弹栈
        }
    }
}

标记法的核心就是:每个节点入栈两次。第一次入栈时标记为0,表示「左子树还没处理完」。第二次入栈时标记为1,表示「右子树也处理完了,可以访问了」。

三种非递归遍历对比:

遍历方式 核心思路 空间复杂度 推荐场景
前序 根先入栈,右左依次入栈 O(h) 复制树、序列化
中序 一路向左入栈,出栈后转右 O(h) BST中序输出
后序(双栈) 根右左入栈1,反转得左右根 O(n) 简单易懂,适合面试
后序(标记法) 每个节点入栈两次,标记状态 O(h) 空间敏感场景

知识体系总览

下面这张图,把三种非递归遍历的核心逻辑串起来了。我建议你多看几遍,理解每个箭头代表的含义。

二叉树非递归遍历核心逻辑 前序遍历 根 → 左 → 右 1. 根入栈 2. 弹出 → 访问 3. 右入栈 → 左入栈 中序遍历 左 → 根 → 右 1. 一路向左入栈 2. 弹出 → 访问 3. 转向右子树 后序遍历 左 → 右 → 根 双栈法: 栈1: 根右左入栈 栈2: 反转得左右根 核心思想总结 1. 非递归遍历 = 用栈模拟递归调用过程 2. 前序:根先入栈,右左依次入栈(右先入,左先出) 3. 中序:一路向左入栈,出栈后转右子树 4. 后序:双栈法(根右左 → 反转)或标记法(每个节点入栈两次)

总结

非递归遍历,说白了就是「手动管理栈」。前序最简单,中序最经典,后序最绕。我个人建议:

  • 前序:记住「右先入,左先出」
  • 中序:记住「一路向左,出栈转右」
  • 后序:面试用双栈法,生产环境用标记法

我曾经在面试中被问到后序遍历的非递归实现,当时脑子一热写了双栈法,面试官点了点头。后来在实际项目中,我改用标记法——省一个栈的空间,代码也不复杂。

嗯,今天就聊到这里。代码多写几遍,自然就熟了。


公众号:蓝海资料掘金营,微信deep3321