在Java中实现二叉搜索树的全过程记录

目录
  • 二叉搜索树
  • 有序符号表的 API
  • 实现二叉搜索树
    • 二叉搜索树类
    • 查找
    • 插入
    • 最小/大的键
    • 小于等于 key 的最大键/大于等于 key 的最小键
    • 根据排名获得键
    • 根据键获取排名
    • 删除
  • 总结

二叉搜索树

二叉搜索树结合了无序链表插入便捷和有序数组二分查找快速的特点,较为高效地实现了有序符号表。下图显示了二叉搜索树的结构特点(图片来自《算法第四版》):

可以看到每个父节点下都可以连着两个子节点,键写在节点上,其中左边的子节点的键小于父节点的键,右节点的键大于父节点的键。每个父节点及其后代节点组成了一颗子树,根节点及其后代节点则组成了完整的二叉搜索树。

在代码层面看来,就是每个节点对象中包含另外两个子节点的指针,同时包含一些要用到的数据,比如键值对和方便后续操作的整课子树的节点数量。

private class Node {
    int N = 1;
    K key;
    V value;
    Node left;
    Node right;

    public Node(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

上述代码实现了一个节点类,这个类是二叉搜索树类 BinarySearchTree 的内部类,使用者无需知道这个节点类的存在,所以访问权限声明为 private。

有序符号表的 API

先来看下无序符号表的 API,这些方法声明了无序符号表的基本操作,包括插入、查询和删除,为了方便符号表的迭代,接口中还有 Iterable<K> keys() 方法用于 foreach 循环:

package com.zhiyiyo.collection.symboltable;

public interface SymbolTable<K, V>{
    void put(K key, V value);
    V get(K key);
    void delete(K key);
    boolean contains(K key);
    boolean isEmpty();
    int size();
    Iterable<K> keys();
}

接下来是有序符号表的 API,其中每个节点的键必须实现了 Comparable 接口:

package com.zhiyiyo.collection.symboltable;

public interface OrderedSymbolTable<K extends Comparable<K>, V> extends SymbolTable<K, V>{
    /**
     * 获取符号表中最小的键
     * @return 最小的键
     */
    K min();

    /**
     * 获取符号表中最大的键
     * @return 最大的键
     */
    K max();

    /**
     * 获取小于或等于 key 的最大键
     * @param key 键
     * @return 小于或等于 key 的最大键
     */
    K floor(K key);

    /**
     * 获取大于或等于 key 的最小键
     * @param key 键
     * @return 大于或等于 key 的最小键
     */
    K ceiling(K key);

    /**
     * 获取小于或等于 key 的键数量
     * @param key 键
     * @return 小于或等于 key 的键数量
     */
    int rank(K key);

    /**
     * 获取排名为 k 的键,k 的取值范围为 [0, N-1]
     * @param k 排名
     * @return 排名为 k 的键
     */
    K select(int k);

    /**
     * 删除最小的键
     */
    void deleteMin();

    /**
     * 删除最大的键
     */
    void deleteMax();

    /**
     * [low, high] 区间内的键数量
     * @param low 最小的键
     * @param high 最大的键
     * @return 键数量
     */
    int size(K low, K high);

    /**
     * [low, high] 区间内的所有键,升序排列
     * @param low 最小的键
     * @param high 最大的键
     * @return 区间内的键
     */
    Iterable<K> keys(K low, K high);
}

实现二叉搜索树

二叉搜索树类

类的基本结构如下述代码所示,可以看到只需用一个根节点 root 即可代表一整棵二叉搜索树:

public class BinarySearchTree<K extends Comparable<K>, V> implements OrderedSymbolTable<K, V> {
    private Node root;

    private class Node{
        ...
    }

    ...
}

查找

从根节点出发,拿着给定的键 key 和根节点的键进行比较,会出现以下三种情况:

  • 根节点的键大于 key,接着去根节点的左子树去查找;
  • 根节点的键小于 key,接着去根节点的右子树去查找;
  • 根节点的键等于 key,返回根节点的值

当我们去左子树或者右子树查找时,只需将子树的根节点视为新的根节点,然后重复上述步骤即可。如果找到最后都没找到拥有和 key 相等的键的节点,返回 null 即可。在《算法第四版》中使用递归实现了上述步骤,这里换为迭代法:

@Override
public V get(K key) {
    Node node = root;
    while (node != null) {
        int cmp = node.key.compareTo(key);
        // 到左子树搜索
        if (cmp > 0) {
            node = node.left;
        }
        // 到右子树搜索
        else if (cmp < 0) {
            node = node.right;
        } else {
            return node.value;
        }
    }
    return null;
}

插入

将键值对放入二叉搜索树时会发生两种情况:

  • 二叉搜索树中已经包含了拥有该键的节点,这时需要更新节点的值
  • 二叉搜索树中没有包含拥有该键的节点,这时需要创建一个新的节点

所以在插入的时候要从根节点出发,比较根节点的键和给定的 key 之间的大小关系,和查找相似,比较会有三种情况发生:

  • 根节点的键大于 key,接着去根节点的左子树去查找;
  • 根节点的键小于 key,接着去根节点的右子树去查找;
  • 根节点的键等于 key,直接更新根节点的值

如果找到最后都没能找到那个拥有相同 key 的节点,就需要创建一个新的节点,把这个节点,接到子树的根节点上,用迭代法实现上述过程的代码如下所示:

@Override
public put(K key, V value){
    if (root == null) {
        root = new Node(key, value);
        return;
    }

    Node node = root;
    Node parent = root;
    int cmp = 0;

    while (node != null){
        parent = node;
        cmp = node.key.compareTo(key);
        // 到左子树搜索
        if (cmp > 0){
            node = node.left;
        }
        // 到右子树搜索
        else if (cmp < 0){
            node = node.right;
        } else {
            node.value = value;
            return;
        }
    }

    // 新建节点并接到父节点上
    if (cmp > 0) {
        parent.left = new Node(key, value);
    } else{
        parent.right = new Node(key, value);
    }
}

可以看到上述过程用了两个指针,一个指针 node 用于探路,一个指针 parent 用于记录子树的根节点,不然当 node 为空时我们是找不到他的父节点的,也就没法把新的节点接到父节点上。

上述代码有个小问题,就是我们新建节点之后没办法更新这一路上所经过的父节点的 N,也就是每一颗子树的节点数。怎么办呢,要么用一个容器保存一下经过的父节点,要么老老实实用递归,这里选择用递归。递归的想法很直接:

  • 如果根节点的键大于 key,就把键值对插到根节点的左子树;
  • 如果根节点的键小于 key,就把键值对插到根节点的右子树;
  • 如果根节点的键等于 key,直接更新根节点的值

别忘了,使用递归的原因是我们要更新父节点的 N,所以递归的返回值应该是更新后的子树根节点,所以就有了下述代码:

@Override
public void put(K key, V value) {
    root = put(root, key, value);
}

private Node put(Node node, K key, V value) {
    if (node == null) return new Node(key, value);
    int cmp = node.key.compareTo(key);
    if (cmp > 0) {
        node.left = put(node.left, key, value);
    } else if (cmp < 0) {
        node.right = put(node.right, key, value);
    } else {
        node.value = value;
    }
    node.N = size(node.left) + size(node.right) + 1;
    return node;
}

private int size(Node node) {
    return node == null ? 0 : node.N;
}

最小/大的键

从根节点出发,一路向左,键会是一个递减的序列,当我们走到整棵树的最左边,也就是 left 为 null 的那个节点时,我们就已经找到了键最小的节点。上述过程的迭代法代码如下:

@Override
public K min() {
    if (root == null) {
        return null;
    }

    Node node = root;
    while (node.left != null) {
        node = node.left;
    }

    return node.key;
}

查找最大键的节点过程和上述过程类似,只是我们这次得向右走,直到找到 right 为 null 的那个节点:

@Override
public K max() {
    if (root == null) {
        return null;
    }

    Node node = root;
    while (node.right != null) {
        node = node.right;
    }

    return node.key;
}

算法书中给出的 min() 实现代码是用递归实现的,因为在删除节点时会用到。递归的过程就是一直朝左子树走的的过程,直到遇到一个节点没有左子树为止,然后返回该节点即可。

@Override
public K min() {
    if (root == null) {
        return null;
    }

    return min(root).key;
}

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

小于等于 key 的最大键/大于等于 key 的最小键

从根节点出发,拿着根节点的的键和 key 进行比较,会出现三种情况:

  • 如果根节点的键大于 key,说明拥有小于或等于 key 的键的节点可能在左子树上(也可能找不到);
  • 如果根节点的键小于 key,这时候先记住根节点,由于根节点的右子树上可能存在键更接近但不大于 key 的节点,所以还得去右子树看看,如果右子树没没找到满足条件的节点,这时候的根节点的键就是小于等于 key 的最大键了;
  • 如果根节点的键等于 key,直接返回根节点的键
@Override
public K floor(K key) {
    if (root == null) {
        return null;
    }

    Node node = root;
    Node candidate = root;
    while (node != null) {
        int cmp = node.key.compareTo(key);
        if (cmp > 0) {
            node = node.left;
        } else if (cmp < 0) {
            candidate = node;
            node = node.right;
        } else {
            return node.key;
        }
    }

    return candidate.key.compareTo(key) <= 0 ? candidate.key : null;
}

《算法第四版》中给出了一个示例图,可以更直观地看到上述查找过程:

查找大于等于 key 的最小键的方法和上述过程很像,拿着根节点的的键和 key 进行比较,会出现三种情况:

  • 如果根节点的键小于 key,说明拥有大于或等于 key 的键的节点可能在右子树上(也可能找不到);
  • 如果根节点的键大于 key,这时候先记住根节点,由于根节点的左子树上可能存在键更接近但不小于 key 的节点,所以还得去左子树看看,如果左子树没没找到满足条件的节点,这时候的根节点的键就是大于等于 key 的最小键了;
  • 如果根节点的键等于 key,直接返回根节点的键
@Override
public K ceiling(K key) {
    if (root == null) {
        return null;
    }

    Node node = root;
    Node candidate = root;
    while (node != null) {
        int cmp = node.key.compareTo(key);
        if (cmp < 0) {
            node = node.right;
        } else if (cmp > 0) {
            candidate = node;
            node = node.left;
        } else {
            return node.key;
        }
    }

    return candidate.key.compareTo(key) >= 0 ? candidate.key : null;
}

根据排名获得键

假设一棵二叉搜索树中有 N 个节点,那么节点的键排名区间就是 [0, N-1],也就是说,key 的排名可以看做小于 key 的键的个数。所以我们应该如何根据排名获得其对应的键呢?这时候每个节点中的维护的 N 属性就可以派上用场了。

从根节点向左看,左子树的节点数就是小于根节点键的键个数,也就是根节点的键排名。所以拿着根节点的左子树节点数 N 和排名 k 进行比较,会出现三种情况:

  • 左子树的节点数和排名相等,直接返回根节点的键;
  • 左子树的节点数大于排名,这时候去左子树接着进行比较;
  • 左子树的节点数小于排名,说明符合排名要求的节点可能出现在右子树上(有可能找不到,比如 k 大于整棵二叉树的节点数),这时候我们得去右子树搜索。由于我们直接忽略了左子树和根节点,所以需要对排名进行一下调整,让 k = k - N - 1 即可。
@Override
public K select(int k) {
    Node node = root;
    while (node != null) {
        // 父节点左子树的大小就是父节点的键排名
        int N = size(node.left);
        if (N > k) {
            node = node.left;
        } else if (N < k) {
            node = node.right;
            k = k - N - 1;
        } else {
            return node.key;
        }
    }

    return null;
}

根据键获取排名

把根据排名获取键的过程写作key = select(k),那么根据键获取排名的过程就是k = select-1(key) = rank(key)。说明这两个函数互为反函数。

从根节点出发,拿着根节点的键和 key 进行比较会出现三种情况:

  • 根节点的键大于 key,这时候得去左子树中寻找
  • 根节点的键小于 key,这时候得去右子树中寻找,同时得记录一下左子树节点数+父节点的那个1
  • 根节点的键等于 key,返回根节点的左子树节点数加上之前跳过的节点数
@Override
public int rank(K key) {
    Node node = root;
    int N = 0;
    while (node != null) {
        int cmp = node.key.compareTo(key);
        if (cmp > 0) {
            node = node.left;
        } else if (cmp < 0) {
            N += size(node.left) + 1;
            node = node.right;
        } else {
            return size(node.left) + N;
        }
    }

    return N;
}

删除

删除操作较为复杂,先来看下较为简单的删除键最小的节点的过程。从根节点出发,一路向左,知道遇到左子树为 null 的节点,由于这个节点可能还有右子树,所以需要把右子树接到父节点上。接完之后还得把这一路上遇到的父节点上的 N - 1。由于没有其他节点引用了被删除的节点,所以这个节点会被 java 的垃圾回收机制自动回收。算法书中给出了一个删除的示例图:

使用迭代法可以实现寻找最小节点和将右子树连接到父节点的操作,但是不好处理每一颗子树的 N 的更新操作,所以还是得靠递归法。由于我们需要将最小节点的右子树接到父节点上,所以满足终止条件时 deleteMin(Node node) 函数应该把右子树的根节点返回,否则就应该返回更新之后的节点。

@Override
public void deleteMin() {
    if (root == null) return;
    root = deleteMin(root);
}

private Node deleteMin(Node node) {
    if (node.left == null) return node.right;
    node.left = deleteMin(node.left);
    node.N = size(node.left) + size(node.right) + 1;
    return node;
}

删除最大的节点的过程和上面相似,只不过我们应该将最大节点的左子树接到父节点上。

@Override
public void deleteMax() {
    if (root == null) return;
    root = deleteMax(root);
}

private Node deleteMax(Node node) {
    if (node.right == null) return node.left;
    node.right = deleteMax(node.right);
    node.N = size(node.left) + size(node.right) + 1;
    return node;
}

讨论完上面两个较为简单的删除操作,我们来看下如何删除任意节点。从根节点出发,通过比较根节点的键和给定的 key,会发生三种情况:

根节点的键大于 key,接着去左子树删除 key

根节点的键小于 key,接着去右子树删除 key

根节点的键等于 key ,说明我们找到了要被删除的那个节点,这时候我们又会遇到三种情况:

  • 节点的右子树为空,直接将左子树的根节点接到父节点上
  • 节点的左子树为空,直接将右子树的根节点接到父节点上
  • 节点的右子树和左子树都不为空,这时候需要找到并删去右子树的最小键节点,然后把这个最小键节点顶替即将被删除节点,把它作为新的子树根节点

算法书中给出了第三种情况(右子树和左子树都不为空)的示例图:

使用递归实现的代码如下所示:

@Override
public void delete(K key) {
    root = delete(root, key);
}

private Node delete(Node node, K key) {
    if (node == null) return null;

    // 先找到 key 对应的节点
    int cmp = node.key.compareTo(key);
    if (cmp > 0) {
        node.left = delete(node.left, key);
    } else if (cmp < 0) {
        node.right = delete(node.right, key);
    } else {
        if (node.right == null) return node.left;
        if (node.left == null) return node.right;
        Node x = node;
        node = min(x.right);
        // 移除右子树的最小节点 node,并将该节点作为右子树的根节点
        node.right = deleteMin(x.right);
        // 设置左子树的根节点为 node
        node.left = x.left;
    }

    node.N = size(node.left) + size(node.right) + 1;
    return node;
}

总结

如果在插入键值对的时候运气较好,二叉搜索树的左右子树高度相近,那么插入和查找的比较次数为 \(\sim2\ln N\) ;如果运气非常差,差到所有的节点连成了一条单向链表,那么插入和查找的比较次数就是 \(\sim N\)。所以就有了自平衡二叉树的出现,不过这已经超出本文的探讨范围了(绝对不是因为写不动了,以上~~

到此这篇关于在Java中实现二叉搜索树的文章就介绍到这了,更多相关Java二叉搜索树内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java创建二叉搜索树,实现搜索,插入,删除的操作实例

    Java实现的二叉搜索树,并实现对该树的搜索,插入,删除操作(合并删除,复制删除) 首先我们要有一个编码的思路,大致如下: 1.查找:根据二叉搜索树的数据特点,我们可以根据节点的值得比较来实现查找,查找值大于当前节点时向右走,反之向左走! 2.插入:我们应该知道,插入的全部都是叶子节点,所以我们就需要找到要进行插入的叶子节点的位置,插入的思路与查找的思路一致. 3.删除: 1)合并删除:一般来说会遇到以下几种情况,被删节点有左子树没右子树,此时要让当前节点的父节点指向当前节点的左子树:当被删节点

  • java实现 二叉搜索树功能

    一.概念 二叉搜索树也成二叉排序树,它有这么一个特点,某个节点,若其有两个子节点,则一定满足,左子节点值一定小于该节点值,右子节点值一定大于该节点值,对于非基本类型的比较,可以实现Comparator接口,在本文中为了方便,采用了int类型数据进行操作. 要想实现一颗二叉树,肯定得从它的增加说起,只有把树构建出来了,才能使用其他操作. 二.二叉搜索树构建 谈起二叉树的增加,肯定先得构建一个表示节点的类,该节点的类,有这么几个属性,节点的值,节点的父节点.左节点.右节点这四个属性,代码如下 sta

  • Java删除二叉搜索树最大元素和最小元素的方法详解

    本文实例讲述了Java删除二叉搜索树最大元素和最小元素的方法.分享给大家供大家参考,具体如下: 在前面一篇<Java二叉搜索树遍历操作>中完成了树的遍历,这一节中将对如何从二叉搜索树中删除最大元素和最小元素做介绍: 我们要想删除二分搜索树的最小值和最大值,就需要先找到二分搜索树的最小值和最大值,其实也还是很容易的,因为根据二叉搜索树的特点,它的左子树一定比当前节点要小,所以二叉搜索树的最小值一定是左子树一直往下走,一直走到底.同样在二叉搜索树中,右子树节点值,一定比当前节点要大,所以右子树一直

  • Java二叉搜索树基础原理与实现方法详解

    本文实例讲述了Java二叉搜索树基础原理与实现方法.分享给大家供大家参考,具体如下: 前言:本文通过先通过了解一些二叉树基础知识,然后在转向学习二分搜索树. 1 树 1.1 树的定义 树(Tree)是n(n>=0)个节点的有限集.n=0时称为空树.在任意一颗非空树中: (1)有且仅有一个特定的称为根(Root)的节点: (2)当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1.T2........Tn,其中每一个集合本身又是一棵树,并且称为根的子树. 此外,树的定义还需要强调以

  • Java删除二叉搜索树的任意元素的方法详解

    本文实例讲述了Java删除二叉搜索树的任意元素的方法.分享给大家供大家参考,具体如下: 一.删除思路分析 在删除二叉搜索树的任意元素时,会有三种情况: 1.1 删除只有左孩子的节点 节点删除之后,将左孩子所在的二叉树取代其位置:连在原来节点父亲元素右节点的位置,比如在图中需要删除58这个节点. 删除58这个节点后,如下图所示: 1.2 删除只有右孩子的节点: 节点删除之后,将右孩子所在的二叉树取代其位置:连在原来节点的位置,比如在下图中需要删除58这个节点. 删除58这个节点后,如下图所示: 这

  • 利用java实现二叉搜索树

    二叉搜索树的定义 它是一颗二叉树 任一节点的左子树上的所有节点的值一定小于该节点的值 任一节点的右子树上的所有节点的值一定大于该节点的值 特点: 二叉搜索树的中序遍历结果是有序的(升序)! 实现一颗二叉搜索树 实现二叉搜索树,将实现插入,删除,查找三个方面 二叉搜索树的节点是不可以进行修改的,如果修改,则可能会导致搜索树的错误 二叉搜索树的定义类 二叉搜索树的节点类 -- class Node 二叉搜索树的属性:要找到一颗二叉搜索树只需要知道这颗树的根节点. public class BST {

  • Java 实现二叉搜索树的查找、插入、删除、遍历

    由于最近想要阅读下JDK1.8 中HashMap的具体实现,但是由于HashMap的实现中用到了红黑树,所以我觉得有必要先复习下红黑树的相关知识,所以写下这篇随笔备忘,有不对的地方请指出- 学习红黑树,我觉得有必要从二叉搜索树开始学起,本篇随笔就主要介绍Java实现二叉搜索树的查找.插入.删除.遍历等内容. 二叉搜索树需满足以下四个条件: 若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值: 若任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值: 任意节点的左.右子

  • Java二叉搜索树遍历操作详解【前序、中序、后序、层次、广度优先遍历】

    本文实例讲述了Java二叉搜索树遍历操作.分享给大家供大家参考,具体如下: 前言:在上一节Java二叉搜索树基础中,我们对树及其相关知识做了了解,对二叉搜索树做了基本的实现,下面我们继续完善我们的二叉搜索树. 对于二叉树,有深度遍历和广度遍历,深度遍历有前序.中序以及后序三种遍历方法,广度遍历即我们寻常所说的层次遍历,如图: 因为树的定义本身就是递归定义,所以对于前序.中序以及后序这三种遍历我们使用递归的方法实现,而对于广度优先遍历需要选择其他数据结构实现,本例中我们使用队列来实现广度优先遍历.

  • 在Java中实现二叉搜索树的全过程记录

    目录 二叉搜索树 有序符号表的 API 实现二叉搜索树 二叉搜索树类 查找 插入 最小/大的键 小于等于 key 的最大键/大于等于 key 的最小键 根据排名获得键 根据键获取排名 删除 总结 二叉搜索树 二叉搜索树结合了无序链表插入便捷和有序数组二分查找快速的特点,较为高效地实现了有序符号表.下图显示了二叉搜索树的结构特点(图片来自<算法第四版>): 可以看到每个父节点下都可以连着两个子节点,键写在节点上,其中左边的子节点的键小于父节点的键,右节点的键大于父节点的键.每个父节点及其后代节点

  • Java底层基于二叉搜索树实现集合和映射/集合Set功能详解

    本文实例讲述了Java底层基于二叉搜索树实现集合和映射功能.分享给大家供大家参考,具体如下: 前言:在第5章的系列学习中,已经实现了关于二叉搜索树的相关操作,详情查看第5章即可.在本节中着重学习使用底层是我们已经封装好的二叉搜索树相关操作来实现一个基本的集合(set)这种数据结构. 集合set的特性: 集合Set存储的元素是无序的.不可重复的.为了能达到这种特性就需要寻找可以作为支撑的底层数据结构. 这里选用之前自己实现的二叉搜索树,这是由于该二叉树是不能盛放重复元素的.因此我们可以使用二叉搜索

  • Java数据结构之二叉搜索树详解

    目录 前言 性质 实现 节点结构 初始化 插入节点 查找节点 删除节点 最后 前言 今天leetcode的每日一题450是关于删除二叉搜索树节点的,题目要求删除指定值的节点,并且需要保证二叉搜索树性质不变,做完之后,我觉得这道题将二叉搜索树特性凸显的很好,首先需要查找指定节点,然后删除节点并且保持二叉搜索树性质不变,就想利用这个题目讲讲二叉搜索树. 二叉搜索树作为一个经典的数据结构,具有链表的快速插入与删除的特点,同时查询效率也很优秀,所以应用十分广泛,例如在文件系统和数据库系统一般会采用这种数

  • Java基础之二叉搜索树的基本操作

    一.二叉搜索树插入元素 /** * user:ypc: * date:2021-05-18; * time: 15:09; */ class Node { int val; Node left; Node right; Node(int val) { this.val = val; } } public void insert(int key) { Node node = new Node(key); if (this.root == null) { root = node; } Node cu

  • C++实现LeetCode(98.验证二叉搜索树)

    [LeetCode] 98. Validate Binary Search Tree 验证二叉搜索树 Given a binary tree, determine if it is a valid binary search tree (BST). Assume a BST is defined as follows: The left subtree of a node contains only nodes with keys less than the node's key. The ri

  • 如何利用JavaScript实现二叉搜索树

    计算机科学中最常用和讨论最多的数据结构之一是二叉搜索树.这通常是引入的第一个具有非线性插入算法的数据结构.二叉搜索树类似于双链表,每个节点包含一些数据,以及两个指向其他节点的指针:它们在这些节点彼此相关联的方式上有所不同.二叉搜索树节点的指针通常被称为"左"和"右",用来指示与当前值相关的子树.这种节点的简单 JavaScript 实现如下: var node = { value: 125, left: null, right: null }; 从名称中可以看出,二

随机推荐