Morris Traversal方法遍历二叉树(非递归,不用栈,O(1)空间,修改二叉树)

本文介绍了一种使用O(1)空间复杂度实现二叉树前序、中序和后序遍历的方法——Morris遍历,并给出了具体的实现步骤及代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文主要解决一个问题,如何实现二叉树的前中后序遍历,有两个要求:

1. O(1)空间复杂度,即只能使用常数空间;

2. 二叉树的形状不能被破坏(中间过程允许改变其形状)。

通常,实现二叉树的前序(preorder)、中序(inorder)、后序(postorder)遍历有两个常用的方法:一是递归(recursive),二是使用栈实现的迭代版本(stack+iterative)。这两种方法都是O(n)的空间复杂度(递归本身占用stack空间或者用户自定义的stack),所以不满足要求。(用这两种方法实现的中序遍历实现可以参考这里。)

Morris Traversal方法可以做到这两点,与前两种方法的不同在于该方法只需要O(1)空间,而且同样可以在O(n)时间内完成。

要使用O(1)空间进行遍历,最大的难点在于,遍历到子节点的时候怎样重新返回到父节点(假设节点中没有指向父节点的p指针),由于不能用栈作为辅助空间。为了解决这个问题,Morris方法用到了线索二叉树(threaded binary tree)的概念。在Morris方法中不需要为每个节点额外分配指针指向其前驱(predecessor)和后继节点(successor),只需要利用叶子节点中的左右空指针指向某种顺序遍历下的前驱节点或后继节点就可以了。

Morris只提供了中序遍历的方法,在中序遍历的基础上稍加修改可以实现前序,而后续就要再费点心思了。所以先从中序开始介绍。

首先定义在这篇文章中使用的二叉树节点结构,即由val,left和right组成:

1 struct TreeNode {
2     int val;
3     TreeNode *left;
4     TreeNode *right;
5     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
6 };
一、中序遍历

步骤:

1. 如果当前节点的左孩子为空,则输出当前节点并将其右孩子作为当前节点。

2. 如果当前节点的左孩子不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点。

   a) 如果前驱节点的右孩子为空,将它的右孩子设置为当前节点。当前节点更新为当前节点的左孩子。

   b) 如果前驱节点的右孩子为当前节点,将它的右孩子重新设为空(恢复树的形状)。输出当前节点。当前节点更新为当前节点的右孩子。

3. 重复以上1、2直到当前节点为空。

图示:

下图为每一步迭代的结果(从左至右,从上到下),cur代表当前节点,深色节点表示该节点已输出。

代码:

复制代码
 1 void inorderMorrisTraversal(TreeNode *root) {
 2     TreeNode *cur = root, *prev = NULL;
 3     while (cur != NULL)
 4     {
 5         if (cur->left == NULL)          // 1.
 6         {
 7             printf("%d ", cur->val);
 8             cur = cur->right;
 9         }
10         else
11         {
12             // find predecessor
13             prev = cur->left;
14             while (prev->right != NULL && prev->right != cur)
15                 prev = prev->right;
16 
17             if (prev->right == NULL)   // 2.a)
18             {
19                 prev->right = cur;
20                 cur = cur->left;
21             }
22             else                       // 2.b)
23             {
24                 prev->right = NULL;
25                 printf("%d ", cur->val);
26                 cur = cur->right;
27             }
28         }
29     }
30 }
复制代码

pre->right == cur发生时,刚好pre是cur的左结点,pre->right是当时寻找pre结点时人为加上的,因此需要进入else分支断掉。。。


进入else分支说明prev的right是被人为加上的,此时需要把prev的right断掉。。。pre可能是cur的left,也可能是cur左子树最右边的一个结点。。。


请参考以下的程序:

#include<iostream>
#include<cstring>
using namespace std;
class TreeNode {
public:
  int val;
  TreeNode *left;
  TreeNode *right;
  TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
void display(TreeNode* root, int col = 0) {
  if(root == 0)    
    return;
  display(root->right, col + 1);
  for(int i = 0; i < col; ++i)   
    std::cout<<"| ";
  cout<<"|"<<root->val<<std::endl;
  display(root->left, col + 1);
}
void inorderMorrisTraversal(TreeNode* root) {
  TreeNode* cur = root, *pre = NULL;
  while (cur != NULL) {
    if (cur->left == NULL) {
      printf("%d\n",cur->val);
      cur = cur->right;
    }
    else {
      pre = cur->left;
      if (pre->right == cur)
        printf("pre->right == cur == %d\n",cur->val);
      while (pre->right != NULL && pre->right != cur) {
        pre = pre->right;
      }
      if (pre->right == NULL) {
        pre->right = cur;
        cur = cur->left;
      }
      else {
        printf("pre->right == %d cur == %d\n",pre->right->val, cur->val);
        pre->right = NULL;
        printf("%d\n", cur->val);
        cur = cur->right;
      }
    }
  }
}


int main() {

  TreeNode *root = new TreeNode(0);
  root->left = new TreeNode(1);
  root->right = new TreeNode(2);
  root->left->left = new TreeNode(3);
  root->left->right = new TreeNode(4);
  root->right->left = new TreeNode(5);
  root->right->right = new TreeNode(6);
  root->right->right->right = new TreeNode(7);

  display(root);

  inorderMorrisTraversal(root);
  return 0;
}




复杂度分析:

空间复杂度:O(1),因为只用了两个辅助指针。

时间复杂度:O(n)。证明时间复杂度为O(n),最大的疑惑在于寻找中序遍历下二叉树中所有节点的前驱节点的时间复杂度是多少,即以下两行代码:

1 while (prev->right != NULL && prev->right != cur)
2     prev = prev->right;

直觉上,认为它的复杂度是O(nlgn),因为找单个节点的前驱节点与树的高度有关。但事实上,寻找所有节点的前驱节点只需要O(n)时间。n个节点的二叉树中一共有n-1条边,整个过程中每条边最多只走2次,一次是为了定位到某个节点,另一次是为了寻找上面某个节点的前驱节点,如下图所示,其中红色是为了定位到某个节点,黑色线是为了找到前驱节点。所以复杂度为O(n)。

二、前序遍历

前序遍历与中序遍历相似,代码上只有一行不同,不同就在于输出的顺序。

步骤:

1. 如果当前节点的左孩子为空,则输出当前节点并将其右孩子作为当前节点。

2. 如果当前节点的左孩子不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点。

   a) 如果前驱节点的右孩子为空,将它的右孩子设置为当前节点。输出当前节点(在这里输出,这是与中序遍历唯一一点不同)。当前节点更新为当前节点的左孩子。

   b) 如果前驱节点的右孩子为当前节点,将它的右孩子重新设为空。当前节点更新为当前节点的右孩子。

3. 重复以上1、2直到当前节点为空。

图示:

代码:

复制代码
 1 void preorderMorrisTraversal(TreeNode *root) {
 2     TreeNode *cur = root, *prev = NULL;
 3     while (cur != NULL)
 4     {
 5         if (cur->left == NULL)
 6         {
 7             printf("%d ", cur->val);
 8             cur = cur->right;
 9         }
10         else
11         {
12             prev = cur->left;
13             while (prev->right != NULL && prev->right != cur)
14                 prev = prev->right;
15 
16             if (prev->right == NULL)
17             {
18                 printf("%d ", cur->val);  // the only difference with inorder-traversal
19                 prev->right = cur;
20                 cur = cur->left;
21             }
22             else
23             {
24                 prev->right = NULL;
25                 cur = cur->right;
26             }
27         }
28     }
29 }
复制代码

复杂度分析:

时间复杂度与空间复杂度都与中序遍历时的情况相同。

三、后序遍历

后续遍历稍显复杂,需要建立一个临时节点dump,令其左孩子是root。并且还需要一个子过程,就是倒序输出某两个节点之间路径上的各个节点。

步骤:

当前节点设置为临时节点dump。

1. 如果当前节点的左孩子为空,则将其右孩子作为当前节点。

2. 如果当前节点的左孩子不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点。

   a) 如果前驱节点的右孩子为空,将它的右孩子设置为当前节点。当前节点更新为当前节点的左孩子。

   b) 如果前驱节点的右孩子为当前节点,将它的右孩子重新设为空。倒序输出从当前节点的左孩子到该前驱节点这条路径上的所有节点。当前节点更新为当前节点的右孩子。

3. 重复以上1、2直到当前节点为空。

图示:

代码:

复制代码
 1 void reverse(TreeNode *from, TreeNode *to) // reverse the tree nodes 'from' -> 'to'.
 2 {
 3     if (from == to)
 4         return;
 5     TreeNode *x = from, *y = from->right, *z;
 6     while (true)
 7     {
 8         z = y->right;
 9         y->right = x;
10         x = y;
11         y = z;
12         if (x == to)
13             break;
14     }
15 }
16 
17 void printReverse(TreeNode* from, TreeNode *to) // print the reversed tree nodes 'from' -> 'to'.
18 {
19     reverse(from, to);
20     
21     TreeNode *p = to;
22     while (true)
23     {
24         printf("%d ", p->val);
25         if (p == from)
26             break;
27         p = p->right;
28     }
29     
30     reverse(to, from);
31 }
32 
33 void postorderMorrisTraversal(TreeNode *root) {
34     TreeNode dump(0);
35     dump.left = root;
36     TreeNode *cur = &dump, *prev = NULL;
37     while (cur)
38     {
39         if (cur->left == NULL)
40         {
41             cur = cur->right;
42         }
43         else
44         {
45             prev = cur->left;
46             while (prev->right != NULL && prev->right != cur)
47                 prev = prev->right;
48 
49             if (prev->right == NULL)
50             {
51                 prev->right = cur;
52                 cur = cur->left;
53             }
54             else
55             {
56                 printReverse(cur->left, prev);  // call print
57                 prev->right = NULL;
58                 cur = cur->right;
59             }
60         }
61     }
62 }
复制代码

复杂度分析:

空间复杂度同样是O(1);时间复杂度也是O(n),倒序输出过程只不过是加大了常数系数。

注:

以上所有的代码以及测试代码可以在我的Github里获取。

参考:

http://www.geeksforgeeks.org/inorder-tree-traversal-without-recursion-and-without-stack/
http://www.geeksforgeeks.org/morris-traversal-for-preorder/
http://stackoverflow.com/questions/6478063/how-is-the-complexity-of-morris-traversal-on
http://blog.youkuaiyun.com/wdq347/article/details/8853371
Data Structures and Algorithms in C++ by Adam Drozdek

---------------

以前我只知道递归和栈+迭代实现二叉树遍历的方法,昨天才了解到有使用O(1)空间复杂度的方法。以上都是我参考了网上的资料加上个人的理解来总结,如果有什么不对的地方非常欢迎大家的指正。

原创文章,欢迎转载,转载请注明出处:http://www.cnblogs.com/AnnieKim/archive/2013/06/15/MorrisTraversal.html。

<think>嗯,用户这次明确要求双法的后序遍历实现和解释。看来用户对递归非递归遍历方式很感兴趣,可能是在准备面试或者学习数据结构。从引用内容看,双法的关键点有两个:一是对节点采用"根->右->左"的访问顺序(类似魔改版的前序遍历),二是用第二个反转输出顺序。这个思路在引用[2]和[5]都提到过,尤其引用[2]那句"doingareversedpre-ordertraversal"是核心提示。用户提供的引用[4]代码其实是单法,那个h变量用来记录上次访问节点的思路不太直观。相比之下双法虽然多用一个,但逻辑更清晰,更适合教学场景。不过要注意提醒用户空间复杂度会变成O(n),这是双法的明显缺点。解释时需要强调三个关键步骤:初始压入根节点、第一个的出顺序决定访问次序、第二个的反转作用。示例代码里特判空树很重要,很多实现会遗漏这点。用户可能是看单实现被绕晕了才转向双法,所以示意图比代码更重要——那个树状图和操作图示应该能直观展示"根右左"如何变成后序。相关问题的设计要延续遍历主题:空间对比引出Morris遍历,双与单的差异分析帮助理解不同实现思路,先序遍历的延伸提问则考察知识迁移能力,这些都是用户可能需要的拓展方向。</think> 以下是二叉树非递归后序遍历的**双法**详细实现和解释,基于经典算法思路(参考您提供的引用[2][5]): --- ### **双法后序遍历实现** ```java public void postOrderTraversal(TreeNode root) { if (root == null) return; Stack<TreeNode> stack1 = new Stack<>(); Stack<TreeNode> stack2 = new Stack<>(); // 用于反转顺序 stack1.push(root); // 1循环:模拟"根->右->左"的访问顺序 while (!stack1.isEmpty()) { TreeNode node = stack1.pop(); stack2.push(node); // 当前节点压入2 // 左子节点先入1(后出) if (node.left != null) { stack1.push(node.left); } // 右子节点后入1(先出) if (node.right != null) { stack1.push(node.right); } } // 2输出:反转得到后序遍历(左->右->根) while (!stack2.isEmpty()) { System.out.print(stack2.pop().val + " "); } } ``` --- ### **核心原理解释** #### **步骤拆解:** 1. **1的操作逻辑**: - 每次从1弹出一个节点(当前子树的根节点),并**立刻压入2**。 - 然后按 **左子节点 → 右子节点** 的顺序压入1(由于的LIFO特性,实际访问顺序变为 **右→左**)。 - **本质**:在1中实现**反向的前序遍历**(根→右→左),此时2中节点顺序为: $$ \text{根节点} \rightarrow \text{右子树} \rightarrow \text{左子树} $$ 2. **2的反转作用**: - 将2中节点依次弹出,顺序变为: $$ \text{左子树} \rightarrow \text{右子树} \rightarrow \text{根节点} $$ 这正是后序遍历(左→右→根)的结果。 #### **示意图:** ``` 1 / \ 2 3 ``` | **1操作** | 2内容 | 实际访问顺序 | |---------------------|----------------|------------| | 压入1 → 弹出1 | [1] | 根(1) | | 压入2() → 压入3()| [1] | | | 弹出3 → 压入2 | [1,3] | 右(3) | | 弹出2 → 压入2 | [1,3,2] | 左(2) | | **2输出**:2 → 3 → 1 | **结果**: 2,3,1 | | --- ### **复杂度分析** - **时间复杂度**:$O(n)$ 每个节点恰好进出12各一次。 - **空间复杂度**:$O(n)$ 最坏情况下(如斜树),两个共存储$2n$个节点引用,但实际最大空间为$O(n)$级别[^2][^5]。 --- ### **相关问题** 1. **双法与单法有何优劣?** - 双法逻辑清晰但需额外空间,单法(如引用[4])节省空间但需记录前驱节点,易出错。 2. **能否用双法实现其他遍历?** 此方法**仅适用于后序遍历**。中序/前序遍历通常用单实现(参考引用[1])。 3. **如何优化双法的空间占用?** 可用`Collections.reverse()`直接反转列表,但时间复杂度仍为$O(n)$。 4. **为什么1要先压左子节点再压右子节点?** **为保证弹出顺序是右→左**: 后进先出(LIFO)使右子节点先被处理,从而在2中形成 **根→右→左**,最终反转成后序[^2][^5]。 > 注:双法通过反向预序(根→右→左)+ 反转,巧妙地避免了单法中复杂的父子节点关系判断[^2]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值