极简数据结构之二分搜索树

145 阅读5分钟

二分搜索树

什么是二叉树,就是一个父亲节点,一个左子节点和一个右子节点。左子节点和右子节点都可以为空。
二分搜索树就是在二叉树的基础上具有排序的性质的。如下图所示:

1686373056665.png
性质就是:

  1. 左子节点比父节点小
  2. 右子节点比父节点大

排序性质是可以自定义的,只不过上面的性质是比较典型的而已。
同时二分搜索树不一定是满的二叉树,有可能退化成链表。

output.png

定义二分搜索树

定义节点

private class Node {

    public E e;
    public Node left, right;

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

定义二分搜索树

public class BST<E extends Comparable> {

    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 BST(){
        root = null;
        size = 0;
    }
}

二分搜索树的插入

如下是一棵二分搜索树,我们需要往这棵二分搜索树中添加新的元素45 1686373963465.png
是.gif
可以看到,插入新元素的过程其实很简单,无非就是:

  1. 比较当前节点currentNode与插入节点newNode的大小
  2. 如果currentNode > newNode,就去currentNode的左子树去查找合适的位置插入
  3. 如果currentNode < newNode,就去currentNode的右子树中查找合适的位置插入

图中有演示>=号,这里我们看做是>号就可以了,因为我不想插入相同的元素,我也会尽量避免插入重复的元素的。所以我的插入代码如下所示:

public void add(E e) {

    root = add(root, e);

}

// 向以node为根的二分搜索树插入元素e, 递归算法
private Node add(Node node, E e) {

    if (node == null){
        size ++;
        return new Node(e);
    }
    
    if (e.compareTo(node.e) < 0){
       node.left = add(node.left, e);
    }else if (e.compareTo(node.e) > 0){
       node.right = add(node.right, e);
    }
    // 如果相等就直接返回了,就不做处理了,不插入相同元素
    return node;

}

我们知道,二分搜索树是天然支持递归的,所以我们采用的是递归的方式来执行的。

二分搜索树的查找

同理,其实查找元素的过程也是类似的,如下图所示:

查找.gif
查找的代码如下:

public boolean contains(E e){
    return contains(root, e);
}

private boolean contains(Node node, E e){

    if (node == null)
        return false;

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

获取最大值/最小值

在讲删除节点之前,我们先通过查找最大值/最小值。 其实查找最大值,无非就是往右子树一直查找,直到找不到为止。同理,查找最小值就是往左子树中查找,直到为null。

public E minimum(){
    if (size == 0)
        throw new IllegalArgumentException("BST is empty");
    return minimum(root).e;
}

private Node minimum(Node node){
    if (node.left == null)
        return node;
    return minimum(node.left);
}

public E maximum(){
    if (size == 0)
        throw new IllegalArgumentException("BST is empty");
    return maximum(root).e;
}

private Node maximum(Node node){
    if (node.right == null)
        return node;
    return maximum(node.right);
}

删除最大值/最小值

删除最大值/最小值的方法也很简单,直接找下一个节点替代即可


public E removeMin(){
    E ret = minimum();
    root =   removeMin(root);
    return ret;
}

private Node removeMin(Node node) {

    if (node.left == null) {

        Node rightNode = node.right;
        node.right = null;
        size --;
        return rightNode;
    }
    node.left = removeMin(node.left);
    return node;
}

public E removeMax(){
    E ret = maximum();
    root =  removeMax(root);
    return ret;
}

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;
}

代码是简单的,不过要特别说明一下,我们是递归调用的,一旦我们找到了可以替代的节点,直接将可以替代的节点覆盖掉,然后把替代节点的原先位置置空,然后返回回去。
node.left = removeMin(node.left);这里的意思就是,删除左子树中的最小值,并把成功删除最小值之后的左子树返回回去,赋值成新的左子树。

删除节点

二分搜索树的删除节点相对来说会复杂一些,因为在删除之前要找到后继节点。 比如在下面这棵二分搜索树中。

1686378440531.png
假设,我们要删除的节点是35,演示如下: 删除2.gif
可以看到,由于35这个节点只有左子树,直接返回左子树给root节点了。
同理,如果只有一个右子树,我们就直接把右子树返回给root节点。也就是root.left = deleteNode.right;这样原先要删除的节点没有指针了,也就会被直接回收了。

再来看这个例子,假设,我们要删除的节点是49。

  1. 从root节点39开始找,发现49 > 39
  2. 去右子树中查找,49 > 45
  3. 再去45的右子树中查找,发现 49 == 49
  4. 执行删除操作

删除的操作演示如下: 删除.gif

我们把这一步称为查找需要删除节点的后继节点。很容易的发现,就是往待删除节点(49)的右子树中查找最小值,查找到后继节点后,缓存起来,然后去待删除节点(49)的右子树中,删除最小值,把删除最小值后的新子树当做后继节点的右子树,把待删除的节点(49)的左子树当做后继节点的左子树。整个删除操作完成后,要返回删除之后的子树给到父节点,这样就真正完成删除操作了。
代码如下:

// 删除以node为根的二分搜索树中值为e的节点,地柜算法
// 返回删除节点后新的二分搜索树的根
private Node remove(Node node, E e){

    if (node == null)
        return null;

    if (e.compareTo(node.e) < 0) {
        node.left = remove(node.left, e);
        return node;
    }else if (e.compareTo(node.e) > 0){
        node.right = remove(node.right, e);
        return node;
    }else { // e == node.e

        if (node.left == null) {
            Node rightNode = node.right;
            node.right = null;
            size --;
            return rightNode;
        }

        if (node.right == null) {
            Node leftNode = node.left;
            node.left = null;
            size --;
            return leftNode;
        }

        // 待删除节点左右字数均不为空的情况
        // 找到比待删除节点大的最小节点,即待删除节点左右子树的最小节点
        // 用这个节点顶替待删除节点的位置
        Node successor = minimum(node.right);
        successor.right = removeMin(node.right);
        successor.left = node.left;

        node.left = node.right = null;
        return successor;
    }
}

删除操作是运用到二分搜索树天然的递归性质,可以好好体会下这种递归的性质。

二分搜索树的遍历

二分搜索树的遍历分为前中后序遍历,还有层序遍历。关于二分搜索树的前中后序遍历挺简单的,借助二分搜索树的天然递归就可以实现了。代码如下:

private void inOrder(){
    inOrder(root);
}

private void inOrder(Node node) {
    if (node == null)
        return;
    inOrder(node.left);
    System.out.println(node.e);
    inOrder(node.right);
}

这是中序遍历,后序遍历跟前序遍历差不多的,调整下位置就可以了。

层序遍历

层序遍历需要借助额外的数据结构——队列来实现,这里不做过多解释了,可以一边画图一边了解,很简单的。

public void levelOrder(){

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

二分搜索树的时间复杂度

我们知道,二分搜索树具有天然递归的性质,我们知道,如果递归太深了,可能会出现stackoverflow的异常。那为什么二分搜索树的实现要采用递归来实现?其实很简单,二分搜索树的递归层次跟二分搜索树的深度有关。
二分搜索树的深度:

image.png

如果我们把根节点所在的层称为第0层,那么二分搜索树的深度就像上图所示的那样。
了解到了二分搜索树的深度之后,我们就可以来讨论时间复杂度了。
假设,我们是一棵满二叉树 1686380037097.png
我们可以观察到:

层数h满二分搜索树节点总数 n
01
13
27
315

很容易看出来,深度和总的节点数存在这样的关系:
20+21+22+23+...2h=n2^0 + 2^1 + 2^2 + 2^3 + ... 2^h = n 这就是一个等比数列,根据等比数列的求和公式,可以得到:
2h=n12^h = n - 1
得到h=log2(n1)h = log_2(n -1)
可以看到,二分搜索树查找的最好时间复杂度是O(logn)O(logn),插入的时间复杂度也是这种分析方法。
既然有最好的时间复杂度,那最坏的呢?最坏的就是退化成链表的二分搜索树,如下图所示:

output.png
如果退化成链表的二分搜索树,查找/插入的时间复杂度就是O(n)O(n)了。为了避免二分搜索树出现退化成链表的情况,所以后面才会有平衡二叉树AVL以及红黑树这些数据结构。
有时间再介绍AVL以及红黑树。
所以经过我们分析,深度大概是lognlogn,拿1000000的规模来说,也不过大概等于19左右而已,如下图:

1686381148769.png
所以在现代计算机的基础上,基本不用担心递归导致栈溢出的问题。