深入理解二叉树(超详细)

二叉树(Binary Tree)

回顾

在前面的文章 — 二叉树前奏中,我们对于二叉树的一些基本概念进行了回顾,同时对比了线性结构与树形结构,总结了一些常见的二叉树的性质,像二叉树,真二叉树,完全二叉树,以及满二叉树等等,但是,我们仅仅是在概念上对于二叉树有所了解,并没有进行编码工作,今天来完善一下这一步的操作

直接进入二叉树的设计与编码,如果你对于二叉树的概念以及性质不了解的话,可以回去翻翻 二叉树前奏,熟悉一下,因为编码实际上就是对于二叉树性质的一个体现

设计

属性与节点

首先,我们的二叉树是用来存放元素的,同时它还需要知道自己的父节点与子节点的关系,那么,很容易想到的是使用节点类,那么二叉树的节点类该如如设计呢,同时我的二叉树类该有哪些基本元素呢?

首先,我们需要知道二叉树的节点数量,同时,对于树而言,要有根,我们需要根节点,那么可以确定的是有:

//树节点的数量
protected int size;

//树的根结点
protected Node<E> root;

这里我们先不说访问修饰符为什么是protected,先来说一说节点类该怎么设计,我们需要知道一个节点的父节点,以及左右子节点的关系,同时还有节点中存储的元素element,那么我们的节点应该是这样:

/**
 * 自定义节点类,用于维护二叉树
 * @param <E>
 */
protected static class Node<E>{
    E element;
    Node<E> left;
    Node<E> right;
    Node<E> parent;

    /**
     * 构造函数,添加节点时,要指定元素
     * 父节点的,但不一定有左右子节点
     * @param element
     * @param parent
     */
    public Node(E element,Node<E> parent){
        this.element = element;
        this.parent = parent;
    }

    /**
     * 判断是否为叶子节点
     * @return
     */
    public boolean isLeaf() {
        return left == null && right == null;
    }

    /**
     * 判断是否左右子节点都具备
     * @return
     */
    public boolean hasTwoChildren() {
        return left != null && right != null;
    }
}

该节点类,提供了一个构造函数以及两个特有的方法,对于构造函数而言,初始化的时候,我们需要指定节点存储的元素以及父节点,为什么左右子节点不初始化呢,因为你添加一个节点时,是一定要知道其父节点的,但是你并不知道它有没有子节点。

对于另外两个方法,我觉的这是节点类所特有的行为,因为节点的概念在二叉树中是通用的,所以直接封装在节点类中,而不是在后面,对于每一种独特的二叉树,需要用到判断叶子节点以及度为 2 的节点的时候,再去编写,那样就太繁琐了

针对前面说到的size,root,以及节点类,这些应该是二叉树的内部逻辑,对外是不公开的,外界不知道节点类的,它只需要指定节点,也就是二叉树存储的类型就可以了,但是我们需要对外界开放接口,通过接口来使用二叉树,也就是说你只要知道怎么用就好了,不需要知道我是怎么实现的。

公共接口

对于二叉树,我们提供给外界的方法应该有以下方法:

public int size() —— 获取元素节点的数量

public boolean isEmpty() —— 判断树是否为空树

public void clear() —— 清空树的所有元素

public void preorder() —— 前序遍历

public void inorder() —— 中序遍历

public void postorder() —— 后序遍历

public void levelOrder() —— 层序遍历

public int height() —— 计算树的高度

public boolean isComplete() —— 判断是否为完全二叉树

public boolean isProper () —— 判断是否为真二叉树

public String toString() —— 重新toString方法,树状打印二叉树

我们对外界提供的方法大致就是这些了,那么问题来了,我们可以有疑惑了,既然二叉树是用来存放元素的,为什么没有addremove添加以及移除节点的方法的呢,那么这样的一棵树new出来之后就是一棵空树呀,是不是忘记了?很明确说明,没有忘了,就是不给提供添加、移除的方法。

我们来思考一下,有下面这么一段代码:

BinaryTree<Integer> bTree = new BinaryTree<>();
bTree.add(9);
bTree.add(5);
bTree.add(8);

可以明确的是第一个添加的元素9就是根节点,那么问题来了,接下来的 5,是要作为 9 的左子节点还是右子节点,8 应该是 5 的兄弟节点还是左右子节点其中一个,是的,我们并没有明确二叉树的添加规则,写起来是很麻烦也是没有意义的,当然,我们也可以默认一致往左或者往右添加,但是这样没有多大意义,没有明确的规则,那就是普通的二叉树,是没有什么用处的,规则就是树的特性,像二叉搜索树,红黑树,AV树等等,都是有明确的规则的。实际上,我们是在普通的二叉树加一些自定义的逻辑和规则,所以这里的二叉树类BinaryTree实际上应该是基类,而添加以及移除等特有的规则,应该在继承普通二叉树的基础上编写的,而二叉树提供的就是一些通用的方法。这也是前面将BinaryTree的类的属性的访问修饰符设计为protected的原因

简单方法

public int size() —— 获取元素节点的数量

/**
 * 获取元素节点的数量
 * @return
 */
public int size() {
    return size;
}

public boolean isEmpty() —— 判断树是否为空树

/**
 * 判断树是否为空树
 * @return
 */
public boolean isEmpty() {
    return size == 0;
}

public void clear() —— 清空树的所有元素

/**
 * 清空树的所有元素
 */
public void clear() {
    root = null;
    size = 0;
}

简单的方法直接放出来,直接瞄一眼即可

有趣的遍历

对于数组,链表等数据结构,我们都能遍历,获取到所有元素,线性数据结构的遍历比较简单 — 正序遍历与逆序遍历,对于我们的二叉树,同样也应该提供遍历的方法

根据节点访问顺序的不同,二叉树的常见遍历方式有4种常见的遍历方式(Preorder Traversal):

  • 前序遍历(Preorder Traversal)
  • 中序遍历(Inorder Traversal)
  • 后序遍历(Postorder Traversal)
  • 层序遍历(Level Order Traversal)

我们以二叉搜索树 —— {7,4,9,2,5,8,11,3,12,1}为例,分别分析这四种遍历方式

前序遍历

访问顺序:根节点、前序遍历左子树、前序遍历右子树(根节点访问在前)

在这里插入图片描述

遍历结果是:7、4、2、1、3、5、9、8、11、10、12

前序遍历、中序遍历、后序遍历用递归遍历的方式实现都很简单,这里就不放出来占篇幅了,但是有提供,如果想看的话,后面会将完整代码上传Github,需要再去下载下来即可,下载的时候,注意选择dev分支,那边才是最新的代码

实现步骤:

  • 利用栈先进后出的特性:
  • 设置node = root,将root入栈,循环执行以下操作,直到栈为空
  • 弹出栈顶节点top,进行访问
  • top.right入栈将top.left入栈
/**
 * 迭代法实现 —— 前序遍历
 */
private void preorderByIteration(){
    if (root == null) return;

    Stack<Node<E>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        Node<E> node = stack.pop();
        
		System.out.print(node.element + "  ");
        if (node.right != null){
            stack.push(node.right);
        }

        if (node.left != null){
            stack.push(node.left);
        }
    }
}

中序遍历

访问顺序: — (根节点访问在中

1、中序遍历左子树、根节点、中序遍历右子树 (如果是二叉搜索树,结果升序)

2、中序遍历右子树、根节点、中序遍历左子树 (如果是二叉搜索树,结果降序)

在这里插入图片描述

遍历结果是:1、2、3、4、5、7、8、9、10、11、12 (升序)

实现步骤:

  • 利用栈先进后出的特性:
  • 设置node = root,将root入栈,循环执行以下操作,直到栈为空
  • 如果node!= nullnode入栈,设置node = node.left
  • 如果node == null如果栈为空,结束遍历,如果栈不为空,弹出栈顶元素并赋值给node,对node进行访问
  • 设置node = node.right
/**
 * 迭代法实现 —— 中序遍历
 */
private void inorderByIteration(){
    if (root == null) return;
    Stack<Node<E>> stack = new Stack<>();
    Node<E> node = root;
    
    while (node != null || !stack.isEmpty()) {

        while (node != null){
            stack.push(node);
            node = node.left;
        }
        node = stack.pop();
        System.out.print(node.element + "  ");
        node = node.right;
    }
}

后序遍历

访问顺序:后序遍历左子树、后序遍历右子树、根节点 — (根节点访问在后

在这里插入图片描述
遍历结果是:1、3、2、5、4、8、10、12、11、9、7

实现步骤:

  • 利用栈先进后出的特性:
  • 设置node = root,将root入栈,循环执行以下操作,直到栈为空
  • 如果栈顶节点是叶子节点或者上一次访问的节点是栈顶节点的子节点,弹出栈顶节点,进行访问
  • 否则,将栈顶节点的rightleft按顺序入栈
/**
 * 迭代法实现 —— 后序遍历
 */
private void postorderByIteration(){
    if (root == null) return;

    Node<E> node = root;
    //记录上一次访问的节点
    Node<E> lastVisited = null;
    Stack<Node<E>> stack = new Stack<>();
    while (node != null || !stack.isEmpty()) {

        while (node != null){
            stack.push(node);
            node = node.left;
        }

        node = stack.pop();
        //栈顶节点是叶子节点或者上一次访问的节点是栈顶节点的子节点
        if (node.right == null || node.right == lastVisited){
            System.out.print(node.element + "  ");
            lastVisited = node;
            //这里node没有改变指向,所以需要指向null,否则会死循环
            node = null;
        }else {
            //既不是子节点且上一次访问的节点又不是栈顶节点的子节点话,代表是符节点,重新进栈
            stack.push(node);
            node = node.right;
        }
    }
}

层序遍历

访问顺序:从上到下、从左到右依次访问每一个节点

在这里插入图片描述

遍历结果是:7、4、9、2、5、8、11、1、3、10、12

层序遍历采用迭代的方式实现,利用队列的先进先出性质,能很好的做到层序遍历

实现步骤:

  • 利用队列先进先出的特性:
  • 将根节点root入队,循环执行以下操作,直到队列为空
  • 将队头节点node出队,进行访问,将node的左子节点入队,将node的右子节点入队

画一波图解:

在这里插入图片描述

结合图解,看代码,很清晰

/**
 * 层序遍历,迭代方式
 */
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.print(node.element + "  ");

        //如果有左子节点,入队
        if (node.left != null){
            queue.offer(node.left);
        }
        //如果有右子节点,入队
        if (node.right != null){
            queue.offer(node.right);
        }
    }
}

补充

如果对于二叉树的四种遍历方式还是比较迷惑的话,我只是画了静态图的顺序,可能阅读起来理解不够到位,但是有时候画图解的时间很长,所以如果看不太明白的话,可以先看看别人的文章,图解二叉树的遍历,看一下图解,再回来阅读,相信会好一些

增强遍历接口

对比: 上面四种遍历的方法都编写出来了,但是你觉得这样的遍历,功能够吗? 或者说,你觉得这个对比我们前面在动态数组,链表、栈和队列中的遍历有什么区别?没有阅读过动手编写 —— 动态数组链表栈和队列的同学,有兴趣的可以点击关键词看看

我们简单写一下JDK数组或者动态链表遍历的代码:

//遍历数组
int[] arr = {1,2,3,4,5,6,7,8,9};
for (int value:arr) {
    System.out.println(value);
}

//遍历链表
List<Integer> list = new LinkedList<>(){{
    add(1);add(2);add(3);add(4);add(5);
}};
for (int value:list) {
    System.out.println(value);
}

这样看起来好像没有什么问题,二叉树遍历是System.out.print(node.element);

而数组和链表是System.out.println(value); 都是打印呀,能有什么区别呀。可能上面的代码具备迷惑性,我们再来看看另一个代码:

int[] arr = {1,2,3,4,5,6,7,8,9};
for (int value:arr) {
    System.out.println(value + "-->" + "Kalton是帅哥");
}

嗨,这样就醒目点了,数组可以打印出节点存储的元素的同时,补上一句Kalton是帅哥,而上面二叉树的遍历却是做不到的,因为一个是写死在类里面的,一个是在类外部编写的。

这样的区别就是,二叉树的遍历只是打印一遍元素,并没有真正获取到存在在二叉树的元素,而数组、链表的遍历是获取到每一个元素,至于做什么,打印还是增加,还是说Kalton是帅哥,这些遍历规则都是由调用者自定义的,而不是写死了,所以我们的二叉树内部应该做到能够遍历的同时将节点元素传给调用者,由用户自定义遍历规则,我们的做法时,在二叉树编写一个抽象内部类Visitor

/**
 * 提供外部使用的遍历器接口
 * @param <E>
 */
public abstract static class Visitor<E>{

    //遍历停止遍历的标记
    boolean stop;

    /**
     * visit方法将节点元素传给调用者
     * @param element
     * @return 如果返回true,结束遍历
     */
    abstract boolean visit(E element);
}

visit方法方法参数为E element,在遍历的时候接收节点元素,传给外部调用者,返回值如果是true,表示用户希望结束遍历,我们以前序遍历为例,实现我们的逻辑:

/**
 * 迭代法实现 —— 前序遍历
 * @param visitor
 */
public void preorderByIteration(Visitor<E> visitor){
    if (root == null || visitor == null) return;

    Stack<Node<E>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        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);
        }
    }
}

实际上,就是用户在调用前序遍历preorderByIteration,需要传入自定义的遍历规则类Visitor,然后在我们原来的方法打印元素的地方改为if (visitor.visit(node.element)) return;,即将节点元素返回给方法调用者,使用调用的遍历规则,同时返回给二叉树类一个boolean变量值,用stop接收,告知是否结束遍历,所以什么时候结束遍历也是有用户自定义规则的

但是有一点不好的是,这样我们调用二叉树的遍历方法时,需要强制传入一个遍历规则类,同时我们遍历的方法是递归还是迭代都对调用者暴露了,所以我做了一下小小的封装:

/**
 * 前序遍历 —— 如果用户没有传遍历规则,默认打印元素
 */
public void preorder(){
    preorder(new Visitor<>() {
        @Override
        boolean visit(E element) {
            System.out.print(element + " ");
            return false;
        }
    });
}

/**
 * 增强前序遍历,提供调用者编写自己的遍历规则
 * @param visitor
 */
public void preorder(Visitor<E> visitor) {
    if (visitor == null) return;

    /**
     * 底层使用递归法
     */
    //preorderByRecursive(root, visitor);

    /**
     * 底层使用迭代法
     */
    preorderByIteration(visitor);
    System.out.println();
}

public void preorder()方法不需要传参,默认遍历规则是打印节点元素,而用户需要自定义比较规则则调用public void preorder(Visitor<E> visitor),传入比较器,至于是使用preorderByRecursive递归还是preorderByIteration,用户并不知道,由我们在设计时决定,其他 3 种遍历也是一样的逻辑,代码比较长,不必要的展示,我就没有贴出来,会在后面给出GitHub地址,需要的话,大家自行下载阅读

树的判定

二叉树前奏中,我们已经讲了完全二叉树真二叉树的特点以及性质,这里就不再复述了,实际上,对于他们的判定以及计算树的高度,都是以层序遍历方法为基础,所完成的,所以层序遍历是很重要的,最好是给我收手写出来

完全二叉树的判定

实现步骤:

在这里插入图片描述

/**
 * 判断是否为完全二叉树 —— (层序遍历)
 * @return
 */
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) {
            //相当于node.left == null && node.right != null
            return false;
        }

        if (node.right != null) {
            queue.offer(node.right);
        } else {
            //node.left == null && node.right == null
            //node.left != null && node.right == null
            // 后面遍历的节点都必须是叶子节点
            leaf = true;
        }
    }
    return true;
}

真二叉树的判定

实现步骤:

1、利用队列先进先出的特性

2、利用真二叉树的节点,要么度为0,要么度为2的特点

3、在层序遍历的时候,判断每层的节点数量,如果levelSize % 2 != 0,返回flase

4、结合上面层序遍历的图解,就会发现,每一层的节点遍历完后,队列中的节点数量size等于下一层的节点数量,而第一层只有根节点

/**
 * 判断是否为真二叉树 —— (层序遍历)
 * @return
 */
public boolean isProper (){
    if (root == null) return false;

    // 存储着每一层的元素数量
    int levelSize = 1;
    Queue<Node<E>> queue = new LinkedList<>();
    queue.offer(root);

    while (!queue.isEmpty()) {
        Node<E> node = queue.poll();
        levelSize--;

        if (node.left != null) {
            queue.offer(node.left);
        }

        if (node.right != null) {
            queue.offer(node.right);
        }

        // 意味着即将要访问下一层
        if (levelSize == 0) {
            //每一层访问完后,下一层的节点个数是队列的size
            levelSize = queue.size();
            if (levelSize % 2 != 0) return false;
        }
    }
    return true;
}

树的高度

树的高度实际上就是树的层数,与上面的判定真二叉树很接近,只需要在设置一个height,在遍历完每一层的时候,height++,结束遍历后,返回的就是树的高度

树的高度实际上所有子节点的高度中最大的一个,然后再 + 1,这样的思路,很容易以递归的方式实现,所以计算树的高度,有递归和迭代两种方法,这里贴出递归的方法,因为迭代的方法就是上面判定二叉树的方法做点小改动,就不贴出来了,需要的话,自行下载源码。

/**
 * 计算树的高度
 * @return
 */
public int height(){
    //递归法
    return heightByRecursive(root);

    //迭代法
    //return heightByIteration();
}

/**
 * (递归法)获取传入节点的高度
 * @param node
 * @return
 */
private int heightByRecursive(Node<E> node){
    if (node == null) return 0;
    return 1 + Math.max(heightByRecursive(node.left),heightByRecursive(node.right));
}

前驱与后继

寻找前驱节点

在这里插入图片描述

根据上面的判定条件给出实现代码:

/**
 * 获取传入节点的前驱节点
 * @param node
 * @return
 */
protected Node<E> predecessor(Node<E> node) {
    if (node == null) return null;

    // 前驱节点在左子树当中(left.right.right.right....)
    Node<E> p = node.left;
    if (p != null) {
        while (p.right != null) {
            p = p.right;
        }
        return p;
    }

    // 从父节点、祖父节点中寻找前驱节点
    while (node.parent != null && node == node.parent.left) {
        node = node.parent;
    }

    // node.parent == null
    // node == node.parent.right
    return node.parent;
}

寻找后继节点

在这里插入图片描述

根据上面的判定条件给出实现代码:

/**
 * 获取传入节点的后继节点
 * @param node
 * @return
 */
protected Node<E> successor(Node<E> node) {
    if (node == null) return null;

    // 前驱节点在左子树当中(right.left.left.left....)
    Node<E> p = node.right;
    if (p != null) {
        while (p.left != null) {
            p = p.left;
        }
        return p;
    }

    // 从父节点、祖父节点中寻找前驱节点
    while (node.parent != null && node == node.parent.right) {
        node = node.parent;
    }

    return node.parent;
}

现在我们可能会比较疑惑,这两个方法,找出前驱或者后继节点有什么用,不知道你有没有看到方法的访问修饰符:protected,实际上着两个方法都不是给用户调用的,正如我们的疑惑一样,用户不知道怎么用,用来干嘛,实际上这是为继承二叉树BinaryTree类的子类所使用的,其作用是在删除度为 2 的时,将找到的前驱或者后继节点用来替代的,这在下一篇,二叉搜索树的时候回说到。

小结

​ 到这里为止,已经将二叉树的基本概念复习以及通用方法的设计编写,对于二叉树的结构有了一定的认识,但知识有时候总是在你觉得记住的时候偷偷溜走,因此,将所学的知识总结成笔记,以便后来翻阅,加深印象

声明

文章为原创,欢迎转载,注明出处即可

个人能力有限,有不正确的地方,还请指正

本文的代码已上传github,欢迎star —— GitHub地址

posted @ 2020-09-18 10:54  衍方  阅读(2816)  评论(0编辑  收藏  举报