开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
利用BST学习二叉树的遍历
写在前面
(1)本文摘要
- 二叉树的四种遍历方式
- 二叉树遍历的常见用途
- 设计模式之访问者模式
(2)本文说明
- ❗注:文章所述遍历及其作用,不局限于二叉搜索树,只要是一棵二叉树即可
- 本文是基于上一篇文章:《如何构建一棵二叉搜索树》来说明的
一、二叉树的遍历
- 谈到遍历,应该都不陌生,简单来说就是把一个容器中的所有元素都访问一遍
- 比如我们学过的数组和链表,用它们的索引,可以
正序遍历或者逆序遍历 - 而今天要学习的二叉树,它是没有索引的。所以我们根据节点的访问顺序的不同,常见的遍历方式有:
- 前序遍历:
Preoder Traversal【根节点在最前即可】 - 中序遍历:
Inoder Traversal【根节点在中间即可】 - 后序遍历:
Postorder Traversal【根节点在后面即可】 - 层序遍历:
Level Order Traversal【从上层往下层,依层遍历】
- 前序遍历:
(1)前序遍历(Preoder Traversal)
-
前序遍历中元素的访问顺序是:
- 方式一:
根节点 -> 前序遍历左子树 -> 前序遍历右子树 - 方式二:
根节点 -> 前序遍历右子树 -> 前序遍历左子树 - 核心在于:最先访问根节点
- 方式一:
-
如上面的一棵二叉搜索树,使用前序遍历的方式,元素的访问顺序是如何的呢?
-
以方式一为例:
- 先访问根节点:
10 - 再访问根节点的左子树、那么紧接着访问的就是
10节点的左子树的根节点:5 - 依次访问完左子树后,再去访问右子树
- 先访问根节点:
-
那么最终的顺序应该是:
10、5、3、1、4、7、9、20、14、11、24
- 经过上面的思路分析,你第一反应应该会想要使用递归的方式来实现,如下代码所示:
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
-
- 细心的你可能发现了,如果使用中序遍历的方式来遍历二叉搜索树,遍历结果要么是升序、要么是降序。如果先访问左子树,将会是升序排列。如果先访问右子树,将会是降序排列
- 也使用递归的方式来实现:
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
- 以方式一为例,元素访问的最终顺序是:
- 也使用递归的方式来实现:
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
- 我们可以发现,先访问的节点,进入下一层时,它的子节点也会先被访问
- 如:访问第二层:先访问 5 后访问 20,那么进入第三层时,5 的子节点3 、7也会先被访问 ,再访问 20 的子节点 14、24
- 这样的思想,是不是和之前学习的队列,有异曲同工之妙:先进先出
- 思路也写在图中了,这时我们已经准备好一个队列了。并且将根节点
10放入队列中了,只需要循环执行如下图所示内容,直至队列为empty
- 将思路用代码实现一下:
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;
}
});
- 在外界的眼中,确实可以随时终止遍历了。那我们内部应该如何使用这个返回值呢?
- 层序遍历不涉及递归操作,我们先看看如何改造它
-
拿到
visit()方法的返回值做判断即可(❗注意我们这里的设计是,当返回 true 时,就结束遍历) -
下面的三个方法,都是使用递归来实现的
-
如何来记录需要停止操作了呢?【在
Visitor中提供一个成员变量】
- ❓看看内部的实现,除了前序遍历,为什么要进行两次判断呢?
- 因为它们的作用是不相同的
- 第①次:用于停止递归调用
- 第②次:用于判断是否还需要调用访问逻辑
三、遍历的作用【二叉树】
- 说完了如何遍历,我们来看看遍历的常见用途
1、打印出树状结构
- 下面是一个简单的打印,大致可以看清楚树的结构:
2、利用中序遍历将二叉搜索树按升序或者降序排列
- 刚刚谈到中序遍历的时候,说它的访问是有序的。也正是因为这一点,中序遍历会很常用
3、利用后序遍历,可以做一些先子后父的操作
- 因为根节点是左右子节点的父节点,而根节点是最后访问的。所以,这样的操作很适合使用中序遍历
4、利用层序遍历,可以计算二叉树的高度、判断一棵树是否为完全二叉树
计算二叉树的高度
- 递归的方式
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(左子树高度, 右子树高度) + 根节点占的高度
- 层序遍历的方式
- 核心点还是层序遍历,主要思想是:
树的高度 = 树的层数 - 也就是要根据层序遍历,得出树的层数。进而算出高度
而树的层数 = 何时进入下一层? + 进入下一层几次?- 弄清楚这两个问题,即可知道如何求树的高度。先回看一下之前的一张图片
- 何时进入下一层,不难想清楚:当某一层元素访问完时,进入下一层(如果还有)
- 那我们怎么知道,某一层的元素什么时候被访问完呢?
- 是不是可以记录每一层的元素数量,该层的元素数量为
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;
}
判断一棵树是否为完全二叉树
- 在实现练习前,我们先看看,什么样的二叉树,是完全二叉树
- 看了上面的定义,用数学排列组合的思想来看,判断一棵二叉树是否为完全二叉树,也就是需要排列出四种情况
- ①:
左子节点 != null,右子节点 != null - ②:
左子节点 != null,右子节点 == null - ③:
左子节点 == null,右子节点 != null - ④:
左子节点 == null,右子节点 == null
- ①:
- 思路也就如下、图所示
- 第①种情况:度为 2 ,直接将
左右节点都入队即可 - 第②种情况:度为 1 ,且靠左对齐。说明之后遍历到的节点,都必须是叶子节点【
节点的度 = 0】 - 第③种情况:度为 1,不满足左对齐。说明可以直接返回
false了 - 第④种情况:度为 0 ,说明之后遍历到的节点,都必须是叶子节点
- 第①种情况:度为 2 ,直接将
- ❗注:上面为了写清楚我们对应的四种情况,没有简化判断逻辑【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;
}
- 这样看代码,是不是好看多了~✌️,但核心思路还是:
四种情况 + 层序遍历
写在后面
本篇收获
- 学会前序、中序、后序、层序,这四种遍历方式
- 了解了二叉树遍历的常见用途,能够利用遍历做一些简单的练习
- 学习了设计模式之访问者模式
读后思考
- 前序、中序、后序遍历,在上面的实现中,都是通过递归来实现的,能否不使用递归实现呢?