《Java版数据结构 & 集合类剖析》Map & Set & 反射:“从哈希碰撞到红黑树转换,反射帮你扒开 HashMap 的桶数组看个究竟”

文章目录

《Java版数据结构 & 集合类剖析》map&set & 反射枚举lambda

  • 在学习map和set之前,要先掌握二叉树的一些进阶结构:
    • 之前的章节里学习了堆的数据结构,其本质也是二叉树,因为堆是完全二叉树,所以我们一般使用的存储方式为顺序存储(顺序表)
    • 这里介绍的进阶二叉树结构为二叉搜索树,使用的存储方式为链式存储
  • 对于AVL树和红黑树我们在下一章节再进行介绍

1、二叉搜索树(BST)

1.1、概念

二叉搜索树又称二叉排序树,其可以是空树,也可以是具有以下性质的二叉树:

  • 如果左子树存在,则左子树上的所有节点的值都小于根节点
  • 如果右子树存在,则右子树上的所有结点的值都大于根节点
  • 它的左右子树也分别为二叉搜索树

1.2、操作逻辑

  • 查找:遍历根节点,与要查找的值进行比较来选择走左子树或右子树,相等则返回结点
  • 插入:在查找的操作基础上每一次遍历储存其父节点的位置,遍历到目标位置即是插入位置,与其父节点的位置进行连接操作即可
  • 删除(难点) : 分为两个步骤
    • 记录父节点遍历该树找到要删除的结点
    • 将要删除的结点分为以下三种情况(每种情况还有其子情况,因为删除该结点不仅要考虑cur 的父节点 情况,还要考虑cur的左右子树 的情况):
      • cur.left == null
        • cur 是 root,则 root = cur.right
        • cur 不是 root,cur 是 parent.left,则 parent.left = cur.right
        • cur 不是 root,cur 是 parent.right,则 parent.right = cur.right
      • cur.right == null
        • cur 是 root,则 root = cur.left
        • cur 不是 root,cur 是 parent.left,则 parent.left = cur.left
        • cur 不是 root,cur 是 parent.right,则 parent.right = cur.left
      • cur.left != null && cur.right != null
        • 删除该节点要拿一个最接近该值的节点 来进行替换删除(左树的右下节点 / 右树的左下节点)

1.3、代码实现:

java 复制代码
public class BinarySearchTree {
    static class  TreeNode{
        public int val;
        public TreeNode left;
        public TreeNode right;

        public TreeNode (int val){
            this.val = val;
        }

        public TreeNode() {

        }
    }
    public TreeNode root;
    public void insert(int key){
        TreeNode cur = root;
        TreeNode prev = null;
        TreeNode node = new TreeNode(key);

        while(cur != null){
            if(cur.val > key){
                prev = cur;
                cur = cur.left;
            }else if (cur.val < key){
                prev = cur;
                cur = cur.right;
            }else{
                return;
            }
        }

        if(prev.val > key){
            prev.left = node;
        }else{
            prev.right = node;
        }
    }
    public void remove(int val){
        TreeNode cur = root;
        TreeNode parent = null;
        while(cur != null){
            if(cur.val > val){
                parent = cur;
                cur = cur.left;
            }else if(cur.val < val){
                parent = cur;
                cur = cur.right;
            }else{
                removeNode(parent,cur   );
                return;
            }
        }
    }

    private void removeNode(TreeNode parent, TreeNode cur) {
        if(cur.left == null){
            if(cur == root){
                root = cur.right;
            }else if(parent.left == cur){
                parent.left = cur.right;
            }else
            {
                parent.right = cur.right;
            }
        }else if(cur.right == null){
            if(cur == root){
                root = cur.right;
            }else if(parent.left == cur){
                parent.left = cur.left;
            }else {
                parent.right = cur.right;
            }
        }else{
            // cur 的left 和 right 节点都存在
            // 此时将将 cur left树的右下角节点或者 cur right树的左下角节点放到cur位置即可:
            TreeNode targetParent = cur;
            TreeNode target = targetParent.right;
            while (target.left != null){
                targetParent = target.left;
            }
            cur.val = target.val;
            if(targetParent.left == target){
                targetParent.left = target.right;
            }else{
                targetParent.right = target.right;
            }
        }
    }

    public TreeNode search(int val){
        TreeNode cur = root;
        while(cur != null){
            if(cur.val > val){
                cur = cur.left;
            }else if(cur.val < val){
                cur = cur.right;
            }else{
                return cur;
            }
        }
        return null;
    }
}

1.4、性能分析

可以看到插入 和 删除 操作都基于 查找 这个操作,所以查找的效率代表了二叉搜索树中各个操作的性能。

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

所以想让性能保持在比较优的状态,要让该搜索二叉树保持平衡。其中典型的二叉平衡搜索树就是AVL 树和 红黑树。

1.5、和java集合类的关系

TreeMap 和 TreeSet 即 java 中利用搜索树 实现的 Map 和 Set ;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 + 颜色以及红黑树性质验证,关于红黑树的内容后序再进行讲解。

2、搜索

2.1、概念及场景

  • Map和set是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关。
  • 我们之前学过的搜索方式有:
    • 直接遍历:时间复杂度为O(N)
    • 二分查找:时间复杂度为 O(log2 N),但搜索前必须要求序列是有序的
  • 上述的排序适合静态类型的查找,即一般不会对区间进行插入和删除操作了。我们面对现实的数据情况进行查找时往往可能也进行这一些插入和删除操作。这就涉及到动态查找,那上述两种的方式就不太适合该情况了。所以Map 和 Set就是正好适合于这种动态查找的集合容器。

2.2、模型

  • 我们一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),将其称之为 key-value的键值对模型分为以下两种:
    • 纯Key模型 : 只储存key
    • Key-Value模型: 储存key-value的键值对

3、Map

继承体系图如下:

  1. 看到Map是一个接口类,该类没有继承Collection,该类中存储的是<K,V>结构的键值树,并且K是唯一的。

  2. Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,相当于map中存储的基本单元。

    • 该内部类Map.Entry<K, V>常见的方法有以下三种:
    方法 解释
    K getKey() 返回 entry 中的 key
    V getValue() 返回 entry 中的 value
    V setValue(V value) 将键值对中的 value 替换为指定 value
    • 注意:Map.Entry<K,V> 没有提供设置Key的方法

3.1、Map的常用方法

方法 解释
V get(Object key) 返回 key 对应的 value
V getOrDefault(Object key, V defaultValue) 返回 key 对应的 value,key 不存在,返回默认值
V put(K key, V value) 设置 key 对应的 value
V remove(Object key) 删除 key 对应的映射关系
Set<K> keySet() 返回所有 key 的不重复集合
Collection<V> values() 返回所有 value 的可重复集合
Set<Map.Entry<K, V>> entrySet() 返回所有的 key-value 映射关系
boolean containsKey(Object key) 判断是否包含 key
boolean containsValue(Object value) 判断是否包含 value
  • map 是一个接口,不能直接对其进行实例化,我们能实例化的只有TreeMap 和 HashMap
  • 以下是该两种实现的差别:
对比项 TreeMap HashMap
底层结构 红黑树 哈希桶
插入/删除/查找时间复杂度 O(logN) O(1)(平均)
是否有序 关于 Key 有序 无序
线程安全 不安全 不安全
插入/删除/查找区别 需要进行元素比较 通过哈希函数计算哈希地址
比较与覆写 key 必须能够比较,否则会抛出 ClassCastException 异常 自定义类型需要覆写 equals 和 hashCode 方法
应用场景 需要 Key 有序的场景 Key 是否有序不关心,需要更高的时间性能

3.2、Map使用注意事项

  • Map中存储的key是唯一的,但是value可以重复
  • 在TreeMap 中插入键值对时,key不能为空 ,否则就会抛NullPointerException异常,value可以为空。但是HashMap的key和value都可以为空。 因为TreeMap的平衡需要通过key的比较来维持。
  • Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
  • Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
  • Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。

4、Set

其继承结构图如下:

4.1、Set的常用方法

方法 解释
boolean add(E e) 添加元素,但重复元素不会被添加成功
void clear() 清空集合
boolean contains(Object o) 判断 o 是否在集合中
Iterator<E> iterator() 返回迭代器
boolean remove(Object o) 删除集合中的 o
int size() 返回 set 中元素的个数
boolean isEmpty() 检测 set 是否为空,空返回 true,否则返回 false
Object[] toArray() 将 set 中的元素转换为数组返回
boolean containsAll(Collection<?> c) 集合 c 中的元素是否在 set 中全部存在,是返回 true,否则返回 false
boolean addAll(Collection<? extends E> c) 将集合 c 中的元素添加到 set 中,可以达到去重的效果

Set的实例化依然也是两种:TreeSet,HashSet:

对比项 TreeSet HashSet
底层结构 红黑树 哈希桶
插入/删除/查找时间复杂度 O(logN) O(1)(平均)
是否有序 关于 Key 有序 不一定有序
线程安全 不安全 不安全
插入/删除/查找区别 按照红黑树的特性来进行插入和删除 1. 先计算 key 哈希地址 2. 然后进行插入和删除
比较与覆写 key 必须能够比较,否则会抛出 ClassCastException 异常 自定义类型需要覆写 equals 和 hashCode 方法
应用场景 需要 Key 有序场景下 Key 是否有序不关心,需要更高的时间性能

4.2、Set使用注意事项

  • Set是继承自Collection的一个接口类
  • Set中只存储了key ,并且要求key一定要唯一
  • TreeSet的底层是使用Map来实现的,其使用key与Object的一个默认对象作为键值对插入到Map中的
  • Set最大的功能就是对集合中的元素进行去重
  • 实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
  • Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入
  • TreeSet中不能插入null的key,HashSet可以。

5、哈希表

5.1、概念

在我们上面学习的结构中,因为其关键码和存储位置之间没有对应的关系,所以查找一个元素时,我们都要进行多此比较。

而我们理想的查找:想要不经过任何比较,一次性从表中找到对应的元素。哈希方法就是可以做到:通过哈希函数,使元素的存储位置和它的关键码之间能够建立一一映射的关系,构造出来的结构称为哈希表/散列表。

我们以一个整型类型充当key的接口来模拟哈希的流程:

例如:数据集合{1,7,6,4,5,9};

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

5.2、哈希冲突

在上面的例子中,我们如果再多放入几个值就会发现:好几个不同的key 通过相同的哈希函数 计算出相同的哈希地址。这种现象就叫做发生了哈希碰撞/哈希冲突

5.2.1、冲突-避免-哈希函数调整
  • 哈希冲突的避免,我们首先可以从哈希函数的设计来入手:
    • 一个好的哈希函数一般都会满足以下三个原则:
      • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
      • 哈希函数计算出来的地址能均匀分布在整个空间中
      • 哈希函数应该比较简单

常见的哈希函数:

  1. 直接定址法:取一组关键字的线性函数来当作散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关
    键字的分布情况
    比如 字符 a - z 的映射,它的地址我们就可以设置为 0 - 25 通过 小写字符 - 97 这个函数来得到地址。
  2. 除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址 (因为% 一个质数可以尽可能让哈希地址保持均匀,因为避免了许多其它因数)
5.2.2、冲突-避免-负载因子调节
  • 哈希表的负载因子定义为 : α = 填入表中的元素 / 哈希表的长度

负载因子和冲突率的关系图如下:

所以当冲突率到达一个很高的情况下,我们就要减小负载因子,通过增大哈希表的长度


以上是我们避免(优化)哈希冲突的手段,下面介绍的两种是解决哈希冲突的方法:

5.2.3、冲突-解决-闭散列

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

  • 寻找下一个空位置的方法有两种:线性探测、二次探测
  1. 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

    • 注意:使用闭散列来处理哈希冲突时,不能随便物理删除哈希表已经存在的元素,否则后面通过探测放进去的元素的查找可能会受到影响。所以只能使用标记来伪删除一个元素。
  2. 二次探测:线性探测的缺陷是,找下一个空位的方式是挨个为往后去找,这样很容易造成产生冲突后数据的堆积情况 ,所以二次探测就是为了解决这个问题存在的: 其寻找下一个地址的方式为": H =( H + i 2 ) % m

  • 闭散列中,通过研究表明,当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

  • 因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

5.2.4、 冲突-解决-开散列/哈希桶

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

当冲突严重时,链表的长度达到一定值时,我们可以将链表转换为搜素树来进行存储。

我们下面进行模拟实现一个哈希桶:

java 复制代码
public class HashBuck<K,V> {
    public static class Node<K,V>{
        public K key;
        public V val;
        public Node<K,V> next;

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

    public Node<K,V>[] arr = (Node<K,V>[]) new Node[10];
    public int usedSize;
    final static private double DEFAULT_LOAD_FACTOR = 0.75;

    public void push(K key,V val){
        int hash = key.hashCode();
        hash = hash ^ (hash >>> 16); // 扰动
        int index = (hash & 0x7fffffff) % arr.length; //转为正整数
        Node<K,V> cur = arr[index];
        while(cur != null){
            if(cur.key.equals(key)){
                //key已存在更新val
                cur.val = val;
                return;
            }
            cur = cur.next;
        }
        Node<K,V> node =  new Node(key,val);
        node.next = arr[index];
        arr[index] = node;
        usedSize++;
        if(doLoadFactor() >= DEFAULT_LOAD_FACTOR){
            resize();
        }

    }

    private void resize() {
        Node<K,V>[] newArr = new Node[2*arr.length];
        for(int i  = 0; i < arr.length;i++){
            Node cur = arr[i];
            while (cur != null){
                int hash = cur.key.hashCode();
                hash = hash ^ (hash >>> 16);
                int newIndex = (hash & 0x7fffffff) % newArr.length;
//                记录遍历节点的下一位:
                Node<K,V> curN = cur.next;
//                头插
                cur.next = newArr[newIndex];
                newArr[newIndex] = cur;
                cur = curN;
            }
        }
        arr = newArr;
    }

    private double doLoadFactor(){
         return usedSize * 1.0 / arr.length;
    }
    public V getVal(K key){
        int hash = key.hashCode();
        hash = hash ^ (hash >>> 16);
        int index = (hash & 0x7fffffff) % arr.length;
        Node<K,V> cur = arr[index];
        while (cur != null){
            if(cur.key.equals(key)){
                return cur.val;
            }
            cur = cur.next;
        }
        return null;
    }

}

6、反射

6.1、定义

  • java的反射机制,是在运行状态,对于任何一个类,都能得到它的所有属性和方法,任意一个对象都能调用它的任何方法和属性。

6.2、用途

  1. 在日常的第三方应用开发过程中,经常会遇到某个类的某个成员变量、方法或是属性是私有的或是只对系统应用开放,这时候就可以利用Java的反射机制通过反射来获取所需的私有成员或是方法 。
  2. 在开发通用框架时,由于我们并未知道运行时所需要的某种具体类型,所以我们就可以利用反射机制拿到实时的对象运行时类型来进行动态处理。
6.3、反射的基本信息

java程序在许多对象运行时会出现两种类型:运行时类型和编译时类型。例如:例如Person p = newStudent();这句代码中p在编译时类型为Person,运行时类型为Student。

6.4、反射相关的类
类名 用途
Class 类 代表类的实体,在运行的 Java 应用程序中表示类和接口
Field 类 代表类的成员变量/类的属性
Method 类 代表类的方法
Constructor 类 代表类的构造方法
6.5、反射的发生时机

Java文件被编译后,生成了 .class 文件,此时,JVM 就需要解读 .class 文件,.class 文件被 JVM 解析成一个对象,这个对象就是 java.lang.Class。因此,程序在运行时,每个Java文件最终都变成了 Class 类对象的一个实例

6.6、常用方法
6.6.1、获取/使用类
方法 用途
getClassLoader() 获得类的加载器
getDeclaredClasses() 返回一个数组,数组中包含该类中所有类和接口类的对象(包括私有的)
forName(String className) 根据类名返回类的对象
newInstance() 创建类的实例
getName() 获得类的完整路径名字
6.6.2、获取属性
方法 用途
getField(String name) 获得某个公有的属性对象
getFields() 获得所有公有的属性对象
getDeclaredField(String name) 获得某个属性对象
getDeclaredFields() 获得所有属性对象
6.6.3、获取注解
方法 用途
getAnnotation(Class annotationClass) 返回该类中与参数类型匹配的公有注解对象
getAnnotations() 返回该类所有的公有注解对象
getDeclaredAnnotation(Class annotationClass) 返回该类中与参数类型匹配的所有注解对象
getDeclaredAnnotations() 返回该类所有的注解对象
6.6.4、获取构造方法
方法 用途
getConstructor(Class...<?> parameterTypes) 获得该类中与参数类型匹配的公有构造方法
getConstructors() 获得该类的所有公有构造方法
getDeclaredConstructor(Class...<?> parameterTypes) 获得该类中与参数类型匹配的构造方法
getDeclaredConstructors() 获得该类所有构造方法
6.6.5、获取方法
方法 用途
getMethod(String name, Class...<?> parameterTypes) 获得该类某个公有的方法
getMethods() 获得该类所有公有的方法
getDeclaredMethod(String name, Class...<?> parameterTypes) 获得该类某个方法
getDeclaredMethods() 获得该类所有方法
  • 使用反射会有效率问题,会导致程序的运行效率降低,且反射绕过了源代码的设计,因此会带来一些维护问题。
相关推荐
H.莓飛2 小时前
【数据结构】排序_OJ题(一):计数、桶与排序后的处理
java·开发语言·数据结构
wangchunyu1142 小时前
JAVA AQS与Lock实现原理(含CAS)
java·开发语言·word
w_zero_one3 小时前
算法--模拟
java·数据结构·算法
Javatutouhouduan4 小时前
阿里1658页Java面试题总结(含答案)
java·高并发·java面试·java面试题·后端开发·java程序员·java八股文
秋天的怀念_David4 小时前
算法一千零一夜 · 第十三夜 幻方,武侠世界里的数学迷宫
java·程序人生·算法
专业程序开发源4 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计
秋天的怀念_David5 小时前
算法一千零一夜 · 第十四夜 幻方与奇妙的二进制
java·程序人生·算法
thefool1122665 小时前
网络编程^
java
薛定e的猫咪5 小时前
(ICLR 2020)MAML 靠的不是快速学习,而是特征复用
人工智能·深度学习·学习·算法·机器学习