图解数据结构:二叉树

前言

是数据结构中的一种非线性结构,包括很多种:二叉树、二分搜索树、AVL树、红黑树、B树、B+树等等。之所以分成这么多种,是为了更好地解决各类问题。其中最简单的是二叉树和二分搜索树,本篇主要讲解二叉树和二分搜索树的构建、删除、遍历等。

二叉树

讲解二叉树之前,先看一下普通的树,以及树中常见的概念。
节点拥有的子树数称为节点的度(Degree),度为0的节点称为叶子节点或者终端节点。度不为0的节点称为分支节点或者非终端节点。除根节点外,分支节点也称为内部节点树的分支度是各个节点的度的最大值
树结构
如图所示:是一棵度为3(节点A的度)的树。根节点为A,内部节点为B、D,叶子节点为C、E、F、G。

了解了一般意义上的树,再来看下二叉树的定义

在计算机科学中,二叉树(英语:Binary tree)是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构。通常分支被称作“左子树”或“右子树”。二叉树的分支具有左右次序,不能随意颠倒。

根据定义可知,二叉树有两个显著的特点:

  • 每个节点最多只有2个分支
  • 分支具有左右次序,不能颠倒,分别称为左子树右子树

二叉树
如图所示,是一棵典型的二叉树。

满二叉树

二叉树的第i层至多有2(i-1)个节点;深度为k的二叉树至多有2k-1个节点;对任何一棵二叉树T,如果其终端结点数为n0,度为2的节点数为n2,则n0=n2+1。

对于二叉树的这几条性质,“最多”都是指满二叉树的情况。其实上图所示的二叉树就是满二叉树
满二叉树:除最后一层(叶子节点层)外,所有节点均有左子树和右子树。也就是说叶子节点都在同一层,其余的层都有两个节点。这样的二叉树称为满二叉树。满二叉树有以下几条性质:

  • 第i层有2(i-1)个节点
  • 深度(也叫层数)为k的满二叉树有2k-1个节点
  • 如果其终端结点数为n0,度为2的节点数为n2,则n0 = n2 + 1这条性质适用于每一棵二叉树
    假设二叉树中节点总数为n,度为1的节点数为n1。则有n = n0 + n1 + n2;再看每个节点之间的连线数(分支数),除根节点外,每个节点都有一个入分支(名字是我随便取的,也就是从父节点指过来的那个分支),总分支数记为s,则有s = n - 1;由于度为2的节点有2个出分支(名字还是我随便取的,也就是自己指向子节点的分支),度为1的节点有1个出分支,则有s = n1 + 2 * n2。有了这三个等式,相信大家不难解出n0 = n2 + 1
完全二叉树

在一棵二叉树中,除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则此二叉树为完全二叉树(Complete Binary Tree)

满二叉树也是完全二叉树。
完全二叉树
如图所示,是完全二叉树的一般形式。在满二叉树的基础上,完全二叉树允许右边缺少连续若干节点。如果去掉F节点,依然是完全二叉树。去掉E、F节点,依然是完全二叉树,但是仅去掉E节点就不是完全二叉树了。

二叉查找树

二叉查找树(英语:Binary Search Tree),也称为二叉搜索树、有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树:
1、若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
2、若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
3、任意节点的左、右子树也分别为二叉查找树;
4、没有键值相等的节点。

扫描二维码关注公众号,回复: 8986119 查看本文章

二叉查找树实际上是就是有序的二叉树。二叉树的遍历分为:前序遍历、中序遍历、后序遍历。中序遍历的结果,实际上就是“有序的二叉树”中的“有序”。
二分搜索树
如图所示是一棵一般化的二叉搜索树。完全满足以上四点性质。它既不是满二叉树,也不是完全二叉树。也就是说二叉搜索树不要求二叉树是满二叉树或者完全二叉树

对于二叉查找树,一般定义为如下结构

/**
 * 二分搜索树
 * 因为节点元素是有序的,所以泛型的类型需实现Comparable接口
 */
public class BinarySearchTree<E extends Comparable<E>> {

	// 每一个节点的数据结构
    private class Node {
    	// 节点存储的数据
        private E val;
        // 左节点
        private Node left;
        // 右节点
        private Node right;

        public Node(E val) {
            this.val = val;
            this.left = null;
            this.right = null;
        }
    }

    // 根节点
    private Node root;
    // 节点个数
    private int size;

    public BinarySearchTree() {
        this.root = null;
        this.size = 0;
    }
}

接下来开始实现二分搜索树的基本操作。

二叉搜索树新增节点

假设依次向一棵空二叉搜索树中插入元素:5、3、7、1、4、6、8、2。以下是动图演示整个插入过程(博主特意将动图做得很慢):

二分搜索树插入元素
根据二分搜树的生成思想可以写出新增元素时的方法
新增元素add(E val)方法

/**
 * 新增元素
 */
public void add(E val) {
	if (val == null) {
        throw new IllegalArgumentException("illegal argument: null ");
    }
    if (root == null) {
    	// 根节点为空,说明是一棵空树。此时新增的节点即为根节点
        size++;
        root = new Node(val);
    } else {
    	// 根节点不为空时,调用递归方法新增
        add(root, val);
    }
}

/**
 * 新增节点的递归方法
 * 向以node为根节点的子树中新增元素
 */
private void add(Node node, E val) {
    if (node == null) {
        return;
    }
    if (val.compareTo(node.val) < 0 && node.left == null) {
    	// 如果当前节点的左子树为空,且新增元素小于当前节点,则新增的元素就是当前节点的左节点
        node.left = new Node(val);
        size++;
        return;
    }
    if (val.compareTo(node.val) > 0 && node.right == null) {
    	// 如果当前节点的右子树为空,且新增元素大于当前节点,则新增的元素就是当前节点的右节点
        node.right = new Node(val);
        size++;
        return;
    }
    if (val.compareTo(node.val) < 0) {
    	// 当前节点的左子树不为空,且新增元素小于当前节点,则继续递归当前节点的左子树
        add(node.left, val);
    } else if (val.compareTo(node.val) > 0) {
    	// 当前节点的右子树不为空,且新增元素大于当前节点,则继续递归当前节点的右子树
        add(node.right, val);
    }
}

虽然能完成新增功能,但是代码不够优雅。更优雅的代码会在接下来的源文件中给出。

二叉搜索树查找节点

要想知道二叉搜索树中是否包含某个元素,需要从根节点开始遍历搜索。因为二叉搜索树中,以任何一个节点为根节点的子树,其左子树的所有元素小于根节点,其右子树中所有元素大于根节点。这两条性质给搜索带来很大的方便,这也是有些地方要把链表换成二叉树的原因。链表查找元素时间复杂度为O(n),而二叉搜索树查找元素的平均时间复杂度为O(logn),最坏为O(n),也就是二叉树退化为链表的时候。
查找元素contains(E val)方法

/**
 * 二分搜索树中是否包含某个元素
 */
public boolean contains(E val) {
    if (val == null) {
        throw new IllegalArgumentException("illegal argument: null ");
    }
    return contains(root, val);
}

/**
 * 查询以node为根节点的二分搜索树中是否包含某个元素,并返回搜索结果
 */
private boolean contains(Node node, E val) {
    if (node == null) {
        return false;
    }
    if (val.compareTo(node.val) == 0) {
    	// 要查找的元素等于当前节点
        return true;
    } else if (val.compareTo(node.val) < 0) {
    	// 要查找的元素小于当前节点,继续递归当前节点的左节点
        return contains(node.left, val);
    } else {
    	// 要查找的元素大于当前节点,继续递归当前节点的右节点
        return contains(node.right, val);
    }
}
二叉树遍历

这里说的是二叉树的遍历,而不仅仅是二叉搜索树的遍历,因为这里的遍历具有一般性,也是二叉树的遍历方式。
二叉树的遍历,一般采用递归的方式:

  • 前序遍历
    /**
     * 前序遍历(递归实现)
     */
    public void preorder() {
        preorder(root);
    }
    
    private void preorder(Node node) {
        if (node == null) {
            return;
        }
        // 访问节点数据
        System.out.print(node.val + " ");
        preorder(node.left);
        preorder(node.right);
    }
    
  • 中序遍历
    /**
     * 中序遍历(递归实现)
     * 中序遍历出来的结果是有序的
     */
    public void inorder() {
        inorder(root);
    }
    
    private void inorder(Node node) {
        if (node == null) {
            return;
        }
        inorder(node.left);
        // 访问节点数据
        System.out.print(node.val + " ");
        inorder(node.right);
    }
    
  • 后序遍历
    /**
     * 后序遍历(递归实现)
     */
    public void postorder() {
        postorder(root);
    }
    
    private void postorder(Node node) {
        if (node == null) {
            return;
        }
        postorder(node.left);
        postorder(node.right);
        // 访问节点数据
        System.out.print(node.val + " ");
    }
    

除了这三种遍历方式外,还有深度优先遍历(DFS)广度优先遍历(BFS)

  • 深度优先遍历,也叫前序遍历的非递归实现
    /**
     * 前序遍历(非递归实现)
     * 深度优先遍历
     */
    public void dfs() {
        if (root == null) {
            System.out.println("tree is empty");
            return;
        }
        // 利用栈LIFO的性质
        Stack<Node> stack = new Stack<>();
        stack.push(root);
        while (!stack.isEmpty()) {
            Node curr = stack.pop();
            System.out.print(curr.val + " ");
            if (curr.right != null) {
            	// 右节点不为null时,先入栈
                stack.push(curr.right);
            }
            if (curr.left != null) {
            	// 左节点不为null时,入栈
                stack.push(curr.left);
            }
        }
    }
    
  • 广度优先遍历,也叫层次遍历
    /**
     * 广度优先遍历
     * 层次遍历
     */
    public void bfs() {
        if (root == null) {
            System.out.println("tree is empty");
            return;
        }
        // 利用队列FIFO的性质
        Queue<Node> queue = new LinkedList<>();
        queue.offer(root);
        while (!queue.isEmpty()) {
            Node curr = queue.poll();
            System.out.print(curr.val + " ");
            if (curr.left != null) {
                queue.offer(curr.left);
            }
            if (curr.right != null) {
                queue.offer(curr.right);
            }
        }
    }
    
二叉搜索树删除节点

删除一个节点可以分成三种情况

  • 待删除的节点只有左子树
  • 待删除的节点只有右子树
  • 待删除的节点既有左子树,也有右子树

对于既没有左子树,也没有右子树的叶子节点,可以包含在以上三种情况中的任何一种。
对于以上三种情况,删除过程如下图所示
二叉搜索树删除节点
直接看下第三种情况,也就是删除既有左子树又有右子树的节点8。删除节点8之前,需要找到节点8的右子树中的最小元素来代替8。
所以在实现删除节点之前,先来实现两个与此相关的方法

  • 查找二分搜索树中的最小节点
  • 删除二分搜索树中的最小节点

对于查找二分搜索树中的最小节点,只需要遍历左子树即可

/**
 * 找到以node为根节点的二分搜索树中最小的节点并返回
 */
private Node min(Node node) {
    if (node.left == null) {
        return node;
    }
    return min(node.left);
}

删除二分搜索树中的最小节点,需要注意该节点是否有右子树,所以需要记录待删除元素的右子树。

/**
 * 删除以node为根节点的二分搜索树中的最小节点,并返回删除后的子树的根节点
 */
private Node removeMin(Node node) {
    if (node.left == null) {
    	// 记录当前节点的右子树
        Node right = node.right;
        node.right = null;
        size--;
        return right;
    }
    node.left = removeMin(node.left);
    return node;
}

有了这两个方法,再开始编写删除任意节点的方法

/**
 * 删除二分搜索树中指定的元素e
 */
public E remove(E val) {
    if (root == null) {
        return null;
    }
    return remove(root, val).val;
}

/**
 * 删除以node为根节点的二分搜索树中的指定元素e,并返回删除后的跟节点
 */
private Node remove(Node node, E val) {
    if (node == null) {
        return null;
    }
    if (val.compareTo(node.val) < 0) {
    	// 待删除的节点小于当前节点,递归当前节点的左子树,并且把删除后的子树作为当节点的左子树
        node.left = remove(node.left, val);
        return node;
    } else if (val.compareTo(node.val) > 0) {
    	// 待删除的节点大于当前节点,递归当前节点的右子树,并且把删除后的子树作为当节点的右子树
        node.right = remove(node.right, val);
        return node;
    } else {
    	// 当前节点就是待删除的节点
        if (node.right == null) {
            // 只有左子树(包括叶子节点)
            Node left = node.left;
            node.left = null;
            size--;
            return left;
        } else if (node.left == null) {
            // 只有右子树
            Node right = node.right;
            node.right = null;
            size--;
            return right;
        } else {
            // 既有左子树,又有右子树
            // 找到当前节点的右子树中最小的元素
            Node curr = min(node.right);
            // 删除当前节点右子树中最小的节点,因为要用来替换当前节点
            curr.right = removeMin(node.right);
            curr.left = node.left;
            node.left = node.right = null;
            return curr;
        }
    }
}

对于删除既有左子树,又有右子树的节点(节点8),这四行代码(第43行至47行),一行一行演示树结构的变化。
删除既有左子树又有右子树的节点

完整代码下载地址:
Github:BinarySearchTree.java

总结

以上就是二叉树及二叉搜索树相关的性质、操作等。二叉树和二叉搜索树是学习其他更复杂树结构的基础,理解了二叉树,才能更好的学习AVL树、红黑树、B树、B+树等等。由于其操作高效,也是很多集合框架、软件等的底层实现原理。

发布了52 篇原创文章 · 获赞 107 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/Baisitao_/article/details/102789736