前面我们学习了List集合。我们知道List集合代表一个元素有序、可重复的集合,集合中每个元素都有对应的顺序索引。今天我们要学习的是一个注重独一无二性质的集合:Set集合。我们可以根据源码上的简介对它进行初步的认识:
/*
* A collection that contains no duplicate elements. More formally, sets
* contain no pair of elements e1
and e2
such that
* e1.equals(e2)
, and at most one null element. As implied by
* its name, this interface models the mathematical set abstraction.
*/
复制代码
这一段说明了Set这个接口的作用,是一个不包含重复元素的集合。这里的重复指,如果元素e1.equals(e2)是true,就不能包含两个。而且最多也只包含一个null元素。
从上面Set的类结构图可以看出,Set接口并没有对Collection做任何扩展。
对象的相等性
引用到堆上同一个对象的两个引用是相等的。如果对两个引用调用hashCode方法,会得到同样的结果,如果对象所属的类没有覆盖Object的hashCode方法的话,hashCode会返回每个对象特有的序号(Java是依据对象的内存地址计算出来此序号),所以两个不同的对象的hashCode是不可能相等的。
如果想要让两个不同的Person对象视为相等的,就必须重写从Object继承下来的hashCode方法和equals方法,因为Object的hashCode方法返回的是该对象的内存地址,所以必须重写,才能保证两个不同的对象具有相同的hashCode,同时也需要两个不同对象比较equals方法会返回true。
Set集合
特点
Set集合中的元素是唯一的,不可重复(取决于hashCode和equals方法),也就是说具有唯一性。
Set集合中元素不保证存取顺序,并不存在索引。
继承关系
Collection
|--Set:元素唯一,不保证存取顺序,只可以用迭代器获取元素。
|--HashSet:哈希表结构,线程不安全,查询速度较快。元素唯一性取决于hashCode和equals方法。
|--LinkedHashSet:带有双向链表的哈希表结构,线程不安全,保持存取顺序,保持了查询速度较快特点。
|--TreeSet:平衡排序二叉树(红黑树)结构,线程不安全,按自然排序或比较器存入元素以保证元素有序。元素唯一性取决于ComparaTo方法或Comparator比较器。
|--EnumSet:专为枚举类型设计的集合,因此集合元素必须是枚举类型,否则会抛出异常。有序,其顺序就是Enum类内元素定义的顺序。存取的速度非常快,批量操作的速度也很快。
HashSet
源码对于HashSet的介绍简洁明了:这个类实现了Set接口,由哈希表支持(实际上是一个HashMap实例)。它不保证集合的迭代顺序;特别是它不能保证随着时间的推移,顺序保持不变。这个类允许使用null元素。这个类是线程不安全的。
所以说看看常用的源码注释还是非常有必要的。
HashSet的equals和hashCode
哈希表里存放的是哈希值。HashSet存储元素的顺序并不是按照存入时的顺序,是按照哈希值来存的,所以取数据也是按照哈希值取的。
元素的哈希值是通过元素的hashCode方法来获取的,HashSet首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals方法,如果equals结果为true,HashSet就视为同一个元素,只存储一个(重复元素无法放入)。如果equals为false就不是同一元素。
基于HashMap实现
HashSet存储的对象都被作为HashMap的key值保存到了HashMap中。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
复制代码
我们知道HashMap是不允许有重复的key值(至于为什么,大家可以先查找资料),所以,这也保证了HashSet存储的唯一性。
LinkedHashSet
照个旧,先看一下源码对LinkedHashSet的定义:由哈希表和链表实现,可以预知迭代顺序。这个实现与HashSet的不同之处在于,LinkedHashSet维护着一个运行于所有条目的双向链表。这个链表定义了迭代顺序,按照元素的插入顺序进行迭代。
可以理解为:HashSet集合具有的优点LinkedHashSet集合都具有。而且LinkedHashSet集合在HashSet查询速度快的前提下,能够保持元素存取顺序。
LinkedHashSet特征总结
LinkedHashSet是HashSet的一个子类,LinkedHashSet也根据HashCode的值来决定元素的存储位置,但同时它还用一个链表来维护元素的插入顺序,插入的时候既要计算hashCode还要维护链表,而遍历的时候只需要按照链表来访问元素。
通过LinkedHashSet的源码可以知道,LinkedHashSet没有定义任何方法,只有四个构造方法。再看父类,可以知道LinkedHashSet本质上也是基于LinkedHashMap实现的。LinkedHashSet所有方法都继承于HashSet,而它能维持元素的插入顺序的性质则是继承于LinkedHashSet。
TreeSet
来继续看TreeSet的定义:基于TreeMap实现的NavigableSet。根据元素的自然顺序进行排序,或根据创建Set时提供的Comparator进行排序,具体取决于使用的构造方法。
TreeSet实现了SortedSet接口(NavigableSet接口继承了SortedSet接口),顾名思义这是一种排序的Set集合,根据源码可以知道底层使用TreeMap实现的,本质上是一个红黑树原理。也正因为它排了序,所以相对HashSet来说,TreeSet提供了一些额外的根据排序位置访问元素的方法。例如:first(),last(),lower(),higher(),subSet(),headSet(),tailSet()。
TreeSet的排序分两种类型,一种是自然排序;一种是定制排序;
自然排序
TreeSet会调用compareTo方法比较元素大小,然后按升序排序。所以自然排序中的元素对象,都必须实现了Comparable接口。不然就会抛出异常。对于TreeSet判断元素是否重复的标准,也是调用元素从Comparable接口继承的compareTo方法,如果返回0就是重复元素(返回一个 -1,0,或1表示这个对象小于、等于或大于指定对象。)。其实Java常见的类基本已经实现了Comparable接口。举个例子吧:
public class Person implements Comparable {
public String name;
public int age;
public String gender;
public Person() {
}
public Person(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public String toString() {
return "Person [name=" + name + ", age=" + age + ", gender=" + gender
+ "]\r\n";
}
@Override
public int compareTo(@NonNull Object o) {
Person p = (Person) o;
if (this.age > p.age) {
return 1;
}
if (this.age < p.age) {
return -1;
}
return this.name.compareTo(p.name);
}
}
复制代码
这边我们先创建一个Person类,实现Comparable接口,重写了compareTo方法。排序条件是,先按照年龄进行排序,年龄相同的情况下,再比较姓名。我们再测试一下:
public class TreeSetTest {
public static void main(String args[]) {
TreeSet ts = new TreeSet();
ts.add(new Person("A", 24, "男"));
ts.add(new Person("B", 23, "女"));
ts.add(new Person("C", 18, "男"));
ts.add(new Person("D", 18, "女"));
ts.add(new Person("D", 20, "女"));
ts.add(new Person("D", 20, "女"));
System.out.println(ts);
System.out.println(ts.size());
}
}
复制代码
结果如下:
[Person [name=C, age=18, gender=男]
, Person [name=D, age=18, gender=女]
, Person [name=D, age=20, gender=女]
, Person [name=B, age=23, gender=女]
, Person [name=A, age=24, gender=男]
]
5
复制代码
非常直观的可以看出,排序是先根据年龄再根据姓名排序的。而且根据元素个数和结果,知道TreeSet去了重。
定制排序
TreeSet另外一种排序就是定制排序,也叫自定义比较器。这种一般是在元素本身不具备比较性,或者元素本身具备的比较性不满足要求,这个时候就只能让容器自身具备。定制排序,需要关联一个Comparator对象,由Comparator提供逻辑。
一般步骤为,定义一个类实现Comparator接口,重写compare方法。然后将该接口的子类对象作为参数传递给TreeSet的构造方法。举个例子:
public class TreeSetTest {
public static void main(String args[]) {
TreeSet ts = new TreeSet(new MyComparator());
ts.add(new Person("A", 24, "男"));
ts.add(new Person("B", 23, "女"));
ts.add(new Person("C", 18, "男"));
ts.add(new Person("D", 18, "女"));
ts.add(new Person("D", 20, "女"));
ts.add(new Person("D", 20, "女"));
System.out.println(ts);
System.out.println(ts.size());
}
class MyComparator implements Comparator {
public int compare(Object o1, Object o2) {
Person p1 = (Person) o1;
Person p2 = (Person) o2;
if (p1.age < p2.age) {
return 1;
}
if (p1.age > p2.age) {
return -1;
}
return p1.name.compareTo(p2.name);
}
}
}
复制代码
这次排序规则是年龄先按照从大到小(倒序),然后再根据姓名的自然排序进行元素的总体排序。Person类没变,依然实现Comparable接口,在两种排序都有的情况下,我们觉得结果会是怎样的呢?
[Person [name=A, age=24, gender=男]
, Person [name=B, age=23, gender=女]
, Person [name=D, age=20, gender=女]
, Person [name=C, age=18, gender=男]
, Person [name=D, age=18, gender=女]
]
5
复制代码
可以看出,当Comparable比较方式,及Comparator比较方式同时存在,以Comparator比较方式为主。其他的都没有疑问。
异同
Comparable是由对象自己实现的,一旦一个对象封装好了,compare的逻辑就确定了,如果我们需要对同一个对象增加一个字段的排序就比较麻烦,需要修改对象本身。好处是对外部不可见,调用者不需要知道排序的逻辑,只要调用排序就可以。
而Comparator由外部实现,比较灵活,对于需要增加筛选条件,只要新增一个Comparator即可。缺点是所有排序逻辑对外部暴露,需要对象外部实现。(这里的外部指对象的外部,我们可以封装好所有的Comparator,对调用者隐藏内部逻辑。)优点是非常灵活,随时可以增加排序方法,只要对象内部字段支持,类似动态绑定。
EnumSet
EnumSet顾名思义就是专为枚举类型设计的集合,因此集合元素必须是枚举类型,否则会抛出异常。EnumSet集合也是有序的,其顺序就是Enum类内元素定义的顺序。EnumSet存取的速度非常快,批量操作的速度也很快。EnumSet主要提供以下方法,allOf, complementOf, copyOf, noneOf, of, range等。注意到EnumSet并没有提供任何构造函数,要创建一个EnumSet集合对象,只需要调用allOf等方法。
EnumSet用的非常少,元素性能是所有Set元素中性能最好的,但是它只能保存Enum类型的元素。
总个结吧
主要介绍了Set的结构,实现原理。Set只是Map的一个马甲,主要逻辑都交给Map实现。东西不多,我们在后面Map的学习中对实现原理再深入研究。再提一嘴:
看到array,就要想到角标。
看到link,就要想到first,last。
看到hash,就要想到hashCode,equals。
看到tree,就要想到两个接口。Comparable,Comparator。