文章目录
基本方法的实现
同样的使这个类支持泛型,但因为二分搜索树必须具有可比较性,让它继承Comparable
。
BST.java
// 支持泛型,这个类型必须拥有可比较性
public class BST<E extends Comparable<E>> {
// 对应的节点类
private class Node {
public E e;
public Node left;
public Node right;
public Node(E e) {
this.e = e;
left = null;
right = null;
}
}
// 需要一个根节点
private Node root;
// 记录二分搜索树存储多少个元素
private int size;
public BST() {
root = null;
size = 0;
}
// 当前存储了多少个元素
public int getSize() {
return size;
}
// 查看当前二分搜索树是否为空
public boolean isEmpty() {
return size == 0;
}
}
添加新元素
1.使用递归添加新元素
如果在最开始的时候,二分搜索树中一个节点也没有,root为NULL。假如现在添加一个新的元素33,那么这个33这个节点将会成为根。
此时在添加一个新的元素22,因为22比根节点33小,所以它需要添加到33的左子树。33的左子树整体为空,22将称为33的左子树的根节点,即33的左孩子。
利用上述规律,每添加一个都从二分搜索树的根开始,如果比根节点小,往左;反之为右。不管是左子树还是右子树,同样是一颗二分搜索树,继续判断大小,添加的过程可以以此类推。
考虑特殊的情况:当添加的新元素与节点相同时,不添加新元素。因为始终需要比较关系,所以实现的二分搜索树不包含重复元素
。如果需要重复,只需要改变定义:左子树小于等于节点或者右子树大于等于节点。
尽管二分搜索树的非递归实现与链表很像,只需要在添加时进行一次大小的if判断即可,但在这里更加关注递归实现
。
- 使用
add(E e)
方法向二分搜索树中添加新的元素e,如果根节点本身为空,只需要将根节点指向新建的元素就可以了,之后维护一下大小对size++;否则从根节点开始添加元素。 - 定义一个私有的递归函数
private void add(Node node, E e)
它的区别在于需要传入一个节点和添加的元素e。它是以node为根的向二分搜索树中添加新的元素
,是一个递归算法。 - 需要注意的是,在进行节点元素之间的比较时,由于它们不是基础类型,不能直接使用大于小于号,因为BST类实现了Compareble接口,所以使用
compareTo()
方法进行比较。
// 向二分搜索树中添加一个新的元素
public void add(E e) {
// 如果根节点本身为null,那么这个元素就是根节点
if (root == null) {
// 根节点直接指向一个新建的元素
root = new Node(e);
// 维护size
size++;
} else {
// 如果不为空,则尝试从根节点开始插入元素e
add(root, e);
}
}
// 像以node为根的二分搜素树插入新的元素e
private void add(Node node, E e) {
// 递归终止条件
// 先检查一下要插入的元素e是否等于node.e
if (e.equals(node.e)) {
// 如果相等,说明元素存在,直接return
return;
}
// 这两个元素之间的比较由于不是基础类型,不能直接比较
// 因为E满足Comparable接口,所以使用compareTo方法进行比较
// 此时node的左子树为null,直接插入
else if (e.compareTo(node.e) < 0 && node.left == null) {
node.left = new Node(e);
size++;
return;
}
// 否则的话,比节点元素值大且为null称为节点的右孩子
else if (e.compareTo(node.e) > 0 && node.right == null) {
node.right = new Node(e);
size++;
return;
}
// 判断待插入元素与节点元素做比较的结果,如果不等并且节点待插入位置为null,直接插入元素。否则进行递归调用
if (e.compareTo(node.e) < 0) {
// 如果待插入元素小于节点,需要递归的像左子树插入元素
add(node.left, e);
}
// 此时一定(e.compareTo(node.e) > 0)
else {
add(node.right, e);
}
}
2.改进添加操作
当前的插入操作所存在的问题:
- 插入操作的算法过程,整体上是向以node为根的二分搜索树插入新的元素e,具体的过程其实是把新的元素e插入给node的左孩子或者是node的右孩子。对当前的node来说,如果想插入的位置不为空的话,在进行递归调用将它插入到对应的左子树或者右子树当中。 对于递归的add()方法初始调用部分,即public void add()中,对根节点进行了特殊的处理,根为空,根直接是一个新的节点,否则才调用递归函数。在递归函数中,都是新元素e作为node的子节点插入进去的。
尽管算法是正确的,但形成了逻辑的不统一
。 - 递归函数
对于元素e和node.e这个元素进行了两轮比较
。第一轮比较,在比较它们的大小同时还需要看一下它们的左右是否为空,如果为空直接插入元素。如果不为空,则进行了第二轮比较,不能作为node的孩子直接插入元素e,只好递归的再去调用add()函数。 - 递归函数的
终止条件显得极为臃肿
,这是因为需要考虑node的左孩子和右孩子是否为空。空本身也是一颗二叉树。如果这个函数走到了一个node为空的地方,此时就一定需要新创建一个节点,在上述代码的if判断中,并没有递归到底。
当插入的元素e小于node.e,不管node.left是否为空,再递归一层
。如果递归的这一层为空,也就是此时新插入一个元素需要插入到空的位置上,此时的位置本身就应该是新元素节点。可以讲递归终止条件改善,如果node==null
,此时一定需要新插入节点new Node(e)
,还需要将这个节点return给函数调用的上一层
,将这个节点与二叉树挂接起来。
// 向二分搜索树中添加一个新的元素
public void add(E e) {
root = add(root,e);
}
// 像以node为根的二分搜素树插入新的元素e
// 返回插入新节点后二分搜索树的根
private Node add(Node node, E e) {
// 递归终止条件
// node为空时,直接返回node,即对于一个空的二叉树来说,插入一个新节点,这颗二分搜素树的根是这个节点本身
if (node == null) {
size++;
return new Node(e);
}
// 递归调用层
// 当前的插入元素比node元素小,向node的左子树中插入元素e
// 为了让整个二叉树发生改变,在node的左子树中插入元素e的结果可能是变化的,让node的左子树接住这个变化
if (e.compareTo(node.e) < 0)
// 如果node.left为空,则这一次add操作就会返回一个新节点,然后node.left赋值新的节点
node.left = add(node.left, e);
else if (e.compareTo(node.e) > 0)
// 上述同理,当右侧为空,先返回一个新节点,然后让右子树等于这个新节点。
node.right = add(node.right, e);
// 插入新节点以后,二分搜索树的根还是node,根据函数定义的功能语意将其返回
return node;
}
查询操作
对于查询操作,只需要比对每一个node里的元素是否匹配。不牵扯像二分搜索树添加元素那样需要挂接节点。
使用递归实现,在递归查找元素e的过程中,需要从二分搜索树的根开始逐渐转移在新的二分搜索树的子树中缩小问题规模,即缩小查询树的规模直到找到元素e或者最终没有找到元素e。
设置私有的递归算法private boolean contains(Node node,E e)
,首先考虑终止情况node为空,不存在元素的情况直接返回false,其次在进行判断。
// 二分搜索树的查询是否包含元素e
public boolean contains(E e) {
return contains(root, e);
}
// 以node为根节点的二分搜索树中是否包含元素e,递归算法。
private boolean contains(Node node, E e) {
// 终止情况,node为空
if (node == null)
return false;
// 找到返回true,未找到根据判断去左右子树寻找
if (e.compareTo(node.e) == 0)
return true;
else if (e.compareTo(node.e) < 0)
return contains(node.left, e);
else
return contains(node.right, e);
}
因为二分搜索树中不存在索引的概念,暂时不设置通过索引查询或者修改元素的方法。
遍历操作
对于数据结构的遍历,就是把数据结构中所存储的所有的元素都访问一遍
。相应的对于二分搜索树来说就是将所有的节点都访问一遍。
在线性数据结构中,对于遍历操作是极其容易的。无论树数组还是链表,从头到尾做一层循环即可。但在树结构下,并非如此。
1.前序遍历
二分搜索树的递归操作,无论是添加元素还是查询元素,在递归的过程中,每次只选择一个子树进行下去,直到达到递归的终止条件。
但对于遍历操作是不同的,由于需要访问二叉树中所有的节点,两个子树读需要考虑。访问完根节点之后,既要访问左子树中所有节点,也要访问右子树中所有节点,进行两次递归的调用。整体简单结构如下:
function traverse(node):
if (node == null)
return;
访问该节点
traverse(node.left);
traverse(node.right);
上述遍历通常称为二叉树的前序遍历
,是因为先访问节点,再访问左右子树。
// 二分搜索树的前序遍历 用户调用的不需要传参数
public void preOrder(){
// 用户的初始调用只需要对root进行调用
preOrder(root);
}
// 前序遍历以node为根的二分搜索树,递归算法
private void preOrder(Node node){
// 首先递归终止条件 如果node为空没得遍历
if(node == null)
return;
// 将node中存储的元素打印输出出来
System.out.println(node.e);
preOrder(node.left);
preOrder(node.right);
}
编写测试代码Main.java,将数组{5, 3, 6, 8, 4, 2};存放进二叉搜索树进行遍历打印。
public class Main {
public static void main(String[] args) {
BST<Integer> bst = new BST<>();
int[] nums = {5, 3, 6, 8, 4, 2};
for(int n :nums){
bst.add(n);
}
// 5
// / \
// 3 6
// / \ \
// 2 4 8
bst.preOrder();
}
}
测试结果
从根节点开始,先遍历左子树,在遍历右子树
5
3
2
4
6
8
前序遍历的简单应用
基于前序遍历递归的生成当前二叉树对应的字符串
@Override
public String toString() {
StringBuilder res = new StringBuilder();
// 根节点 - 左子树 - 右子树
generateBSTString(root, 0, res);
return res.toString();
}
// 递归函数传入三个参数,遍历的根节点、遍历的当前深度、字符串对象
// 生成以node为根节点,深度为depth的描述二叉树的字符串
private void generateBSTString(Node node, int depth, StringBuilder res) {
if (node == null) {
res.append(generateDepthString(depth) + "null\n");
return;
}
// 如果当前节点不为空,直接访问
res.append(generateDepthString(depth) + node.e + "\n");
// 递归的访问节点的左右子树
generateBSTString(node.left, depth + 1, res);
generateBSTString(node.right, depth + 1, res);
}
// 表达深度的字符串 用"--"来表达深度
private String generateDepthString(int depth) {
StringBuilder res = new StringBuilder();
for (int i = 0; i < depth; i++) {
res.append("--");
}
return res.toString();
}
在Main中打印bst对象,System.out.println(bst);
输出结果如下:
5
--3
----2
------null
------null
----4
------null
------null
--6
----null
----8
------null
------null
通过结果进行简单的分析:
- 根节点是5,前面没有“–”,它的深度是0,
- 5的下面有两个子节点,分别是3和6,他们前面有一组“–”,则深度为1
- 3下面有两个子节点分别是2和4,深度为2
- 由于2和4都是叶子节点,所以它们下面都是两个空节点null
- 对于6,他只有右孩子,左孩子为空
- 同理8是叶子节点,下面两个为空节点
2.中序遍历
对于二分搜索树,前序遍历是最自然的一种遍历方式,同时也是最常用的遍历方式,在大多数情况下使用前序遍历。
前序遍历是先访问节点,然后访问左子树,右子树。相应的,顺序可以改变,先访问节点的左子树,其次访问节点,再访问节点的右子树
。如此称为中序遍历
。
function traverse(node):
if (node == null)
return;
traverse(node.left);
访问该节点
traverse(node.right);
中体现在访问节点放在了左子树和右子树的中间。中序遍历的实现有了前面实现前序遍历的方法,也变得更加简单,只需要调换遍历左子树与打印节点的位置即可。
// 中序遍历
public void inOrder(){
inOrder(root);
}
// 中序遍历以node为根的二分搜索树,递归算法
private void inOrder(Node node) {
// 首先递归终止条件 如果node为空没得遍历
if (node == null)
return;
// 先访问左子树
inOrder(node.left);
// 将node中存储的元素打印输出出来
System.out.println(node.e);
inOrder(node.right);
}
同样在Main函数中调用inOder方法bst.inOrder();
进行结果输出
2
3
4
5
6
8
根据中序遍历的结果明显的发现就是在二分搜索树中所有元素排序的结果
。对于二分搜索树,任何一个节点x,它的左子树的所有节点都比x小,右子树的所有节点都比x大,而中序遍历恰好是先遍历左子树,即先把比节点小的元素都遍历,在遍历节点,最后遍历比节点大的所有元素。得到的结果是一个由小及大的顺序结果
。所以二分搜索树又被称为排序树
,这是它额外的效能。由于使用数组或者链表对元素进行排序的话,还需要额外的工作,不能保证一次遍历就能得到排序的结果,而使用二分搜索树只需要遵从定义,最终使用中序遍历就能得到所有元素顺序排列的结果。
3.后序遍历
同理,有了前序遍历和中序遍历,可想而知,后序遍历的结构就很明显了。
function traverse(node):
if (node == null)
return;
traverse(node.left);
traverse(node.right);
访问该节点
先访问节点的左子树,在访问右子树,最后访问节点。
// 后序遍历
public void posOrder(){
posOrder(root);
}
// 后序遍历以node为根的二分搜索树,递归算法
private void posOrder(Node node){
if(node==null)
return;
posOrder(node.left);
posOrder(node.right);
System.out.println(node.e);
}
在Main中调用bst.posOrder();
进行结果输出
2
4
3
8
6
5
观察结果,发现后序遍历和前序遍历不像中序遍历一样拥有规律。
后序遍历的应用场景
,一个典型的应用就是二分搜索树内存的释放
。需要先把孩子节点都是释放完再释放节点本身。对于Java语言来说,由于有自动的垃圾回收机制,不需要对内存管理进行手动的控制。如果写C++语言等需要手动的控制内存,在二分搜索树释放这方面就需要使用后序遍历。
深入理解前中后序遍历
虽然这三种遍历的方式在程序的实现上是非常容易的,如果有一颗树结构,如何快速的写出它的遍历结果。
对于二分搜索树都可以看作是如下图所示的一种递归结构
节点
/ \
左子树 右子树
对于每一个节点,都连接着左子树和右子树。在具体遍历的时候,对每一个节点都有三次的都访问机会:
- 在遍历左子树之前会访问一次节点
- 遍历完左子树之后,会回到当前节点,接着遍历右子树
- 遍历完右子树之后,又回到了这个节点
所以对于每一个节点使用递归遍历的时候会访问它三次,由此,对二分搜索树的前、中、后三种顺序的遍历,其实就是对节点在哪里进行真正的访问操作,也就是对应代码中在哪里打印输出节点相应的值。
function traverse(node):
if (node == null)
return;
第一次访问该节点
traverse(node.left);
第二次访问该节点
traverse(node.right);
第三次访问该节点
现有如下图一颗二叉树。向二叉树存入元素:15,6,17,3,9,16,23,7,12,20,25,8,11,10,14
15
/ \
6 17
/ \ / \
3 9 16 23
/ \ / \
7 12 20 25
\ / \
8 11 14
/
10
1.理论分析结果
前序遍历也就是在第一次访问节点的时候打印node.e,根据图示分析:
- 首先访问节点15,
打印15
,接下来访问15的左子树,先访问节点6,打印6
, - 接着访问6的左子树,
打印叶子节点3
,接下来访问6的右子树9,打印9
- 开始访问9的左子树,
打印7
,7没有左子树,访问7的右子树,打印叶子节点8
;接着访问9的右子树,打印12
- 访问12的左子树,
打印11
,访问11的左子树,打印叶子节点10
,11没有右子树,访问12的右子树,打印叶子节点14
。至此,已访问完根节点15的左子树的所有节点。 - 开始访问15的右子树先访问节点17,
打印17
- 访问17的左子树,
打印叶子节点16
- 访问17的右子树,
打印23
,访问23的左子树,打印叶子节点20
,访问23的右子树,打印叶子节点25
。至此,已第一次访问完根节点右子树的所有节点
需要注意的是,前序遍历是在第一次访问使打印节点,并不代表此时的递归已经完全访问结束。
根据上述的步骤分析,可以得出当前二叉树的前序遍历结果为:15,6,3,9,7,8,12,11,10,14,17,16,23,20,25
。
同样的中序遍历就是在第二次访问节点的时候打印node.e,根据二叉搜索树的图示分析如下:
- 第一次访问根节点15,接下来访问15的左子树, 第一次访问6,开始访问6的左子树
- 第一次访问3,接下来访问3的左子树,由于3没有左子树,
第二次访问到3
,这时打印3
,由于3没有右子树,第三次访问回3。接着递归继续往回访问,这是第二次访问到6,打印6
,接下来访问6的右子树。 - 同理,第一次访问9,接下来第一次访问7,接着访问7的左子树,由于没有左子树,再一次访问回到7,
打印7
,接下来访问7的右子树。 - 第一次访问8,开始访问左子树,没有左子树,第二次访问8,
打印8
,访问右子树,没有右子树,第三次访问8,往上返回第三次访问到7,再往上第二次访问回到到9,打印9
。 - 开始访问9的右子树,第一次访问12,接着访问11,访问11的左子树,因为没有左子树,第二次访问11,
打印11
,向上返回,第二次访问12,打印12
。12的右子树,同理第二次访问14时进行打印14
。 - 特别注意,打印14以后接下来访问14的右子树,14没有右子树,这时第三次访问14,接着向上返回,第三次访问12,9,6。再向上返回,
第二次访问15
,这时打印15
。至此,已访问完根节点15的左子树的所有节点 - 接下来访问15的右子树,根据上述同样的道理可以分别打印出,16,17,20,23,25。至此,已第二次访问完所有右子树的节点,继续递归往上访问直到第三次访问15才算结束。
最终遍历结束得到的打印结果为:3,6,7,8,9,10,11,12,14,15,16,17,20,23,25
,也就是由小及大的排序结果。
接下来的后序遍历就是在第三次访问节点的时候打印node.e,根据二分搜索树的图示分析如下(有了上面前序和中序的分析,这里我进行简写):
- 第一次访问15,6,3,访问3的左子树,因为没有左子树,第二次访问3,开始访问3的右子树,由于没有右子树,第三次访问3,这时
打印3
- 第二次访问6,第一次访问9,7,第二次访问7,第一次访问8,同3一样,在没有右子树返回第三次访问8进行
打印8
,往上第三次访问7,打印7
,第二次访问9 - 第一次访问12,11,10,同3一样,第三次访问10
打印10
,向上返回第二次访问11,无右子树第三次访问11,打印11
. - 第二次访问12,第一次访问14,同3一样,因为无右子树,第三次访问
打印14
后,向上返回,第三次访问12,9,6,进行打印
,继续向上返回第二次访问15,至此,完成了根节点15所有左子树节点的访问。 - 接下来第一次访问17,16,同3一样,第三次访问16,
打印16
后第二次访问17 - 接着第一次访问23,20,同3一样,
打印20
后,第二次访问23,第一次访问25 - 还是同3一样,第三次访问25,
打印25
之后,开始向上返回,依次打印第三次访问到的23,17,15
,至此,完成了所有节点的遍历过程。
最终根据上述分析,可以得到后序遍历的结果:3,8,7,10,11,14,12,9,6,16,20,25,23,17,15
2.代码测试对比
以上是所有分析的结果,根据理论可以快速得出一颗二分搜索树的前、中、后序遍历的结果。接下来用代码进行测试验证理论结果。
在Main中将15,6,17,3,9,16,23,7,12,20,25,8,11,10,14
定义为一个num2的数组分别存进二叉树bst2中,调用preOrder()
,inOrder()
,posOrder()
进行打印输出。前面实现的代码是换行逐一换行输出,为了观察方便,相应的BST类的前、中、后序遍历递归算法的函数中,将打印函数改写成单行输出 System.out.print(node.e + " ");
BST<Integer> bst2 = new BST<>();
int[] nums2 = {15,6,17,3,9,16,23,7,12,20,25,8,11,10,14};
for (int n : nums2) {
bst2.add(n);
}
bst2.preOrder();
System.out.println();
bst2.inOrder();
System.out.println();
bst2.posOrder();
测试结果如下:
15 6 3 9 7 8 12 11 10 14 17 16 23 20 25
3 6 7 8 9 10 11 12 14 15 16 17 20 23 25
3 8 7 10 11 14 12 9 6 16 20 25 23 17 15
通过观察发现测试输出结果与理论结果完全一致。
前序遍历的非递归实现
1.借助栈实现原理
对于中序、后序遍历也可以使用非递归的方法实现,一方面代码复杂,另一方面实际的应用并不多,所以主要实现前序遍历的非递归写法。
5 | | 栈顶
/ \ | |
3 6 | |
/ \ \ | |
2 4 8 |___| 栈底
以根节点为5的二叉树为例子,右侧是一个栈。
在遍历的过程中首先访问的是根节点5,所以一上来就先将根节点5压入栈
,压入栈的意思就是接下来要访问5这个根节点了。相当于使用自己的栈来模拟系统栈,指导下一次具体要访问谁,初始的时候就把根节点压入栈。
程序开始运行,首先将栈底元素5拿出来,这是记录的下面要访问的元素,对它相应的进行出栈操作,5这个节点就访问结束了。
| | | | 5
| | | |
| | | |
| | ----> | |
| | | |
|_5_| |___|
访问了5,下面接着访问5的子树,在这里将5的两个孩子以先右孩子,后左孩子的顺序压入栈
,遵循栈后入先出
的原则。现在栈顶的元素是3,就是下一次要访问的节点,栈顶元素出栈,对3进行相应的操作,3节点就访问结束了。
| | 5 | | 5 | | 5
| | | | | | 3
| | | | | |
| | ----> | | ----> | |
| | | 3 | | |
|___| |_6_| |_6_|
之后要对3节点的左孩子右孩子进行操作,先压入右孩子4,在压入左孩子2,接下来栈顶元素2就是下面要访问的节点,将2出栈,之后要压入2的左孩子与右孩子,但因为2是叶子节点,就不需要任何压入操作
,继续来看栈顶元素,此时的栈顶元素为4,将4出栈。
| | 5 | | 5 | | 5
| | 3 | | 3 | | 3
| | | | 2 | | 2
| 2 | ----> | | ----> | | 4
| 4 | | 4 | | |
|_6_| |_6_| |_6_|
此时因为4是叶子节点,什么都不需要压入继续看栈顶元素,这时的栈顶元素是根节点的右孩子6,6出栈以后,先压入6的右孩子8,因为6没有左孩子,所以不做操作。接着访问栈顶元素,8出栈。
| | 5 | | 5 | | 5
| | 3 | | 3 | | 3
| | 2 | | 2 | | 2
| | 4 ----> | | 4 ----> | | 4
| | 6 | | 6 | | 6
|___| |_8_| |___| 8
因为8是叶子节点,所以什么都不操作,但这时还要继续拿出栈顶元素,此时的栈已经为空
,说明栈已经没有记录下面要访问的任何节点。至此,整棵二分搜索树遍历结束。
2.代码实现
// 借助栈实现非递归
public void preOrderNR() {
Stack<Node> stack = new Stack<>();
// 初始的时候,将根节点压入栈
stack.push(root);
// 在循环中,每一次stack不为空时候,需要相应的访问节点
while (!stack.isEmpty()) {
// 声明当前访问的节点cur 把栈顶元素拿出来放进cur里
Node cur = stack.pop();
// 对于当前要访问的节点进行打印
System.out.print(cur.e+" ");
// 访问cur之后要依次访问cur的左子树和右子树
// 由于整个栈是后入先出,所以压入先压入右子树
// 压入之前需要判断是否为空,为空不需呀压入栈中
if (cur.right != null)
stack.push(cur.right);
if (cur.left != null)
stack.push(cur.left);
}
}
3.递归与非递归结果比较分析
在Main中调用递归方法和非递归方法进行比较
BST<Integer> bst = new BST<>();
int[] nums = {5, 3, 6, 8, 4, 2};
for (int n : nums) {
bst.add(n);
}
// 5
// / \
// 3 6
// / \ \
// 2 4 8
bst.preOrder();
System.out.println();
bst.preOrderNR();
打印结果如下:
5 3 2 4 6 8
5 3 2 4 6 8
通过对比,两种遍历的结果是一致的。
二分搜索树的遍历,非递归的实现要比递归实现复杂的多,因为它必须使用一个辅助的数据结构才能完成这一过程,而且在算法语意的解读上,也远比递归实现的语意难很多。
层序遍历
对于前序遍历,不管是递归算法还是非递归算法,在这颗二分搜索树在遍历的过程中是一扎到底,最终的遍历结果都会先到达整棵树最深的地方,直到不能更深,才开始返回。这样的方式叫做深度优先遍历。与深度优先遍历相对应的即是广度优先遍历,它遍历的结果对于整棵树其实是按照一层一层的顺序遍历,即层序遍历的结果。
不小心手误在手机点了发布,晚上在写。。。