1.HashSet
学习Collection接口时,记得Collection中可以存放重复元素,也可以不存放重复元素,那么我们知道List中是可以存放重复元素的。那么如果想存储不重复的元素,该如何存储呢?可以考虑使用Set体系,它里面的集合,所存储的元素就是不重复的,并且存取元素顺序不一定一致。
在此之前,我们先来了解一下HashSet的简单使用:
特点:
1.不能存储重复元素
2.存储元素的顺序和取元素的顺序不一定一致
3.没有索引,无法通过索引操作集合中的元素
1.1.HashSet原理
HashSet底层原理(哈希算法)
哈希值(hashCode): 根据某种算法生成的值,在Java中一般使用对象地址值或对象属性值计算出哈希值。
下面看代码,理解哈希值如何计算和转换:
public class Person {
private String name;
private int age;
public Person(){
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
public class HashSetDemo02 {
public static void main(String[] args) {
Person p1 = new Person("张三", 19);
Person p2 = new Person("李四", 29);
System.out.println(p1);
System.out.println(p2);
System.out.println("---------------------");
System.out.println(p1.hashCode());//public native int hashCode();native修饰就是涉及到底层硬件的实现,相当于java调用c语言
System.out.println(p2.hashCode());
System.out.println("---------------------");
System.out.println(Integer.toHexString(p1.hashCode()));
System.out.println(Integer.toHexString(p2.hashCode()));
}
}
对于上述代码直接打印对象p1和p2,在没重写toString方法情况下肯定是包名.类名@十六进制值(如下图格式输出)。
这时如果调用hashCode方法(如下图),其实会根据对象内存地址值生成十进制哈希值。这里的hashCode没有方法体,可以看到native,意思就是本地方法,非java语言写的(C语言、C++等),所以调用该方法会使用本地方法库。这里会使用即可,不做深入探究。
对于输出的十六进制,是由Integer的方法toHexString()(看该方法名就是理解去转化为十六进制字符串)来转化的。Integer.toHexString(p1.hashCode()就可以返回p1在上面转化二来的十进制。所以下面看最后的运行结果图:
HashSet 在JDK 7及之前版本的原理
HashSet是基于哈希表的实现,那么先来看看哈希表(不懂的可以理解为数组):
哈希表: 哈希表是一种表结构,我们可以直接根据给定的值计算出目标位置。一般使用数组构建哈希表,根据元素的哈希值计算出存储位置(目标位置)哈希值(hashCode): 根据某种算法生成的值,在Java中一般使用对象地址值或对象属性值计算出哈希值。
下面看看基于上图HashSet是如何存储的(假设依次往HashSet里存入abc、def、abc、ghk、mvp等元素):
---->TreeSet底层用的红黑树的结构
JDK 7及之前版本:
HashSet第一次存储 abc 过程
首先底层会创建一个长度为16的数组(如上图API),该数组作为哈希表2.根据当前元素(abc)的哈希值(利用元素的hashcode(0方法),底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引(假设为3)3.此时会判断3索引位置是否为nul (判断该位置是否存储元素),为null,直接存入abc
第二次存储def过程
根据当前元素(def)利用hashcode(方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引(假设为1)2.此时会判断1索引位置是否为null (判断该位置是否存储元素),为null,直接存入def
第三次存储abc过程
根据当前元素(abc),利用hashcode0方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为32.此时会判断3索引位置是否为nu,不为null,发现此位置已存储abc元素会将即将存储的abc 与 集合中已有 abc 的 哈希值 进行比较:
abc和abc 的哈希值相同,此时再利用equals方法比较,结果也相同
底层认为 即将存储的abc 和 集合中的 abc 是相同元素,不再存入HashSet
第四次存储ghk过程:
根据当前元素(ghk),利用hashcode0方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为 3
此时会判断3索引位置是否为nu(是否已存储元素),不为null,说明该位置已有元素(abc)
会将即将存储的 ghk 与 集合中已有 abc 的 哈希值 进行比较:
假设 ghk 和 abc的哈希值不同,此时不再比较 equals
底层认为ghk和abc是不同元素,将ghk以链表的形式存入HashSet(哈希表)
第五次存储mvp过程:
1.根据当前元素(mvp),利用hashcode(方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为3
2.此时会判断3索引位置是否为nul(是否已存储元素),不为nul,说明该位置已有元素(abc)
由于3位置已经有元素,而且形成了链表结构,那么myp 会与链表上元素逐个比较(假设满足b情况)
a.如果myp与链表上的abc比较发现哈希值不同
此时再去比较链表上的ghk发现哈希值也不同=>认为是不同元素=>存入
b.如果mvp 与链表上的abc比较发现哈希值相同,equals不同
此时再去比较链表上的ghk发现哈希值相同,equals不同 =>认为是不同元素 =>存入
c.如果mvp 与链表上的abc比较发现哈希值相同,equals相同
此时说明mvp和abc是相同元素,不再比较其它元素>不存
总结: 1.新添加元素 与 集合中已有元素进行比较:
a.如果新添加的元素与集合中已有的元素的哈希值 均不相同 =>存
b.如果新添加的元素与集合中已有的元素的哈希值 相同
此时再去比较equals =>equals一旦相同=>说明该元素重复=>不存
equals均不相同 =>存
HashSet 在JDK 8及以后版本的原理
JDK 8及以后版本 底层存储原理 和JDK 7及之前版本 相同,但是底层数据结构上做了一些优化:底层存储原理:
如果新添加的元素 与 集合中已有元素 哈希值不同 =>存
如果新添加的元素 与 集合中已有元素 哈希值相同
此时再去比较equals=>equals一日有相同=>不存
=>equals均不相同 =>存
JDK 8及以后版本 不同点:
1.如果链表上元素个数>=8时候,将链表转换成 一种 树形结构 (底层使用的是红黑树)为了提高查找效率(减少比较次数)
2.如果链表上的元素<8 依然保持链表结构
HashSet扩容思想
在理解HashSet的扩容思想之前,先看一下加载因子
加载因子: 加载因子 = 填入表中的元素个数 / 哈希表的长度
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数
HashSet底层的 加载因子是 0.75 是 冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷
也就是说当哈希表中存储了 16* 0.75=12个元素的时候, 哈希表就会首次扩容为原先的两倍,即32 ,然后将老表中的元素重新存入新表中。
对于HashSet的存储以及达到扩容的过程,可以理解下面的核心源码:
JDK21中HashSet核心源码分析:
//添加元素的源码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.如果哈希表为空,则造表(初始容量为16)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2.存取的元素利用哈希值计算的索引位置 没有元素直接存入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { //3.else说明该位置有元素
Node<K,V> e; K k;
//4.如果存取的元素的哈希值与该位置第一个元素的哈希值相同并且equals也相同,不存
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) //5.存取的元素的哈希值与该位置第一个元素
//哈希值不同 或者 哈希值相同,equals不同
//该条件为真,说明原来的链表已经树化,将该元素加入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//6.走到这里,说明没有树化,依然是链表结构
//存储的元素与该索引位置的第一个元素已经比较过(哈希值不同 或者 哈希值相同,equals不同)
//下面的for循环,就是将存储的元素与链表上剩余的元素逐个比较
// 比较过程中,一旦出现哈希值相同,equals也相同,不存
// 与所有的元素 哈希值全都不同 存
// 与所有的元素 哈希值相同,equals不同 也存
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//比较过程中,一旦出现哈希值相同,equals也相同,就会终止比较
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
}
}
1.2.HashSet存储自定义对象
使用默认hashCode()与equals()方法
1.自定义引用类型使用默认的hashCode和equals方法
hashCode(): 默认是根据对象的内存地址值生成哈希值, 不同对象生成的哈希值不同
equals(): 默认比较两个对象的地址值,如果两个对象地址值相同返回true,否则返回false
下面来验证一下,上面的两个方法是如何被调用的:
public class Person{
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
/**
* 为了查看hashCode方法被调用
* @return
*/
@Override
public int hashCode() {
System.out.println("hashCode方法被调用..."+this.name+" "+super.hashCode());
return super.hashCode();
}
@Override
public boolean equals(Object obj) {
System.out.println("equals方法被调用..."+this.name+" "+((Person)obj).name);
return super.equals(obj);
}
}
public class HashSetDemo01 {
public static void main(String[] args) {
HashSet<Person> people = new HashSet<>();
Person p1 = new Person("张三", 19);
Person p2 = new Person("张三", 19);
Person p3 = new Person("王五", 22);
people.add(p1);
people.add(p2);
people.add(p3);
System.out.println(people);
}
}
HashSet保证元素唯一的原理:
如果新添加的元素与集合中已有的元素哈希值均不相同 =>存
如果新添加的元素与集合中已有的元素哈希值相同
此时再去比较equals => 一旦有equals相同 =>不存
=> 与equals比较均不相同=>存
根据这个存储原理,很容易推断出people中的元素:
由于equals和hashCode我们并没有重写,而是只是为了方便查看加了一条被调用的输出语句。所以显然后面打印的十进制哈希值不一样,那就直接存入。
如果把上面的HashCode重写:
@Override
public int hashCode() {
System.out.println("hashCode方法被调用..."+this.name);
return 0;
}
那么这样无论谁调用HashCode方法返回的哈希值都为0,即哈希值都一样,再来看看运行结果:
第一次存入p1,HashSet为空,直接存入
第二次存入p2, p1.hashCode()和 p2.hashCode()比较相同
再去比较equals =>p2.equals(p1)=>比较的是对象地址值=>equals返回false=>存入
第三次
1.存入的p3先和p1比较
p3.hashCode()和 p1.hashCode()比较相同
再去比较equals =>p3.equals(p1)=>比较的是对象地址值=>equals返回false
2.存入的p3再和p2比较
p3.hashCode()和 p2.hashCode()比较相同
再去比较equals =>p3.equals(p2) =>比较的是对象地址值=>equals返回false
p3和p1,p2比较,哈希值相同,但equals均不相同,认为是不同元素 存入
既然哈希值都一样,那么在存入第二个对象时,就会进一步通过equals()方法挨个比较是否存在完全相同的元素,由于equals方法我们也没重写,所以此时利用equals比较的时候,就是比较地址值,p1和p2虽然内容相同,但是地址值不一样,还是存入了。因此,要是把equals重写掉(比较内容而不是地址值),那就能确定HashSet存入不重复的元素了,这也就是上面说的:HashSet保证元素唯一的原理:
使用hashCode()与equals()方法来确保存入的元素不重复!!!根据实际存入的数据类型情况来修改方法来确保元素的唯一性!
下面来重写hashCode()与equals()方法
自定义引用类型使用默认的hashCode和equals方法
hashCode(): 默认是根据对象的内存地址值生成哈希值, 不同对象生成的哈希值不同
equals(): 默认比较两个对象的地址值,如果两个对象地址值相同返回true,否则返回false
还是依据上面的Person类,来重写:
@Override
public final boolean equals(Object object) {
if (this == object) return true;
if (!(object instanceof Person person)) return false;
return age == person.age && name.equals(person.name);
}
/**
* 通过属性值来计算哈希值
* @return
*/
@Override
public int hashCode() {
int result = name.hashCode();
result = 31 * result + age;
return result;
}
这样一来,就可以达到去重元素了,运行结果:
上图的运行过程可以理解为:
第一次存入p1,HashSet为空,直接存入
第二次存入p2, p1.hashCode()和 p2.hashCode()比较相同
再去比较equals =>p2.equals(p1)=>比较的是各个属性值=>equals返回true=>不存
第三次
1.存入的p3先和p1比较
p3.hashCode()和 p1.hashCode()不同=>直接存入
也就是说此时p1和p2比较的就是内容了,不再比较地址值,既然内容一样,p2就在对比发现hashCode值一样,equals比较内容也一样,认为是相同元素,不再存入。
2.TreeSet
2.1.TreeSet使用
TreeSet也是Set体系的一员,具有以下特点:
1.不包含重复元素
2.无带索引的方法
3.可以将元素按照一定规则排序(与HashSet最大区别)
public class TreeSetDemo01 {
public static void main(String[] args) {
TreeSet<Integer> ts = new TreeSet<>();
ts.add(12);//new Integer(12)
ts.add(2);
ts.add(5);
ts.add(7);
ts.add(7);
System.out.println(ts);
}
}
运行结果:
显然,ts中的元素从小到大被存入,而且重复的存不进去。这就要说---->存入TreeSet的元素需要指定比较规则:自然排序 或 比较器排序.
2.2.TreeSet自然排序规则
自然排序规则:
1.存入集合的元素需要实现Comparable接口
2.重写上面接口中的public int compareTo(T o)方法
可以看一下Integer中的compareTo方法的API:
compareTo并没有直接排序,而是又调用了compare方法,如下图:
自然排序规则及简单原理:
例如:Integer实现了Comparable接口,重写public int compareTo(T o)方法,代表Integer可比较
3.compareTo方法决定了TreeSet中存入元素的顺序,简单原理如下:
a.如果compareTo方法返回负数,说明新添加的元素比集合中的元素小, 存左边
b.如果compareTo方法返回0, 说明新添加的元素与集合中的元素相同, 不存
c.如果compareTo方法返回正数,说明新添加的元素比集合中的元素大, 存右边
2.3.TreeSet比较器排序规则
比较器排序规则及简单原理:
需求: 如果想对TreeSet中存入的Integer实现从大到小排序?
由于Integer类是JDK提供的,别人写好的,我们无法修改其中的compareTo方法,也就是无法修改自然排序规则
这时候我们就需要利用比较器Comparator(接口)进行排序
查API可以看到,TreeSet可以传入一个比较器,即比较器的使用:一般通过TreeSet的构造方法来使用比较器,一旦传入比较器,就不再按照自然排序规则比较。
compare方法决定了TreeSet中存入元素的顺序,简单原理如下:
a.如果compare方法返回负数,说明新添加的元素比集合中的元素小, 存左边
b.如果compare方法返回0, 说明新添加的元素与集合中的元素相同, 不存
c.如果compare方法返回正数,说明新添加的元素比集合中的元素大, 存右边
比较器排序也就是去继承Comparator接口,方法内定义重写规则,o1-o2是默认的从小到大,那换成o2-o1就可以实现从大到小了。
class Sort implements Comparator<Integer>{
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
}
public class TreeSetDemo01 {
public static void main(String[] args) {
TreeSet<Integer> ts = new TreeSet<>(new Sort());
ts.add(12);//new Integer(12)
ts.add(2);
ts.add(5);
ts.add(7);
ts.add(7);
System.out.println(ts);//[12, 7, 5, 2]
}
}
可以这样写(上面),但是我们一般不这样写,而是这样写(下面):
public class TreeSetDemo02{
public static void main(String[] args) {
TreeSet<Integer> ts = new TreeSet<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
ts.add(5);//new Integer(5)
ts.add(7);
ts.add(5);
ts.add(1);
ts.add(3);
System.out.println(ts);
}
}
运行结果图:
2.4.自定义类使用自然排序规则
还是用Person类举例:
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
/**
* 如果只使用年龄比较,可能出现
* 两个人姓名不同,但年龄相同 compareTo方法返回0
* 导致另外一人无法存入TreeSet,与现实业务违背
* @return
*/
@Override
public int compareTo(Person o) {
return this.age - o.age;
}
}
显然如果仅仅是像上面的利用compareTo比较年龄来确定是不是重复元素,那现实中名字不同,年龄相同的大有人在,不能说年龄只要一样就直接判定为相同元素而放弃存入集合,显然和实际情况不符合(这里不再代码演示),所以重写compareTo方法:
@Override
public int compareTo(Person o) {
return this.age == o.age ? this.name.compareTo(o.name) : this.age-o.age;
}
先按照年龄比较,如果年龄相同,再按照姓名比较
年龄:主要比较条件
姓名:次要比较条件 姓名对于英文字符来说默认按照字典顺序排序,如果是中文字符按照unicode码表比较,在码表前面的字符认为小字符,在码表后面的就认为大字符,进行排序。
那根据重写后的compareTo方法,再来验证一下,相同年龄的也能存入:
public class TreeSetDemo01 {
public static void main(String[] args) {
TreeSet<Person> people = new TreeSet<>();
Person p1 = new Person("张三", 18);
Person p2 = new Person("李四", 18);
Person p3 = new Person("王二", 20);
people.add(p1);
people.add(p2);
people.add(p3);
System.out.println(people);
}
}
运行结果:
2.5.自定义类使用比较器规则
把用到的Person类再写一下:
public class Person{
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
其实利用比较器来实现,原理和自然排序规则类似,不难理解,直接上代码:
public class TreeSetDemo02 {
public static void main(String[] args) {
TreeSet<Person> people = new TreeSet<>(new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.getAge()== o2.getAge()?(o1.getName().compareTo(o2.getName())):o1.getAge()- o2.getAge();
}
});
Person p1 = new Person("zhangsan", 29);
Person p2 = new Person("lisi", 24);
Person p3 = new Person("wanger", 24);
people.add(p1);
people.add(p2);
people.add(p3);
System.out.println(people);
}
}
运行结果:
结果图也和我们意料中的一样,就是实现不重复元素的存入,重要的就是判断何为重复元素,在这里就是属性名和属性年龄都一样的情况。单一的名或年龄一样并不能判断出属于同一个元素。