【Java集合框架】3 - 7 HashSet, LinkedHashSet 集合与原理

§3-7 HashSet, LinkedHashSet 集合与原理

3-7.1 Set 集合特点

image

SetCollection 中的一个接口,该系列集合属于单列集合,其特点为:

  • 无序:数据存取的顺序可能是不一致的;
  • 无重复:集合中的元素不可重复,可用于数据去重;
  • 无索引:该集合不具备索引,因此也不具备带有索引的方法,也不能使用普通 for 遍历,也不能通过索引获取元素;

Set 集合接口的实现类

  • HashSet:无序、不重复、无索引;
  • LinkedHashSet:有序、不重复、无索引;
  • TreeSet:可排序、不重复、无索引;

Set 集合中的方法Set 接口中的方法基本上与 Collection API 一致

方法 描述
boolean add(E e) 把给定的对象添加到当前集合中
void clear() 清空集合中所有元素
boolean remove(E e) 删除集合中的给定对象
boolean contains(Object obj) 判断当前集合中是否包含给定对象
boolean isEmpty() 判断当前集合是否为空
int size() 返回集合中元素个数 / 集合大小(长度)

3-7.2 HashSet 集合

HashSetSet 接口的一个实现类,位于 java.util 包下,该实现类没有特定方法,一般直接沿用 Collection 中的方法。

HashSet 集合底层采用哈希表(hash table)存取数据,这是一种增删改查性能都较好的结构。

在常用算法章节中,详细地介绍了哈希表的基本概念,此处做简单的回顾:

哈希函数:对于一个要存储 \(n\) 个元素的哈希表,以其中每个元素的关键字 \(k_i(0 \leq i \leq n-1)\) 为自变量,将其映射到哈希表中的内存单元地址的函数 \(h(k_i)\) 称为哈希函数(hash function),\(h(k_i)\) 又称为哈希地址(hash address)。

常见的构造哈希函数的方法有直接定址法、除留余数法、数字分析法、平方取中法、折叠法等。

哈希冲突:对于两个不同的关键字 \(k_i, k_j(k_i \not= k_j, i \not= j)\),可能会出现 \(h(k_i) = h(k_j)\) 的情况,这种情况就称为哈希冲突,又称哈希碰撞(hash collisions)。通常把这种具有不同关键字而具有相同哈希地址的元素称为同义词(synonym),因此这种冲突也称同义词冲突。

常见的解决哈希冲突的方法有开放定址法(线性探测法、平方探测法)、拉链法等。

装填因子:装填因子 \(\alpha\)(loading factor)是指哈希表中已存入的元素个数 \(n\) 与哈希地址空间大小 \(m\) 的比值。\(\alpha\) 越小,空间利用率越低,发生哈希冲突的可能性越低;\(\alpha\) 越大,空间利用率越高,发生哈希冲突的可能性越高。通常为了同时兼顾减少冲突发生和提高存储空间利用率,使最终的 \(\alpha\) 位于 \(0.6 \sim 0.9\)

综上而看,一个好的哈希表应当设计一个好的哈希函数和良好的哈希冲突解决方案。

在 Java 中,对象在哈希表中的哈希地址(索引)为

int index = (arr.length - 1) & obj.hashCode();

其中,哈希值是对象的整数表示形式,由 Object 类中的 hashCode 方法计算获得,默认使用地址值计算。在一般情况下,会重写 hashCode 方法,利用对象内部的成员属性计算哈希值。

哈希值特点

  • 若没有重写 hashCode 方法,不同对象计算得到的哈希值不同;
  • 若已重写 hashCode 方法,不同对象只要成员属性值相同,计算出的哈希值相同;
  • 在极少数情况下,会发生哈希冲突(哈希值的取值范围为 int 上下限);
  • 对象是否成功添加到 hashSet 中,主要判断对象 hashCode 是否相等;

3-7.3 HashSet 底层原理

在 JDK 8 前,HashSet 采用数组 + 链表的方式存储数据,JDK 8 后,使用的是数组 + 链表 + 红黑树

  1. 调用参构造方法,会创建一个默认长度 16,默认加载因子为 0.75 的数组,名为 table

    HashSet<String> hm = new HashSet<>();
    

    加载因子实际上定义了哈希表的扩容时机,当存入元素恰好满足 \(16 \times 0.75\) 个时,会扩容至原数组大小的 2 倍;

  2. 根据元素的哈希值与数组长度,计算出其应存入的位置:

    int index = (table.length - 1) & e.hashCode();
    
  3. 判断当前位置是否为空(null),是则直接存入;

  4. 若不为 null,则会调用 equals 方法比较对象内部成员属性的值;

    若结果为 true,则二者一样,元素重复,不存;若结果为 false,则二者不同,则存入数组,形成链表;

    在 JDK 8 以前,新元素存入数组,旧元素通过链表挂在新元素后面;JDK 8 以后,新元素直接挂在旧元素后面;

    JDK 8 以后,当链表长度大于 8 且 数组长度大于等于 64 时,该链表会自动转变为红黑树,提高查找效率;

    若集合中存储的是自定义对象,则应重写 hashCodeequals 方法。

根据上述的原理,可以解释 HashSet 的无序、不重复和无索引三个特征:

  • 无序:存储数据时,元素实际存储位置是由哈希函数决定的,因此不同的元素的先后顺序与其数组中实际存储顺序不一定相同,而遍历时,会按照内部数组索引和链表索引的顺序依次遍历,这导致了数据存取的无序性;
  • 无重复:元素通过 hashCode 方法找到存储位置,依赖 equals 比较元素是否相等,若存储的是自定义对象,这两个方法必须基于类中的成员属性重写(否则二者基于地址计算,这一般而言没有意义),这种机制保证了 HashSet 的数据去重;
  • 无索引:即使数组本身具有索引,但个别索引处可能悬挂着一个链表或红黑树,因此具有多个元素共用同一索引的情况,为避免这一问题,HashSet 本身不具备索引;

3-7.4 LinkedHashSet 集合

LinkedHashSet 继承自 HashSet,其最大的不同是其数据存取具有有序性。

实现原理LinkedHashSet 底层仍然使用了哈希表的数据结构,同样也是采用了单链表的形式解决哈希冲突。但额外地,引入了双链表机制,记录数据的存储顺序,使得 LinkedHashSet 具有有序性。

  1. 调用空参构造器时,会在内部创建一个默认长度为 16,默认加载因子为 0.75 的数组;

  2. 根据相同的计算方法,根据元素的哈希值与数组长度,计算出其应存入的位置;

  3. 判断当前位置是否为空,若为空,则直接存入;

    对于第一个成功存入的数据,同时会创建一个双向链表,并将其设为头结点;

  4. 若不为空,则用相同方法将新元素添加至对应存储位置的单链表中;

    这时,也会将该元素添加至双向链表中,并将其设为尾结点,该双链表与哈希表中挂载的单链表同时存在,互不冲突;

这样,双链表就记录了哈希表中所有元素的添加顺序。

一般而言,数据去重优先考虑使用 HashSet,若还需考虑有序性,这时考虑使用 LinkedHashSet(由于内部还需要创建双链表,效率会低一些)。

posted @ 2023-08-09 12:56  Zebt  阅读(148)  评论(0)    收藏  举报