Day28 | Java集合框架之Set接口详解

30 阅读8分钟

Day27 | Java集合框架之List接口详解中,我们学习了List接口的一些用法。

同时也通过List接口常见的实现ArrayList和LinkedList进一步加深了对List的理解。

今天我们一起看一下Collection的另外一个分支——Set。

一、Set概览

Set接口是java.util包中的又一个重要接口,同样继承自Collection。是一个不包含重复元素的无序集合。

之前我们学习的List是一个有序、可重复的接口。

Set与List相比,最重要的区别就是,Set中元素具有唯一性。

如果你尝试往Set里添加一个已经存在的元素,添加会失败,原本的集合不会有任何变化。

这种特性就很适合用在需要对数据进行去重的场景。

Set接口的主要方法跟Collection接口基本一致,但是他的大部分实现都没有索引这个概念,没有提供像List那样通过索引访问元素的方法 get(index)。

二、HashSet

HashSet是Set接口最常用、性能最高的实现类。

它内部不保证元素的存储和取出顺序,也就是说,你添加元素的顺序和遍历时看到的顺序很可能完全不同。

既然说元素唯一是Set的核心特性,就来看看他是怎么实现这个特性的。

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

add是HashSet添加元素的方法。

add方法中出现了一个map,这个map实际是一个HashMap。

当我们用add添加元素时,实际就是往map中添加一组键值对(key-value)。

其中键就是我们添加的元素,值是一个静态不可变的Object。

我们都知道Set存储的是单值元素,这里借用了HashMap来实现。

而HashMap中的键是唯一的,所以HashSet自然就能保证元素的唯一性。

HashMap的具体实现后文会详细的讲。

当前我们只要掌握,HashSet中的元素是唯一的。

两个对象,只有在hashCode()返回相同值,并且equals()方法也返回true的情况下,HashSet才会把它们视为同一个对象。

下面来看一个案例:

package com.lazy.snail.day28;

import java.util.HashSet;
import java.util.Set;

/**
 * @ClassName Day28Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/6/27 14:48
 * @Version 1.0
 */
public class Day28Demo {
    public static void main(String[] args) {
        Set<Student> students = new HashSet<>();
        Student s1 = new Student();
        System.out.println("s1的hashCode:" + s1.hashCode());

        Student s2 = new Student();
        System.out.println("s2的hashCode:" + s2.hashCode());
        System.out.println("s1.equals(s2):" + s1.equals(s2));

        System.out.println("----------------------------------");

        System.out.println("s1添加结果:" + students.add(s1));
        System.out.println("s2添加结果:" + students.add(s2));

    }
}
class Student {
}

从输出结果来看,两个Student对象都成功添加到了集合中。

Student中没有重写hashCode()和equals()方法,使用的是从Object继承的两个方法。

一起来稍微改写一下:

package com.lazy.snail.day28;

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

/**
 * @ClassName Day28Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/6/27 14:48
 * @Version 1.0
 */
public class Day28Demo {
    public static void main(String[] args) {
        Set<Student> students = new HashSet<>();
        Student s1 = new Student(1, "懒惰蜗牛");
        System.out.println("s1的hashCode:" + s1.hashCode());

        Student s2 = new Student(1, "勤奋蜗牛");
        System.out.println("s2的hashCode:" + s2.hashCode());
        System.out.println("s1.equals(s2):" + s1.equals(s2));

        System.out.println("----------------------------------");

        System.out.println("s1添加结果:" + students.add(s1));
        System.out.println("s2添加结果:" + students.add(s2));

    }
}
class Student {
    private int id;
    private String name;

    public Student(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        Student student = (Student) o;
        return id == student.id;
    }

    @Override
    public int hashCode() {
        int result = id;
        result = Objects.hash(id);
        return result;
    }
}

在Student类中重写了equals和hashCode方法。案例中我们假设id(学号)相同的学生就是同一个学生。

当我们试图将第二个id相同的对象添加到集合中时,失败了。

三、LinkedHashSet

LinkedHashSet是HashSet的一个子类。它在HashSet的基础上,又另外维护了一个双向链表,链表上记录了元素的插入顺序。

我们知道,HashSet实际使用的是HashMap实现。

而LinkedHashSet继承了HashSet,他使用的是LinkedHashMap来实现。

这里的LinkedHashMap在后续再讨论。

我们通过案例来感受一下HashSet和LinkedHashSet的区别:

package com.lazy.snail.day28;

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;

/**
 * @ClassName Day28Demo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/6/27 16:26
 * @Version 1.0
 */
public class Day28Demo2 {
    public static void main(String[] args) {
        Set<String> hashSet = new HashSet<>();
        hashSet.add("C");
        hashSet.add("C++");
        hashSet.add("Java");
        hashSet.add("Python");

        System.out.println("HashSet:");
        System.out.println(hashSet);

        System.out.println("-------------------------");

        Set<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("C");
        linkedHashSet.add("C++");
        linkedHashSet.add("Java");
        linkedHashSet.add("Python");

        System.out.println("LinkedHashSet:");
        System.out.println(linkedHashSet);
    }
}

HashSet的输出是乱序的,而LinkedHashSet是先到先得,按照插入顺序排好了序的。

如果既想在集合中保证元素的唯一性,又想保持插入顺序,就可以使用LinkedHashSet。

去翻看LinkedHashSet的源码,就会发现基本上源码里就几个构造方法。

因为LinkedHashSet本身就不是从零开始设计的,完全依靠HashSet和LinkedHashMap。

它跟HashSet相比,迭代速度快点,内存多占了一点。

四、TreeSet

TreeSet是Set家族里最高级的成员,不仅能保证元素唯一,还能时刻对元素进行排序。

它的底层是一个红黑树数据结构。

每次添加新元素,都会把他插入到红黑树的正确位置,从而维持整个集合的有序性。

当前我们要知道红黑树就是一种自平衡的二叉查找树,红黑树种的颜色、旋转我们暂时不讲。

记住这两点:

红黑树天生就是有序的。对于任何一个节点,它左子树的所有节点都比它小,右子树的所有节点都比它大。

这样TreeSet就可以快速地进行中序遍历,从而按顺序输出所有元素。

还有就是普通的二叉查找树在最坏情况下有可能退化成一个链表,导致操作效率变为O(n)。

红黑树通过颜色变换和树旋转确保树的高度大致保持在log(n)级别,从而保证了add(), remove(), contains() 等核心操作的性能始终稳定在O(logn)。

详细的红黑树先埋个坑,这个坑看后续在数据结构中讲还是Map中来填。

既然要根据"大小"来确定元素的位置。必然就有比较元素的操作。

有两种方式来进行比较:自然排序和定制排序。

自然排序

元素通过实现Comparable接口,重写compareTo方法,让元素天生就知道在呢么排序。

再说一句,一般实现某个able结尾的接口,大概就是让其拥有某种能力,比如这里实现Comparable,就是让元素具有比较排序的能力。

package com.lazy.snail.day28;

import java.util.TreeSet;

/**
 * @ClassName Day28Demo3
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/6/27 17:30
 * @Version 1.0
 */
public class Day28Demo3 {
    public static void main(String[] args) {
        TreeSet<Book> bookSet = new TreeSet<>();

        bookSet.add(new Book("Java编程思想", 1998));
        bookSet.add(new Book("算法导论", 2001));
        bookSet.add(new Book("计算机程序的构造和解释", 1995));
        bookSet.add(new Book("编译原理", 2008));
        bookSet.add(new Book("计算机操作系统", 1993));

        System.out.println("自然顺序-年份排序:");
        bookSet.forEach(System.out::println);
    }
}
class Book implements Comparable<Book> {
    private String title;
    private int publishYear;

    public Book(String title, int publishYear) {
        this.title = title;
        this.publishYear = publishYear;
    }

    @Override
    public String toString() {
        return "Book{" + "title='" + title + ''' + ", publishYear=" + publishYear + '}';
    }

    @Override
    public int compareTo(Book other) {
        return Integer.compare(this.publishYear, other.publishYear);
    }
}

compareTo方法中,this表示当前对象,other表示传入的需要比较的元素。

这是直接比较的是书籍的出版年份。

如果compareTo返回负数,this就比other小。

返回0,this就等于other。

返回正数,this就比other大。

输出结果:

自定义排序

当元素的类没有实现Comparable,或者你希望以一种不同于其自然顺序的方式排序的时候,就需要定制排序。

在创建TreeSet的时候,往构造函数里面传一个实现了Comparator接口的对象(注意这里是Comparator)。

Comparator里的compare()方法,功能和compareTo()差不多,但它是独立于元素类之外的。

int compare(T o1, T o2)负责比较两个对象o1和o2。

我们还是用上面的书籍示例来改写:

package com.lazy.snail.day28;

import java.util.Comparator;
import java.util.TreeSet;

/**
 * @ClassName Day28Demo4
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/6/30 11:10
 * @Version 1.0
 */
public class Day28Demo4 {
    public static void main(String[] args) {
        Comparator<Book> titleComparator = Comparator.comparing(Book::getTitle);

        TreeSet<Book> bookSet = new TreeSet<>(titleComparator);

        bookSet.add(new Book("1Java编程思想", 1998));
        bookSet.add(new Book("3算法导论", 2001));
        bookSet.add(new Book("5计算机程序的构造和解释", 1995));
        bookSet.add(new Book("4编译原理", 2008));
        bookSet.add(new Book("2计算机操作系统", 1993));

        System.out.println("自定义顺序-书名排序:");
        bookSet.forEach(System.out::println);
    }
}

class Book {
    private String title;
    private int publishYear;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getPublishYear() {
        return publishYear;
    }

    public void setPublishYear(int publishYear) {
        this.publishYear = publishYear;
    }

    public Book(String title, int publishYear) {
        this.title = title;
        this.publishYear = publishYear;
    }

    @Override
    public String toString() {
        return "Book{" + "title='" + title + ''' + ", publishYear=" + publishYear + '}';
    }
}

为了便于观察结果,在书名前面加了编号。

结语

关于Set接口的三个常见实现,做一个简单的小结。

特性HashSetLinkedHashSetTreeSet
唯一性
顺序无序插入顺序自然/自定义排序
内部结构哈希表哈希表 + 双向链表红黑树
性能 (add/contains)O(1)O(1)O(logn)
允许 null×

下一篇预告

Day29 | Java集合框架之Map接口详解

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》