高级数据结构(Ⅳ)二叉查找树(BST,Binary Sort Tree)

Posted 白鳯

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高级数据结构(Ⅳ)二叉查找树(BST,Binary Sort Tree)相关的知识,希望对你有一定的参考价值。

高级数据结构(Ⅳ)二叉查找树(BST,Binary Sort Tree)

在这里插入图片描述

◈基础概念

此数据结构由结点组成,结点包含的链接可以为空(null)或者指向其他结点。在二叉树中,每个结点只能有一个父结点(只有一个例外,也就是根结点,它没有父结点),而且每个结点都只有左右两个链接,分别指向自己的左子结点和右子结点。每个结点的两个链接都指向了一棵独立的子二叉树或空链接。在二叉查找树中,每个结点还包含了一个键和一个值,键之间也有顺序之分以支持高校的查找。

定义:一棵二叉查找树BST)是一棵二叉树,其中每个结点都含有一个Comparable的键(以及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。

int类型为键,String类型为值的二叉查找树的API如下

class BST<Key extends Comparable<Key>, Value> 
-------------------------------------------------------------------------------------
              Node root;                         根结点
              int size(Node x);                  返回以结点x为根节点的子树大小
            Value get(Key key)                   返回键key对应的值
             void put(Key key, Value val)        插入键值对{key : val}
              Key min()                          返回最小键
              Key max()                          返回最大键
              Key floor(Key key)                 向下取整(返回小于等于key的最大值)
              Key ceiling(Key key)               向上取整(返回大于等于key的最小值)
              Key select(int k)                  返回排名为k的键
              int rank(Key key)                  返回键key的排名
    Iterable<Key> keys(Key lo, Key hi)           返回查找(返回指定范围内的所有键值)
             void deleteMin()                    删除最小键
             void deleteMax()                    删除最大键
             void delete (Key key)               删除键key

◈基本实现

🎈

数据表示

我们嵌套定义一个私有类来表示二叉查找树上的一个结点。每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器。左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找树。变量N给出了以该结点为根的子树的结点总数。
s i z e ( x ) = s i z e ( x . l e f t ) + s i z e ( x . r i g h t ) + 1 size(x)=size(x.left)+size(x.right)+1 size(x)=size(x.left)+size(x.right)+1

class BST<Key extends Comparable<Key>, Value> {
    
    private Node root;             //二叉查找树的根节点
    
    private class Node{
        private Key key;           //键
        private Value val;         //值
        private Node left, right;  //指向子树的链接
        private int N;             //以该结点为根的子树中的结点总数
        
        public Node(Key key, Value val, int N) {
            this.key = key;
            this.val = val;
            this.N = N;
        }
    }

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

一棵二叉查找树代表了一组键(及其相应的值)的集合,而同一个集合可以用多棵不同的二叉查找树表示。如果我们将一棵二叉查找树的所有键投影到一条直线上,保证一个结点的左子树中的键出现在它的左边,右子树中的键出现在它的右边,那么我们一定可以得到一条有序的键列。

🎈

查找

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

    /*** 查找 ***/
    public Value get(Key key) {
        return get(root, key);
    }

    private Value get(Node x, Key key) {
        //在以x为根节点的子树中查找并返回key所对应的值
        //如果找不到则返回null
        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.val;
        }
    }

🎈

插入

二叉查找树插入的实现难度和查找差不多。如果树是空的,就返回一个含有该键值对的新结点;如果被查找的键小于根结点的键,继续在左子树中搜索插入该键,否则在右子树中插入该键。

   /***  插入 ***/
    public void put(Key key, Value val) {
        //查找key,找到则更新它的值,否则为它创建一个新的结点
        root = put(root, key, val);
    }

    private Node put(Node x, Key key, Value val) {
        //如果key存在于以x为根结点的子树中则更新它的值;
        //否则将以key和val为键值对的新结点插入到该子树中
        if (x == null) {
            return new Node(key, val, 1);
        }
        int cmp = key.compareTo(x.key);
        if (cmp < 0) {
            x.left = put(x.left, key, val);
        } else if (cmp > 0) {
            x.right = put(x.right, key, val);
        } else {
            x.val = val;
        }
        x.N = size(x.left) + size(x.right) + 1;
        return x;
    }

命题:在由N个随机键构造的二叉查找树中,查找命中平均所需的比较次数为~2lnN(约1.39lgN

在二叉查找树中查找随机键的成本比二分查找约高39%。

命题:在由N个随机键构造的二叉查找树中,插入操作和查找未命中平均所需的比较次数为~2lnN(约1.39lgN

🎈


◈有序性相关的方法

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

🎈

最小键和最大键

如果根节点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么树中的最小键就是左子树中的最小键,显示可以由递归操作实现。

找出最大键的方法也是类似的,只不过是变为查找右子树而已。

最小键

    /***  最小键 ***/
    public Key min() {
        if (root == null) {
            return null;
        }
        return min(root).key;
    }
    
    private Node min(Node x) {
        if (x.left == null) {
            return x;
        }
        return min(x.left);
    }

最大键

    /***  最大键 ***/
    public Key max() {
        if (root == null) {
            return null;
        }
        return max(root).key;
    }

    private Node max(Node x) {
        if (x.right == null) {
            return x;
        }
        return max(x.right);
    }

🎈

向上取整和向下取整

如果给定的键key小于二叉查找树的根结点的键,那么小于等于key的最大键floor(key)一定在根结点的左子树中;如果给定的键key大于二叉查找树的根结点,那么只有当根结点右子树中存在小于等于key的结点时,小于等于key的最大键才会出现在右子树中,否则根结点就是小于等于key的最大键。这段描述说明了floor()方法的递归实现,同时也递推地证明了它能够计算出预期的结果。将“左”变为“右”(同时将小于变为大于)就能够得到ceiling()的算法。

向下取整floor()

    /***  向下取整 ***/
    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;
        }
        if (cmp < 0) {
            return floor(x.left, key);
        }
        Node t = floor(x.right, key);
        if (t != null) {
            return t;
        } else {
            return x;
        }
    }

向上取整ceiling()

    /****  向上取整 **/
    public Key ceiling(Key key) {
        Node x = ceiling(root, key);
        if (x == null) {
            return null;
        }
        return x.key;
    }
    
    private Node ceiling(Node x, Key key) {
        if (x == null) {
            return null;
        }
        int cmp = key.compareTo(x.key);
        if (cmp == 0) {
            return x;
        }
        if (cmp > 0) {
            return ceiling(x.right, key);
        }
        Node t = ceiling(x.left, key);
        if (t != null) {
            return t;
        } else {
            return x;
        }
    }

🎈

选择操作

二叉查找树的选择操作和基于切分的数组选择操作类似。我们在二叉查找树中的每个结点中维护的子树结点计数器变量N就是用来支持此操作的。

假设我们想找到排名为k的键(即树中正好有k个小于它的键)。如果左子树中的结点数t大于k,那么我们就继续(递归地)在左子树中查找排名为k的键;如果t等于k,我们就返回根结点中的键;如果t小于k,我们就(递归地)在右子树查找排名为(k-t-1)的键。这段描述既说明了select()方法的递归实现同时也证明了它的正确性。

    /***  选择操作(返回排名为k的键) ***/
    public Key select(int k) {
        if (root == null) {
            return null;
        }
        return select(root, k).key;
    }

    private Node select(Node x, int k) {
        //返回排名为k结点
        if (x == null) {
            return null;
        }
        //注:书中此处为int t = size(x.left);
        //个人觉得此处应该加上当前结点,即+1(若有建议欢迎指正)
        int t = size(x.left) + 1;
        if (t > k) {
            return select(x.left, k);
        } else if (t < k) {
            return select(x.right, k - t - 1);
        } else {
            return x;
        }
    }

🎈

排名

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

    /*** 返回给定键key的排名 ***/
    public int rank(Key key) {
        if (root == null) {
            return 0;
        }
        return rank(root, key);
    }

    private int rank(Node x, Key key) {
        //返回以x为根结点的子树中小于x.key的键的数量
        if (x == null) {
            return 0;
        }
        int cmp = key.compareTo(x.key);
        if (cmp < 0) {
            return rank(x.left, key);
        } else if (cmp > 0) {
            return 1 + size(x.left) + rank(x.right, key);
        } else {
            //若返回给定键的排名,我认为这里要+1,不然可以在上面调用后的return语句后+1
            //书中此处为return size(x.left)(若有建议欢迎指正);
            return size(x.left) + 1;
        }
    }

🎈

范围查找

要实现能够返回给定范围键的keys()方法,我们首先需要一个遍历二叉查找树的基本方法,为中序遍历。要说明这个方法,我们先看看如何能够将二叉查找树中的所有键按照顺序打印出来。要做到这一点,我们应该先打印出根结点的 左子树中的所有键(根据二叉查找树的定义它们应该都小于根结点的键),然后打印出根结点的键,最后打印出根结点的右子树中的所有键(根据二叉查找树的定义它们应该都大于根结点的键)。

为了确保以给定结点为根的子树中所有在指定范围之内的键加入队列,我们会(递归地)查找根结点的左子树,然后查找根结点,然后(递归地)查找根结点的右子树。

    /***  范围查找(返回给定范围内的所有键值) ***/
    public Iterable<Key> keys() {
        return keys(min(), max());
    }

    public Iterable<Key> keys(Key lo, Key hi) {
        Queue<Key> queue = new LinkedList<Key>();
        keys(root, queue, lo, hi);
        return queue;
    }

    private void keys(Node x, Queue<Key> queue, Key lo, Key hi) {
        if (x == null) {
            return;
        }
        int cmplo = lo.compareTo(x.key);
        int cmphi = hi.compareTo(x.key);
        if (cmplo < 0) {
            keys(x.left, queue, lo, hi);
        }
        if (cmplo <= 0 && cmphi >= 0) {
            queue.offer(x.key);
        }
        if (cmphi > 0) {
            keys(x.right, queue, lo, hi);
        }
    }

◈与删除相关的方法

🎈

删除最小键

二叉查找树中最难实现的方法就是delete()方法,即从符号表中删除一个键值对。在此之前,我们先考虑deleteMin()方法(删除最小键所对应的键值对)。对于deleteMin(),我们要不断深入根节点的左子树直至遇见一个空链接,然后将指向该结点的右子树(只需要在递归调用中返回它的右链接即可)。此时它会被垃圾收集器清理掉。我们给出的标准递归代码在删除结点后会正确地设置它的父结点的链接并更新它到根结点的路径上的所有结点的计数器的值。详细过程如下图所示

在这里插入图片描述

代码如下

    /***  删除最小键 ***/
    public void deleteMin() {
        if (root == null) {
            return;
        }
        deleteMin(root);
    }
    
    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;
    }

🎈

删除最大键

deleteMax()方法的实现和deleteMin()完全类似,相应地,只需删除右子树最右端结点,然后返回其最右端结点的左子树即可。

    /***  删除最大键 ***/
    public void deleteMax() {
        if (root == null) {
            return;
        }
        deleteMax(root);
    }

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

🎈

删除操作

我们可以用类似的方式删除任意只有一个子结点(或者没有子结点)的结点,但应该怎样删除一个拥有两个子结点的结点呢?删除之后我们要处理两棵子树,但被删除结点的父结点只有一条空出来的链接。T.Hibbard在1962年提出了解决这个难题的第一个方法,在删除结点x后用它的后继结点填补它的位置。因为x只有一个右子结点,因此它的后继结点就是其右子树中的最小结点。这样的替换仍然能够保证树的有序性

以上是关于高级数据结构(Ⅳ)二叉查找树(BST,Binary Sort Tree)的主要内容,如果未能解决你的问题,请参考以下文章

详解二叉查找树(BST)

leetcode - 两数之和Ⅳ 输入BST(653)

二叉查找树 Binary Serach Tree

平衡二叉树(Balanced Binary Tree)

Python描述数据结构之二叉排序树篇

Find Mode in Binary Search Tree 二叉搜索树中查找众数