【数据结构】二叉搜索树介绍 + 手写简单的BinarySearchTree

379 阅读6分钟

二叉搜索树

什么是二分搜索树

二分搜索树(Binary Search Tree),也称二叉搜索树、有序二叉树,是一种以二叉树为基础的数据结构。

二叉搜索树(Binary Search Tree),也称二叉查找树。有序二叉树(Ordered Binary tree)、排序二叉树(Sorted Binary Tree)都是同一个东西。

image.png

性质: 可以看到左节点永远小于根节点,右节点永远大于根节点。

二分搜索树的特点

  • 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
  • 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
  • 任意节点的左、右子树也分别为二叉查找树。

常见操作

二分搜索树的常见操作:

  1. 插入节点,按照二分查找树的规则来插入元素
  2. 索引节点,查找指定元素,从头遍历按值来选择左右节点
  3. 删除节点,删除节点后需要把它下面的节点进行顺序调整

使用场景

  1. 用于快速查找数据。因为其分层结构,可以将查找范围缩小一半,从而实现log(n)的查询复杂度。
  2. 实现排序。可以通过中序遍历得到有序的结果。
  3. AVL 树、红黑树都是基于二分搜索树进行平衡和调整的。
  4. 树状数组。通过将每个节点的值记作区间范围来实现。

手写BinarySearchTree

完成后的整体结构

image.png

这个是二分搜索类的基本框架,从上往下来介绍:

  • 根节点root
  • 比较器comparator
  • 构造方法
  • 添加方法
  • 添加方法(递归形式,方法重载)
  • 中序遍历
  • 搜索方法
  • 删除方法
  • 删除最大节点
  • 查询最大节点

静态节点类

static class Node<T> {
    private final T value;
    private Node<T> left;
    private Node<T> right;
    Node(T value) {
        this.value = value;
    }
}

里面维护了一个

  • 泛型T value
  • 左子节点
  • 右子节点
  • 构造方法

增加节点的方法

增加节点就是按照节点的大小来进行插入:
对根节点进行遍历:

public void add(T value) {
    root = add(root, value);
}
private Node<T> add(Node<T> node, T value) {
    if (node == null) {
        return new Node<>(value);
    }
    // 添加的元素小于当前元素, 向左递归
    if (comparator.compare(node.value, value) > 0) node.left = add(node.left, value);
    else node.right = add(node.right, value);
    return node;
}
  1. 如果当前节点比要插入的节点大, 就往要插入节点的左边递归
  2. 如果当前节点比要插入的节点小, 就往要插入节点的右边递归
  3. 如果递归过程中某个节点为空,表示没有数据,此时把数据返回。

下面是一个比较的例子,参考一下:

image.png

中序遍历

public void interOrder(Node<T> node) {
    if (node == null) return;
    interOrder(node.left);
    System.out.println(node.value);
    interOrder(node.right);
}

这里是使用中序遍历的方法来打印这个树,当然也可以使用前序和后序遍历,这里是递归的写法。

  1. 首先对节点的左节点进行递归操作,如果左子节点不为null,则一直堆栈,直到左子节点为空
  2. 方法栈里不断调用弹出方法,从最后进入的开始,依次打印当前节点的值,当然先打印的当前节点肯定是左子节点,然后递归当前节点的右子节点,最终它就是一个中序遍历的输出。

删除节点的方法

删除是这里面最难的步骤:

public void delete(T value) {
    delete(root, value);
}
private Node<T> delete(Node<T> node, T value) {
    if (node == null) {
        return null;
    }
    // node.value > value
    if (comparator.compare(node.value, value) > 0) {
        node.left = delete(node.left, value);
        return node;
        // node.value < value
    } else if (comparator.compare(node.value, value) < 0) {
        node.right = delete(node.right, value);
        return node;
    } else { // value == node.value
        // 待删除节点左子树为空的情况
        // 这里返回的节点替换原来被删除的节点
        if (node.left == null) {
            Node<T> rightNode = node.right;
            node.right = null;
            return rightNode;
        }
        // 待删除节点右子树为空的情况
        if (node.right == null) {
            Node<T> leftNode = node.left;
            node.left = null;
            return leftNode;
        }
        // 待删除节点左右子树均不为空的情况
        // 查找待删除节点的前继节点
        // 用前继节点替换当前待删除节点

        // 查找前继节点, 从待删除节点的左子树,查找最大值
        Node<T> successor = searchMax(node.left);

        removeMax(node ,node.left);
        successor.left = node.left;
        // 删除左边的最大值
        successor.right = node.right;

        // 后继节点完成替换, 删除当前节点
        node.left = node.right = null;
        return successor;
    }
}
public void removeMax(Node<T> pre ,Node<T> node){
    while (node.right!=null){
        pre = node;
        node = node.right;
    }
    assert pre != null;
    if(node.left == null){
        pre.left = null;
    }
    else{
        pre.left = node.left;
    }
}
// 找出比删除节点小的里面的最大的
public Node<T> searchMax(Node<T> node) {
    while (node!=null){
        if (node.right == null) return node;
        node = node.right;
    }
    return null;
}

首先我们通过递归找到待删除的节点,将其操作之后的节点作为返回结果,最终这个返回结果会作为它前继节点的左子节点或右子节点。

删除分为三种情况:

  1. 删除的节点为叶子节点: 直接删除
  2. 删除的节点有一个子节点:用子节点与其替换
  3. 删除的节点有两个子节点: 寻找待删除节点的左节点中最大的节点来将其填充在删除的位置
    这个删除有两种办法: (总之就是这样可以维持二叉搜索树左小右大的结构)
    • 前驱转换(predecessor swapping):使用节点的左子树最大值来替换这个节点,然后删除原左子树最大值节点。
    • 后继转换(successor swapping):使用节点的右子树最小值来替换这个节点,然后删除原右子树最小值节点。

我这里使用的是前驱转换:

  1. 首先需要找到待删除节点的左边的最大值,因此使用searchMax()方法,就是寻找它的右子节点,没有就返回当前节点
  2. 然后删除 待删除节点的左边的最大值:
    1. 然后又需要判断待删除节点的左边是否有值
    2. 有值的话就把左边的值作为前继节点的左子节点,没有值的话就把前继节点的left指向null
  3. 将我们找到的待删除节点的左边的最大值(也就是successor), 让它的左右节点指向待删除节点的左右节点,待删除节点的左右节点指向null,这样就完成了替换。

测试方法

public static void main(String[] args) {
    BinarySearchTree<Integer> tree = new BinarySearchTree<>(Integer::compare);
    tree.add(10);
    tree.add(5);
    tree.add(15);
    tree.add(3);
    tree.add(8);
    tree.add(25);
    tree.add(6);
    tree.interOrder(tree.root);
    System.out.println("=====================");
    tree.delete(5);
    tree.interOrder(tree.root);
}

输出:
3
5
6
8
10
15
25
=====================
3
6
8
10
15
25

可以看到我们正确输出了。

总结

  1. 二分搜索树是一种树形结构,使用左右节点来维护。
  2. 二分搜索树的查询速度和插入速度一般
    • 插入:O(h),h是树的高度。最差情况下为O(n)
    • 删除:O(h),h是树的高度。最差情况下为O(n)
    • 搜索:O(h),h是树的高度。最差情况下为O(n)
  3. 如果添加顺序不当,可能会退化为链表
  4. 手写时在添加和删除都需要知道前继节点,而使用递归方法更方便,但是逻辑很难理解
  5. 它适用于快速查找数据(前提是添加顺序正确),正序,中序和后序遍历数据。
  6. 有一个缺点, 该二分查找树可能会退化为链表,时间复杂度退化为O(n)。