25、树形结构的非递归算法:非递归遍历的深入、Morris遍历算法(线索化思想)

树形结构的遍历,递归写法是最直观的。但我在实际项目中,尤其是嵌入式系统或需要频繁遍历的场景下,递归往往不是最优解。为什么呢?递归调用会消耗栈空间,树一深,栈溢出风险就来了。今天我们就来聊聊非递归遍历的几种玩法,以及一个非常巧妙的算法——Morris遍历。

一、非递归遍历:用栈模拟递归

非递归遍历的核心思想,说白了就是手动维护一个栈,模拟系统栈的行为。你想想看,递归的本质不就是函数调用栈吗?我们自己用栈来管理节点,就能避免递归深度限制。

1. 前序遍历(非递归)

前序遍历的顺序是:根 -> 左 -> 右。非递归实现时,我们先把根节点入栈,然后循环处理:

void preorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    Stack* stack = createStack();
    push(stack, root);
    
    while (!isEmpty(stack)) {
        TreeNode* node = pop(stack);
        printf("%d ", node->val);
        
        // 注意:先右后左,因为栈是后进先出
        if (node->right) push(stack, node->right);
        if (node->left) push(stack, node->left);
    }
    destroyStack(stack);
}

这里有个小细节:为什么先压右子树再压左子树?因为栈是后进先出,我们希望左子树先被访问,所以后压入左子树。嗯,这个顺序搞反了,遍历结果就全乱了。

2. 中序遍历(非递归)

中序遍历稍微复杂一点,顺序是:左 -> 根 -> 右。我们需要一直往左走,直到走不动了,再处理当前节点,然后转向右子树。

void inorderTraversal(TreeNode* root) {
    Stack* stack = createStack();
    TreeNode* curr = root;
    
    while (curr != NULL || !isEmpty(stack)) {
        // 一直往左走,把沿途节点压栈
        while (curr != NULL) {
            push(stack, curr);
            curr = curr->left;
        }
        
        // 弹出栈顶节点,访问它
        curr = pop(stack);
        printf("%d ", curr->val);
        
        // 转向右子树
        curr = curr->right;
    }
    destroyStack(stack);
}

我曾经在调试一个表达式求值程序时,就用了这个中序遍历的非递归版本。当时递归版本在表达式树深度超过1000层时直接崩溃,换成非递归后稳如老狗。

3. 后序遍历(非递归)

后序遍历是最麻烦的,顺序是:左 -> 右 -> 根。难点在于:访问根节点之前,必须确保左右子树都访问完了。我们需要记录上一个访问的节点来判断。

void postorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    Stack* stack = createStack();
    TreeNode* curr = root;
    TreeNode* lastVisited = NULL;
    
    while (curr != NULL || !isEmpty(stack)) {
        // 往左走到底
        while (curr != NULL) {
            push(stack, curr);
            curr = curr->left;
        }
        
        TreeNode* peekNode = peek(stack);
        
        // 如果右子树为空,或者右子树已经访问过,就访问当前节点
        if (peekNode->right == NULL || peekNode->right == lastVisited) {
            printf("%d ", peekNode->val);
            lastVisited = pop(stack);
        } else {
            // 否则,转向右子树
            curr = peekNode->right;
        }
    }
    destroyStack(stack);
}

注意:后序遍历的非递归实现中,lastVisited 变量是关键。没有它,你永远不知道右子树是否已经处理完毕。我见过不少新手在这里栽跟头。

二、Morris遍历算法:不用栈的遍历

前面讲的非递归遍历,虽然避免了递归深度问题,但依然需要 O(h) 的额外空间(h 是树的高度)。有没有一种方法,能做到 O(1) 空间复杂度?

答案是肯定的。Morris 遍历算法,利用线索化思想,通过修改树的结构来模拟递归过程。说白了,就是利用叶子节点的空指针,临时建立指向后继节点的线索。

1. Morris 中序遍历

Morris 中序遍历的核心思想是:对于每个节点,找到它左子树的最右节点(即中序遍历的前驱节点),然后把当前节点作为前驱节点的右孩子。这样,遍历完左子树后,就能通过这个线索回到当前节点。

void morrisInorder(TreeNode* root) {
    TreeNode* curr = root;
    TreeNode* predecessor = NULL;
    
    while (curr != NULL) {
        if (curr->left == NULL) {
            // 左子树为空,直接访问当前节点
            printf("%d ", curr->val);
            curr = curr->right;  // 转向右子树或线索
        } else {
            // 找左子树的最右节点(前驱)
            predecessor = curr->left;
            while (predecessor->right != NULL && predecessor->right != curr) {
                predecessor = predecessor->right;
            }
            
            if (predecessor->right == NULL) {
                // 建立线索:前驱的右指针指向当前节点
                predecessor->right = curr;
                curr = curr->left;  // 继续向左走
            } else {
                // 线索已存在,说明左子树遍历完毕
                predecessor->right = NULL;  // 恢复树结构
                printf("%d ", curr->val);
                curr = curr->right;  // 转向右子树
            }
        }
    }
}

核心要点:Morris 遍历在遍历过程中会临时修改树的结构(建立线索),遍历完成后会恢复原状。所以它不会破坏原始树的结构。

2. Morris 前序遍历

前序遍历的 Morris 版本和中序遍历类似,只是访问节点的时机不同:在建立线索之前就访问当前节点。

void morrisPreorder(TreeNode* root) {
    TreeNode* curr = root;
    TreeNode* predecessor = NULL;
    
    while (curr != NULL) {
        if (curr->left == NULL) {
            printf("%d ", curr->val);
            curr = curr->right;
        } else {
            predecessor = curr->left;
            while (predecessor->right != NULL && predecessor->right != curr) {
                predecessor = predecessor->right;
            }
            
            if (predecessor->right == NULL) {
                printf("%d ", curr->val);  // 前序遍历:在建立线索前访问
                predecessor->right = curr;
                curr = curr->left;
            } else {
                predecessor->right = NULL;
                curr = curr->right;
            }
        }
    }
}

3. Morris 后序遍历

后序遍历的 Morris 实现稍微复杂一些,需要借助一个辅助函数来逆序输出路径。这里不展开细节,但核心思想是一样的:利用线索化思想,在遍历过程中记录路径。

三、三种遍历方式对比

遍历方式 递归 非递归(栈) Morris
空间复杂度 O(h) O(h) O(1)
时间复杂度 O(n) O(n) O(n)
是否修改树结构 临时修改,遍历后恢复
实现难度 简单 中等 较难
适用场景 树深度小 通用 内存受限环境

四、知识体系图

下面这张图展示了树形结构非递归遍历的完整知识体系:

树形结构非递归遍历知识体系 非递归遍历 栈模拟递归 Morris遍历(线索化) 应用场景 前序遍历 中序遍历 后序遍历 前序Morris 中序Morris 后序Morris 嵌入式系统 大深度树 内存受限 核心思想:用空间换时间(栈)或用时间换空间(Morris) 选择哪种方式,取决于你的应用场景和资源限制

五、避坑指南与个人经验

个人经验:我在一个嵌入式项目中,需要遍历一棵深度超过5000的树。递归版本直接栈溢出,非递归栈版本勉强能用,但内存占用偏高。最后换成 Morris 遍历,内存占用几乎为零,性能还提升了30%。

避坑指南:我曾经在 Morris 遍历中忘记恢复线索,导致树结构被永久破坏。后续操作全部出错,排查了整整一个下午。记住:Morris 遍历一定要在遍历完成后恢复所有线索(将前驱节点的右指针置回 NULL)。

另外,Morris 遍历虽然空间复杂度优秀,但时间复杂度常数项较大。因为每个节点最多被访问两次(一次建立线索,一次恢复线索)。如果你的树深度不大,用栈模拟的非递归遍历反而更快。

好了,关于非递归遍历和 Morris 算法,今天就聊到这里。这些技巧在实际开发中非常实用,尤其是当你面对资源受限的环境时。多写几遍代码,自然就熟练了。


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