Collection集合体系全景图(三)

195 阅读14分钟

Collection中Set家族:

image.png

Set集合的特点

  1. 无序性:Set集合中的元素没有顺序,即无法通过下标或索引来访问元素。
  2. 唯一性:Set集合中的元素是唯一的,即不允许重复元素存在。
  3. 不可变性:Set集合中的元素一旦被添加到集合中,就不能被修改,只能被删除。
  4. 灵活性:Set集合本身是可变的,即可以添加、删除元素。
  5. 集合运算:Set集合支持集合运算,如并集、交集、差集等操作。
  6. 高效性:Set集合的查找、添加、删除等操作都是高效的,时间复杂度为O(1)。

Set集合的实现类

Java中提供了多种Set集合的实现类,如HashSet、TreeSet、LinkedHashSet等,需要了解它们的特点、使用场景和区别。

HashSet特点

  1. 无序性:HashSet中的元素没有顺序,即不能保证元素的顺序与添加顺序相同。
  2. 唯一性:HashSet中的元素是唯一的,即不能有重复元素。
  3. 高效性:HashSet的查找、插入、删除操作都具有很高的效率,时间复杂度为O(1)。
  4. 底层实现:HashSet的底层实现是基于HashMap实现的,即使用了HashMap的键值对存储方式,但是HashSet只存储键,不存储值。(HashMap是基于哈希表实现的一个Map接口的实现类)
  5. 线程不安全:HashSet是非线程安全的,如果多个线程同时访问HashSet,可能会导致数据不一致的问题。如果需要在多线程环境下使用HashSet,可以使用Collections.synchronizedSet()方法将HashSet转换为线程安全的Set。

为什么HashSet中的元素没有顺序,也不能重复

HashSet中的元素没有顺序是因为它是基于哈希表实现的,哈希表是一种无序的数据结构。在哈希表中,元素的存储位置是根据它们的哈希值计算得出的,而不是按照它们的插入顺序或者其他顺序来存储的。因此,HashSet中的元素没有顺序

HashSet中不能重复的原因是因为它是基于哈希表实现的,哈希表中每个元素都有一个唯一的哈希值。当我们向HashSet中添加元素时,它会先计算元素的哈希值,然后根据哈希值来判断该元素是否已经存在于HashSet中。如果已经存在,则不会再次添加,保证了HashSet中元素的唯一性

什么是哈希表

哈希表是一种数据结构,用于存储和查找数据

具体来说,哈希表将每个数据元素通过哈希函数计算得到一个哈希值,然后将该值作为数组下标,将数据存储在对应的数组位置上。当需要查找数据时,只需要通过哈希函数计算出该数据的哈希值,然后在数组中查找对应位置上的数据即可。哈希表的优点是查找速度快,但缺点是可能会出现哈希冲突,即不同的数据元素计算得到的哈希值相同,需要使用特定的解决冲突方法来处理。

哈希表类似于字典或者电话簿,它可以通过一个关键字(比如姓名或者电话号码)快速地查找到对应的值(比如地址或者其他联系方式)。在哈希表中,关键字被映射到一个固定的位置,这个位置就是哈希表中的索引

HashSet使用场景

  1. 去重:HashSet可以用来去除重复的元素,因为它只会保存不重复的元素。
  2. 查找:HashSet可以用来快速查找某个元素是否存在于集合中,因为它的查找时间复杂度是O(1)。
  3. 缓存:HashSet可以用来作为缓存,因为它可以快速地判断某个元素是否已经存在于缓存中。
  4. 集合运算:HashSet可以用来进行集合运算,如求交集(中共同存在)、并集(所有元素组成)、差集(一个集合中去掉另一个集合中的元素所得到的集合)等。
  5. 数据存储:HashSet可以用来存储数据,因为它可以快速地插入、删除、查找元素。

TreeSet特点

  1. 有序性:TreeSet中的元素是有序的,它们按照自然顺序或者指定的比较器顺序进行排序。
  2. 唯一性:TreeSet中的元素是唯一的,它们不会重复。
  3. 可排序性:TreeSet中的元素可以按照自然顺序或者指定的比较器顺序进行排序。
  4. 底层数据结构:TreeSet底层采用红黑树实现,因此插入、删除、查找等操作的时间复杂度为O(log n)。
  5. 线程不安全:TreeSet不是线程安全的,如果多个线程同时访问一个TreeSet对象,可能会出现并发问题。
  6. 不允许空元素:TreeSet不允许插入空元素,否则会抛出NullPointerException异常。

为什么TreeSet中的元素是有序的,并且不能重复

因为它是基于红黑树实现的。红黑树是一种自平衡二叉查找树,它能够保证在插入、删除元素时,树的高度始终保持在log(n)级别,从而保证了查找、插入、删除等操作的时间复杂度都是O(logn)

O(logn) 是一种时间复杂度的表示方法,表示算法的运行时间与输入规模 n 的对数成正比。也就是说,当输入规模 n 增加时,算法的运行时间不会呈线性增长,而是以对数的方式增长。

举个例子,假设有一个有序数组,我们想要在其中查找一个元素。如果我们使用线性查找,需要遍历整个数组,时间复杂度为 O(n)。但是如果我们使用二分查找,每次可以将查找范围缩小一半,时间复杂度就是 O(logn)

什么是红黑树

image.png

特点

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点是黑色的。
  3. 每个叶子节点(NIL节点,空节点)是黑色的。
  4. 如果一个节点是红色的,则它的两个子节点都是黑色的。
  5. 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。

缺点

  1. 实现复杂:红黑树的实现比较复杂,需要考虑多种情况,包括旋转、颜色变换等操作,容易出错。
  2. 空间占用较大:红黑树需要维护额外的颜色信息,因此相比于其他平衡树,它的空间占用较大。
  3. 不适合频繁插入删除操作:虽然红黑树的插入和删除操作的时间复杂度都是O(log n),但是由于需要维护平衡,每次插入和删除操作都需要进行旋转和颜色变换,因此相比于其他数据结构,它不太适合频繁进行插入和删除操作。
  4. 不支持高效的区间查询:红黑树虽然支持快速的查找、插入和删除操作,但是不支持高效的区间查询操作,需要遍历整棵树来查找符合条件的节点,时间复杂度为O(n)。

TreeSet使用场景

  1. 需要对元素进行排序的场景:TreeSet内部采用红黑树实现,可以自动对元素进行排序,因此适用于需要对元素进行排序的场景。
  2. 需要去重的场景:TreeSet内部采用红黑树实现,可以自动去重,因此适用于需要去重的场景。
  3. 需要快速查找元素的场景:TreeSet内部采用红黑树实现,可以快速查找元素,因此适用于需要快速查找元素的场景。
  4. 需要按照范围查找元素的场景:TreeSet提供了一系列的方法,可以按照范围查找元素,例如:headSet、tailSet、subSet等方法,因此适用于需要按照范围查找元素的场景。
  5. 需要对元素进行高级操作的场景:TreeSet提供了一系列的方法,可以对元素进行高级操作,例如:first、last、ceiling、floor等方法,因此适用于需要对元素进行高级操作的场景。

LinkedHashSet特点

  1. 有序性:LinkedHashSet中的元素是按照插入顺序进行排序的,因此可以保证元素的顺序性。
  2. 可以避免重复元素:LinkedHashSet中不允许存储重复元素,如果尝试添加重复元素,将会被忽略。
  3. 性能较好:LinkedHashSet的性能与HashSet相当,但是由于需要维护元素的插入顺序,因此在插入和删除元素时可能会稍微慢一些。
  4. 底层实现是HashMap:LinkedHashSet的底层实现是一个HashMap,因此具有HashMap的所有特点,如快速查找、高效存储等。

为什么LinkedHashSet是按照顺序进行排序的

LinkedHashSet的底层实现是一个哈希表和一个双向链表。哈希表用于快速查找元素,双向链表用于维护元素的插入顺序。当元素被插入时,它会被添加到哈希表中,并且在链表的末尾添加一个新节点。当元素被删除时,它会从哈希表中删除,并且从链表中删除相应的节点。

LinkedHashSet的应用场景包括:

  1. 需要维护元素的插入顺序。
  2. 需要快速查找、删除和插入元素。
  3. 不允许元素重复。
  4. 需要保证元素的顺序不变。

HashSet源码解析

JDK1.8,哈希表采用的数据结构:

哈希表采用的数据结构是数组+链表/红黑树的组合结构。具体来说,哈希表中的每个元素都是一个链表或红黑树,数组中的每个元素指向一个链表或红黑树的根节点。当哈希表中的元素数量较少时,每个元素都是一个链表;当元素数量较多时,会将链表转化为红黑树,以提高查找效率

image.png

JDK1.8之前,哈希表采用的数据结构:

哈希表采用的数据结构是数组和链表的组合,也就是链表散列。每个数组元素都是一个链表的头节点,当发生哈希冲突时,新的元素会被插入到对应数组元素的链表中。这种数据结构的缺点是在哈希冲突严重时,链表会变得很长,导致查询效率降低。

image.png

数组和链表+红黑树为什么比数组+链表更高效

  • 数组:随机访问元素的时间复杂度为O(1),插入和删除元素的时间复杂度为O(n)
  • 链表:插入和删除元素的时间复杂度为O(1),随机访问元素的时间复杂度为O(n)
  • 红黑树的时间复杂度为O(log n),具有较好的平衡性和搜索性能,适用于需要频繁插入、删除和搜索的场景。 因此,红黑树相比于数组和链表,具有更高的效率,尤其是在需要频繁插入、删除和搜索的场景下。
public class HashSet<E>  
extends AbstractSet<E>  
implements Set<E>, Cloneable, java.io.Serializable  

该类继承了AbstractSet类,实现了Set接口,并且支持克隆和序列化。泛型参数E表示集合中元素的类型,提供了一些方法来操作集合,例如添加、删除、包含、遍历等。

static final long serialVersionUID = -5024744406713321676L;  
private transient HashMap<E,Object> map;  
private static final Object PRESENT = new Object();

序列化版本号和一个私有的 HashMap 对象,使用了一个静态的 final Object 对象作为 value,用于关联 HashMap 中的 key。

transient 关键字用于修饰类的成员变量,,表示该变量不会被序列化。在反序列化对象时,transient 修饰的变量会被赋予默认值,例如数字类型的变量会被赋值为 0,对象类型的变量会被赋值为 null

    public HashSet() {  
    map = new HashMap<>();  
    }
    public HashSet(Collection<? extends E> c) {  
    map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));  
    addAll(c);  
    }

第一个构造方法是无参构造方法,直接创建一个空的 HashMap 对象。第二个构造方法是带有一个集合参数的构造方法,先根据集合的大小计算出 HashMap 的初始容量,然后将集合中的元素添加到 HashMap 中。

如果集合 c 的大小除以 0.75 后得到的值大于 16,则使用这个值作为 HashSet 的初始容量;否则,使用 16 作为初始容量

//接收一个初始容量和负载因子作为参数,
//它会创建一个指定初始容量和负载因子的 HashMap 对象作为 HashSet 的底层实现
public HashSet(int initialCapacity, float loadFactor) {  
map = new HashMap<>(initialCapacity, loadFactor);  
}  
//接收一个初始容量HashMap 对象作为 HashSet 的底层实现 
public HashSet(int initialCapacity) {  
map = new HashMap<>(initialCapacity);  
}  
//这个构造方法创建一个具有指定初始容量和负载因子的 HashSet,
//并使用 LinkedHashMap 作为其底层实现。这个构造方法通常不会被直接使用,而是由 HashSet 的子类调用   
HashSet(int initialCapacity, float loadFactor, boolean dummy) {  
map = new LinkedHashMap<>(initialCapacity, loadFactor);  
}
    //返回一个迭代器,用于遍历Map中所有的key
    public Iterator<E> iterator() {  
    return map.keySet().iterator();  
    }  
    //返回此set中的元素的数
    public int size() {  
    return map.size();  
    }  

   //判断map是否为空,返回此set中的元素的数
    public boolean isEmpty() {  
    return map.isEmpty();  
    }  

   //底层实际调用HashMap的containsKey判断是否包含指定key,如果此set包含指定元素,则返回true。
    public boolean contains(Object o) {  
    return map.containsKey(o);  
    }  

  /* 底层实际将将该元素作为key放入HashMap。 
  * 由于HashMap的put()方法添加key-value对时,当新放入HashMap的Entry中key 
  * 与集合中原有Entry的key相同(hashCode()返回值相等,通过equals比较也返回true),
  * 新添加的Entry的value会将覆盖原来Entry的value,但key不会有任何改变,
  * 因此如果向HashSet中添加一个已经存在的元素时,新添加的集合元素将不会被放入HashMap中,
  * 原来的元素也不会有任何改变,这也就满足了Set中元素不重复的特性。
  /
    public boolean add(E e) {  
    return map.put(e, PRESENT)==null;  
    }  
    /**
    * 如果指定元素存在于此set中,则将其移除。 
    * 如果此set已包含该元素,则返回true
    * * 底层实际调用HashMap的remove方法删除指定Entry。
    /
    public boolean remove(Object o) {  
    return map.remove(o)==PRESENT;  
    }  

    /** 
    * 从此set中移除所有元素。此调用返回后,该set将为空。
    * 底层实际调用HashMap的clear方法清空Entry中所有元素。
    */
   public void clear() {  
    map.clear();  
    }  

    //首先,它调用父类的Cloneable接口的clone()方法方法来创建一个新的HashSet对象
    //然后,它将原始HashSet对象的map字段的副本分配给新的HashSet对象的map字段
    //以确保两个对象具有相同的元素。最后,它返回新的HashSet对象。
    @SuppressWarnings("unchecked")  //抑制编译器产生的“未经检查的转换”警告信息,常用于泛型
    
    public Object clone() {  
    try {  
    HashSet<E> newSet = (HashSet<E>) super.clone();  
    newSet.map = (HashMap<E, Object>) map.clone();  
    return newSet;  
    } catch (CloneNotSupportedException e) {  
    throw new InternalError(e);  
    }  
    }  
   //用于将HashSet对象序列化为字节流并写入输出流中
    //s.defaultWriteObject():调用默认的序列化方法,将HashSet对象的非transient字段写入输出流中。
   //s.writeInt(map.capacity()):将HashSet对象内部使用的HashMap的容量写入输出流中。
   //s.writeFloat(map.loadFactor()):将HashSet对象内部使用的HashMap的负载因子写入输出流中。
  // s.writeInt(map.size()):将HashSet对象内部使用的HashMap中元素的数量写入输出流中。
  //遍历HashSet对象内部使用的HashMap中的所有键,并将它们写入输出流中。注意,这里只写入键而不写入值
    private void writeObject(java.io.ObjectOutputStream s)  throws java.io.IOException {  
    s.defaultWriteObject();  
    s.writeInt(map.capacity());  
    s.writeFloat(map.loadFactor());  
    s.writeInt(map.size());  
    for (E e : map.keySet())  
    s.writeObject(e);  
    }  

   //该方法是用于反序列化HashSet对象的私有方法,它会从输入流中读取HashSet对象的各个属性,并根据这些属性重新构建一个新的HashSet对象。在读取过程中,如果发现某些属性的值不合法,则会抛出InvalidObjectException异常
    private void readObject(java.io.ObjectInputStream s)  throws java.io.IOException, ClassNotFoundException {  
    s.defaultReadObject();  
    int capacity = s.readInt();  
    if (capacity < 0) {  
    throw new InvalidObjectException("Illegal capacity: " +  
    capacity);  
    }  

    float loadFactor = s.readFloat();  
    if (loadFactor <= 0 || Float.isNaN(loadFactor)) {  
    throw new InvalidObjectException("Illegal load factor: " +  
    loadFactor);  
    }  
    int size = s.readInt();  
    if (size < 0) {  
    throw new InvalidObjectException("Illegal size: " +  
    size);  
    }  
  
    capacity = (int) Math.min(size * Math.min(1 / loadFactor, 4.0f),  
    HashMap.MAXIMUM_CAPACITY);  
    SharedSecrets.getJavaOISAccess()  
    .checkArray(s, Map.Entry[].class, HashMap.tableSizeFor(capacity));  

    map = (((HashSet<?>)this) instanceof LinkedHashSet ?  
    new LinkedHashMap<E,Object>(capacity, loadFactor) :  
    new HashMap<E,Object>(capacity, loadFactor));  
    for (int i=0; i<size; i++) {  
    @SuppressWarnings("unchecked")  
    E e = (E) s.readObject();  
    map.put(e, PRESENT);  
    }  
    }  
    //该方法返回一个Spliterator对象,用于遍历HashMap中所有的键值。Spliterator是Java 8中新增的接口,用于支持并行遍历和分割迭代器。
    public Spliterator<E> spliterator() {  
    return new HashMap.KeySpliterator<E,Object>(map, 0, -1, 0, 0);  
    }