前言
树是数据结构中的一种非线性结构,包括很多种:二叉树、二分搜索树、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+树等等。由于其操作高效,也是很多集合框架、软件等的底层实现原理。