【数据结构与算法】Morris遍历详解

3,716 阅读10分钟

Morris遍历

1. 介绍

我们不管是用递归方式还是非递归方式遍历二叉树,只能做到时间复杂度为O(N),额外空间复杂度为O(logN),根本做不到额外空间复杂度为O(1)。

因为递归方式遍历二叉树的本质是系统帮我们压栈,而非递归方式遍历二叉树的本质是我们自己压栈,遍历二叉树沿途的节点都需要被压倒栈里去。

Morris一种遍历二叉树的方式,并且时间复杂度为O(N),额外空间复杂度O(1)。

Morris遍历通过利用树中叶子节点大量空闲指针的方式,以达到节省空间的目的。

在Morris遍历的基础上可以加工出先序遍历、中序遍历和后续遍历。

为什么Morris遍历重要?

因为和树遍历有关的题的解法非常多,如果找到了一种比别的遍历方式都好的遍历方式,那么就代表该解法比别的解法都好。

如果在遍历一棵树时严令禁止修改树的结构,那么Morris遍历就用不了。

Morris遍历有另外一个学名叫做:线索二叉树。

2. 流程

20211019214107.png

开始时cur来到根节点位置:

  • 如果cur有左孩子,找到左子树上最右的节点mostRight
    • 如果mostRight的右指针指向null,让其指向cur,然后cur向左移动(cur = cur.left)
    • 如果mostRight的右指针指向cur,让其指向null,然后cur向右移动(cur = cur.right)
  • 如果cur没有左孩子,cur向右移动(cur = cur.right)
  • cur为空时遍历停止

3. 分析

在Morris遍历的过程中,如果一个节点有左孩子,一定能访问到两次;如果一个节点没有左孩子,只能访问到一次。

在Morris遍历到一个有左孩子的节点时,能否知道是第几次访问到该节点?

可以,根据该节点左子树最右节点的 right 指针指向来判断。如果 right 指向null,则是第一次访问;如果 right 指向 该节点自身,则是第二次访问。

4. 实质

如下是标准的递归版本的二叉树遍历:

public static void process(Node root) {
    if (root == null) {
        return ;
    }
    // 第一次访问该节点
    process(root.left);
    // 第二次访问该节点
    process(root.right);
    // 第三次访问该节点
}

二叉树的递归遍历中,是将每一个节点当作root,如果root是null,则返回。否则就去root 的左子树遍历一遍,回到root,再去root的右子树上遍历一遍,再回到root。

这其实是根据系统的自动压栈和弹栈来实现递归函数的调用和返回,从而可以让每一个节点都会被访问三次,构建出二叉树遍历的递归序列。

Morris实际上是递归函数的一种模拟,但是它只能够做到如果一个节点root有左孩子,那么可以在root的左子树遍历一遍后再次回到 root,而不能实现在root的右子树上遍历一边后再次回到root。如果一个节点root没有左孩子,那么只能访问它一次。

这其实是利用底层线索的关系实现的,从而可以让有左孩子的节点被访问两次,没有左孩子的节点被访问一次,构建出Morris序列。

5. 实现

public static void morrisTraversal(Node root) {
    if (root == null) {
        return ;
    }

    Node cur = root;
    // 当前节点为空时遍历停止
    while (cur != null) {
        // 打印
        System.out.println(cur.value);

        // 如果当前节点有左孩子
        if (cur.left != null) {
            // 找到左子树上最右的节点
            Node mostRight = cur.left;
            // mostRight的right可能还没被改(第一次访问)也可能已经被改了(第二次访问)
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }

            // 如果mostRight的right指向null(第一次访问cur)
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            }
            // 如果mostRight的right指向cur(第二次访问cur)
            else {
                mostRight.right = null;
                cur = cur.right;
            }
        }
        // 如果当前节点没有左孩子
        else {
            cur = cur.right;
        }
    }
}

6. 复杂度

Morris遍历的额外空间复杂度很明显就是O(1),因为由程序中可以得到,我们只准备了cur和mostRight两个变量。

Morris遍历的时间复杂度是O(N)。

对于时间复杂度,我们需要思考一个问题:

在Morris遍历中,每访问一个有左孩子的节点都需要找到该节点左子树的最右节点,这个操作会不会导致整个过程的时间复杂度上升?

我们来计算一下在Morris遍历中,所有需要找的节点找到该节点左子树的最右节点的总代价。

20211019211917.png

首先看哪些节点会去找该节点的左子树的最右节点,并列出寻找路径:

1 节点遍历了两遍 2,5,11 节点。

2 节点遍历了两遍 4,9 节点。

3 节点遍历了两遍 6,13 节点。

4 节点遍历了两遍 8 节点。

5 节点遍历了两遍 10 节点。

6 节点遍历了两遍 12 节点。

7 节点遍历了两遍 14 节点。

我们发现所有需要找左子树最右节点的节点在找的过程中遍历的节点都是不重复的。

遍历这些节点总共的代价是逼近O(N)的。

也就是说每访问一个有左孩子的节点都需要找到该节点左子树的最右节点的这个操作最大时间复杂度就是O(N)。

7. 先序遍历

20211020110405.png

Morris遍历加工成先序遍历的规则是:

  • 如果一个节点只能被访问一次,被访问时打印。
  • 如果一个节点能被访问两次,在第一次被访问时打印。

实现代码:

public static void preMorrisTraversal(Node root) {
    if (root == null) {
        return ;
    }

    Node cur = root;
    // 当前节点为空时遍历停止
    while (cur != null) {
        // 如果当前节点有左孩子
        if (cur.left != null) {
            // 找到左子树上最右的节点
            Node mostRight = cur.left;
            // mostRight的right可能还没被改(第一次访问)也可能已经被改了(第二次访问)
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }

            // 如果mostRight的right指向null(第一次访问cur)
            if (mostRight.right == null) {
                // 访问第一次时打印
                System.out.println(cur.value);

                mostRight.right = cur;
                cur = cur.left;
            }
            // 如果mostRight的right指向cur(第二次访问cur)
            else {
                mostRight.right = null;
                cur = cur.right;
            }
        }
        // 如果当前节点没有左孩子
        else {
            // 访问时打印
            System.out.println(cur.value);

            cur = cur.right;
        }
    }
}

8. 中序遍历

20211020112712.png

Morris遍历加工成中序遍历的规则是:

  • 如果一个节点只能被访问一次,被访问时打印。
  • 如果一个节点能被访问两次,在第二次被访问时打印。

实现代码:

public static void inMorrisTraversal(Node root) {
    if (root == null) {
        return ;
    }

    Node cur = root;
    // 当前节点为空时遍历停止
    while (cur != null) {

        // 如果当前节点有左孩子
        if (cur.left != null) {
            // 找到左子树上最右的节点
            Node mostRight = cur.left;
            // mostRight的right可能还没被改(第一次访问)也可能已经被改了(第二次访问)
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }

            // 如果mostRight的right指向null(第一次访问cur)
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            }
            // 如果mostRight的right指向cur(第二次访问cur)
            else {
                // 访问第二次时打印
                System.out.println(cur.value);

                mostRight.right = null;
                cur = cur.right;
            }
        }
        // 如果当前节点没有左孩子
        else {
            // 访问时打印
            System.out.println(cur.value);

            cur = cur.right;
        }
    }
}

9. 后序遍历

20211021102923.png

Morris遍历加工成中序遍历的规则是:

  • 如果一个节点能被访问两次,在第二次被访问时自底向上打印该节点左子树的右边界
  • 当所有节点遍历完后,单独自底向上打印整棵树的右边界

在代码实现之前,我们需要解决自底向上打印的问题,并且将自底向上打印的额外空间复杂度控制在O(1)。

20211020181901.png

如上图的二叉树的右边界为:a,b,c,d。自底向上打印该二叉树的右边界就是:d,c,b,a。

如何只使用有限的几个变量就能逆二叉树原指向顺序打印右边界呢?

将右边界整体看成一个单向链表,将其中每个节点的right看成单项链表中节点的next并指向每个节点的父节点,没有父节点的指向null,每个节点的left指向保持不变,调整之后自底向上开始打印,打印结束后恢复right的原指向即可。

根据上面思路,我们需要设计两个函数,一个函数用来反转右边界,一个函数用来打印右边界。

// 自底向上打印以root为根的树的右边界
public static void printRightEdge(Node root) {
    // 反转右边界并获取右边界最底部节点
    Node tail = reverseRightEdge(root);

    Node cur = tail;
    while (cur != null) {
        // 打印
        System.out.println(cur.value);

        cur = cur.right;
    }

    // 将右边界反转回去
    reverseRightEdge(tail);
}

// 反转以root为根的树的右边界
public static Node reverseRightEdge(Node root) {
    Node pre = null;
    Node next = null;

    while (root != null) {
        next = root.right;
        root.right = pre;
        pre = root;
        root = next;
    }

    return pre;
}

实现代码:

public static void postMorrisTraversal(Node root) {
    if (root == null) {
        return ;
    }

    Node cur = root;
    // 当前节点为空时遍历停止
    while (cur != null) {

        // 如果当前节点有左孩子
        if (cur.left != null) {
            // 找到左子树上最右的节点
            Node mostRight = cur.left;
            // mostRight的right可能还没被改(第一次访问)也可能已经被改了(第二次访问)
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }

            // 如果mostRight的right指向null(第一次访问cur)
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            }
            // 如果mostRight的right指向cur(第二次访问cur)
            else {
                mostRight.right = null;

                // 访问第二次时逆序打印左子树的右边界
                printRightEdge(cur.left);

                cur = cur.right;
            }
        }
        // 如果当前节点没有左孩子
        else {
            cur = cur.right;
        }
    }

    // 当所有节点遍历完后,逆序打印整棵树的右边界
    printRightEdge(root);
}

10. 和二叉树递归套路的比较

在前文《二叉树实战入门》中,我们讲述了二叉树的递归套路作为最优解来解决二叉树中的一些问题。在本文中,我们讲述了Morris遍历作为最优解来解决二叉树中的一些问题。

哪些二叉树的问题是以二叉树的递归套路作为最优解的?哪些二叉树的问题是以Morris遍历作为最优解的?

  • 如果你发现你的方法必须做第三次信息的强整合,只能使用二叉树的递归套路,并且二叉树的递归套路就是最优解。

    第三次信息的强整合指的是:一个节点必须在得到它的左子树的信息和右子树的信息之后,在第三次访问自己时结合左右子树信息和自身信息整合并返回。

  • 如果你发现你的方法并不需要做第三次信息的强整合,可以使用二叉树的递归套路,也可以使用Morris遍历,但是Morris遍历是最优解。

判断搜索二叉树

**题目:**请问如何判断一棵树是否是搜索二叉树?

分析:

中序遍历的序列如果是升序,则该树是搜索二叉树。

这道题使用Morris遍历来解决将会比常规解法节省大量的空间,是本题的最优解。

代码:

public static boolean isBST(Node root) {
    if (root == null) {
        return true;
    }

    // 记录当前节点前一个节点的value
    int preValue = Integer.MIN_VALUE;

    Node cur = root;
    // 当前节点为空时遍历停止
    while (cur != null) {
        // 如果当前节点有左孩子
        if (cur.left != null) {
            // 找到左子树上最右的节点
            Node mostRight = cur.left;
            // mostRight的right可能还没被改(第一次访问)也可能已经被改了(第二次访问)
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }

            // 如果mostRight的right指向null(第一次访问cur)
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            }
            // 如果mostRight的right指向cur(第二次访问cur)
            else {
                // 和前一个节点的value比较
                if (cur.value <= preValue) {
                    return false;
                }
                preValue = cur.value;

                mostRight.right = null;
                cur = cur.right;
            }
        }
        // 如果当前节点没有左孩子
        else {
            // 和前一个节点的value比较
            if (cur.value <= preValue) {
                return false;
            }
            preValue = cur.value;

            cur = cur.right;
        }
    }

    return true;
}