算法:二叉树的Morris遍历

题目描述

给定一颗二叉树的头节点 head,完成二叉树的先序、中序和后序遍历。如果二叉树的节点数为 N,要求时间复杂度为 O(N),额外空间复杂度为 O(1)。

题目解析

能不能用二叉树的前序、中序、后序遍历实现

不管是递归版本,还是非递归版本,实质上它都用到了栈,无法做到额外空间复杂度为O(1)。

比如当我们二叉树的高度有100层,那么递归时,系统就会一直压栈,最坏情况下,一直要压入100次遍历的递归函数,因为此处的空间复杂度是跟这颗二叉树的高度相关的。

为什么遍历二叉树需要压栈呢?

举个例子,如下二叉树:

在这里插入图片描述

  • 当我们一直往下遍历时,走到值为4的节点,打印输出4之后,该怎么回到2节点呢?
  • 每个节点都只有指向左右孩子的引用,并没有指向父节点的引用。
  • 所以在遍历4的时候,会提前将2节点的所有信息,放入栈里面,只有在4节点遍历完成之后,才会弹出栈里的信息(2节点)。然后继续遍历其他的节点

这就是为什么遍历二叉树需要压栈的原因。

那么有没有方法,能够不压栈,就能从4节点直接返回到2节点呢?

  • 我们可以利用4节点的右孩子引用
  • 因为4节点是叶子节点,没有左右孩子节点的。
  • 我们将4节点的右孩子引用,执行2节点,这就能从4节点直接返回到2节点了

在这里插入图片描述
上图橙色的线条,是Morris方法的作用:

  • 改变某些节点的右孩子引用
  • 然后在具体的遍历过程中,将橙色线条擦除即可。这样就恢复成了原二叉树

这就是Morris的思想。

那么问题来了,我们该如何画这些橙色的线条呢?

前提:我们准备两个引用变量—curr和mostRight

  • curr表示当前遍历的节点
  • mostRight表示curr节点的左子树中,往右边遍历,第一个没有右孩子的节点

举个例子,上图中:

  • curr是节点1
  • mostRight:节点1左子树中,往右下角遍历,第一个没有没有右孩子的节点,为节点5

遍历原则:(curr初始化为根节点)

  1. 如果curr没有左孩子,curr就向右移动(curr = curr->right
  2. 如果cur有左孩子,找到cur左子树上最右的节点,记为mostright
    1. 如果mostRight的right指针指向空,让其指向curr,cur向左移动(cur=cur->left
    2. 如果mostright的right指针指向cur,让其指向空,cur向右移动(cur=cur->right

看个例子:

在这里插入图片描述

  • curr 一开始来到节点4,节点4有左子树,找左子树的最右边节点,找到3,因为3.right = null,所以让3的right指向cur,也就是4,然后cur向左移动,来到2;
    在这里插入图片描述

  • cur来到节点2,节点2有左子树,找左子树的最右边节点,找到节点1,因为1.right = null,所以让1的right指向cur,也就是2,然后cur向左移动,来到1;
    在这里插入图片描述

  • curr 来到节点1,节点1没有左孩子,根据morris原则的第一条,curr向右移动,但是此时节点1的right已经指向了节点2,所以curr会回到节点2

  • curr 回到节点2,节点2有左孩子,找左子树的最右边节点,找到节点1。因为1.right = cur,所以让1.right = null,cur向右移动,来到3;

  • cur来到节点3,节点3没有左子树,所以向右移动,来到节点4;

  • cur来到节点4,节点4有左子树,找左子树的最右边节点,找到节点3,因为3.right = cur,所以让3.right = null,cur向右移动,来到节点6;

  • cur来到节点6,节点6有左子树,找左子树的最右边节点,找到节点5,因为5.right = null,所以让5.right = cur,也就是6,cur向左移动,来到移动5;
    在这里插入图片描述

  • cur来到节点5,节点5没有左子树,所以向右移动,来到节点6;

  • cur来到节点6,节点6有左子树,找左子树的最右边节点,找到节点5,因为5.right = cur,所以让5.right = null,cur向右移动,来到节点7;

  • cur来到节点7,节点7没有左子树,所以向右移动,来到null;

  • 因为cur为null,所以停止移动。

​ 从以上遍历过程可以看出,cur依次到达的节点分别是:4、2、1、2、3、4、6、5、6、7,我们将这个序列叫 Morris 序。从到达的这些节点中可以看出,有子节点的节点4,2,6分别来到了两次,第一次来到是在左子树的最右节点指向空的时候,第二次来到是在左子树最右边节点指向自己的时候,这也是 Morris 遍历和 Morris 序的实质。

morris遍历的实质: 建立一种机制,对于没有左子树的节点只到达一次,对于有左子树的节点会到达两次

struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
void morris(TreeNode * head){
    if(head == nullptr){
        return;
    }

    TreeNode *curr = head;
    TreeNode *mostRight = nullptr;
    while (curr != nullptr){
        mostRight = curr->left;
        if(mostRight != nullptr){ //有左子树
        	//查找最靠右的节点
            while (mostRight->right != nullptr && mostRight->right != curr){  
                mostRight = mostRight->right;
            }

            if(mostRight->right == nullptr){  //第一次来到这个最右节点
                mostRight->right = curr;
                curr = curr->left;
                continue;
            }else{  //第二次来到这个最右节点
                mostRight->right = nullptr;
            }
        }
        curr = curr->right; //没有左子树时&&mostRight.right = cur时
    }
}
void morris(TreeNode * head){
    if(head == nullptr){
        return;
    }

    TreeNode *curr = head;
    TreeNode *mostRight = nullptr;
    while (curr != nullptr){
        mostRight = curr->left;
        if(mostRight == nullptr){
           curr = curr->right;
        }else{
            while (mostRight->right != nullptr && mostRight->right != curr){
                mostRight = mostRight->right;
            }
            
            if(mostRight->right == nullptr){
                mostRight->right = curr;
                curr = curr->left;
            }else{
                mostRight->right = nullptr;
                curr = curr->right;
            }
        };

    }
}

注意:当第一次遍历到mostRight时,让mostRight的右孩子指向curr之后,curr再往左子树走,然后应该再写一次continue,让代码继续往curr的左子树遍历

问题:什么时候是第一次遍历到mostRight,什么时候是第二次遍历到mostRight?

  • 第一次是mostRight的右孩子是null时,此时我们需要让它的右孩子指向curr,curr = curr.left
  • 第二次是mostRight的右孩子是null时,此时我们需要让它的右孩子指向null,curr = curr.right

关于前序、中序、后序遍历,都是在Morris的基础之上,添加printf即可。

在这里插入图片描述

前序遍历&&中序遍历

遍历完成后对 cur 进过的节点序列稍作处理就很容易得到该二叉树的先序、中序序列:
在这里插入图片描述
morris遍历:有左子树就会回到curr两次,没有只会来一次

我们都知道前序遍历的顺序是:根左右。而Morris改前序遍历,只需要改两个地方:

  • 如果curr节点没有左孩子,那么就打印当前curr的值(只会经过一次)
  • 如果是第一次遍历到mostRight节点,那么就打印当前curr的值(第一次遍历到mostRight时就打印—这时它还没有处理它的左子树)
void morris(TreeNode * head){
    if(head == nullptr){
        return;
    }

    TreeNode *curr = head;
    TreeNode *mostRight = nullptr;
    while (curr != nullptr){
        mostRight = curr->left;
        if(mostRight == nullptr){
            printf("%d\t", curr->val);
            curr = curr->right;
        }else{
            while (mostRight->right != nullptr && mostRight->right != curr){
                mostRight = mostRight->right;
            }
            
            if(mostRight->right == nullptr){
                printf("%d\t", curr->val);
                mostRight->right = curr;
                curr = curr->left;
            }else{
                mostRight->right = nullptr;
                curr = curr->right;
            }
        };
    }
}

而使用morris遍历打印中序序列时(左根右):

  • 如果cur没有左孩子,直接打印输出cur的值(只会经过一次)
  • 如果mostRight是第二次遍历到,那就打印输出cur的值(第二次来到该结点时)
void morris(TreeNode * head){
    if(head == nullptr){
        return;
    }

    TreeNode *curr = head;
    TreeNode *mostRight = nullptr;
    while (curr != nullptr){
        mostRight = curr->left;
        if(mostRight == nullptr){
            printf("%d\t", curr->val);
            curr = curr->right;
        }else{
            while (mostRight->right != nullptr && mostRight->right != curr){
                mostRight = mostRight->right;
            }
            
            if(mostRight->right == nullptr){
                mostRight->right = curr;
                curr = curr->left;
            }else{
            	printf("%d\t", curr->val);
                mostRight->right = nullptr;
                curr = curr->right;
            }
        };
    }
}

后序遍历

Morris改后序遍历,稍微有一点点难,因为Morris遍历出来的左右结果,都满足一个规律:当前节点没有左子树时,那么只会遍历一次这个节点;当前节点有左子树时,那么mostRight节点会返回到curr节点,也就是说有左孩子的节点,会遍历两次。

后序遍历是每个节点,遍历到第三次时,才会打印输出,但是限制Morris遍历最多只会遍历到两次,怎么办?(相比递归版本的遍历,每个节点最多会经过3次,而Morris遍历最多经过2次。)

我们先来看一张图

在这里插入图片描述
不难发现,红色的数值就是后序遍历的结果。对比下左边的二叉树,红色线条的指向,从左到右,从下到上的打印顺序,竟然跟后序遍历的结果是一样的。

那我们就以这个为切入点,当遍历cur的时候,我们就可以打印cur左子树的最靠右的那一排的节点。比如cur等于1节点的时候,我们就可以打印2、5节点。

要满足从下到上的打印顺序,最容易想到的就是搞一个栈,压栈进行,然后再依次弹出栈并打印。这样的话,空间复杂度就不是O(1)了。 大家是否还记得逆序单链表?我们将那一排的节点,进行逆序,然后打印输出之后,再逆序回来即可。

morris实现后序遍历:如果在每次遇到第二次经过的节点时,将该节点的左子树的右边界上的节点,从下到上打印,最后再将整棵树的右边界从下到上打印,就得到了后序遍历

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

// 打印节点的右边界
void printRightEdge(TreeNode * root){
    if(root == nullptr){
        return;
    }

    // reverse the right edge
    TreeNode *prev = nullptr, *curr = root;
    while (curr != nullptr){
        TreeNode *next = curr->right;
        curr->right = prev;
        prev = curr;
        curr = next;
    }
    
    //print
    curr = prev;
    while (curr != nullptr){
        printf("%d\n", curr->val);
        curr = curr->right;
    }
    
    // recover
    curr = prev, prev = nullptr;
    while (curr != nullptr){
        TreeNode *next = curr->right;
        curr->right = prev;
        prev = curr;
        curr = next;
    }
}

void morris(TreeNode * head){
    if(head == nullptr){
        return;
    }

    TreeNode *curr = head;
    TreeNode *mostRight = nullptr;
    while (curr != nullptr){
        mostRight = curr->left;
        if(mostRight == nullptr){
            curr = curr->right;
        }else{
            while (mostRight->right != nullptr && mostRight->right != curr){
                mostRight = mostRight->right;
            }
            
            if(mostRight->right == nullptr){
                mostRight->right = curr;
                curr = curr->left;
            }else{
                mostRight->right = nullptr;
                // 在这打印左子树的右边界
                printRightEdge(curr->left);
                curr = curr->right;
            }
        };
    }
    // 在这打印整颗树的右边界
    printRightEdge(head);
}

morris遍历结点的顺序不是先序、中序、后序,而是按照自己的一套标准来决定接下来要遍历哪个结点

morris遍历的独特之处就是充分利用了叶子结点的无效引用(引用指向的是空,但该引用变量仍然占内存),

从而实现了O(N)的时间复杂度和O(1)的空间复杂度

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值