二叉搜索树(BST),哈希表讲解及Java实现

14 篇文章 0 订阅
4 篇文章 0 订阅

目录

二叉搜索树(BST)

    概念

    操作

        向BST中添加一个元素

        在BST中查找一个元素是否存在

        在BST中找最小值/最大值

        删除最小值和最大值

        删除任意值***

         在BST中修改一个元素

    总结

    性能分析

    和Java类集的关系

哈希表

    概念

    冲突 - 概念

    冲突 - 避免

   冲突 - 避免 - 哈希函数设计

        常见哈希函数

    冲突 - 避免 - 负载因子调节(重点掌握)

    冲突 - 解决

    冲突 - 解决 - 闭散列

    1. 线性探测

        2. 二次探测

    冲突 - 解决 - 开散列(重点掌握)

    冲突严重时的解决办法

基于开散列方式下整型哈希表的实现

    性能分析

    和java类集的关系

注意:在哈希表讲解中用的的数组图可能有点错误,自行理解即可


二叉搜索树(BST)

    此处写的不是平衡的二叉搜索树

    概念

        二叉搜索树又称二叉排序树(BinarySeachTree),它或者是一棵空树**,或者是具有以下性质的二叉树:
            若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
            若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
                左子树中的所有节点值  <  根节点  <  右子树的所有节点值
            它的左右子树也分别为二叉搜索树
         二分搜索树中一般不考虑值相等的情况(元素不重复)JDK中的搜索树就不存在相同的值(TreeMap - key)
        对二分搜索树进行元素的查找过程实际上就是一个二分查找 = 》二分搜索树
    最大特点:也是判断是否是搜索树的方法,对该树进行中序遍历,就可以得到一个升序集合
        int a [] = {5,3,4,1,7,8,2,6,0,9};
    

     

 

    操作

        向BST中添加一个元素

            新添加的元素一定是叶子节点
        代码实现
//添加操作
public void add(int val) {
    root = add(root,val);
}


/**
* 向以root为根的BST中插入一个新的节点val
* @param root
* @param val
* @return
*/
private TreeNode add(TreeNode root, int val) {
    TreeNode  newNode = new TreeNode(val);
    if (root == null) {
        size++;
        return newNode;
    }
    if (val < root.val) {
        //左树中插入
        root.left = add(root.left,val);
    }
    //不允许等于的情况
    if (val > root.val) {
        //右树中插入
        root.right = add(root.right,val);
    }
    return root;
}

        在BST中查找一个元素是否存在

        代码实现
public boolean contains(int val) {
    return contains(root, val);
}


/**
* 判断当前以root为根的BST中是否包含了val
*
* @param root
* @param val
* @return
*/
private boolean contains(TreeNode root, int val) {
    if (root == null) {
        return false;
    }
    if (root.val == val) {
        return true;
    } else if (val > root.val) {
        return contains(root.right, val);
    } else {
        return contains(root.left, val);
    }
}

        在BST中找最小值/最大值

            对于任意一颗BST,最小值一定处在左树的最左侧,但不一定是叶子节点,而是不断向左树递归查找,找到的第一个node.left == null的节点,此时node一定是最小值
            同理,对于任意一颗BST,最大值一定处在右树的最右侧,但不一定是叶子节点,而是不断向右树递归查找,找到的第一个node.right == null的节点,此时node一定是最大值(模仿小天才!!!)
        代码实现
//找到BST中的最小数
public int findMin() {
    if (size == 0) {
        throw new NoSuchElementException("BST is empty! cannot find min");
    }
    TreeNode minNode = minNode(root);
    return minNode.val;
}


//找到BST中的最大值
public int findMax() {
    if (size == 0) {
        throw new NoSuchElementException("BST is empty! cannot find max");
    }
    TreeNode maxNode = maxNode(root);
    return maxNode.val;
}


/**
* 找到以root为根节点的BST中的最大节点
* @param root
* @return
*/
private TreeNode maxNode(TreeNode root) {
    if (root.right == null) {
        //此时root为最大值
        return root;
    }
    //不断向右树中递归
    return maxNode(root.right);
}


/**
* 找到以root为根节点的BST中的最小节点
* @param root
* @return
*/
private TreeNode minNode(TreeNode root) {
    if (root.left == null) {
        //此时root为最小值
        return root;
    }
    //不断向左树中递归
    return minNode(root.left);
}

        删除最小值和最大值

                找到最小/大值所在的节点,连接右/左子树即可。
            代码实现
//删除最小值节点,并返回最小值
public int removeMin() {
    int min = findMin();
    root = removeMin(root);
    return min;
}


//删除最大值节点,并返回最大值
public int removeMax() {
    int max = findMax();
    root = removeMax(root);
    return max;
}


/**
* 删除以root为根的最大值节点
* @param root
* @return
*/
private TreeNode removeMax(TreeNode root) {
    if (root.right == null) {
        TreeNode left = root.left;
        root.left = root = null;
        size--;
        return left;
    }
    root.right = removeMax(root.right);
    return root;
}


/**
* 删除以root为根的最小值节点
* @param root
* @return
*/
private TreeNode removeMin(TreeNode root) {
    if (root.left == null) {
        TreeNode right = root.right;
        root.right = root = null;
        size--;
        return right;
    }
    root.left = removeMin(root.left);
    return root;
}

        删除任意值***

                四种情况:
                1.当删除的元素左子树为空时,直接拼接右子树。
                2.当删除的元素右子树为空时,直接拼接左子树。
                3.当要删除一个左右子树都存在的节点,则找到以删除节点为根节点的前驱或者后继作为删除后的新节点。
                    前驱:以删除节点为根节点的BST中”最后一个“小于删除节点的节点
                    后继:以删除节点为根节点的BST中”第一个“大于删除节点的节点
                实现代码(思路解析都在代码解释中)
    //删除值为val的任意节点
    public void remove(int val) {
        root = remove(root, val);
    }


    /**
     * 删除以root为根节点中值为val的节点
     *
     * @param root
     * @param val
     * @return
     */
    private TreeNode remove(TreeNode root, int val) {
        if (root == null) {
            // 把树中所有节点都遍历完还没找到值为val的节点,就不存在值val的节点
            throw new NoSuchElementException("BST中没有值为" + val + " 的节点。");
        } else if (val > root.val) {
            // 此时待删除的节点位于左子树
            root.right = remove(root.right,val);
            return root;
        } else if (val < root.val) {
            // 此时待删除的节点位于右子树
            root.left = remove(root.left,val);
            return root;
        } else {
            // 此时root.val == val
            // root就是待删除的节点
            if (root.left == null) {
                //只有有右孩子
                TreeNode right = root.right;
                root.right = root = null;
                size--;
                return right;
            }
            if (root.right == null) {
                //只有左孩子
                TreeNode left = root.left;
                root.left = root = null;
                size--;
                return left;
            }
            // 此时说明root.left 和 root.right 都不为空
            // Hibbard Deletion
            // 方法一:找到后继节点
            TreeNode successor = minNode(root.right);
            // removeMin中size已经--
            // 1.先让后继节点的右树连接root删除右树最小节点后的右树
            successor.right = removeMin(root.right);
            // 2.再让后继节点的左树连接root的左树
            // 1和2顺序不可颠倒
            successor.left = root.left;
            // 删除root节点
            root.left = root.right = root = null;
            // 拼接后继节点
            return successor;
            //方法二:找到前驱节点
//            TreeNode prevNode = maxNode(root.left);
//            prevNode.left = removeMax(root.left);
//            prevNode.right = root.right;
//            root.left = root.right = root = null;
//            return prevNode;
        }
    }

         在BST中修改一个元素

                 (BST中不存在重复值) 要在BST中修改一个已经存在的元素,需要先删除再插入,保证BST的性质不变,不能直接修改,所以操作可以是,remove(val);add(newVal);

    总结

        BST是一个非常高效的查询数据的结构,但是上述写的BST是不平衡的,在某些场景下会出现退化的情况,比如向上述BST中插入1,2,3,4,5,6,7,8,9 会 变成一个单支树,等同于链表退化为O(N)。
    为了避免在数据插入时,BST左右子树高度严重倾斜,引入了平衡树。
    AVL - 严格平衡:任意一个子树左右高度差不超过1,BST不会退化为链表,性能保证是O(logN)
    RBTree - 黑节点严格平衡
    以上都属于二分平衡搜索树。
(博主暂时还不没学习,所以需要了解可以去找厉害的大佬)

    性能分析

        插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
        对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度 的函数,即结点越深,则比较次数越多。
        但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
         

 

        最优情况下,二叉搜索树为完全二叉树,其平均比较次数为:logN
        最差情况下,二叉搜索树退化为单支树,其平均比较次数为:N/2

    和Java类集的关系

        TreeMap 和 TreeSet 即 java 中利用搜索树实现的 Map 和 Set;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 颜色以及红黑树性质验证,

哈希表

        哈希表实际上是通过数组衍生出来的,哈希表高效查找的奥秘在于数组的随机访问特性。

    概念

        顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键 码的多次比较顺序查找时间复杂度为O(N),平衡树中为树的高度,
    即O( ),搜索的效率取决于搜索过程中 元素的比较次数。
        理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函 数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,
    那么在查找时通过该函数可以很快 找到该元素
        当向该结构中:
        插入元素
            根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
        搜索元素
            对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
    该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{176459};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

 

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快 问题:按照上述哈希方式,向集合中插入元 素44,会出现什么问题? —》哈希冲突

    冲突 - 概念

        对于两个数据元素的关键字 和 (i != j),有 != ,但有:Hash( ) == Hash( ),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。 
        把具有不同关键码而具有相同哈希地址的数据元素称为同义词

    冲突 - 避免

        首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率

   冲突 避免 哈希函数设计

        引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 哈希函数设计原则:
            哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0m-1 之间
            哈希函数计算出来的地址能均匀分布在整个空间中
            哈希函数应该比较简单    

        常见哈希函数

            直接定制法--(常用)
                取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况。
            除留余数法--(常用)
                设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数: Hash(key) = key% p(p<=m),将关键码转换成哈希地址
            平方取中法--(了解)
                假设关键字为1234,对它平方就是1522756,抽取中间的3227作为哈希地址; 再比如关键字为4321,对 它平方就是18671041,抽取中间的3671(710)作为哈希地址
                平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
            折叠法--(了解折叠法   
                是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
     折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
            随机数法--(了解 )
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key), 其中 random 为随机数函数。 通常应用于关键字长度不等时采用此法
            数学分析法--(了解 )
    设有 n d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某 些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据 散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:

 

    假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以 选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如 1234改成4321)、右环位移(1234改成4123)、左环移位、前两数与后两数叠加(1234改成12+34=46)等方 法。

    数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况

    注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

    冲突 避免 负载因子调节(重点掌握)

        

 

        所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
        已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。

    冲突 - 解决

        解决哈希冲突两种常见的方法是:闭散列开散列

    冲突 - 解决 - 闭散列

        闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个” 空位置中去。
        那如何寻找下一个空位置呢? 

    1. 线性探测

        比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该 位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
        线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
        插入
            通过哈希函数获取待插入元素在哈希表中的位置
            如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
              

 

            采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他 元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。
        因此线性探测采用标记的伪删除法来删除一个元素。

        2. 二次探测

            线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨 着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
         Hi= (H0 + i^2)% m,或者:Hi= (H0 - i^2)% m。其中:i = 1,2,3..,H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置, m是表的大小。 对于2.1中如果要插入44,产生冲突,
        使用解决后的情况为:
         

 

            研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不 会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。
        在搜索时可以不考虑表装满的情 况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
            因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。 

    冲突 - 解决 - 开散列(重点掌握)

        开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,
    各链表的    头结点存储在哈希表中。    
           
        从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
        开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

    冲突严重时的解决办法

        刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的
    小集合搜索问题继续进行转化,例如:
        1. 每个桶的背后是另一个哈希表
        2. 每个桶的背后是一棵搜索树

基于开散列方式下整型哈希表的实现

    代码实现
/**
* 基于开散列方式下整型哈希表的实现
*
* @author 是阿秋啊
* @date 2022/03/23 14:57
**/
public class MyHashMap {
    private class Node {
        // 对key求哈希运算
        int key;
        int value;
        Node next;


        public Node(int key, int value, Node next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }


    // 记录存入的键值对个数
    private int size;
    // 默认哈希表长度
    private static final int DEFAULT_CAPACITY = 16;
    // 默认负载因子
    private static final double LOAD_FACTOR = 0.75;
    // 取模数,用于获取key索引
    private int M;
    // 实际存储的数组
    private Node[] data;


    public MyHashMap() {
        // 默认无参构造数组长度为默认值
        this(DEFAULT_CAPACITY);
    }


    public MyHashMap(int initCap) {
        // 定义初始长度
        this.M = initCap;
        this.data = new Node[initCap];
    }


    // 哈希函数
    public int hash(int key) {
        // 获取索引
        return Math.abs(key) % M;
    }


    // 在当前的哈希表添加一个键值对 key = value
    public int put(int key, int value) {
        // 1.先获取key值对应的索引
        int index = hash(key);
        // 2.在哈希表中查找是否存储过
        for (Node x = data[index]; x != null; x = x.next) {
            if (x.key == key) {
                //key存在,修改value值
                int oldVal = x.value;
                x.value = value;
                return oldVal;
            }
        }
        // 走到这里则表示哈希表中没有存储key值
        // 使用头插法将新key插入
        Node node = new Node(key, value, data[index]);
        data[index] = node;
        size++;
        // 4.添加一个元素后查看哈希表是否需要扩容
        if (data.length * LOAD_FACTOR <= size) {
            // 扩容
            resize();
        }
        return value;
    }


    // 哈希表扩容
    private void resize() {
        Node[] newData = new Node[data.length << 1];
        this.M = newData.length;
        for (Node datum : data) {
            if (datum != null) {
                // 对应的链表不为空
                // 进行对应的链表遍历
                for (Node x = datum; x != null; ) {
                    // 此处不在for循环中写x递进条件,是因为x会指向新数组的节点,next会发生变化
                    // 暂存一下后继节点
                    Node next = x.next;
                    // 新数组的头插
                    int newIndex = hash(x.key);
                    x.next = newData[newIndex];
                    newData[newIndex] = x;
                    // 继续进行下一个节点的搬移操作
                    x = next;
                }
            }
        }
        data = newData;
    }


    /**
     * 删除哈希表中key对应的节点
     * 返回删除前的value
     *
     * @param key
     * @return
     */
    public int remove(int key) {
        int index = hash(key);
        // 判断头节点是否是待删除的节点
        Node head = data[index];
        if (head.key == key) {
            int val = head.value;
            data[index] = head.next;
            head.next = head = null;
            size--;
            return val;
        }
        // 此时不是头节点
        Node prev = head;
        // 此时不需要判断头节点
        while (prev.next != null) {
            // prev为待删除值的前驱节点
            if (prev.next.key == key) {
                Node cur = prev.next;
                int val = cur.value;
                prev.next = cur.next;
                cur.next = cur = null;
                return val;
            }
            prev = prev.next;
        }
        // 走到此时说明哈希表中没有key的节点
        throw new NoSuchElementException("no such key! cannot remove!");
    }


    // 判断哈希表中是否含有key的节点
    public boolean containsKey(int key) {
        int index = hash(key);
        for (Node x = data[index]; x != null; x = x.next) {
            if (x.key == key) {
                return true;
            }
        }
        return false;
    }


    // 判断含有值为value的节点
    public boolean containsValue(int value) {
        // 不同于查找key,查找value需要遍历整个哈希表
        for (Node datum : data) {
            for (Node x = datum; x != null; x = x.next) {
                if (x.value == value) {
                    return true;
                }
            }
        }
        return false;
    }
}

    性能分析

        虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的, 也就是每个桶中的链表的长度是一个常数,所以,通常意义下,
我们认为哈希表的插入/删除/查找时间复杂度是 O(1) 

    和java类集的关系

        HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
        java 中使用的是哈希桶方式解决冲突的
        java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
        java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,
    必须覆写 hashCode 和 equals 方 法,而且要做到 equals 相等的对象,hashCode 一定是一致的。

注意:在哈希表讲解中用的的数组图可能有点错误,自行理解即可

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

是啊秋啊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值