使用递归底层实现二分搜索树

一、基本方法的实现

同样的使这个类支持泛型,但因为二分搜索树必须具有可比较性,让它继承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.改进添加操作

当前的插入操作所存在的问题:

  1. 插入操作的算法过程,整体上是向以node为根的二分搜索树插入新的元素e,具体的过程其实是把新的元素e插入给node的左孩子或者是node的右孩子。对当前的node来说,如果想插入的位置不为空的话,在进行递归调用将它插入到对应的左子树或者右子树当中。 对于递归的add()方法初始调用部分,即public void add()中,对根节点进行了特殊的处理,根为空,根直接是一个新的节点,否则才调用递归函数。在递归函数中,都是新元素e作为node的子节点插入进去的。尽管算法是正确的,但形成了逻辑的不统一
  2. 递归函数对于元素e和node.e这个元素进行了两轮比较。第一轮比较,在比较它们的大小同时还需要看一下它们的左右是否为空,如果为空直接插入元素。如果不为空,则进行了第二轮比较,不能作为node的孩子直接插入元素e,只好递归的再去调用add()函数。
  3. 递归函数的终止条件显得极为臃肿,这是因为需要考虑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++语言等需要手动的控制内存,在二分搜索树释放这方面就需要使用后序遍历。

五、深入理解前中后序遍历

虽然这三种遍历的方式在程序的实现上是非常容易的,如果有一颗树结构,如何快速的写出它的遍历结果。

二分搜索树都可以看作是如下图所示的一种递归结构

          节点
          /  \
       左子树 右子树

它的每一个节点,都连接着左子树和右子树。在具体遍历的时候,对每一个节点都有三次的都访问机会:

  1. 在遍历左子树之前会访问一次节点
  2. 遍历完左子树之后,会回到当前节点,接着遍历右子树
  3. 遍历完右子树之后,又回到了这个节点

所以对于每一个节点使用递归遍历的时候会访问它三次,由此,对二分搜索树的前、中、后三种顺序的遍历,其实就是对节点在哪里进行真正的访问操作,也就是对应代码中在哪里打印输出节点相应的值。

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

通过对比,两种遍历的结果是一致的。
二分搜索树的遍历,非递归的实现要比递归实现复杂的多,因为它必须使用一个辅助的数据结构才能完成这一过程,而且在算法语意的解读上,也远比递归实现的语意难很多。

七、层序遍历

前序遍历不管是递归算法还是非递归算法,在这颗二分搜索树在遍历的过程中是一扎到底,最终的遍历结果都会先到达整棵树最深的地方,直到不能更深,才开始返回。这样的方式叫做深度优先遍历。与深度优先遍历相对应的即是广度优先遍历,它遍历的结果对于整棵树其实是按照一层一层的顺序遍历,即层序遍历的结果。

1.借助队列实现原理

二分搜索树的每一个节点都有一个深度值,根节点所在的是就是第0层,层序遍历就是一层一层的往下遍历。先遍历第0层节点5,再遍历第一层节点3和6,在遍历第二层节点2,4,8。这样的遍历方式被称为广度优先遍历逐层向下遍历的节点在广度上进行拓展,不同于深度优先遍历先顺着一个至叉走向最深处。
层序遍历的实现通常使用非递归方式实现,需要借助数据结构队列。从根节点开始排着队进入队列,队列中存储的就是待遍历的元素,每次遍历一个元素出队之后再将它的左右孩子入队,这一过程依此类推。

         5                      |   | 队首
        / \                     |   |
       3   6                    |   |
      / \   \                   |   |
     2   4   8                  |   | 队尾

依旧使用以5为根节点的二分搜索树使用,右侧是一个队列。初始化时将根节点5入队,之后每次一次操作先看一下队首,将队首出队。此时的队首是5,将5出队,5就遍历结束。

   | 5 |                 |   |  5
   |   |                 |   |
   |   |                 |   |
   |   |      ---->      |   |
   |   |                 |   |
   |   |                 |   |

接下来需要将节点5的左右孩子分别入队,依照先进先出的原则将3,6分别入队。此时队首是3,将3出队。之后3的左右孩子2,4入队,等待遍历访问。

   | 3 |  5              | 6 |  5              | 6 |  5     
   | 6 |                 |   |  3              | 2 |  3
   |   |                 |   |                 | 4 |  
   |   |      ---->      |   |      ---->      |   |
   |   |                 |   |                 |   |
   |   |                 |   |                 |   |

此时的队首是6,将6进行访问,即出队操作,然后将6的左右孩子进行入队,由于6没有左孩子,只将8入队。

   | 6 |  5              | 2 |  5              | 2 |  5     
   | 2 |  3              | 4 |  3              | 4 |  3
   | 4 |                 |   |  6              | 8 |  6
   |   |      ---->      |   |      ---->      |   |  
   |   |                 |   |                 |   |
   |   |                 |   |                 |   |

之后队首是2,将2出队,这时需要将2的左右孩子入队,由于2是叶子节点,不做任何操作继续看队首是谁进行出队,同理将4,8依次出队。

   | 4 |  5              | 8 |  5              |   |  5     
   | 8 |  3              |   |  3              |   |  3
   |   |  6              |   |  6              |   |  6
   |   |  2   ---->      |   |  2   ---->      |   |  2
   |   |                 |   |  4              |   |  4
   |   |                 |   |                 |   |  8

8出队之后还是需要看一下队列中队首的元素是谁,但现在整个队列已经为空,说明没有任何元素排队,此时层序遍历也就结束了。可以直观的看到,出队的顺序就是二分搜索树由浅及深,从左至右的排列顺序。

2.实现代码及测试结果

 // 二分搜索时的层序遍历
public void levelOrder() {
    // 借助队列进行
    // Java Queue本质是一个接口,真正实现需要选择一个底层的数据结构
    Queue<Node> q = new LinkedList<>();
    // 首先添加根节点
    q.add(root);
    while (!q.isEmpty()) {
        // 声明当前遍历节点 它是队列的出队元素
        Node cur = q.remove();
        System.out.print(cur.e + " ");

        // 左右孩子不为空添加进队列
        if (cur.left != null)
            q.add(cur.left);
        if (cur.right != null)
            q.add(cur.right);
    }
}

在Main中使用bst.levelOrder();打印层序遍历的结果:

5 3 6 2 4 8 

相对深度优先遍历,广度优先遍历可以更快的找到需要查询的元素,主要用于搜索策略。常用于算法设计中—最短路径,它是无权图最短路径的标准解法。

八、删除节点

在二分搜索树中删除一个节点是相对复杂。

         5                                5
        / \                             /   \
       3   6                           3     8
      / \   \                           \   /
     2   4   8                           4 7

1.删除最小值与最大值

由于左子树中所有的节点都小于根节点,所以在一颗二分搜索树中,最小值就是从根节点开始向左走直到再也走不动,最左的节点一定是整棵树的最小值。同理,二分搜索树的最大值就是最右边的节点。在上图左边的树中,最小值是2,最大值是8。

需要注意的是,走不动的时候不一定代表这个节点就是叶子节点,它指的是最小值最后一个没有左孩子的节点,最大值是最后一个没有右孩子的节点。在上图右侧的树中,最小值是3,最大值是8。

寻找最大值

// 寻找二分搜索树的最大元素
public E maximum(){
    // 如果整棵树元素个数为0
    if(size == 0)
        throw new IllegalArgumentException("BST is empty.");
    // 返回递归调用所对应的元素值e
    return maximum(root).e;
}

// 返回以node为根的二分搜索树的最大值的节点
private Node maximum(Node node){
    // 向右走不动的时候,就找到了最大值
    if(node.right == null)
        return node;
    // 如果不为空,看它的右孩子相应的最大值
    return maximum(node.right);
}

寻找最小值

// 寻找二分搜索树的最小元素
public E minimum(){
    // 如果整棵树元素个数为0
    if(size == 0)
        throw new IllegalArgumentException("BST is empty.");
    // 返回递归调用所对应的元素值e
    return minimum(root).e;
}

// 返回以node为根的二分搜索树的最小值的节点
private Node minimum(Node node){
    // 向左走不动的时候,就找到了最小值
    if(node.left == null)
        return node;
    // 如果不为空,看它的左孩子相应的最小值
    return minimum(node.left);
}

删除二分搜索树的最小值,如果这个节点是叶子节点,直接删掉,不需要改变二分搜索树的任何结构,如果不是叶子节点,最小值向左走再也走不动了,此节点有右子树,这种情况下删除最小值后需要将它的整个右子树都变成是它父节点的左子树。同理,删除二分搜索树的最大值,如果这个节点为叶子节点直接删除,如果不是此节点拥有左子树,删除节点后将它的左子树变成它父节点的右子树。

删除最小值

// 从二分搜索树中删除最小值并将其返回
public E removeMin() {
    // 直接接收查找最小值的函数
    E ret = minimum();
    // 删除逻辑 从以root为根的二分搜索树中删除最小值,之后返回了新二分搜素树的根节点
    // 因为removeMin有返回值用root接收
    root = removeMin(root);
    return ret;
}

// 删除以node为根的二分搜索树的最小节点
// 返回删除节点后新二分搜索树的根
private Node removeMin(Node node) {
    // node.left为null说明是最小值的时候
    if (node.left == null) {
        // 先保存一下当前节点的右子树
        Node rightNode = node.right;
        // 即将删除的节点的right等于空 即将这颗节点从二叉树脱离
        node.right = null;
        size--;
        // rightNode变为新的根
        return rightNode;
    }

    // 删除掉对应的左子树所对应的最小值,
    // 让node.left等于结果才能真正改变二分搜索树的结构
    node.left = removeMin(node.left);
    return node;
}

删除最大值

// 从二分搜索树中删除最大值并将其返回
public E removeMax() {
    // 直接接收查找最小值的函数
    E ret = maximum();
    // 删除逻辑 从以root为根的二分搜索树中删除最大值,之后返回了新二分搜素树的根节点
    // 因为removeMax有返回值用root接收
    root = removeMax(root);
    return ret;
}

// 删除以node为根的二分搜索树的最大节点
// 返回删除节点后新二分搜索树的根
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;
}

2.删除任意值

删除任意值有哪些情况:

  1. 删除只有左孩子的节点,删除节点后让它的左子树取代它的位置
  2. 删除只有右孩子的节点,删除节点后让它的右子树取代它的位置
  3. 删除左右都有孩子的节点

在下面例子中,删除左右都有孩子的节点15(Hibbard Deletion)

         5
        / \
       3   15(d)
          /   \
         10   18  
         / \  / \
        8  11 16 20

15节点取名为d,d既有左孩子,又有右孩子,删除d之后需要将它的左右子树融合起来。

  1. 寻找d的后继节点s
    如果将15删除,则需要找一个节点替代15的位置,此时寻找15的后继节点,在所有的子树元素中,离15最近且比15还要大的节点。在上述例子中,这个后继节点是16,即15的右子树中对应的最小值。
  2. 在d的右子树中删除后继节点s,也就是删除d右子树中的最小值16
  3. 删除掉最小值之后,让剩下的子树称为后继节点s的右子树
  4. 最后让s的左子树等于d的左子树,即让16.left等于10
  5. 最后删除d,s已经取代了d,s是新的子树的根
         5
        / \
       3   16(s)
          /   \
         10   18  
         / \    \
        8  11   20

下面是代码实现

// 从二分搜索树中删除元素e的节点
public void remove(E e) {
    // 删除掉元素e之后得到的新的二分搜索树的根返回
    root = remove(root, e);
}

// 递归算法删除以node为根的二分搜索树中元素为e的节点
// 返回删除节点后新的二分搜索树的根
private Node remove(Node node, E e) {
    // 没有找到节点
    if (node == null)
        return null;

    // 小于当前的node.e,继续去左子树找
    if (e.compareTo(node.e) < 0) {
        node.left = remove(node.left, e);
        return node;
    }
    // 大于当前的node.e,继续去右子树找
    else if (e.compareTo(node.e) > 0) {
        node.right = remove(node.right, e);
        return node;
    }
    // e == node.e
    else {
        // 存在三种情况
        // 待删除节点的左子树为空的情况
        if (node.left == null) {
            // 先保存一下当前节点的右子树
            Node rightNode = node.right;
            // 即将删除的节点的right等于空 即将这颗节点从二叉树脱离
            node.right = null;
            size--;
            // rightNode变为新的根
            return rightNode;
        }
        // 待删除节点的右子树为空的情况
        if (node.right == null) {
            Node leftNode = node.left;
            node.left = null;
            size--;
            return leftNode;
        }
        // 待删除节点的左右子树都不为空的情况
        // 先找到比待删除节点大的最小节点,右子树的最小节点
        // 用这个节点顶替待删除节点的位置
        Node successor = minimum(node.right); // 声明新的Node保存右子树最小节点
        successor.right = removeMin(node.right); // 后继节点的右子树等于右子树中删除掉最小节点
        successor.left = node.left; // 左子树等于待删除节点的左子树
        // 此时node节点已经没用了,将这颗节点与二分搜索树脱离关系
        node.left = node.right = null;
        // 这里不需要维护size--,因为在removeMin中已经维护过一次size--
        return successor; // 最后直接返回新的根是后继节点
    }
}

上述例子在删除拥有左右子树的节点时找的是d的后继节点,同时也可以找到d的前驱节点,即左子树的最大值。找前驱节点的逻辑与后继节点的逻辑是相同的,只需要改变核心代码。

Node predecessor = maximum(node.left); // 声明新的Node保存左子树最大节点
predecessor.right = removeMax(node.left); // 前驱节点的左子树等于左子树中删除掉最大节点
predecessor.right = node.right; // 右子树等于待删除节点的右子树
// 此时node节点已经没用了,将这颗节点与二分搜索树脱离关系
node.left = node.right = null;
// 这里不需要维护size--,因为在removeMax中已经维护过一次size--
return predecessor; // 最后直接返回新的根是后继节点

九、最终实现的二分搜索树类BST

BST.java

import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;

// 支持泛型,这个类型必须拥有可比较性
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;
    }

    // 向二分搜索树中添加一个新的元素
    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;
    }

    // 二分搜索树的查询是否包含元素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);
    }

    // 二分搜索树的前序遍历 用户调用的不需要传参数
    public void preOrder() {
        // 用户的初始调用只需要对root进行调用
        preOrder(root);
    }

    // 前序遍历以node为根的二分搜索树,递归算法
    private void preOrder(Node node) {
        // 首先递归终止条件 如果node为空没得遍历
        if (node == null)
            return;

        // 将node中存储的元素打印输出出来
        System.out.print(node.e + " ");
        preOrder(node.left);
        preOrder(node.right);
    }

    // 前序遍历的非递归实现
    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);
        }
    }


    // 中序遍历
    public void inOrder() {
        inOrder(root);
    }

    // 中序遍历以node为根的二分搜索树,递归算法
    private void inOrder(Node node) {
        // 首先递归终止条件 如果node为空没得遍历
        if (node == null)
            return;

        // 先访问左子树
        inOrder(node.left);
        // 将node中存储的元素打印输出出来
        System.out.print(node.e + " ");
        inOrder(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.print(node.e + " ");
    }

    // 二分搜索时的层序遍历
    public void levelOrder() {
        // 借助队列进行
        // Java Queue本质是一个接口,真正实现需要选择一个底层的数据结构
        Queue<Node> q = new LinkedList<>();
        // 首先添加根节点
        q.add(root);
        while (!q.isEmpty()) {
            // 声明当前遍历节点 它是队列的出队元素
            Node cur = q.remove();
            System.out.print(cur.e + " ");

            // 左右孩子不为空添加进队列
            if (cur.left != null)
                q.add(cur.left);
            if (cur.right != null)
                q.add(cur.right);
        }
    }

    // 寻找二分搜索树的最小元素
    public E minimum() {
        // 如果整棵树元素个数为0
        if (size == 0)
            throw new IllegalArgumentException("BST is empty.");
        // 返回递归调用所对应的元素值e
        return minimum(root).e;
    }

    // 返回以node为根的二分搜索树的最小值的节点
    private Node minimum(Node node) {
        // 向左走不动的时候,就找到了最小值
        if (node.left == null)
            return node;
        // 如果不为空,看它的左孩子相应的最小值
        return minimum(node.left);
    }

    // 寻找二分搜索树的最大元素
    public E maximum() {
        // 如果整棵树元素个数为0
        if (size == 0)
            throw new IllegalArgumentException("BST is empty.");
        // 返回递归调用所对应的元素值e
        return maximum(root).e;
    }

    // 返回以node为根的二分搜索树的最大值的节点
    private Node maximum(Node node) {
        // 向右走不动的时候,就找到了最大值
        if (node.right == null)
            return node;
        // 如果不为空,看它的右孩子相应的最大值
        return maximum(node.right);
    }

    // 从二分搜索树中删除最小值并将其返回
    public E removeMin() {
        // 直接接收查找最小值的函数
        E ret = minimum();
        // 删除逻辑 从以root为根的二分搜索树中删除最小值,之后返回了新二分搜素树的根节点
        // 因为removeMin有返回值用root接收
        root = removeMin(root);
        return ret;
    }

    // 删除以node为根的二分搜索树的最小节点
    // 返回删除节点后新二分搜索树的根
    private Node removeMin(Node node) {
        // node.left为null说明是最小值的时候
        if (node.left == null) {
            // 先保存一下当前节点的右子树
            Node rightNode = node.right;
            // 即将删除的节点的right等于空 即将这颗节点从二叉树脱离
            node.right = null;
            size--;
            // rightNode变为新的根
            return rightNode;
        }

        // 删除掉对应的左子树所对应的最小值,
        // 让node.left等于结果才能真正改变二分搜索树的结构
        node.left = removeMin(node.left);
        return node;
    }

    // 从二分搜索树中删除最大值并将其返回
    public E removeMax() {
        // 直接接收查找最小值的函数
        E ret = maximum();
        // 删除逻辑 从以root为根的二分搜索树中删除最大值,之后返回了新二分搜素树的根节点
        // 因为removeMax有返回值用root接收
        root = removeMax(root);
        return ret;
    }

    // 删除以node为根的二分搜索树的最大节点
    // 返回删除节点后新二分搜索树的根
    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;
    }

    // 从二分搜索树中删除元素e的节点
    public void remove(E e) {
        // 删除掉元素e之后得到的新的二分搜索树的根返回
        root = remove(root, e);
    }

    // 递归算法删除以node为根的二分搜索树中元素为e的节点
    // 返回删除节点后新的二分搜索树的根
    private Node remove(Node node, E e) {
        // 没有找到节点
        if (node == null)
            return null;

        // 小于当前的node.e,继续去左子树找
        if (e.compareTo(node.e) < 0) {
            node.left = remove(node.left, e);
            return node;
        }
        // 大于当前的node.e,继续去右子树找
        else if (e.compareTo(node.e) > 0) {
            node.right = remove(node.right, e);
            return node;
        }
        // e == node.e
        else {
            // 存在三种情况
            // 待删除节点的左子树为空的情况
            if (node.left == null) {
                // 先保存一下当前节点的右子树
                Node rightNode = node.right;
                // 即将删除的节点的right等于空 即将这颗节点从二叉树脱离
                node.right = null;
                size--;
                // rightNode变为新的根
                return rightNode;
            }
            // 待删除节点的右子树为空的情况
            if (node.right == null) {
                Node leftNode = node.left;
                node.left = null;
                size--;
                return leftNode;
            }
            // 待删除节点的左右子树都不为空的情况
            // 先找到比待删除节点大的最小节点,右子树的最小节点
            // 用这个节点顶替待删除节点的位置
            Node successor = minimum(node.right); // 声明新的Node保存右子树最小节点
            successor.right = removeMin(node.right); // 后继节点的右子树等于右子树中删除掉最小节点
            successor.left = node.left; // 左子树等于待删除节点的左子树
            // 此时node节点已经没用了,将这颗节点与二分搜索树脱离关系
            node.left = node.right = null;
            // 这里不需要维护size--,因为在removeMin中已经维护过一次size--
            return successor; // 最后直接返回新的根是后继节点
        }
    }

    @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();
    }

}

十、更多的二分搜索树问题

二分搜索树具有顺序性

  1. 寻找元素的floor和ceil
    这个元素可以不在二分搜索树中,floor
  2. rank,select
    rank是指二分搜索树中某一元素的排名是多少,select则是排名是多少的元素是谁?
  3. 维护size的二分搜索树,为node类添加新的成员变量size
    size指每一个为根节点的二分搜索树拥有多少个元素,每一个node维护size之后实现rank和select会简便很多,看整棵二分搜索树有多少元素只需要看root.size
  4. 维护depth的二分搜索树,即节点处在第几层的位置

支持重复元素的二分搜索树

  1. 在定义二分搜索树是让子树>=根节点或者<=根节点。即可以定义一颗二分搜索树的左子树都是<=根节点的。将相同的重复元素存放进左子树中
  2. 可以添加可维护的count,记录每一个节点存放的相同元素个数,即添加重复元素时只需要count++

写在最后

如果代码有还没有看懂的或者我写错的地方,欢迎评论,我们一起学习讨论,共同进步。
推荐学习地址:
liuyubobobo老师的《玩转数据结构》:https://coding.imooc.com/class/207.html
最后,祝自己早日咸鱼翻身,拿到心仪的Offer,冲呀!

猜你喜欢

转载自blog.csdn.net/wankcn/article/details/105602446
今日推荐