二叉搜索树

此博客用于个人学习,来源于算法的书籍和网上的资料,对知识点进行一个整理。

1. 概述:

二叉树是是一种能将链表插入的灵活性和有序数组的高效性结合起来的符号表实现。具体来说,就是使用每个结点含有两个连接(链表中每个结点只含有一个链接)的二叉查找树来高效地实现符号表,这也是计算机科学中重要的算法之一。

在二叉树中,每个结点只能有一个父结点(只有一个例外,也就是根结点,它没有父结点),而且每个结点都只有左右两个链接,分别指向自己的左子结点和右子结点。在二叉搜索树中,每个结点还包含一个键和一个值,键之间也有顺序之分以支持高效的查找。

2. 基本实现:

2.1 基本 API:

下面这段代码用二叉查找树实现了有序符号表的 API,树由 Node 对象组成,每个对象都含有一对键值对,两条链接和一个结点计数器 N。每个 Node 对象都是一棵含有 N 个结点的子树的根结点,它的左链接指向一棵小于该结点的所有键组成的二叉查找树,右链接指向一棵大于该结点的所有键组成的二叉查找树。root 变量指向二叉查找树的根结点 Node 对象。

public class BST <Key extends Comparable<Key>,Value>{

    private Node root;

    private class Node{
        private Key key;
        private Value value;
        private Node left,right;
        private int N;

        public Node(Key key,Value value,int N){
            this.key = key;
            this.value = value;
            this.N = N;
        }
    }

    public int size(){
        return size(root);
    }

    private int size(Node x){
        if (x == null) return 0;
        else return x.N;
    }
}

2.2 查找:

一般来说,在符号表中查找一个键可能得到两种结果。如果含有该键的结点存在于表中,我们的查找就命中了,然后返回相应的值,否则查找未命中(并返回 null)。在二叉查找树中查找一个键的递归算法:如果树是空的,则查找未命中;如果被查找的键和根结点的键相等,查找命中,否则我们就(递归地)在适当的子树中继续查找。如果被查找的键较小就选择左子树,较大则选择右子树。

public Value get(Key key){
    return get(root,key);
}

private Value get(Node x,Key key){
    if (x == null) return null;
    int cmp = key.compareTo(x.key);
    if (cmp < 0) return get(x.left,key);
    else if (cmp > 0) return get(x.right,key);
    else return x.value;
}

2.3 插入:

查找代码几乎和二分查找的一样,这种简洁性是二叉树的重要特性之一,而二叉查找树的另一个更重要的特性就是插入的实现难度和查找差不多。当查找一个不存在于树中的结点并结束于一条空链接时,我们需要做的就是将链接指向一个含有被查找的键的新结点。如果树是空的,就返回一个含有该键值对的新结点;如果被查找的键小于根结点的键,我们会继续在左子树中插入该键,否则就在右子数中插入该键。

public Value get(Key key){
    return get(root,key);
}

private Value get(Node x,Key key){
    if (x == null) return null;
    int cmp = key.compareTo(x.key);
    if (cmp < 0) return get(x.left,key);
    else if (cmp > 0) return get(x.right,key);
    else return x.value;
}

3. 有序性相关的方法:

二叉查找树得以广泛应用的一个重要原因就是它能够保存键的有序性,因此它可以作为实现有序符号表 API 中众多方法的基础。这使得符号表的用例不仅能够通过键还能通过键的相对顺序来访问键值对。

3.1 最大键和最小键:

如果根结点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么树中的最小键就是左子树中的最小键。我们可以让递归调用返回键 Key 而非结点对象 Node,但我们后面还会用到这方法来找出含有最小键的结点。找出最大键的方法也是类似的,只是变为查找右子树而已。

3.2 向上取整和向下取整:

如果给定的键 key 小于二叉查找树的根结点的键,那么小于等于 key 的最大键 floor(key) 一定在根结点的左子树中;如果给定的键 key 大于二叉查找树的根结点,那么只有当根结点右子树中存在小于等于 key 的结点时,小于等于 key 的最大键才会出现在右子数中,否则根结点就是小于等于 key 的最大键。

public Key min(){
    return min(root).key;
}

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

/**
 * 向下取整
 * @param key
 * @return
 */
public Key floor(Key key){
    Node x = floor(root,key);
    if (x == null) return null;
    return x.key;
}

private Node floor(Node x,Key key){
    if (x == null) return null;
    int cmp = key.compareTo(x.key);
    if (cmp == 0) return x;
    //key位于x左边,向x的左子树进行向下取整
    if (cmp < 0) return floor(x.left,key);
    //右边情况特殊,有可能出现都比key大的节点,先要进行判断
    Node t = floor(x.right,key);
    if (t != null) return t;
    else return x;
}

每个共有方法都对应着一个私有方法,它接受一个额外的链接作为参数指向某个结点,通过递归方法查找返回 null 或者含有指定 Key 的结点 Node。max() 和 ceiling() 的实现分别与 min() 和 floor() 方法基本相同,只是将代码中的 left 和 right(以及 > 和 <)调换而已。

3.3 选择操作:

假设我们想找到排名为 k 的键(即树中正好有 k 个小于它的键)。如果左子树中的结点数 t 大于 k,那么我们就继续(递归地)在左子树中查找排名为 k 的键;如果 t 等于 k,我们就返回根结点中的键;如果 t 小于 k ,我们就(递归地)在右子树中查找排名为(k-t-1)的键。

3.4 排名:

rank() 方法是 select() 的逆方法,它会返回给定键的排名。它的实现和 select() 类似:如果给定的键和根结点的键相等,我们就返回左子树中的结点总数 t;如果给定的键小于根结点,我们会返回该键在左子树中的排名(递归计算);如果给定的键大于根结点,我们会返回 t+1(根结点)加上它在右子树中的排名(递归计算)。

public Key select(int k){
    return select(root,k).key;
}

/**
 * 返回排名为k的节点
 * @param x
 * @param k
 * @return
 */
private Node select(Node x,int k){
    if (x == null) return null;
    int t = size(x.left);
    if (t > k) return select(x.left,k);
    else if (t < k) return select(x.right,k-t-1);
    else return x;
}

/**
 * 返回小于x.key的健的数量
 * @param key
 * @return
 */
public int rank(Key key){
    return rank(root,key);
}

/**
 * 返回以x为根节点的子树中小于x.key的健的数量
 * @param x
 * @param key
 * @return
 */
private int rank(Node x,Key key){
    if (x == null) return 0;
    int cmp = key.compareTo(x.key);
    if (cmp > 0) return size(x.left) + 1 + rank(x.right,key);
    else if (cmp < 0) return rank(x.left,key);
    else return size(x.left);
}

3.5 删除最大键和删除最小键:

二叉查找树中最难实现的方法就是 delete() 方法,即从符号表中删除一个键值对。我们先考虑 deleteMin() 方法(删除最小键所对应的键值对)。和 put() 一样,我们的递归方法接受一个指向结点的链接,并返回一个指向结点的链接。这样我们就能方便地改变树的结构,将返回的链接赋给作为参数的链接。对于 deleteMin() ,我们要不断深入根结点的左子树中直至遇见一个空链接,然后将指向该结点的链接指向该结点的右子树(只要在递归调用中返回它的右链接即可)。此时已经没有任何链接指向要被删除的结点,因此它会被垃圾收集器清理掉。

3.6 删除操作:

我们可以用类似的方法删除任意只有一个子结点(或者没有子结点)的结点,但应该删除一个拥有两个子结点的结点呢?删除之后我们要处理两棵子树,但被删除结点的父结点只有一条空出来的链接。在删除结点 x 后用它的后继结点填补它的位置。因为 x 有一个右子结点,因此它的后继结点就是其右子树中的最小结点。这样的替换仍然能够保证树的有序性,因为 x.key 和它的后继结点的键之间不存在其他的键。我们能够用 4 个简单的步骤完成将 x 替换为它的后继结点的任务:

  • 将指向即将被删除的结点的链接保存为 t;
  • 将 x 指向它的后继结点 min(t.right);
  • 将 x 的右链接(原本指向一棵所有结点都大于 x.key 的二叉查找树)指向 deleteMin(t.right),也就是在删除后所有结点仍然都大于 x.key 的子二叉查找树;
  • 将 x 的左链接(本为空)设为 t.left(其下所有的键都小于被删除的结点和它的后继结点)。
/**
 * 删除最小节点后的树
 */
public void deleteMin(){
    root = deleteMin(root);
}

/**
 * 返回以x为根节点的删除最小节点后的树
 * @param x
 * @return
 */
private Node deleteMin(Node x){
    if (x.left == null) return x.right;
    x.left = deleteMin(x.left);
    x.N = size(x.left)+size(x.right)+1;
    return x;
}

public void delete(Key key){
    root = delete(root,key);
}

private Node delete(Node x,Key key){
    if (x == null) return null;
    int cmp = key.compareTo(x.key);
    if (cmp > 0) delete(x.right,key);
    else if (cmp < 0) delete(x.left,key);
    else {
        if (x.left == null) return x.right;
        if (x.right == null) return x.left;
        Node t = x;
        x = min(x.right);
        x.right = deleteMin(t.right);
        x.left = t.left;
    }
    x.N = size(x.left) + size(x.right) + 1;
    return x;
}

4. 性能分析:

总的来说,二叉查找树的实现并不困难,且当树的结构和随机模型近似时在各种实际应用场景中它都能进行快速地查找和插入。但同时,正如我们所强调过的,在某些场景中二叉查找树在最坏情况下的恶劣性能仍然是不可接受的。二叉查找树的基本实现的良好性能依赖于其中的键的分布是足够随机以消除长路径。

算法(数据结构)最坏情况下的运行时间的增长数量级(N 次插入之后)平均情况下的运行时间的增长数量级(N 次插入随机键之后)是否支持有序性相关的操作
查找插入查找命中插入
顺序查询(无序链表)NNN/2N
二分查找(有序数组)lgNNlgNN/2
二叉树查找(二叉查找树)NN1.39 lgN1.39 lgN
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值