同线性结构的类似,树型结构也是一种组织数据元素的数据结构。这种结构中的数据元素存在一对多的关系,在逻辑上像一颗倒立(一对多)的树。若树中节点的分支个数的最大值为m,则该树被称为m叉树,因此二叉树指树中所有节点的的分叉最多只能有2个。特别指出的是,树型结构没有一叉树,最小的就是二叉树,即分支个数的最大值为1时,该树也可称为二叉树,甚至只有一个节点也是二叉树,null也是二叉树。
二分搜索树又称为二分查找树、二分排序树,是一种添加了限定条件的二叉树。其定义为
- 若树的左子树不为空,则左子树上的所有节点的值都小于它的根节点;
- 若树的右子树不为空,则右子树的所有节点的值都大于它的根节点;
- 所有子树也都是二分搜索树;
- 默认情况下,二分搜索树中不包括重复元素;
数据结构实质是指数据之间的组织关系,主要有四种结构:1)集合 元素之间无关系,除了同属于一个集合;2)线性结构 元素之间存在一对一的关系 3)树型结构 元素之间存在一对多的关系; 4)图状或网状结构 元素之间存在多对多的关系;
特点
-
有序性
定义中明确指出了二分搜索树的元素之间的大小关系。在实现层面,这隐式要求数据元素必须可比较。在java中,数据元素需要实现Comparable接口或有外部比较器Comparator -
动态性
由于节点维护了父子节点间的关系信息,因此二分搜索树在编译节点不必声明树的存储空间,可以在程序运行过程中动态的增删节点,即具备动态性。数组不能动态扩容,进而产生链表。但链表不能随机查询且查询效率低,进而出现树结构。
-
高效查找
从定义可知,二分搜索树是一颗有序树,且具备高效的查找效率。这是因为相对于线性结构每次只能排除一个元素,树型结构每次同某一节点对比后,下一步只能选择搜索左子树或右子树中的一种,即每一次对比都排除了一个子树的无效数据。
基本框架
二叉搜索树需要维护三项信息
- 根节点root。用于表示整棵树
- 内部节点类Node。同链表类似,二叉搜索树内部需要维护一个节点类,用于保存真正的数据信息和节点关系,其中节点关系是一条单向射线,由父节点指向子节点。
- 树大小size。表示树中节点的个数。
除以上三项信息外,其他均为向外部用户提供功能方法及类内部辅助方法。其基本框架如下
//二分搜索树(不包含重复元素)
//泛型E需要继承Comparable接口
public class BinarySearchTree<E extends Comparable<E>> {
//内部节点类。
//只在二叉搜索树内部使用,因此是私有的
private class Node{
//注意泛型E需要具备可比性
public E e;
//表示节点间的逻辑关系
public Node left,right;
public Node(E e){
this.e = e;
left = null;
right = null;
}
}
//树的根。
//因为节点中保存的有节点间的父子关系,因此拿到树的根就可以找到其他所有的节点;
//根是树的操作入口,它代表了整棵树。
private Node root;
//树的节点数量
private int size;
//构造函数 初始是一棵空树
public BinarySearchTree(){
root = null;
size = 0;
}
//功能方法
public int getSize(){
return size;
}
//功能方法
public boolean isEmpty(){
return size == 0;
}
//其他增删改查功能方法
}
添加元素
从定义可知,二叉搜索树是一颗有序树,这为高效查询提供了基础,但同时也限制内部方法的实现。它要求在对二叉搜索树执行各类操作后,仍需保持树的有序性,而有序性问题的关键是定位元素的目标位置,随后执行相应操作。
对于添加元素操作,目标位置指元素最终插入位置。又因为是插入操作,那在插入完成之前,目标位置上一定没有元素,即目标位置为null,插入完成之后,该节点是一个叶子节点。根据定位的“目标位置”的不同,有两种实现方式
定位目标位置的父节点
对于某一节点N,比较待插入元素E和节点N中的元素值:
- 若两者相等,表示树中已有该元素,不需再次执行插入操作,方法返回。
- 若待插入元素E小于节点N中元素值,则在节点N的左子树中重复执行插入逻辑,直到节点N满足1)节点值小于E;2)节点的左子树为空。此时N的左子树位置即为目标位置,构造元素E的节点,插入该位置即可。
- 若待插入元素E大于节点N中元素值,则在节点N的右子树中重复执行插入逻辑,直到节点N满足1)节点值大于E;2)节点的右子树为空。此时N的右子树位置即为目标位置,构造元素E的节点,插入该位置即可。
代码如下:
/**
* 向binarysearchtree添加元素
* 公有方法,提供给外部用户
* @param e
*/
public void addFirst(E e){
//空树。新建一个节点直接返回
if (root == null) {
root = new Node(e);
size++;
}else{
//直接把根节点root传递进去,相当于给了操作入口
//使用root代表整棵树,从整棵树出发,是一种整体的思想。
//此时把整棵树都交给你操作,操作的结果直接写入树中,不必返回值。
addFirst(root,e);
}
}
/**
* 向根为node的binarysearchtree中插入元素
* 私有方法,内部调用
* @param node
* @param e
*/
private void addFirst(Node node,E e){
//终止条件分支1
//因为比较的是目标位置的父节点,因此需要判断值是否相等。
//add方法中判断的是目标树自身,因此判断条件是 树是否为null
if(e.equals(node.e)){
//节点值相等,无操作
return;
}else
//终止条件分支2
if(e.compareTo(node.e) < 0 && node.left == null ){
node.left = new Node(e);
size++;
return;
}else
//终止条件分支3
if(e.compareTo(node.e) > 0 && node.right == null){
node.right = new Node(e);
size++;
return;
}
//递归判断及调用
if(e.compareTo(node.e) < 0){
addFirst(node.left,e);
}
//递归判断及调用
if(e.compareTo(node.e) > 0){
addFirst(node.right,e);
}
}
直接定位目标位置
上面的插入方法使用了递归的思想,但由于定位的位置是最终插入位置的父节点,因此需要讨论根节点为空和不为空两种情况,没有统一处理逻辑。实际上,null也是一棵树,若把定位目标位置的父节点改为直接定位目标位置,即找到那棵最终要插入元素E的null树,就可把根为空的情况包括进去,统一处理逻辑。
特别指出的是,该方法的内部执行返回了每次插入元素后的子树的根节点,因此右赋值操作。
/**
* 为什么外部方法要包裹一层?
* @param e
* @return
*/
public void add(E e){
//返回插入节点,并挂接
root = add(root,e);
}
/**
* 返回插入新节点后bst的根
* 前提:1)元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
* 2)null也可认为是一棵树,只是一颗空树。
* 方法思路:插入元素实质上找到插入位置,因为前提1,2,只需按照规则【左小右大】找到null位置,
* 在该位置上创建新节点,并把新节点挂载到树上即可。
* @param node
* @param e
* @return
*/
private Node add(Node node ,E e){
//递归终止条件
//因为比较的是目标位置的父节点,因此需要判断值是否相等。
//add方法中判断的是目标树自身,因此判断条件是 树是否为null
//注意循环终止条件处的附加操作,处是size + 1
if(node == null){
size++;
return new Node(e);
}
//递归调用 隐含元素值跟当前节点值相等时,不操作。
//元素小,插入左子树
if(e.compareTo(node.e) < 0){
//等号右边得add返回值是一棵树的根节点,即代表了一棵树
//将这棵树挂到当前节点上
node.left = add(node.left,e);
}else
//元素大,插入右子树
if(e.compareTo(node.e) > 0){
//返回插入节点,并挂接
node.right = add(node.right,e);
}
return node;
}
其他操作
对比两种插入思路,发现
- addFirst方法是找到目标位置的父节点,处理逻辑不统一;按照遍历树的节点的角度递归;每次内部插入操作无返回值,无赋值操作。
- add方法是直接找到目标位置【null树】,处理逻辑统一;按照整棵树的角度递归,把一个大树看成很多小树构成;每次内部插入操作有返回值,有赋值操作。
为统一操作逻辑,推荐使用add方法。同插入操作类似,其他操作具备同样的特点。
将整棵树看成一个“具有两个叶子节点,一个根节点的树”,在处理时,分两大部分
- 处理根节点
对根节点的处理一般两种1)根节点元素为空时 怎么操作 2)根节点元素为目标元素时,执行各种真实的业务操作。 - 处理两个叶子节点
此时要注意元素E隐含的可比性。将其同左右子节点的元素对比,符合条件执行操作。
这个操作需在逻辑把左右子树分别和根节点断开,形成两个分别以左右子节点为根的新树(因此外层有赋值操作),进而递归执行各种真实的业务操作。
特别注意,null也是一颗树,统一处理逻辑。
上述过程可进一步抽象为一个节点,对一个节点有三处访问位置,访问左子树,访问元素、访问右子树
因业务的原因,需把所有节点都访问一遍,称为遍历。遍历实质是将树型结构中节点排列成一个线性序列。
递归的过程实质上是查找目标位置的过程。上述可以add或preOrder为例描述。
全部源码
package com.company.binarysearchtree;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
/**
* @description: 二分搜索树
* @Date: 2021/8/31 15:00
*/
//二分搜索树要求元素可比,因此E需要继承Comparable<E>结构
//不包含重复元素
public class BinarySearchTree<E extends Comparable<E>> {
private class Node{
public E e;
public Node left,right;
public Node(E e){
this.e = e;
left = null;
right = null;
}
}
private Node root;
private int size;
public BinarySearchTree(){
root = null;
size = 0;
}
public int getSize(){
return size;
}
public boolean isEmpty(){
return size == 0;
}
/**
* 向binarysearchtree添加元素
* 公有方法,提供给外部用户
* @param e
*/
public void addFirst(E e){
if (root == null) {
root = new Node(e);
size++;
}else{
//直接把根节点root传递进去,相当于给了操作入口
//使用root代表整棵树,从整棵树出发,是一种整体的思想。
//此时把整棵树都交给你操作,操作的结果直接写入树中,不必返回值。
addFirst(root,e);
}
}
/**
* 向根为node的binarysearchtree中插入元素
* 私有方法,内部调用
* 前提:元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
* 方法思路:通过判断叶子节点是否为空(node.left == null),找到插入的目标位置的父节点
* 然后判断插入左侧还是右侧(e.compareTo(node.e) > 0)
* @param node
* @param e
*/
private void addFirst(Node node,E e){
//终止条件分支1
//因为比较的是目标位置的父节点,因此需要判断值是否相等。
//add方法中判断的是目标树自身,因此判断条件是 树是否为null
if(e.equals(node.e)){
//节点值相等,无操作
return;
}else
//终止条件分支2
if(e.compareTo(node.e) < 0 && node.left == null ){
node.left = new Node(e);
size++;
return;
}else
//终止条件分支3
if(e.compareTo(node.e) > 0 && node.right == null){
node.right = new Node(e);
size++;
return;
}
//递归判断及调用
if(e.compareTo(node.e) < 0){
addFirst(node.left,e);
}
//递归判断及调用
if(e.compareTo(node.e) > 0){
addFirst(node.right,e);
}
}
/**
* 为什么外部方法要包裹一层?
* @param e
* @return
*/
public void add(E e){
//返回插入节点,并挂接
root = add(root,e);
}
/**
* 返回插入新节点后bst的根
* 前提:1)元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
* 2)null也可认为是一棵树,只是一颗空树。
* 方法思路:插入元素实质上找到插入位置,因为前提1,2,只需按照规则【左小右大】找到null位置,
* 在该位置上创建新节点,并把新节点挂载到树上即可。
* 区别:addFirst方法是找到目标位置的父节点,处理逻辑不统一;按照遍历树的节点的角度递归
*add方法是直接找到目标位置【null树】,处理逻辑统一;按照整棵树的角度递归,把一个大树看成很多小树构成。
* @param node
* @param e
* @return
*/
private Node add(Node node ,E e){
//递归终止条件
//因为比较的是目标位置的父节点,因此需要判断值是否相等。
//add方法中判断的是目标树自身,因此判断条件是 树是否为null
//注意循环终止条件处的附加操作,处是size + 1
if(node == null){
size++;
return new Node(e);
}
//递归调用 隐含元素值跟当前节点值相等时,不操作。
//元素小,插入左子树
if(e.compareTo(node.e) < 0){
//等号右边得add返回值是一棵树的根节点,即代表了一棵树
//将这棵树挂到当前节点上
node.left = add(node.left,e);
}else
//元素大,插入右子树
if(e.compareTo(node.e) > 0){
//返回插入节点,并挂接
node.right = add(node.right,e);
}
return node;
}
/**
* 是否包含元素
* @param e
* @return
*/
public boolean contains(E e){
return contains(root,e);
}
/**
* 查询以node为根节点的bst中,是否包含元素e
* @param node
* @param e
* @return
*/
private boolean contains(Node node,E e){
/* if(e.equals(node.e )){
return true;
}
return contains(node.left,e) ||contains(node.right,e);*/
//递归终止条件1
//只需处理根节点1 判空处理
if(node == null){
return false;
}
//递归终止条件2
//只需处理根节点2 判等处理
if(e.equals(node.e)){
return true;
}else if(e.compareTo(node.e) > 0){
return contains(node.right,e);
}else{
return contains(node.left,e);
}
}
/**
* 前序遍历
* 外部用户使用,不需要传递参数
* 因为根节点root是类的成员变量,可以直接使用
* 且root是用户同bst类通信的唯一入口
* 因此用户不需要显式的传递参数
*/
public void preOrder(){
preOrder(root);
}
/**
* 功能方法,以某个节点为根节点,遍历树
* 此时根节点可以是root节点,也可以是其他节点,
* 因此方法需要提供形参
* @param node
*/
private void preOrder(Node node){
//1 节点判空
//一般null为递归终止条件
if(node == null){
//2 空条件下的操作
return;
}
//3 业务操作
System.out.println(node.e);
//4 左子树递归
preOrder(node.left);
//5 右子树递归
preOrder(node.right);
}
/**
* 中序遍历
*/
public void inOrder(){
inOrder(root);
}
/**
*遍历的最后一次非空节点是叶子节点,
* 但是null也被当作一个树执行了一次,只是null树符合递归终止条件,返回
* @param node
*/
private void inOrder(Node node){
//1 节点判空
//一般null为递归终止条件
if(node == null){
//2 空条件下的操作
return;
}
//3 左子树递归
inOrder(node.left); //倒数第二次执行叶子节点的左子树(null树)返回,不执行任何操作
//4 业务操作
System.out.println(node.e);
//5 右子树递归
inOrder(node.right); //倒数第二次执行叶子节点的右子树(null)返回,不执行任何操作
}
/**
* 后续遍历
*/
public void postOrder(){
postOrder(root);
}
private void postOrder(Node node){
//1 节点判空
//一般null为递归终止条件
if(node == null){
//2 空条件下的操作
return;
}
//3 左子树递归
postOrder(node.left); //倒数第二次执行叶子节点的左子树(null树)返回,不执行任何操作
//4 右子树递归
postOrder(node.right); //倒数第二次执行叶子节点的右子树(null)返回,不执行任何操作
//5 业务操作
System.out.println(node.e);
}
private void preOrder1(Node node){
if(node == null) return;
Node node1 = node;
while(node1.left != null){
System.out.println(node1.e);
node1 = node1.left;
}
System.out.println(node1.e);
Node node2 = node;
while(node.right != null){
System.out.println();
}
}
/**
* 序遍历的非递归写法
* no recursion
*/
public void preOrderNR(){
preOrderNR(root);
}
/**
* 前序遍历的非递归写法
* 思路:使用辅助数据结构 -栈,构建节点栈
* 先将根节点压栈,随后循环出栈,每次出栈需把出栈节点的右节点、左节点压栈,
* 注意:左右节点入栈顺序不能变化,因为栈的后进先出特点,左节点后入栈,
* 可先于右子树出栈,进而满足前序要求。
* @param node
*/
private void preOrderNR(Node node){
Stack<Node> es = new Stack<>();
es.push(node);
//栈为空,说明没有入栈操作,说明所有节点都已经遍历完了
while(!es.empty()){
Node cur = es.pop();
System.out.println(cur.e);
if(cur.right != null){
es.push(cur.right);
}
if(cur.left != null){
es.push(cur.left);
}
}
}
/**
* 借助队列实现层序遍历
*/
public void levelOrder(){
levelOrder(root);
}
/**
* 非递归方式
* 借助队列实现层序遍历
* @param node
*/
private void levelOrder(Node node){
//队列的使用
Queue<Node> q = new LinkedList<>();
q.add(root);
while(!q.isEmpty()){
Node cur = q.remove();
System.out.println(cur.e);
if(cur.left != null){
q.add(cur.left);
}
if(cur.right != null){
q.add(cur.right);
}
}
}
/**
* 查找bst的最小值
* @return
*/
public E minimum(){
if(size == 0){
throw new IllegalArgumentException("bst为空");
}
return minimum(root).e;
}
/**
* 从根节点出发,一直左子树,直到找到没有左子树的节点,该节点即为目标节点。
* @param node
* @return
*/
private Node minimum(Node node){
if(node.left == null){
return node;
}
return minimum(node.left);
}
/**
* 查找bst的最小值
* @return
*/
public E maxmum(){
if(size == 0){
throw new IllegalArgumentException("bst为空");
}
return maxmum(root).e;
}
/**
* 从根节点出发,一直右子树,直到找到没有右子树的节点,该节点即为目标节点。
* @param node
* @return
*/
private Node maxmum(Node node){
if(node.right == null){
return node;
}
return minimum(node.right);
}
/**
* 删除最小值
* 常识:当删除的目标不是用户指定时,一般会把删除元素值返回给用户。
* @return
*/
public E removeMin(){
//找到这个最小值
E e = minimum();
root = removeMin(root);
return e;
}
/**
* 删除以node为根的最小节点
* 返回删除节点后新的bst的根
* 由于bst的特性,最小值一定是在最左侧的、没有左子树的节点。
* 有可能是1)一个叶子节点,此时直接删除该节点,bst结构不发生变化;
* 也有可能是2)一个没有左子树,有右子树的节点。
* 此时需要把 目标节点的右子树 挂接到 目标节点父节点 的左子树上。
*
* @param node
* @return
*/
private Node removeMin(Node node){
//若节点的左子树为空,说明当前节点是最小节点
//已经到底部
if(node.left == null){
//不管有没有右子树【叶子节点可以看作左节点为空或右节点为空的节点】,
// 都先拿到将目标节点的右子树的根节点
//拿到右子树的根即代表拿到了右子树
Node rightNode = node.right;
//目标节点右子树置空,即断开连接
node.right = null;
//整个bst大小减一
size--;
//返回目标节点右子树的根节点,即返回右子树
return rightNode;
}
//节点的左子树不为空,递归调用该方法
//返回值为删除以左子树为根的bst后,新生成的bst的根
node.left = removeMin(node.left);
return node;
}
/**
* 删除最大值
* 常识:当删除的目标不是用户指定时,即用户不知道删除的具体值,一般会把删除元素值返回给用户。
* @return
*/
public E removeMax(){
//找到这个最小值
E e = maxmum();
root = removeMax(root);
return e;
}
private Node removeMax(Node node){
if(node.right == null){
Node leftNode = node.left;
node.left = null;
size--;
return leftNode;
}
node.right = removeMax(node.right);
return node;
}
/**
* 返回删除节点后的 新二叉树的根
* @param e
* @return
*/
public Node remove(E e){
return remove(root,e);
}
/**
* 返回删除节点后的 新二叉树的根
* @param node
* @param e
* @return
*/
private Node remove(Node node,E e){
if(node == null){
return null;
}
//不能这么比,e可能是一个对象,
//if(e < node.e)
//元素e比node的值大,说明e在node的右子树上
if(e.compareTo(node.e) > 0){
return remove(node.right,e);
}else
//元素e比node的值小,说明e在node的左子树上
if(e.compareTo(node.e) < 0){
return remove(node.left,e);
}else{
//元素e和node.e相等 分三种情况讨论
//node 左右子树都为空的情况 已经包含在情况1中
//情况1)node无左子树 只需用右子树的根节点替换node的位置
if(node.left == null){
Node rightNode = node.right;
node.right = null;
size--;
return rightNode;
}else
//情况2)node无右子树 只需用左子树的根节点替换node的位置
if(node.right == null){
Node leftNode = node.left;
node.left = null;
size--;
return leftNode;
}else
//情况3) node的左右子树都不为空 此时需要拿到node右子树的最小值,使用该节点替换node的位置
{
//找到node节点右子树上得最小值
Node minimum = minimum(node.right);
//使用最小值替换node节点位置 分两步
// step1 将node的左子树直接赋值给最小值。因为左子树没有发生变化
minimum.left = node.left;
//step2 删除node的右节点的最小值 并将删除后的根节点赋值给minimum的右节点。
minimum.right = removeMin(node.right);
//将node置空,以便回收
node.left = node.right = null;
return minimum;
}
}
}
}
延申
- Comparable是排序接口;若一个类实现了Comparable接口,就意味着“该类支持排序”。
而Comparator是比较器;我们若需要控制某个类的次序,可以建立一个“该类的比较器”来进行排序。我们不难发现:Comparable相当于“内部比较器”,而Comparator相当于“外部比较器”。
参考资料
- 树的定义和基本术语
- 数据结构,严蔚敏版