详细解读二叉树 以及 用Java实现二叉树的基本操作

348 阅读9分钟

1. 树

(ps:写完这篇文章后我才发现,文章中有的地方写成了“节点”,有的地方写成了“结点”,在这里先给大家说明一下,本篇这两个名词都是指二叉树的 “Node”)

1.1 树的概念

  树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。树形结构中,子树之间不能有交集,否则就不是树形结构。

  1. 每个节点都有一个唯一的标识符(节点键值)。
  2. 如果节点A是节点B的子节点,则节点B是节点A的父节点,并且它们之间有一条有向边(B -> A)。
  3. 除了根节点,每个节点都有且只有一个父节点。
二叉树.drawio.png

1.2 关于树的一些名词

二叉树1.5.drawio.png
  1. 结点的度: 一个结点含有子树的个数称为该结点的度; 如上图:A的度为3B的度为2

  2. 树的度: 一棵树中,所有结点度的最大值称为树的度; 如上图:树的度为3

  3. 叶子结点或终端结点: 度为0的结点称为叶结点; 如上图:E、F、G、H、D节点为叶结点。

  4. 双亲结点或父结点: 若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:AB的父结点。

  5. 孩子结点或子结点: 一个结点含有的子树的根结点称为该结点的子结点; 如上图:BA的孩子结点。

  6. 根结点: 一棵树中,没有双亲结点的结点;如上图:A是根节点。

  7. 结点的层次: 从根开始定义起,根为第1层,根的子结点为第2层,以此类推。

  8. 树的高度或深度: 树中结点的最大层次; 如上图:树的高度为3

  9. 兄弟结点: 具有相同父结点的结点互称为兄弟结点; 如上图:BC是兄弟结点。

  10. 结点的祖先: 从根到该结点所经分支上的所有结点;如上图:A是所有结点的祖先。

  11. 子孙: 以某结点为根的子树中任一结点都称为该结点的子孙。如上图:所有结点都是A的子孙。

2. 二叉树

2.1 什么是二叉树?

  一棵二叉树是结点的一个有限集合,它有以下的性质:

  1. 每个节点最多有两个子节点;
  2. 左子节点和右子节点在二叉树中的位置是固定的,左子节点位于其父节点的左侧,右子节点位于其父节点的右侧;
  3. 二叉树可以为空,即不含任何节点,也可以只有一个结点;
  4. 二叉树可以是斜的,即所有节点都只有左子节点或右子节点;
  5. 对于任何一个节点,其左子树和右子树都是二叉树,且它们之间是互不重叠的。

二叉树-第 2 页.drawio.png

2.2 两种特殊的二叉树

  1.满二叉树: 一棵二叉树,如果每层的结点数都达到最大值,则这棵二叉树就是满二叉树。如果一棵二叉树的层数为K,且结点总数是 2k12^k-1 ,则它就是满二叉树。

二叉树-第 3 页.drawio.png

  2.完全二叉树: 完全二叉树是一种特殊的二叉树,其中除了最后一层外,每一层的节点数都达到最大,并且最后一层的节点都靠左排列。也就是说,如果最后一层的节点没有填满,那么这些节点只能出现在最后一层的最右侧。

二叉树-第 4 页.drawio.png

2.3 二叉树的性质(重要)

  二叉树的性质是非常重要的,这为后面编程打下铺垫,二叉树有以下性质:

二叉树-第 7 页.drawio.png
  1. 若规定根结点的层数为1,则一棵非空二叉树的第i层上最多有2i1(i>0) 2^{i-1} (i > 0)个结点
  2. 若规定根结点的二叉树的深度为1,则深度为K的二叉树的最大结点数是 2k1(k>=0)2^k-1 (k>=0)
  3. 对任何一棵二叉树, 如果其叶结点个数为 n0, 度为2的非叶结点个数为 n2则有 n0=n21n_0 = n_2+1

针对完全二叉树:

  1. 具有n个结点的完全二叉树的深度k为: log2(n+1)log_2(n+1) 向上取整。
  2. 对于具有n个结点的完全二叉树,如果按照从上至下、从左至右的顺序对所有节点从0开始编号,则对于序号为i 的结点有:
    • i>0父节点的序号:(i1)/2(i-1)/2i=0时,i为根结点编号,无父结点。
    • 2i+1<n2i+1<n,左孩子序号: 2i+12i+1,否则无左孩子。
    • 2i+2<n2i+2<n,右孩子序号: 2i+22i+2,否则无右孩子。
  3. 有一棵完全二叉树,当完全二叉树的节点数为奇数时,n1=0;为偶数时,n1=1;n1:结点的度为1的个数)

  上面的性质一定要熟悉,我们来做做一些例题:

1.某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )

  解:我们知道 n0 = n2 + 1; n2 = 199、求n0,答案就是 n0 = 199 + 1 = 200;

  1. 一个具有767个节点的完全二叉树,其叶子节点个数为()

解:由题得:n0 + n1 + n2 = 767;

因为 767 为奇数,所以 n1 = 0;而n0 = n2 + 1,得出n2 = n0 - 1,带入上面的式子:

n0 + 0 + no - 1 = 767-->2 * n0 = 768-->n0 = 384。答案就是 384。

2.4 二叉树的存储

  二叉树的存储方式一般有两种方式:顺序存储、链式存储。

1.顺序存储:顺序存储就是用数组来存储数据,一般是用来存储完全二叉树的,因为这样不浪费空间,在后面的这个数据结构就是用数组来存储的(后面会更新)。

2.链式存储:链式存储是指用链表的形式来存储二叉树,存储的结点有两种方式:

  • 二叉链表:结点中有数据域、左孩子地址、右孩子地址。
二叉树-第 5 页.drawio.png
  • 三叉链表:结点中有数据域、左孩子地址、右孩子地址、双亲结点的地址。

二叉树-第 6 页.drawio.png

  本篇用的是二叉链式存储。

2.5 二叉树的基本操作

  由于二叉树的创建需要用到二叉树的遍历等知识,所以这里就手动快速创建一棵简单的二叉树,等后面再来介绍二叉树的创建。

创建如下的二叉树:

二叉树-第 8 页.drawio.png
public class MyBinaryTree {

    private static class Node {
        int val;
        Node left;//左孩子(左子树)
        Node right;//右孩子(右子树)

        Node(int val) {
            this.val = val;
        }
    }

    public Node root;//头节点

    //先手动创建一棵二叉树
    public Node create() {
        
        Node root = new Node(1);
        root.left = new Node(2);
        root.right = new Node(3);
        root.left.left = new Node(4);
        root.left.right = new Node(5);
        root.right.left = new Node(6);
        root.right.right = new Node(7);
        return root;
    }
}

2.5.1 二叉树的遍历

  学习二叉树最重要的是学会如何遍历二叉树,二叉树的大部分操作都是需要递归实现的,当然包括二叉树的遍历。二叉树的遍历方式有:前序遍历、中序遍历、后序遍历、层序遍历。 这几种遍历方式是必须要掌握的,因为不管是去刷题、还是后面二叉树的基本操作都是基于遍历操作来完成的。

  (注意,这四种遍历方法适用于所有二叉树,不止是完全二叉树,下面之所以举一个完全二叉树的例子是因为完全二叉树观赏性好😁)

(1)前序遍历: 二叉树的遍历是有一个大致模板的,这里先把代码给出,再来理解是怎么遍历二叉树的。

//通过二叉树的前序遍历来打印二叉树
public void preOrder(Node root){

    //该结点为null,说明没有左子树或者没有右子树
    if(root == null){
        return;
    }
    //先打印(可以改为对该结点的其它操作)
    System.out.print(root.val + " ");
    //遍历左子树
    preOrder(root.left);
    //遍历右子树
    preOrder(root.right);
}

  前序遍历的“前”体现在先对当前结点进行操作,然后再遍历左子树,左子树遍历完后再来遍历右子树,下面是它的演示图,自己结合下面的动图再到草稿纸上画一画这递归是怎么走的。

演示文稿1.gif

可以看出,访问根结点--->根的左子树--->根的右子树。

class Main{

    public static void main(String[] args) {
        MyBinaryTree tree = new MyBinaryTree();
        tree.root = tree.create();
        tree.preOrder(tree.root);
    }
}
结果:
1 2 4 5 3 6 7 

(2)中序遍历: 根的左子树--->根节点--->根的右子树。先遍历完左子树,然后操作结点,最后遍历右子树。

public void inOrder(Node root){

    //该结点为null,说明遍历完了
    if(root == null){
        return;
    }
    //遍历左子树
    inOrder(root.left);
    //打印(可以改为对该结点的其它操作)
    System.out.print(root.val + " ");
    //遍历右子树
    inOrder(root.right);
}

  中序遍历与前序遍历的区别就是操作结点的时机不一样而已。

演示文稿2.gif

class Main{

    public static void main(String[] args) {
        MyBinaryTree tree = new MyBinaryTree();
        tree.root = tree.create();
        tree.inOrder(tree.root);
    }
}
结果:
4 2 5 1 6 3 7 

(3)后序遍历: 根的左子树--->根的右子树--->根节点。先遍历完左右子树,最后操作结点。

//通过二叉树的后序遍历来打印二叉树
public void postOrder(Node root){
   if(root == null){
       return;
   }
   //遍历左子树
   postOrder(root.left);
   //遍历右子树
   postOrder(root.right);
   //打印(可以改为对该结点的其它操作)
   System.out.print(root.val + " ");
}

演示文稿4.gif

class Main{

   public static void main(String[] args) {
       MyBinaryTree tree = new MyBinaryTree();
       tree.root = tree.create();
       tree.postOrder(tree.root);
   }
}
结果:
4 5 2 6 7 3 1 

(4)层序遍历: 层序遍历比较特殊,它是一层一层来遍历,层序遍历需要借助队列来实现,利用队列的先进先出的性质。

//通过二叉树的层序遍历来打印二叉树
public void levelOrder(Node root){

    if(root == null){
        return;
    }
    //借助队列来进行存储下一层的结点
    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);//先入队
    
    while(!queue.isEmpty()){
        int size = queue.size();//当前层的结点个数
        //遍历当前层,把每一个结点的左右结点放入队列,
        //当这一层遍历完后,下一层已经全部放入队列了。
        for (int i = 0; i < size; i++) {
            //当前层依次出队
            Node tmp = queue.poll();

            //打印(可以改为对该结点的其它操作)
            System.out.print(tmp.val + " ");

            //依次将它们的左右结点放入队列,如果有左右结点就放入,没有就不放入。
            //注意,必须是先放左再放右
            if(tmp.left != null){
                queue.offer(tmp.left);
            }
            if(tmp.right != null){
                queue.offer(tmp.right);
            }
        }
        //每一层换行(可有可无)
        System.out.println();
    }
}
class Main{

   public static void main(String[] args) {
       MyBinaryTree tree = new MyBinaryTree();
       tree.root = tree.create();
       tree.levelOrder(tree.root);
   }
}
结果:
1 
2 3 
4 5 6 7 

2.5.2 二叉树的创建

  我们已经熟悉二叉树的遍历,接下来就来实现一下二叉树的创建。

  二叉树的创建方式有很多种,这里介绍一种。

(1)通过连续输入来创建二叉树, 当输入完成后二叉树就创建成功。这里进行约定,当输入为-1时,表示为null。

//通过输入来创建二叉树
public static Node createBinaryTree(Scanner scanner) {
    int val = scanner.nextInt();
    if (val == -1) { // 输入-1表示当前节点为空
        return null;
    }
    Node root = new Node(val);
    root.left = createBinaryTree(scanner);
    root.right = createBinaryTree(scanner);
    return root;
}

image.png

class Main{
    public static void main(String[] args) {

        MyBinaryTree tree = new MyBinaryTree();
        tree.root = MyBinaryTree.createBinaryTree(new Scanner(System.in));
        tree.levelOrder(tree.root);
    }
}

输入:1 2 -1 -1 3 -1 -1

结果:

image.png

  上面程序是先序遍历二叉树的方式来构建二叉树的,但是这样构造出来的二叉树是一颗完全二叉树,对于非完全二叉树,是需要先知道先序遍历序列和中序遍历序列 或者 后序遍历序列和中序遍历序列 才能构建出来。(👉根据前序、中序遍历构造二叉树 and 根据中序、后序遍历构造二叉树 - 掘金 (juejin.cn)

2.5.3 二叉树的其它操作

(1)获取树中结点的个数

//获取树中结点的个数
public int size(Node root){

    if(root == null){
        return 0;
    }
    int number = 0;
    
    number++;//当前有一个结点,需要知道它的左子树有几个结点、右子树有几个结点。
    int leftNum = size(root.left);//左子树有几个结点
    int rightNum = size(root.right);//右子树有几个结点
    number += leftNum + rightNum;//总共有多少结点。
    return number;
}

  递归就是将大问题转化为相同小问题,这里的思路就是:我想获取树中的结点个数,那么就是当前结点+左子树的结点+右子树的结点,左子树的结点怎么求?同样是这个思路,依此类推。

(2)获取叶子结点的个数。

// 获取叶子结点的个数
public int getLeafNodeCount(Node root){
    //如果根结点为null
    if(root == null){
        return 0;
    }
    //当前结点是叶子结点
    if(root.left == null && root.right == null){
        return 1;
    }
    //getLeafNodeCount(root.left)为左子树的叶子结点,getLeafNodeCount(root.right)为右子树结点。
    return getLeafNodeCount(root.left) + getLeafNodeCount(root.right);
}

(3)获取第K层节点的个数。

  还是大问题化成多个相同小问题,

image.png

  当k = 1时表示当前层就“根节点”一个。

//获取第k层结点的个数
public int getKLevelNodeCount(Node root,int k){
    if(root == null || k < 0){
        return 0;
    }
    //判断是否为第k层
    if(k == 1){
        return 1;
    }
    //左子树的k - 1层的结点个数+右子树k - 1层的结点个数
    return getKLevelNodeCount(root.left,k - 1) + getKLevelNodeCount(root.right,k - 1);
}

(4)获取二叉树的高度

  二叉树的高度 = Max(左子树高度,右子树高度)+ 1,以此类推。

// 获取二叉树的高度
public int getHeight(Node root){
    if(root == null){
        return 0;
    }

    int lefHeight = getHeight(root.left);//左子树的高
    int rightHeight = getHeight(root.right);//右子树的高
    int tmp = Math.max(lefHeight,rightHeight) + 1;
    return tmp;
}

(5)检测值为value的元素是否存在。

//检测值为value的元素是否存在
public Node find(Node root,int val){
    if(root == null){
        return null;
    }
    //判断根节点是不是待查元素,是就返回。
    if(root.val == val){
        return root;
    }
    //判断左子树有没有,没有就到右子树中找。
    Node leftFind = find(root.left,val);
    if(leftFind != null){
        return leftFind;
    }
    //左子树没有找到,到右子树中找。
    Node rightFind = find(root.right,val);
    if(rightFind != null){
        return rightFind;
    }
    //左右子树都没找到,并且根结点也不是,说明该二叉树中没有 val 元素。
    return null;
}

(6)判断一棵树是不是完全二叉树。

  完全二叉树的特征:结点是靠左的,根据这个性质我们可以借助队列。

  维护一个队列,从根节点开始入队,出队的时候再依次将出队结点的左右结点入队,以此类推。

演示文稿5.gif

  当走到最后,如果队列里面全是null,则该二叉树是完全二叉树,否则不是。

//判断一棵树是不是完全二叉树
public boolean comBinaryTree(Node root){
    if(root == null){
        return true;
    }
    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);
    while(!queue.isEmpty()){
        //每出队一个结点就把它的左右孩子结点入队。
        Node cur = queue.poll();
        if(cur != null){
            queue.offer(cur.left);
            queue.offer(cur.right);
        } else{
            break;//出队时第一个 null 
        }
    }
    //当出现第一个null时,需要判断队列里是否有非空结点。
    while(!queue.isEmpty()){
        Node cur = queue.poll();
        if(cur != null){
            return false;
        }
    }
    //队列里全是 null 就返回 true 。
    return true;
}

  (如有错误,请在评论区指出,谢谢🌹)