我们今天对Java中Collection接口下的Set集合进行详细讲解,Set继承于Collection接口,没有新增方法,不允许出现重复元素且无序,主要有HashSet与TreeSet两大实现类,以及一个不常见的EnumSet。

一、Set简介

1.Set特点

  • Set集合中的元素是唯一的,不可重复(取决于hashCode和equals方法),也就是说具有唯一性。
  • Set集合中元素不保证存取顺序,并不存在索引。
    总结起来就是Set 集合中的对象不按特定的方式排序,只是简单地把对象加入集合,集合中不能包含重复的对象,并且最多只允许包含一个 null 元素。

2.继承关系
Collection
  |–Set:元素唯一,不保证存取顺序,只可以用迭代器获取元素。
    |–HashSet:哈希表结构,线程不安全,查询速度较快。元素唯一性取决于hashCode和equals方法。
      |–LinkedHashSet:带有双向链表的哈希表结构,线程不安全,保持存取顺序,保持了查询速度较快特点。
  |–TreeSet:平衡排序二叉树(红黑树)结构,线程不安全,按自然排序或比较器存入元素以保证元素有序。元素唯一性取决于ComparaTo方法或Comparator比较器。
  |–EnumSet:专为枚举类型设计的集合,因此集合元素必须是枚举类型,否则会抛出异常。有序,其顺序就是Enum类内元素定义的顺序。存取的速度非常快,批量操作的速度也很快。
  
3.Set和List的区别

  1. Set 接口实例存储的是无序的,不重复的数据。List 接口实例存储的是有序的,可以重复的元素。
  2. Set 检索效率低下,删除和插入效率高,插入和删除不会引起元素位置改变<实现类有HashSet,TreeSet等>。
  3. List和数组类似,可以动态增长,根据实际存储的数据的长度自动增长List的长度。查找元素效率高,插入删除效率低,因为会引起其他元素位置改变 <实现类有ArrayList,LinkedList,Vector> 。

二、HashSet

1.HashSet介绍

       JDK中对HashSet集合的解释:这个类实现了Set接口,由哈希表支持(实际上是一个HashMap实例)。它不保证集合的迭代顺序;特别是它不能保证随着时间的推移,顺序保持不变。这个类允许使用null元素。这个类是线程不安全的。
  
  HashSet底层数据结构采用哈希表实现,元素无序且唯一,线程不安全,效率高,可以存储null元素,元素的唯一性是靠所存储元素类型是否重写hashCode()和equals()方法来保证的,如果没有重写这两个方法,则无法保证元素的唯一性。 具体如何实现的呢,它按照哈希算法来存储集合中的元素:哈希表里存放的是哈希值,HashSet 存储元素的顺序并不是按照存入时的顺序,是按照哈希值来存的。当向Set集合中添加一个元素时,HashSet 会调用该元素的hashCode() 方法,获取其哈希码,然后根据这个哈希码计算出该元素在集合中的存储位置将其存入(所以取数据也是按照哈希值取的)。如果哈希值一样,接着会比较equals方法,如果equals结果为true,HashSet就视为同一个元素,只存储一个(重复元素无法放入)。如果equals为false就不是同一元素,新元素就可以存入。这样做的好处就是大大提高集合元素的存储速度!!!

P.S.在Java中存在一种哈希表结构,它通过一个算法,计算出的结果就是哈希码值;这个算法叫哈希算法。

2.HashSet保证唯一性原理
  • 我们使用Set集合都是需要去掉重复元素的, 如果在存储的时候逐个equals()比较, 效率较低,哈希算法提高了去重复的效率, 降低了使用equals()方法的次数
  • 当HashSet调用add()方法存储对象的时候, 先调用对象的hashCode()方法得到一个哈希值, 然后在集合中查找是否有哈希值相同的对象
  • 如果没有哈希值相同的对象就直接存入集合;
  • 如果有哈希值相同的对象, 就和哈希值相同的对象逐个进行equals()比较,比较结果为false就存入, true则不存
3.HashSet存储对象

(1)存储String等封装好的对象,无需手动重写hashCode()和equals()方法,因为像String这种封装类源码已经实现了。

public static void main(String[] args) {
        HashSet<String> hs = new HashSet<>();
        boolean b1 = hs.add("first");
        boolean b2 = hs.add("first");
        boolean b3 = hs.add("second");
        
        // 打印b1,b2,b3,hs,看结果是否存储成功
        System.out.println(b1);
        System.out.println(b2);
        System.out.println(b3);
        System.out.println(hs);
}

结果为b2为false,很明显重复元素没有存储成功,证明HashSet里的的元素都是不重复的,无序就不说了。

java set集合 用指定字符拼接 java中的set集合_重复元素


(2)存储自定义对象,必须手动重写hashCode()和equals()方法,两者缺一不可,这是保证元素唯一性的根本原因。

// 自定义Person类
public class Person {
    private String name;
    private int age;
    public Person() {
    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 重写equals()方法,如果内容相等则为true
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age &&
                Objects.equals(name, person.name);
    }
    
    // 重写hashCode()方法,根据name和age生成哈希码,属性一样则为相同
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
-----------------------------------------------------------------
//主方法中
public static void main(String[] args) {
        HashSet<Person> hs = new HashSet<>();
        hs.add(new Person("张三", 23));
        hs.add(new Person("张三", 23));
        hs.add(new Person("李四", 23));
        hs.add(new Person("李四", 23));
        hs.add(new Person("王五", 23));
        System.out.println(hs);    // 打印该HashSet
}

运行结果为该HashSet中也是无重复元素,这个是在重写了hashCode()和equals()方法的前提下。

java set集合 用指定字符拼接 java中的set集合_System_02


如果不同时重写这两个就不能保证元素唯一性(可以自己去尝试一下)

java set集合 用指定字符拼接 java中的set集合_比较器_03

三、LinkedHashSet

1.LinkedHashSet介绍

       LinkedHashSet是HashSet的子类。JDK中对LinkedHashSet的解释是:由哈希表和链表实现,可以预知迭代顺序。这个实现与HashSet的不同之处在于,LinkedHashSet维护着一个运行于所有条目的双向链表。这个链表定义了迭代顺序,按照元素的插入顺序进行迭代。
       可以这么理解:HashSet集合具有的优点LinkedHashSet集合都具有。而LinkedHashSet集合在HashSet查询速度快的前提下,能够保持元素存取顺序。(但是由于要维护元素的插入顺序,所以在性能上略低于HashSet。)

2.LinkedHashSet独特点

底层数据结构是链表和哈希表。(FIFO插入有序,唯一)

  1. 由链表保证元素有序
  2. 由哈希表保证元素唯一

LinkedHashSet底层数据结构采用链表和哈希表共同实现,链表保证了元素的顺序与存储顺序一致,哈希表保证了元素的唯一性。线程不安全,效率高。
下面是一个简单用LinkedHashSet实现存储的例子:

public static void main(String[] args) {
        LinkedHashSet<String> lhs = new LinkedHashSet<>();
        lhs.add("a");
        lhs.add("a");
        lhs.add("a");
        lhs.add("a");
        lhs.add("b");
        lhs.add("c");
        lhs.add("d");
        System.out.println(lhs);  //[a,b,c,d]  去除了重复的元素,同时又保证了存取有序
}

输出结果如下:

java set集合 用指定字符拼接 java中的set集合_重复元素_04

注意:HashSet遍历输出元素其实有一定的顺序,只是不能保证是元素添加时的顺序,集合中元素与hashCode()方法没变时,遍历输出的元素顺序其实是不变。而LinkedHashSet遍历输出元素总是按照元素添加时的顺序。

四、TreeSet

1.TreeSet介绍

       JDK源码中对TreeSet是这么定义的:基于TreeMap的NavigableSet来实现。元素使用自然顺序进行排序,或者根据创建Set时提供的Comparator进行排序,具体取决于所使用的构造函数。

       TreeSet继承于AbstractSet,实现了NavigableSet接口(NavigableSet接口继承了SortedSet接口)SortedSet接口可以实现对集合进行自然排序,因此使用 TreeSet 类默认情况下是自然排序的,这里的自然排序指的是升序排序。另外根据源码可以知道底层使用TreeMap实现的,本质上是一个红黑树原理。也正因为它排了序,所以相对HashSet来说,TreeSet提供了一些额外的根据排序位置访问元素的方法。例如:first(),last(),lower(),higher(),subSet(),headSet(),tailSet()。

2.TreeSet特点

(1)底层数据结构是红黑树(是一个自平衡的二叉树) —唯一,有序
(2)保证元素的排序方式

  • 自然排序(这种排序方式可以理解成元素本身具备比较性)
    让元素所属的类实现Comparable接口
  • 比较器排序(这种排序可以理解成集合类具备比较性)
    让集合构造方法接收Comparator的实现类对象,实现方式可以用匿名类来实现。

(3)保证元素唯一性

  • 根据比较的返回值是否是0来决定
3.两种排序方式

TreeSet的排序分两种类型,一种是自然排序;一种是比较器排序;
(1)自然排序(Comparable):元素自身具备比较性
       元素自身具备比较性,需要元素必须实现Comparable接口,重写compareTo()方法(不然就会抛出异常)。也就是让元素自身具备比较性,这种方式叫做元素的自然排序也叫做默认排序(升序)。添加元素时,TreeSet会调用集合中元素所属类的compareTo()方法来比较元素之间的大小关系,有序存储元素,对于TreeSet判断元素是否重复的标准,也是调用集合中元素所属类的compareTo()方法,如果返回0就是重复元素则不添加,非0则有序添加。(实现原理:红黑树结构)

TreeSet在添加元素的时候, 根据元素所在类的compareTo()方法的返回值来添加元素。

  • 返回正数:往二叉树的右边添加
  • 返回负数:往二叉树的左边添加
  • 返回 0 : 说明重复,不添加

a. 我们以实现了Comparable接口的String类做一个代码演示:

public static void main(String[] args) {
        TreeSet ts = new TreeSet();
        ts.add("aaa");
        ts.add("ccc");
        ts.add("bbb");
        ts.add("nba");
        ts.add("cba");
        ts.add("aaa");   //与上面构成一个重复元素
        System.out.println(ts);
}

运行结果:可以看到添加的元素都是唯一,而且经过了排序的,默认都是升序。

java set集合 用指定字符拼接 java中的set集合_System_05


b. 还可以自定义一个类实现Comparable接口,重写compareTo()方法

如果是复写 compareTo方法,复写规则如下:

1.返回 1 那么当前的值会排在 被比较者 后面。
2.返回 0 那么当前的值【不会被加入到TreeSet中】,因为当前的值【被认为是跟现有的某一个值相等】。
3.返回 -1 会被添加到 被比较者 的前边。

自定义Person类,重写了compareTo()方法:

// Person类
public class Person implements Comparable<Person> {
    private String name;
    private int age;
    public Person() {
    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    @Override
    public int compareTo(Person o) {
        int num = this.name.compareTo(o.name);    //姓名为主要条件
        int num2 = num == 0 ? this.age - o.age : num;  //年龄为次要条件
        return num2;
    }
}
------------------------------------------------------------------------------------------
//主方法      
public static void main(String[] args) {
        TreeSet<Person> ts = new TreeSet<>();
        //添加元素,其中包括重复元素。
        ts.add(new Person("张三", 23));
        ts.add(new Person("李四", 24));
        ts.add(new Person("李四", 24));
        ts.add(new Person("张三", 24));
        ts.add(new Person("王五", 25));
        System.out.println(ts);
}

运行结果可以看到都是有序唯一的:

java set集合 用指定字符拼接 java中的set集合_System_06


P.S.表 1 列举了 JDK 类库中实现 Comparable 接口的类,以及这些类对象的比较方式。

java set集合 用指定字符拼接 java中的set集合_System_07


(2)比较器排序(Comparator):集合类具备比较性

       当元素自身不具备比较性,或者自身具备的比较性不是所需要的。那么此时可以让集合类自身具备。需要定义一个类实现接口Comparator,重写compare方法,并将该接口的子类实例对象作为参数传递给TreeMap集合的构造方法。(实现方式可以用匿名类来实现) 添加元素时,TreeSet会调用Comparator接口中compare()方法(跟上面自然排序的compareTo不一样)来比较元素之间的大小关系,有序存储元素,对于TreeSet判断元素是否重复的标准,也是调用元素从Comparable接口继承的compare()方法,如果返回0就是重复元素则不添加,非0则有序添加。(实现原理:红黑树结构)

注意:当Comparable比较方式和Comparator比较方式同时存在时,以Comparator的比较方式为主;
注意:在重写compareTo或者compare方法时,必须要明确比较的主要条件相等时要比较次要条件。

TreeSet在添加元素的时候, 根据比较器的compare()方法的返回值来添加元素。

  • 返回正数:往二叉树的右边添加
  • 返回负数:往二叉树的左边添加
  • 返回 0 : 说明重复,不添加

我们还是用上面那个自定义的Person类来做演示,因为Person类已经重写了compareTo()方法,我们突然不想要这种自然排序了,那就可以使用比较器排序。这里我们用比较器实现降序!

// Person类还是不变
public class Person implements Comparable<Person> {
    private String name;
    private int age;
    public Person() {
    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    @Override
    public int compareTo(Person o) {
        int num = this.name.compareTo(o.name);    //姓名为主要条件
        int num2 = num == 0 ? this.age - o.age : num;  //年龄为次要条件
        return num2;
    }
}
------------------------------------------------------------------------------------------
//主方法      
public static void main(String[] args) {
        //运行时会调用compare,因为原来是升序,这里我们利用compare让它反过来就行
        TreeSet<Person> ts = new TreeSet<>(new Comparator<Person>() {
            @Override
            public int compare(Person o1, Person o2) {
                int num = o2.compareTo(o1);
                return num;
            }
        });
        //添加元素,其中包括重复元素。
        ts.add(new Person("张三", 23));
        ts.add(new Person("李四", 24));
        ts.add(new Person("李四", 24));
        ts.add(new Person("张三", 24));
        ts.add(new Person("王五", 25));
        System.out.println(ts);
}

运行结果:

java set集合 用指定字符拼接 java中的set集合_java set集合 用指定字符拼接_08

4.总结存储过程:

自然顺序(Comparable):

  1. TreeSet类的add()方法中会把存入的对象提升为Comparable类型
  2. 调用对象的compareTo()方法和集合中的对象比较
  3. 根据compareTo()方法返回的结果进行存储

比较器顺序(Comparator):

  1. 创建TreeSet的时候可以制定 一个Comparator
  2. 如果传入了Comparator的子类对象, 那么TreeSet就会按照比较器中的规则比较
  3. add()方法内部会自动调用Comparator接口中compare()方法排序
  4. 调用的对象是compare方法的第一个参数,集合中的对象是compare方法的第二个参数
5.TreeSet存储元素对元素进行排序的源码解析

       TreeSet在存储元素的时候会首先去判断是否有比较器存在(也就是判断比较器是否为null),如果存在:就会让比较器去调用compare(T t1, T t2)方法,去依次比较即将存入的值和已经存入TreeSet集合的值,如果返回正数:往二叉树右侧放;如果返回负数:往二叉树左侧放;如果返回 0 :不添加。如果不存在:底层就会把即将存入的元素自动提升为Comparable类型的对象(所以如果没有比较器的情况下,元素所在的类没有实现Comparable接口,在做自动提升类型的时候就会报类型转换错误),并让该对象调用CompareTo(T t)方法,和已经存入TreeSet集合的元素依次比较,如果返回正数:往二叉树右侧放;如果返回负数:往二叉树左侧放;如果返回 0 :不添加。所以,如果两种方式同时使用,底层会优先使用方式二(比较器的方式)。

源码片段分析:

public V put(K key, V value) {     //key则是TreeSet即将存入的元素
     Entry<K,V> t = root;       //获取TreeSet中已存入的元素列表也就是 t
     /*此处有代码省略*/
     
     //comparator是一个成员变量,初始值是null,如果TreeSet构造方法中传入了比较器
     //则comparator就不再是null
     Comparator<? super K> cpr = comparator;
     if (cpr != null) { //如果有比较器 就使用比较器来比较
        do {
             parent = t;
             cmp = cpr.compare(key, t.key); //让比较器 cpr 去调用compare(T t1, T t2)方法,去依次比较即将存入的值 key 和已经存入TreeSet集合的值 t.key
             if (cmp < 0)           //如果返回负数
                 t = t.left;            //往二叉树左侧放
             else if (cmp > 0)      //如果返回正数
                 t = t.right;       //往二叉树右侧放
             else               //如果返回 0
                 return t.setValue(value);  //不添加
         } while (t != null);
     } else {               //如果没有比较器 则使用自然排序来比较
         if (key == null)
             throw new NullPointerException();
         Comparable<? super K> k = (Comparable<? super K>) key; //把即将存入的元素 key 自动提升为Comparable类型的对象 k (所以如果没有比较器的情况下,元素所在的类没有实现Comparable接口,在做自动提升类型的时候就会报类型转换错误),
         do {
             parent = t;
             cmp = k.compareTo(t.key);  //让即将存入的元素 k 调用CompareTo(T t)方法,和已经存入TreeSet集合的元素 t.key 依次比较
             if (cmp < 0)           //如果返回负数
                 t = t.left;            //往二叉树左侧放
             else if (cmp > 0)      //如果返回正数
                 t = t.right;       //往二叉树右侧放
             else               //如果返回 0
                 return t.setValue(value);  //不添加
         } while (t != null);
     }
     /*此处有代码省略*/
 }
6.TreeSet保证唯一性原理和有序性原理

       唯一性和有序性都是通过自然排序的compareTo()方法或者比较器排序的compare()方法,这里注意我们并不要重写hashCode()和equals()方法来保证元素唯一,这个“h”和“e”一般用的多的是散列表结构中。

五、EnumSet

       JDK对EnumSet的阐释:专门用于枚举类型的Set实现。 枚举集中的所有元素都必须来自创建该枚举集时显式或隐式指定的单个枚举类型。

       从上面我们可以知道EnumSet顾名思义就是专为枚举类型设计的集合,因此集合元素必须是枚举类型,否则会抛出异常。EnumSet集合也是有序的,其顺序就是Enum类内元素定义的顺序。EnumSet存取的速度非常快,批量操作的速度也很快。EnumSet主要提供以下方法,allOf, complementOf, copyOf, noneOf, of, range等。注意到EnumSet并没有提供任何构造函数,要创建一个EnumSet集合对象,只需要调用allOf等方法。
  EnumSet用的非常少,元素性能是所有Set元素中性能最好的,但是它只能保存Enum类型的元素。