神迹!开启遍历新时代 —— 神级遍历Morris算法,时间复杂度O(n),空间复杂度O(1)

本文深入解析Morris遍历算法,一种空间复杂度仅为O(1)的二叉树遍历方法,详细阐述其原理及如何实现先序、中序、后序遍历,对比递归与非递归方式,提供代码示例。

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

一、递归方式和非递归方式遍历

二叉树节点数据结构

   public static class Node {
        public Integer value;
        public Node left;
        public Node right;
        public Node(Integer value){
            this.value = value;
        }
    }

递归的方式遍历二叉树算法。

    public void process(Node head){
        if(head == null){
            return ;
        }
        //1、先序遍历
        process(head.left);
        //2、中序遍历
        process(head.right);
        //3、后序遍历
    }

这是我们平时写的递归方式遍历节点,只要在对应的1、2、3位置插入输出语句就变成了一个先序、中序、后序遍历。

实际上也是隐含了这样一个意思:只要二叉树节点不为null,那么这个在当前节点的这个函数就会被访问三次。

这种递归方式的时间复杂度是O(n),那么有的小伙伴可能说这个节点的函数不是被访问了三次吗,我们平时说的在常数倍数的O(n),实际上就是O(n)这个时间复杂度。这个方法的空间复杂度度是O(n),这个时间复杂度不是体现在变量上面,而是递归,jvm内存栈的堆积。

而非递归方式,举一个栗子,先序遍历。

public static void preOrder(Node head){
    if(head == null){
        return ;
    }
    Stack<Node> stack = new Stack<>();
    while(!stack.isEmpty() || head != null){
        if(head != null){
            System.out.println(head.value);
            stack.push(head);
            head = head.left;
        }else{
            Node temp = stack.pop();
            head = temp.right;
        }
    }
}

同样用了栈这个结构,所以空间复杂度是O(n)。实际上时间复杂度已经到了不可优化,但是空间复杂度是否可以继续优化呢?答案是肯定的,这个就是我们要说的神级遍历Morris算法。空间复杂度O(1),这个指标也就是说,我不利用额外栈这些的辅助空间,我只在二叉树上面就能完成先、中、后序遍历。

二、Morris算法

morris主要利用了二叉树的那些null节点

先说一下morris算法的规则,然后画图举例,最后讲算法code。

规则:

(1)判断节点是否有左孩子,如果没有直接进入左孩子即cur = cur.right。否则进入(2)

(2)如果节点含有左子树,找到该左孩子的右子树上面最右节点mostRight,如果mostRight.right为null,那么进入(3),否则进入4

(3)如果mostRight.right = null,那么设置该mostRight.right = cur,指向当前节点。当前节点向左移动,cur = cur.left。

(3)如果mostRight.right != null,那么设置mostRight.right = null,当前节点向右移动cur = cur.right。

以这个为例:

当前cur = 1,左子树存在,找到mostRight = 5,mostRight.right = null,所以符合(3)cur = cur.left,当前节点来到2,然后5的右孩子指向1,则如图:

同理符合(3):cur来到4位置,如图:

这个时候,符合(1),cur直接通过4右节点到2。

当前情况,mostRight = 4,mostRight.right = cur,不等于null,符合条件(4)如图:

这个情况符合情况(1),那么直接回退到1。如图

符合(4),如图:

符合(3):

符合(1):

符合(4):

遍历结束!!

我们可以发现一个规律就是,有的节点被访问了两次,而这些节点都是有左孩子的,没有左孩子的节点只被访问了一次。这个对于我们之后的先序、中序、后序遍历至关重要!!!

然后我们上代码:

    public static void morrisIn(Node head) {
        if (head == null) {
            return;
        }
        Node cur = head;
        Node mostRight = null;
        while(cur != null){
            mostRight = cur.left;
            //规则(2)查看左子树是否是null,如果不等于null
            if(mostRight != null){
                //找到左子树最右节点
                while(mostRight.right != null && mostRight.right != cur){
                    mostRight = mostRight.right;
                }
                //规则(3)如果等于null,指向当前节点,当前节点左移动
                if(mostRight.right == null){
                    mostRight.right = cur;
                    cur = cur.left;
                    continue;
                }else{
                //规则(4)如果指向当前节点,那么就指向null,然后跳到右子树
                    mostRight.right = null;
                }
            }
            //规则(1)等于null,直接到右子树
            cur = cur.right;
        }
    }

这个代码就是严格按照规则走的morris遍历。先不要去管什么先序、后序、中序。那个很简单都是基于这个规则来的。先看懂遍历的代码,就像看递归的那个似的,看清楚执行的本身,自然而然就知道了如何去搞。

三、用morris实现先序、中序、后序遍历

(1)先序遍历:我们之前提到过,如果一个节点有左子树,那么它会被访问两次,如果一个节点没有左子树,就只会被访问一次。所以呢,我们在第一次到达节点的时候输出语句对应的值,就是先序遍历!

如何判断是第一次到达节点呢,规则(3)那么就是mostRight = null的时候,这个时候遍历就是第一次,因为第二次的时候会指向当前的节点。对于规则(1),只有一次到达的机会,那么就直接访问就可以了,下面的代码就是添加了一个else和输出。

    public static void morrisPre(Node head) {
        if (head == null) {
            return;
        }
        Node cur = head;
        Node mostRight = null;
        while(cur != null){
            mostRight = cur.left;
            if(mostRight != null){
                while(mostRight.right != null && mostRight.right != cur){
                    mostRight = mostRight.right;
                }
                if(mostRight.right == null){
                    mostRight.right = cur;
                    System.out.print(cur.value + " ");
                    cur = cur.left;
                    continue;
                }else{
                    mostRight.right = null;
                }
            }else{
                System.out.print(cur.value + " ");
            }
            cur = cur.right;
        }
        System.out.println();
    }

(2)中序遍历:就是在有左孩子的节点第二次回退到该节点的时候才输出,对于没有左孩子的节点,访问的时候直接打印。

        public static void morrisIn(Node head) {
            if (head == null) {
                return;
            }
            Node cur = head;
            Node mostRight = null;
            while(cur != null){
                mostRight = cur.left;
                //规则(2)查看左子树是否是null,如果不等于null
                if(mostRight != null){
                    //找到左子树最右节点
                    while(mostRight.right != null && mostRight.right != cur){
                        mostRight = mostRight.right;
                    }
                    //规则(3)如果等于null,指向当前节点,当前节点左移动
                    if(mostRight.right == null){
                        mostRight.right = cur;
                        cur = cur.left;
                        continue;
                    }else{
                        //规则(4)如果指向当前节点,那么就指向null,然后跳到右子树
                        mostRight.right = null;
                    }
                }
                System.out.print(cur.value + " ");
                //规则(1)等于null,直接到右子树
                cur = cur.right;
            }
            System.out.println();
    }

对于先序和中序还好,跟递归的过程差不多,他们输出的时机还是跟递归先序和中序的时机是一样的。

在Morris发表论文的时候,只有先序和中序,后序是后来的大佬们在morris基础上研究得出的。

后序遍历实际上采用了一个技巧:

只关注能够来到两次的节点,逆序打印左子树右边界,然后单独逆序打印整棵树的右边界。

    public static void morrisPost(Node head) {
        if (head == null) {
            return;
        }
        Node cur = head;
        Node mostRight = null;
        while(cur != null){
            mostRight = cur.left;
            if(mostRight != null){
                while(mostRight.right != null && mostRight.right != cur){
                    mostRight = mostRight.right;
                }
                if(mostRight.right == null){
                    mostRight.right = cur;
                    cur = cur.left;
                    continue;
                }else{
                    mostRight.right = null;
                    printEdge(cur.left);
                }
            }
            cur = cur.right;
        }
        printEdge(head);
        System.out.println();
    }

    //逆序打印
    private static void printEdge(Node head) {
        Node pre = tranfer(head);
        printInfo(pre);
        tranfer(pre);
    }
    //打印
    private static void printInfo(Node head){
        Node next = head;
        while(next != null){
            System.out.print(next.value + " ");
            next = next.right;
        }
    }
    
    //逆序
    private static Node tranfer(Node node){
        Node pre = null;
        Node curl = node;
        while(curl != null){
            Node next = curl.right;
            curl.right = pre;
            pre = curl;
            curl = next;
        }
        return pre;
    }

四、分析一下时间复杂度为什么是O(n)

以这个为例子!

当到达1节点的时候,我们需要遍历2、5、11,找到mostRight。

当到达2节点的时候,我们需要遍历4,9,找到mostRight。

当到达4节点的时候,我们需要遍历8,找到mostRight。

当到达5节点的时候,我们需要遍历10,找到mostRight。

当回退到4的时候,需要遍历8。

当回退到5的时候,需要遍历10

当回退到2的时候,需要遍历4,9。

当回退到1的时候,需要遍历2,5,11。

当遍历到3的时候,我们需要遍历6、13。

当遍历到6的时候,我们需要遍历12。

当回退到6的时候,需要遍历12。

当回退到3的时候,需要遍历6、13。

当遍历到7的时候,我们需要遍历14。

当回退到7的时候,需要遍历14。

当遍历到15的时候结束。

可以看到,我们的整个遍历过程中,访问每一个节点的次数都是在常数时间内的!所以是O(n)。

 

 

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值