遍历二叉树的四种方式

366 阅读10分钟

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

利用BST学习二叉树的遍历

写在前面

(1)本文摘要

  1. 二叉树的四种遍历方式
  2. 二叉树遍历的常见用途
  3. 设计模式之访问者模式

(2)本文说明

一、二叉树的遍历

  • 谈到遍历,应该都不陌生,简单来说就是把一个容器中的所有元素都访问一遍
  • 比如我们学过的数组和链表,用它们的索引,可以正序遍历或者逆序遍历
  • 而今天要学习的二叉树,它是没有索引的。所以我们根据节点的访问顺序的不同,常见的遍历方式有:
    • 前序遍历:Preoder Traversal【根节点在最前即可】
    • 中序遍历:Inoder Traversal【根节点在中间即可】
    • 后序遍历:Postorder Traversal【根节点在后面即可】
    • 层序遍历:Level Order Traversal【从上层往下层,依层遍历】

image-20221026144315525

(1)前序遍历(Preoder Traversal)

  • 前序遍历中元素的访问顺序是:

    • 方式一:根节点 -> 前序遍历左子树 -> 前序遍历右子树
    • 方式二:根节点 -> 前序遍历右子树 -> 前序遍历左子树
    • 核心在于:最先访问根节点
  • 如上面的一棵二叉搜索树,使用前序遍历的方式,元素的访问顺序是如何的呢?

  • 以方式一为例:

    • 先访问根节点:10
    • 再访问根节点的左子树、那么紧接着访问的就是10节点的左子树的根节点: 5
    • 依次访问完左子树后,再去访问右子树
  • 那么最终的顺序应该是:10、5、3、1、4、7、9、20、14、11、24

image-20221026144055898

  • 经过上面的思路分析,你第一反应应该会想要使用递归的方式来实现,如下代码所示:
    private void preorderTraversal(Node<E> node) {
        if (node == null) return;
        // 先访问根节点
        System.out.println(node.element);
        // 再使用递归方式,前序遍历左子树
        preorderTraversal(node.left);
        // 最后使用递归方式前序遍历右子树
        preorderTraversal(node.right);
    }

(2)中序遍历(Inoder Traversal)

  • 中序遍历中元素的访问顺序是:
    • 方式一:中序遍历左子树 -> 根节点 -> 中序遍历右子树
    • 方式二:中序遍历右子树 -> 根节点 -> 中序遍历左子树
    • 核心在于:根节点放在中间访问
  • 如上面的一棵二叉搜索树:
    • 使用方式一,元素访问的最终顺序是:1、3、4、5、7、9、10、11、14、20、24

    • 使用方式二,元素访问的最终顺序是:24、20、14、11、10、9、7、5、4、3、1

image-20221026145748178

  • 细心的你可能发现了,如果使用中序遍历的方式来遍历二叉搜索树,遍历结果要么是升序、要么是降序。如果先访问左子树,将会是升序排列。如果先访问右子树,将会是降序排列
  • 也使用递归的方式来实现:
    private void inorderTraversal(Node<E> node) {
        if (node == null) return;
        // 先使用递归方式,中序遍历左子树
        inorderTraversal(node.left);
        // 再访问根节点
        System.out.println(node.element);
        // 最后使用递归方式,中序遍历右子树
        inorderTraversal(node.right);
    }

(3)后序遍历(Postorder Traversal)

  • 后序遍历中元素的访问顺序是:
    • 方式一:后序遍历左子树 -> 后序遍历右子树 -> 根节点
    • 方式二:后序遍历右子树 -> 后序遍历左子树 -> 根节点
  • 如上面的一棵二叉搜索树:
    • 以方式一为例,元素访问的最终顺序是:1、4、3、9、7、5、11、14、24、20、10

image-20221026151355319

  • 也使用递归的方式来实现:
private void postorderTraversal(Node<E> node) {
    if (node == null) return;
    // 先使用递归的方式,后续遍历左子树
    postorderTraversal(node.left);
    // 再使用递归的方式,后续遍历右子树
    postorderTraversal(node.right);
    // 最后访问根节点
    System.out.println(node.element);
}

(4)层序遍历(Level Order Traversal)

  • 层序遍历的元素访问顺序是按层依次访问
  • 就不同于上面的前、中、后序遍历的思想了,那该如何访问呢?
  • 我们也先来看看使用层序遍历,元素最终的访问顺序:10、5、20、3、7、14、24、1、4、9、11

image-20221026152503474

  • 我们可以发现,访问的节点,进入下一层时,它的子节点也会被访问
    • 如:访问第二层:先访问 5 后访问 20,那么进入第三层时,5 的子节点3 、7也会先被访问 ,再访问 20 的子节点 14、24
  • 这样的思想,是不是和之前学习的队列,有异曲同工之妙:先进先出

image-20221026154955439

  • 思路也写在图中了,这时我们已经准备好一个队列了。并且将根节点10放入队列中了,只需要循环执行如下图所示内容,直至队列为empty

image-20221026155256956

  • 将思路用代码实现一下:
public void levelOrderTraversal() {
        if (root == null) return;

        // 准备队列,并且将根节点入队
        Queue<Node<E>> queue = new LinkedList<>();
        queue.offer(root);

        while (!queue.isEmpty()) {
            // 将队头元素出队
            Node<E> node = queue.poll();

            // 访问队头元素
            System.out.println(node.element);

            // 如果出队节点的左子节点不为空,将其入队
            if (node.left != null) {
                queue.offer(node.left);
            }
            // 如果出队节点的右子节点不为空,将其入队
            if (node.right != null) {
                queue.offer(node.right);
            }
        }
    }
  • 四种不同遍历顺序的二叉树遍历方式,都已经讨论完成了
  • ❓可是,真的完成了吗?万一我不仅仅想打印元素呢?我想要自定义访问逻辑,又该怎么办呢?

二、改造成访问者模式

(1)基础访问器

  • 我们原先的访问逻辑,都是写在遍历逻辑的内部。而且都是最简单的打印操作
  • 和前面写的比较器类似的想法。我们能不能做到,每一次遍历,可以自定义访问逻辑,让其更加的灵活呢?
    /**
     * 用于访问内部某些细节
     */
    public interface Visitor<E> {
        /**
         * 访问时,执行的操作
         * @param element:被访问元素
         */
        void visit(E element);
    }
  • 先定义一个访问者的接口,❗注意:让其访问元素,而不是节点Node,因为节点对外是不可见的
  • 在需要遍历的时候,让对方传入一个访问者接口Visitor,并且实现visit()方法,告诉遍历时,如何访问元素
// 层序遍历
public void levelOrder(Visitor<E> visitor) {}
// 前序遍历
public void preorder(Visitor<E> visitor) { }
// 中序遍历
public void inorder(Visitor<E> visitor) { }
// 后序遍历
public void postorder(Visitor<E> visitor) { }
  • 那我们的递归函数也需要改造了
  • 下面以中序遍历为例,其余的类似
    public void inorder(Visitor<E> visitor) {
        if (visitor == null) return;
        inorder(root, visitor);
    }

    private void inorder(Node<E> node, Visitor<E> visitor) {
        if (node == null) return;
        inorder(node.left, visitor);
        // 访问根节点
        visitor.visit(node.element);
        inorder(node.right, visitor);
    }
  • 这样就完成了吗?我们看看现在外界是如何使用的:
bst.inorder(new BSTImpl.Visitor<>() {
    @Override
    public boolean visit(Integer element) {
        System.out.print((element + 5) + " ");
    }
});
  • 可以看到,上面的需求确实完成了:可以动态传入访问逻辑。
  • ❓那如果有一万个元素,可是我们仅仅想要遍历到第十个元素呢?

(2)增强访问器

  • 在外界使用者的眼中,可能遍历到某种程度,就不想遍历了。可是我们的操作,就是将其全部遍历
  • 所以,我们可以做以下的增强:让外界可以控制遍历的停止时机
    public static abstract class Visitor<E> {

        /**
         * 用于记录是否需要停止访问
         */
        boolean stop;

        /**
         * 访问时,执行的操作
         * @param element:被访问元素
         * @return :返回 true 就不继续访问了
         */
        protected abstract boolean visit(E element);
    }
  • 将接口变成抽象类【因为接口不可以放置成员变量】

  • 增添成员变量stop,用于记录每一次遍历时,下一次是否需要停止访问

  • 将访问方法visit()增添bolean类型的返回值,返回 true 就不访问了【因为实现此方法时,默认的返回值就是false(当然,看你自己的设计)】

  • 通过上面的改造后,外界就可以这样使用了:

        // 使用前序遍历
	bst.preorder(new BinarySearchTreeImpl.Visitor<Integer>() {
            @Override
            protected boolean visit(Integer element) {
                System.out.print(element + " ");
                // 默认情况,需要全部遍历
                return false;
            }
        });
	// 使用层序遍历
        bst.levelOrder(new BinarySearchTreeImpl.Visitor<>() {
            @Override
            public boolean visit(Integer element) {
                System.out.print(element + " ");
                // 当遍历到元素为 7 时,停止遍历
                return element == 7;
            }
        });
  • 在外界的眼中,确实可以随时终止遍历了。那我们内部应该如何使用这个返回值呢?
  • 层序遍历不涉及递归操作,我们先看看如何改造它

image-20221026201935506

  • 拿到visit()方法的返回值做判断即可(❗注意我们这里的设计是,当返回 true 时,就结束遍历)

  • 下面的三个方法,都是使用递归来实现的

  • 如何来记录需要停止操作了呢?【在Visitor中提供一个成员变量】

image-20221026201555542

  • ❓看看内部的实现,除了前序遍历,为什么要进行两次判断呢?
  • 因为它们的作用是不相同的
    • 第①次:用于停止递归调用
    • 第②次:用于判断是否还需要调用访问逻辑

三、遍历的作用【二叉树】

  • 说完了如何遍历,我们来看看遍历的常见用途
1、打印出树状结构
  • 下面是一个简单的打印,大致可以看清楚树的结构:

image-20221026210723860

2、利用中序遍历将二叉搜索树按升序或者降序排列
  • 刚刚谈到中序遍历的时候,说它的访问是有序的。也正是因为这一点,中序遍历会很常用
3、利用后序遍历,可以做一些先子后父的操作
  • 因为根节点是左右子节点的父节点,而根节点是最后访问的。所以,这样的操作很适合使用中序遍历
4、利用层序遍历,可以计算二叉树的高度、判断一棵树是否为完全二叉树
计算二叉树的高度
  1. 递归的方式
    public int height() {
        return height(root);
    }

    /**
     * 计算树的高度【递归法】
     */
    private int height(Node<E> node) {
        if (node == null) return 0;
        return 1 + Math.max(height(node.left), height(node.right));
    }
  • 递归的思路很简单:树的高度 = Math.max(左子树高度, 右子树高度) + 根节点占的高度
  1. 层序遍历的方式
  • 核心点还是层序遍历,主要思想是:树的高度 = 树的层数
  • 也就是要根据层序遍历,得出树的层数。进而算出高度
  • 而树的层数 = 何时进入下一层? + 进入下一层几次?
  • 弄清楚这两个问题,即可知道如何求树的高度。先回看一下之前的一张图片

image-20221027082727634

  • 何时进入下一层,不难想清楚:当某一层元素访问完时,进入下一层(如果还有)
  • 那我们怎么知道,某一层的元素什么时候被访问完呢?
  • 是不是可以记录每一层的元素数量,该层的元素数量为0时,这一层就访问完了。可是如何记录呢?
  • 看看图中每一层已访问的元素,每层都访问完成时,看看队列里现有的元素个数,是不是就是下一层元素个数的总数呢。所以,需要将其记录下来
  • 搞清楚了何时进入下一层遍历。那么是不是进入下层遍历多少次,树的高度就是多少啊
  • 代码实现如下:
    private int heightLevelOrder() {
        if (root == null) return 0;

        Queue<Node<E>> queue = new LinkedList<>();
        queue.offer(root);
        
        // 最终返回的树的高度
        int height = 0;
        // 每一层的元素个数
        int levelSize = 1;

        while (!queue.isEmpty()) {
            Node<E> node = queue.poll();
            // 出队一个元素,每层的元素个数就 -1
            levelSize--;
            if (node.left != null) {
                queue.offer(node.left);
            }

            if (node.right != null) {
                queue.offer(node.right);F
            }
            
            // 当每层元素个数为 0 时,说明要开始遍历下一层了【如果还有的话】,有几层,height就等于多少
            if (levelSize == 0) {
                levelSize = queue.size();
                height++;
            }

        }
        return height;
    }
判断一棵树是否为完全二叉树
  • 在实现练习前,我们先看看,什么样的二叉树,是完全二叉树

image-20221027092459169

  • 看了上面的定义,用数学排列组合的思想来看,判断一棵二叉树是否为完全二叉树,也就是需要排列出四种情况
    • ①:左子节点 != null,右子节点 != null
    • ②:左子节点 != null,右子节点 == null
    • ③:左子节点 == null,右子节点 != null
    • ④:左子节点 == null,右子节点 == null
  • 思路也就如下、图所示
    • 第①种情况:度为 2 ,直接将左右节点都入队即可
    • 第②种情况:度为 1 ,且靠左对齐。说明之后遍历到的节点,都必须是叶子节点节点的度 = 0
    • 第③种情况:度为 1,不满足左对齐。说明可以直接返回 false
    • 第④种情况:度为 0 ,说明之后遍历到的节点,都必须是叶子节点

image-20221027094843698

  • :上面为了写清楚我们对应的四种情况,没有简化判断逻辑【IDEA都看不下去了~】
  • 对应着我们上面的四种排列组合。确实很清晰,也利用了层序遍历的思想,但是代码也太难看了吧🥶而且有很多重复判断
  • 改造代码:
public boolean isComplete() {
        if (root == null) return false;
        Queue<Node<E>> queue = new LinkedList<>();
        queue.offer(root);
        // 记录是否该出现叶子节点的情况:②、④
        boolean leaf = false;
        while (!queue.isEmpty()) {
            Node<E> node = queue.poll();

            // 本该是叶子节点,但不是叶子节点
            if (leaf && !node.isLeaf()) return false;
            
            if (node.left != null) { // 保证左子节点不为空时,将其放入队列
                queue.offer(node.left);
            } else if (node.right != null) { // 说明是 情况 ③
                return false;
            }

            if (node.right != null) { // 保证右子节点不为空时,将其放入队列
                queue.offer(node.right);
            } else { // 说明是情况 ②、④
                leaf = true;
            }
        }
        return true;
    }
  • 这样看代码,是不是好看多了~✌️,但核心思路还是:四种情况 + 层序遍历

写在后面

本篇收获

  1. 学会前序、中序、后序、层序,这四种遍历方式
  2. 了解了二叉树遍历的常见用途,能够利用遍历做一些简单的练习
  3. 学习了设计模式之访问者模式

读后思考

  • 前序、中序、后序遍历,在上面的实现中,都是通过递归来实现的,能否不使用递归实现呢?