二叉树的非递归遍历

235 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情

前、中、后序的非递归遍历

写在前面

本文摘要

  1. 前序遍历
  2. 中序遍历
  3. 后序遍历

二叉树遍历的复习

  • 上篇文章,我们了解了如何遍历一棵二叉树。常见的有四种方式:
    • 前序、中序、后序、层序
  • 其中除了层序遍历,其余的都是使用递归实现的,如下图所示:

image-20221124204039262

  • 这三个递归函数,思路确实很好想,代码也很精简
  • 可是我们都知道,调用函数会开辟栈帧,消耗栈空间
  • 而遍历基本上就是要访问所有元素,时间复杂度一般情况都为O(n),而此递归函数的空间复杂度应该也差不多为O(n)
  • 能不能优化一下这三个遍历的方式,不用递归来实现呢?
  • 这就是我们今天要谈的内容,有如下一棵二叉树:

image-20221124204701092

一、前序遍历

规律探寻

  • 如果利用前序遍历,那么访问该树的顺序为:20、15、10、5、12、18、25。这一点相信大家都清楚
  • 有什么规律呢?我们一起来研究一下:

image-20221124205127622

  • 首先要明确的点:一开始只能拿到根节点

  • 可以发现,前序遍历的顺序,刚好就是拿到根节点,一直往左走。走到为空为止

  • 譬如上图:

    • 先访问 20,20的左边不为空
    • 访问15,15的左边不为空
    • 访问10,10的左边不为空
    • 访问5,5的左边为空
  • 左边为空之后,该访问右边了。可以发现,左边最后访问的元素,它的右边会最先被访问(如果有)。左边最先被访问的元素,它的右边会最后被访问(如果有)

  • 譬如上图:

    • 访问完5之后,发现左边为空了,该访问它的右边了,它的右边为空
    • 向上访问10的右边,不为空,访问12
    • 访问12时,12的左边为空,右边也为空
    • 向上访问15的右边,不为空,访问18
    • 访问18时,18的左边为空,右边也为空
    • 向上访问20的右边,不为空,访问25
    • 访问25时,25的左边为空,右边也为空
    • 至此,所有元素访问完毕
  • 如果你感到晕,没关系,我说了这么半天,就是想让你注意到:先被访问的元素,它的右子节点反而会后被访问。后被访问的元素,它的右子节点反而会先被访问

  • 这和什么有点像来着?是不是和栈Stack的思想很像:先进后出

思路分析

  • 先准备一个栈:

image-20221124213442876

  • 访问节点的同时,将它的右子节点入栈(如果有),直至左边为空:

image-20221124214007945

  • 左边为空后,弹出栈顶元素,重复上述逻辑:

image-20221124214950054

代码实现

  • 将其思路转换为代码:
    public void preorder(Visitor<E> visitor) {
	if (visitor == null || root == null) return;

        Node<E> node = root;
        Stack<Node<E>> stack = new Stack<>(); // 准备一个栈

        while (true) {
            if (node != null) { // 如果 node 没有到最左边

                if (visitor.visit(node.element)) return; // 访问元素,若外界需要停止,直接返回即可

                if (node.right != null) { // 右子节点不为空,将其入栈
                    stack.push(node.right);
                }

                node = node.left; // 再向左走
            } else { // 处理右边
                if (stack.isEmpty()) return; // 如果 node 和 栈 都为空了,就直接返回
                node = stack.pop(); // 将栈顶元素弹出来,让 node 再次不为 null,执行上面的逻辑
            }
        }
    }

实现思路2

  • 前序遍历比较特殊,还有一种非递归的实现思路:
    • 准备一个栈,将根节点直接入栈
    • 当栈不为空时,遍历栈:
      • 取出栈顶元素,执行访问逻辑
      • 如果栈的右子节点不为空,将右子节点入栈
      • 如果栈的左子节点不为空,将左子节点入栈
  • 将其思路转换为代码:
    public void preorder(Visitor<E> visitor) {
        if (visitor == null || root == null) return;

        // 准备一个栈,并且将根节点放入其中
        Stack<Node<E>> stack = new Stack<>();
        stack.push(root);

        while (!stack.isEmpty()) {
            final Node<E> node = stack.pop(); // 弹出栈顶元素
            if (visitor.visit(node.element)) return; // 访问元素,若外界需要停止,直接返回即可

            if (node.right != null) { // 如果右子节点不为空,将其入栈
                stack.push(node.right);
            }

            if (node.left != null) { // 如果左子节点不为空,将其入栈
                stack.push(node.left);
            }
        }

    }
  • 哈哈哈,有没有感觉这样清晰很多,而且,它和之前学习的层序遍历是不是很像:

image-20221124221047288

二、中序遍历

规律探寻

  • 如果利用中序遍历,那么访问该树的顺序为:5、10、12、15、18、20、25
  • 我们来探寻一下规律:

image-20221125104946124

  • 首先也是要明确:一开始只能拿到根节点
  • 可以发现,中序遍历的顺序,也是拿到根节点,走到最左边。最先访问最左边的元素
  • 有了上面的铺垫,不难发现,这里也可以使用栈的思想来实现:先进后出

思路分析

  • 先准备一个栈:

image-20221124213442876

  • 用一个临时引用:current,当它不为空,将它入栈,然后一路向左:

image-20221125113605544

  • 到最左边了,栈顶元素不为空,弹出栈顶元素进行访问。如果它的右边不为空,将它赋值给current,重复上述逻辑。直至栈为空。current也为空

image-20221125193307999

image-20221125193525640

代码实现

  • 将其思路转换为代码:
public void inorder(Visitor<E> visitor) {
        if (visitor == null || root == null) return;

        Node<E> node = root;
        Stack<Node<E>> stack = new Stack<>(); // 准备一个栈

        while (true) {
            if (node != null) { // node 不为空
                stack.push(node); // 将自己入栈
                node = node.left; // 一直往左边走,直到左边为 null
            } else { // 处理右边
                if (stack.isEmpty()) return; // 如果 stack 也为空,直接返回
                final Node<E> pop = stack.pop(); // 弹出栈顶元素
                if (visitor.visit(pop.element)) return; // 访问元素,若外界需要停止,直接返回即可

                if (pop.right != null) { // 若栈顶元素的左子节点不为空
                   node = pop.right; // 让 node 再次不为 null,执行上面的逻辑
                }
            }
        }
    }

三、后序遍历

规律探寻

  • 如果利用后序遍历,那么访问该树的顺序为:5、12、10、18、15、25、20

  • 我们来探寻一下规律:

image-20221125194343889

  • 首先也是要明确:一开始只能拿到根节点
  • 可以发现,后序遍历的顺序,也是拿到根节点,走到最左边。最先访问最左边的元素
  • 20是最早能够拿到的元素,但是最后才能访问它。这里也可以使用栈的思想来实现:先进后出

思路分析

  • 先准备一个栈,并且直接将根节点入栈。再准备一个prev节点,用于记录前一个访问的节点

image-20221127172729475

  • 开始遍历,直至栈为空
    • 查看栈顶元素,看它是否为叶子节点或者是否是prev的父节点。(仅查看,并未出栈)
    • 如果是,将其栈顶弹出,赋值给prev节点。然后进行访问。
    • 如果不是,查看它的左和右是否为空,如果不为空,将其入栈。

image-20221127205103780

image-20221127205150437

代码实现

    public void postorder(Visitor<E> visitor) {
        if (visitor == null || root == null) return;

        Stack<Node<E>> stack = new Stack<>();
        stack.push(root); // 直接将根节点入栈
        Node<E> prev = null; // 用于记录上一个被访问的元素

        while (!stack.isEmpty()) {
            final Node<E> peek = stack.peek(); // 只是看看栈顶元素,并不是弹出

            // 如果栈顶元素是叶子节点 或 前一个访问的父节点是栈顶元素
            if (peek.isLeaf() || (prev != null && prev.parent == peek)) {
                prev = stack.pop(); // 弹出栈顶元素
                if (visitor.visit(prev.element)) return; // 访问元素,若外界需要停止,直接返回即可
            } else {
                if (peek.right != null) { // 栈顶元素的右子节点不为空,将其入栈
                    stack.push(peek.right);
                }
                if (peek.left != null) { // 栈顶元素的右子左子节点不为空,将其入栈
                    stack.push(peek.left);
                }
            }
        }
    }

四、总结

  • 其实三种遍历方式,我都说了一个需要注意的地方:最开始只能拿到根节点。所以我们只能从根节点开始,从上往下找

    "虽然不同顺序的遍历各有各的不同,但是遍历的思想都是相似的"

  • 都是准备一个栈Stack,然后先处理左边。看遍历的顺序,将它的相关节点入栈

  • 最后再处理右边,直至栈为空,退出循环

  • 当然,你也可以试试先处理右边,再处理左边~

写在后面

本篇收获

  1. 复习了二叉树的遍历
  2. 复习了栈的使用
  3. 学习了非递归遍历二叉树的方式