在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接口的三个常见实现,做一个简单的小结。
| 特性 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 唯一性 | √ | √ | √ |
| 顺序 | 无序 | 插入顺序 | 自然/自定义排序 |
| 内部结构 | 哈希表 | 哈希表 + 双向链表 | 红黑树 |
| 性能 (add/contains) | O(1) | O(1) | O(logn) |
| 允许 null | √ | √ | × |
下一篇预告
Day29 | Java集合框架之Map接口详解
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多文章请关注我的公众号《懒惰蜗牛工坊》