递归遍历二叉树谁都会写,但面试官追问"能不能不用栈、不用递归、O(1) 空间?",Morris 遍历就是答案。

第一次看 Morris 觉得很魔幻——怎么能修改树结构还能恢复?其实核心思想就一句话:把空闲的 right 指针当面包屑,走过去之后还能找回来。

本文用一棵树从头到尾跑一遍 Morris 的中序、前序、后序,每一步都画图。


全文用这棵树举例:

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))

一、问题:回不去了

中序遍历是"左-根-右"。站在节点 1,你得先往左走到 2,再往左走到 4。访问完 4 之后,你需要回到 2

递归靠函数调用栈记住回去的路。迭代靠显式栈。两者都要 O(h) 空间。

Morris 的思路是:走之前,先在左子树里留一条线索(thread),让你将来能沿着这条线索走回来。


二、线索怎么留

从节点 cur 往左走之前,找到左子树里中序遍历的最后一个节点——也就是左子树的最右节点(inorder predecessor)。

为什么找它?因为中序遍历中,这个节点访问完之后,下一个该访问的就是 cur。如果这个最右节点的 right 指针是空的(本来就没右孩子),我们可以临时让它指向 cur

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    n5 -.->|"线索"| n1
    style n1 fill:#f96,stroke:#333

节点 5 是节点 1 左子树的最右节点。正常情况下 5 的 right 是 NULL,这里我们临时把它指向 1。这样将来访问完 5 之后,顺着 right 就能回到 1。


三、两种状态的判定

站在 cur,找到左子树的最右节点 pred 后,有两种情况:

pred->right含义操作
NULL第一次到 cur,还没往左走过建线索:pred->right = cur,cur 往左走
== cur第二次到 cur,左子树已遍历完拆线索:pred->right = NULL,恢复树结构

这就是 Morris 遍历的全部机制。没有左子树的节点不需要线索,直接处理后往右走。


四、Morris 中序遍历:完整模拟

中序是"左-根-右",所以第二次到达 cur 时才访问(左子树处理完了才轮到根)。

规则

  1. cur 没有左子树 → 访问 cur,cur = cur->right
  2. cur 有左子树 → 找 pred(左子树最右节点)
    • pred->right == NULL → 建线索,cur = cur->left
    • pred->right == cur → 拆线索,访问 cur,cur = cur->right

逐步模拟

第 1 步:cur = 1

有左子树。找 pred:从 2 一路往右到 5,5->right 是 NULL → 第一次到达。

建线索 5→1,cur 往左走到 2。

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    n5 -.->|"线索"| n1
    style n2 fill:#f96

第 2 步:cur = 2

有左子树。找 pred:4,4->right 是 NULL → 第一次到达。

建线索 4→2,cur 往左走到 4。

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    n5 -.->|"线索"| n1
    n4 -.->|"线索"| n2
    style n4 fill:#f96

第 3 步:cur = 4

没有左子树 → 访问 4,cur = cur->right = 2(沿线索回去!)

输出:[4]

第 4 步:cur = 2

有左子树。找 pred:4,4->right == 2 == cur → 第二次到达

拆线索 4->right = NULL,访问 2,cur = cur->right = 5。

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    n5 -.->|"线索"| n1
    style n5 fill:#f96

输出:[4, 2]

第 5 步:cur = 5

没有左子树 → 访问 5,cur = cur->right = 1(沿线索回去!)

输出:[4, 2, 5]

第 6 步:cur = 1

有左子树。找 pred:从 2 一路往右到 5,5->right == 1 == cur → 第二次到达

拆线索 5->right = NULL,访问 1,cur = cur->right = 3。

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    style n3 fill:#f96

输出:[4, 2, 5, 1]——树结构已完全恢复!

第 7 步:cur = 3

没有左子树 → 访问 3,cur = cur->right = NULL。结束。

输出:[4, 2, 5, 1, 3]

代码

class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> res;
        TreeNode* cur = root;
        while (cur) {
            if (!cur->left) {
                // 没有左子树,直接访问,往右走
                res.push_back(cur->val);
                cur = cur->right;
            } else {
                // 找左子树的最右节点(predecessor)
                TreeNode* pred = cur->left;
                while (pred->right && pred->right != cur) {
                    pred = pred->right;
                }
                if (!pred->right) {
                    // 第一次到达:建线索,往左走
                    pred->right = cur;
                    cur = cur->left;
                } else {
                    // 第二次到达:拆线索,访问,往右走
                    pred->right = nullptr;
                    res.push_back(cur->val);
                    cur = cur->right;
                }
            }
        }
        return res;
    }
};

时间复杂度

看起来找 pred 的 while 会让复杂度变成 $O(n^2)$?其实不会。每条边最多被"找 pred"走两次(建线索走一次、拆线索走一次),整棵树一共 n-1 条边,所以总的 pred 查找开销是 $O(n)$。总时间 $O(n)$,空间 $O(1)$。


五、Morris 前序遍历:改一行

前序是"根-左-右",所以第一次到达 cur 就访问(根先于左子树)。

和中序的唯一区别:访问时机从"拆线索时"挪到"建线索时"。

没有左子树第一次到达(建线索)第二次到达(拆线索)
中序访问访问
前序访问访问

用同一棵树跑一遍

步骤cur动作输出
11建线索 5→1,访问 1,往左[1]
22建线索 4→2,访问 2,往左[1, 2]
34无左子树,访问 4,沿线索到 2[1, 2, 4]
42拆线索,往右到 5
55无左子树,访问 5,沿线索到 1[1, 2, 4, 5]
61拆线索,往右到 3
73无左子树,访问 3[1, 2, 4, 5, 3] ✓

代码

class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> res;
        TreeNode* cur = root;
        while (cur) {
            if (!cur->left) {
                res.push_back(cur->val);
                cur = cur->right;
            } else {
                TreeNode* pred = cur->left;
                while (pred->right && pred->right != cur) {
                    pred = pred->right;
                }
                if (!pred->right) {
                    res.push_back(cur->val);  // ← 区别在这:建线索时就访问
                    pred->right = cur;
                    cur = cur->left;
                } else {
                    pred->right = nullptr;
                    // 中序这里有 push_back,前序没有
                    cur = cur->right;
                }
            }
        }
        return res;
    }
};

和中序代码只差一个 push_back 的位置。


六、Morris 后序遍历:逆序右边界

后序是"左-右-根",根最后才访问。这就麻烦了——Morris 的线索机制是围绕"左子树的最右节点"设计的,没有直接给后序留接口。

思路

加一个 dummy 节点作为虚拟根,让 dummy->left = root:

graph TD
    dummy((dummy)) --> n1((1))
    n1 --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))

然后用和中序一样的线索机制,但在拆线索时,不是访问 cur,而是逆序输出从 cur->left 到 pred 这条"右边界"上的所有节点

什么是"右边界"

从某个节点开始,沿 right 指针一直走到底的那条链。

graph TD
    n1((1)) --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    style n2 fill:#f96
    style n5 fill:#f96

节点 1 的左子树的右边界就是 2 → 5。拆线索时,逆序输出为 [5, 2]

为什么逆序右边界能得到后序

后序是"左-右-根"。每次拆线索时逆序输出一段右边界,刚好覆盖整棵树:

拆线索时的 cur右边界(cur->left 到 pred)逆序输出
24[4]
12 → 5[5, 2]
dummy1 → 3[3, 1]

拼起来:[4, 5, 2, 3, 1]

逐步模拟

步骤cur动作输出
1dummy建线索 3→dummy,往左到 1
21建线索 5→1,往左到 2
32建线索 4→2,往左到 4
44无左子树,沿线索到 2
52拆线索,逆序右边界 [4][4]
65无左子树,沿线索到 1
71拆线索,逆序右边界 [2→5] → [5, 2][4, 5, 2]
83无左子树,沿线索到 dummy
9dummy拆线索,逆序右边界 [1→3] → [3, 1][4, 5, 2, 3, 1] ✓

用图看第 7 步(最关键的一步)。拆线索前,树长这样:

graph TD
    dummy((dummy)) --> n1((1))
    n1 --> n2((2))
    n1 --> n3((3))
    n2 --> n4((4))
    n2 --> n5((5))
    n5 -.->|"线索"| n1
    n3 -.->|"线索"| dummy
    style n1 fill:#f96

cur = 1,检测到 5→1 是线索 → 拆掉,逆序输出 1->left (2) 到 pred (5) 的右边界:2 → 5,逆序为 [5, 2]

代码

class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        vector<int> res;
        TreeNode dummy;
        dummy.left = root;
        dummy.right = nullptr;
        TreeNode* cur = &dummy;

        while (cur) {
            if (!cur->left) {
                cur = cur->right;
            } else {
                TreeNode* pred = cur->left;
                while (pred->right && pred->right != cur) {
                    pred = pred->right;
                }
                if (!pred->right) {
                    pred->right = cur;
                    cur = cur->left;
                } else {
                    // 拆线索,逆序输出右边界
                    addReversedRightEdge(cur->left, pred, res);
                    pred->right = nullptr;
                    cur = cur->right;
                }
            }
        }
        return res;
    }

private:
    // 逆序输出从 from 到 to 沿 right 走的路径
    void addReversedRightEdge(TreeNode* from, TreeNode* to, vector<int>& res) {
        vector<int> tmp;
        TreeNode* node = from;
        while (node != to) {
            tmp.push_back(node->val);
            node = node->right;
        }
        tmp.push_back(to->val);
        for (int i = tmp.size() - 1; i >= 0; i--) {
            res.push_back(tmp[i]);
        }
    }
};

addReversedRightEdge 用了一个临时数组来逆序,严格来说不是 O(1) 空间。真正的 O(1) 做法是像反转链表一样反转 right 指针、遍历、再反转回来——原理一样,面试中一般不要求。


七、总结

时间空间访问时机
Morris 中序O(n)O(1)拆线索时访问
Morris 前序O(n)O(1)建线索时访问
Morris 后序O(n)O(1)拆线索时逆序输出右边界

三种遍历用的是同一套线索机制,区别只在于什么时候输出、输出什么

Morris 遍历的本质就是利用叶节点空闲的 right 指针作为返回路标。建线索时留下面包屑,拆线索时沿着面包屑回来,整棵树遍历完后结构完全恢复——从外面看就像什么都没发生过。