Set 接口与去重集合 | JavaSE
Set 接口与去重集合
一、学习目标
完成本章学习后,你应该能够:
- 能够解释
Set接口在 Java 集合框架中的定位。 - 能够准确说明 Set 最核心的语义是“不允许重复元素”。
- 能够解释为什么 Set 通常不提供
get(index)这样的索引访问方法。 - 能够区分
HashSet、LinkedHashSet、TreeSet在顺序语义上的差异。 - 能够使用 Set 完成基础数据去重和成员判断。
- 能够理解 Set 的“去重”不是简单删除重复文本,而是由具体实现的相等性规则决定。
二、核心知识
2.1 Set 是什么
Set<E> 是 Java 集合框架中的单列集合接口之一。
集合体系可以简化表示为:
Collection
├── List
│ ├── ArrayList
│ └── LinkedList
│
└── Set
├── HashSet
├── LinkedHashSet
└── TreeSet
前面学习的 List 最核心的特点是:
有序
可重复
有索引
而 Set 最核心的特点是:
不保存重复元素。
例如:
Set<String> set = new HashSet<>();
set.add("Java");
set.add("MySQL");
set.add("Java");
从集合抽象上看,第二个 "Java" 不会形成另一个重复元素。
因此 Set 非常适合表达:
不重复的数据集合
2.2 Set 对应数学中的“集合”
数学中的集合:
{1, 2, 3}
通常不会写成:
{1, 1, 2, 2, 3}
Java 的 Set 接口正是在程序中表达类似的:
集合(Set)抽象
因此 Set 的核心关注点不是:
第几个元素
而是:
某个元素是否属于这个集合
例如:
set.contains("Java");
这比:
set.get(0);
更加符合 Set 的设计思想。
2.3 Set 最核心的特征:不重复
官方 Set 契约要求:
集合中不能同时存在两个被认为相等的元素。
因此:
set.add("Java");
set.add("Java");
最终不是两个 "Java"。
add() 方法本身就可以观察添加结果:
boolean result1 = set.add("Java");
boolean result2 = set.add("Java");
通常:
第一次添加 → true
第二次重复添加 → false
false 表示:
此次操作没有让集合新增这个元素。
2.4 Set 没有普通 List 那样的索引模型
List 可以:
list.get(0);
list.get(1);
list.set(2, value);
list.add(3, value);
Set 的核心抽象不是“第几个元素”。
因此通常不会提供:
set.get(index);
这样的操作。
例如下面代码不存在:
Set<String> set = new HashSet<>();
// 错误思想
// String value = set.get(0);
如果需要遍历 Set,可以使用:
for (String value : set) {
System.out.println(value);
}
或者 Iterator、forEach 等遍历方式。
2.5 “Set 是无序的”需要准确理解
这是集合框架中一个非常重要的术语问题。
很多入门教程会把 Set 简化为:
无序、不重复、无索引
但如果把这句话理解成:
所有 Set 实现类都没有任何顺序。
那就是错误的。
更准确的说法应该是:
Set 接口本身的核心契约是不重复;是否具有确定的遍历顺序,由具体实现决定。
Java 中常见的三种 Set:
| 实现 | 重复 | 顺序特点 |
| --------------- | ------ | ------------------ |
| HashSet | 不允许 | 不保证遍历顺序 |
| LinkedHashSet | 不允许 | 具有确定的遇见顺序 |
| TreeSet | 不允许 | 按排序规则组织元素 |
因此:
Set
不能直接等价于:
完全无序
2.6 HashSet
HashSet 是最常见的 Set 实现之一。
例如:
Set<String> set = new HashSet<>();
它重点解决:
快速保存不重复元素
但是:
不应该依赖 HashSet 的遍历顺序。
例如:
set.add("Java");
set.add("MySQL");
set.add("Redis");
不能因为添加顺序是:
Java → MySQL → Redis
就要求遍历时永远也是:
Java → MySQL → Redis
HashSet 的底层哈希表结构将在下一章专门学习。
2.7 LinkedHashSet
LinkedHashSet 在 Set 的去重能力基础上,进一步维护确定的遇见顺序。
例如:
Set<String> set = new LinkedHashSet<>();
如果业务要求:
既不能重复
又希望保持稳定的元素顺序
就可以考虑 LinkedHashSet。
具体底层原理将在:
05-11 · LinkedHashSet 与有序去重
中学习。
2.8 TreeSet
TreeSet 不仅不允许重复元素,还可以按照一定规则对元素进行排序。
例如:
Set<Integer> set = new TreeSet<>();
set.add(30);
set.add(10);
set.add(20);
遍历时会体现 TreeSet 的排序语义。
TreeSet 的:
- 红黑树结构;
- 自然排序;
- Comparable;
- Comparator;
会在后面的:
05-12
05-13
继续深入。
三、使用方法
3.1 创建 Set
由于:
Set
是接口,因此不能直接:
new Set<>();
通常使用某个实现类。
最常见:
import java.util.HashSet;
import java.util.Set;
Set<String> set = new HashSet<>();
左边:
Set<String>
表示使用 Set 抽象。
右边:
new HashSet<>()
表示使用 HashSet 具体实现。
这是典型的:
面向接口编程。
3.2 添加元素
Set<String> set = new HashSet<>();
set.add("Java");
set.add("MySQL");
set.add("Redis");
add() 返回:
boolean
可以直接判断:
boolean success = set.add("Java");
3.3 观察重复添加
Set<String> set = new HashSet<>();
System.out.println(set.add("Java"));
System.out.println(set.add("Spring"));
System.out.println(set.add("Java"));
重点不是死记输出,而是理解:
Set 不会因为再次 add 相同元素而保存第二份重复元素。
3.4 判断元素是否存在
Set 非常常见的操作是:
contains()
例如:
Set<String> permissions = new HashSet<>();
permissions.add("USER_READ");
permissions.add("USER_EDIT");
if (permissions.contains("USER_EDIT")) {
System.out.println("拥有编辑权限");
}
这种业务逻辑天然符合 Set:
某个成员是否属于一个集合?
3.5 删除元素
set.remove("Java");
Set 删除元素不是:
remove(index)
而通常是根据元素本身:
remove(Object)
3.6 获取元素数量
int size = set.size();
例如:
Set<String> set = new HashSet<>();
set.add("Java");
set.add("Java");
set.add("MySQL");
System.out.println(set.size());
分析时一定要考虑 Set 的去重语义。
3.7 遍历 Set
因为 Set 没有普通索引访问,因此不能写:
for (int i = 0; i < set.size(); i++) {
// set.get(i);
}
可以使用增强 for:
for (String value : set) {
System.out.println(value);
}
或者:
set.forEach(System.out::println);
也可以使用 Iterator:
Iterator<String> iterator = set.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
这些遍历方式在前面的集合遍历章节已经学习过。
3.8 完整案例:标签去重
假设用户给一篇文章添加标签:
Java
Spring
Java
MySQL
Spring
Redis
我们只希望保存唯一标签。
import java.util.HashSet;
import java.util.Set;
public class SetDemo {
public static void main(String[] args) {
Set<String> tags = new HashSet<>();
tags.add("Java");
tags.add("Spring");
tags.add("Java");
tags.add("MySQL");
tags.add("Spring");
tags.add("Redis");
System.out.println("标签数量:" + tags.size());
for (String tag : tags) {
System.out.println(tag);
}
}
}
Set 自动维护:
元素唯一性
因此业务代码不需要自己不断写:
if (!list.contains(tag)) {
list.add(tag);
}
从数据结构层面就能够表达:
这里只允许保存唯一标签。
四、原理与进阶
4.1 “重复”到底是什么意思
这是理解 Set 最重要的问题之一。
对于:
String
Integer
这样的常用类型,重复判断比较直观。
但是如果加入自定义对象:
Student s1 = new Student(...);
Student s2 = new Student(...);
到底什么时候认为:
s1 和 s2 是重复元素?
不能简单理解成:
两个对象内容看起来一样
不同 Set 实现判断唯一性的机制并不完全相同。
例如:
HashSet
会涉及:
hashCode()
equals()
而:
TreeSet
会涉及元素的:
比较规则
这些问题非常重要,因此不会在本章一次性展开。
后续依次学习:
05-09 · HashSet 与哈希表
05-10 · hashCode、equals 与对象去重机制
05-12 · TreeSet 与红黑树排序
05-13 · Comparable 与 Comparator 比较器
本章只需要建立一个关键认知:
Set 的“去重”由集合实现遵循的相等性/比较规则决定,而不是一句简单的“看起来一样”。
4.2 为什么 Set 不适合修改影响相等性的元素状态
假设把一个可变对象放进 Set:
Student student = new Student(...);
set.add(student);
之后又修改了某些参与:
equals()
hashCode()
计算的字段。
这可能破坏哈希集合内部对元素位置和相等关系的判断。
因此对于作为:
Set 元素
Map 键
的数据对象,需要特别谨慎地设计:
equals()
hashCode()
以及对象可变性。
具体原因将在 HashSet 与 hashCode/equals 章节继续分析。
4.3 Set 为什么特别适合“成员判断”
很多业务问题本质上不是:
这个数据排在第几个?
而是:
这个数据是否存在?
例如:
这个用户名是否已经使用?
这个角色拥有哪些权限?
这个用户关注过哪些文章?
这个标签是否已经添加?
这个 IP 是否在黑名单中?
这些问题非常符合:
Set
的抽象。
因此学习数据结构时不要只关注 API,还需要理解:
一个集合类型是在表达什么业务模型。
五、实践应用
5.1 去重
例如原始数据:
Java
Java
MySQL
Redis
Redis
Spring
如果业务只需要唯一值,可以考虑:
Set<String>
5.2 权限集合
例如:
Set<String> permissions = new HashSet<>();
保存:
USER_READ
USER_CREATE
USER_UPDATE
USER_DELETE
判断:
permissions.contains("USER_DELETE");
这种设计比普通 List 更能直接表达:
权限是否存在。
5.3 用户兴趣标签
例如:
Set<String> interests = new HashSet<>();
一个用户拥有:
Java
篮球
音乐
算法
同一个标签没有必要重复保存多次。
5.4 唯一 ID
例如:
Set<Long> selectedIds = new HashSet<>();
可以保存用户已经选择的对象 ID。
自然避免:
同一个 ID 被重复选中
5.5 HashSet、LinkedHashSet、TreeSet 怎么初步选择
目前可以先建立最简单的选型框架:
只关心去重和成员判断
优先考虑:
HashSet
去重,同时需要稳定的遇见顺序
考虑:
LinkedHashSet
去重,同时需要排序
考虑:
TreeSet
后续章节会逐个分析它们的底层实现与性能特点。
六、常见问题
6.1 Set 是否就是“无序集合”?
不能这样绝对表述。
准确说法是:
Set 的核心公共契约是“不允许重复元素”,Set 接口本身并不要求所有实现都采用同一种顺序语义。
例如:
HashSet → 不保证遍历顺序
LinkedHashSet → 具有确定的遇见顺序
TreeSet → 按排序规则组织
所以“Set = 无序”只能作为非常粗略的初学印象,不能作为最终知识结论。
6.2 Set 为什么没有 get(index)?
因为 Set 抽象表达的是:
集合成员
而不是:
位置序列
如果你的业务高度依赖:
get(0)
get(1)
get(2)
通常意味着:
你真正需要的数据结构可能是 List,而不是 Set。
6.3 重复添加元素为什么不报错?
Set 的:
add()
设计为返回:
boolean
如果添加操作真的让集合新增了元素,返回 true。
如果元素已经存在,集合通常保持不变,并返回 false。
这是一种正常的数据结构语义,而不是程序异常。
6.4 Set 是否一定可以保存 null?
不能一概而论。
不同 Set 实现对:
null
的支持可能不同。
因此应该:
根据具体实现类的 API 契约判断。
而不是看到:
Set
就直接认为:
一定能存 null
或者:
一定不能存 null
6.5 为什么自定义对象放进 HashSet 后仍可能出现“逻辑重复”?
因为 Java 不知道:
你的业务认为怎样的两个 Student 才算同一个学生。
例如:
学号相同算重复?
姓名相同算重复?
身份证号相同算重复?
所有字段都相同才算重复?
这需要程序员通过对象相等性规则表达。
具体将在:
05-10 · hashCode、equals 与对象去重机制
中重点解决。
6.6 Set 能不能保存多个 null?
从集合“不能存在重复元素”的基本语义出发:
如果某种 Set 实现允许 null,也不应该出现多个独立重复的 null 元素。
但是是否允许 null 本身仍由具体实现决定。
七、练习与验收
7.1 知识问答
- Set 接口属于 Java 集合框架中的哪一体系?
- Set 最核心的公共特征是什么?
- 为什么不能简单把所有 Set 都描述成“无序”?
- HashSet 的顺序特点是什么?
- LinkedHashSet 的顺序特点是什么?
- TreeSet 的顺序特点是什么?
- 为什么 Set 通常没有
get(index)? - Set 的通用 API 为什么大量继承自 Collection?
add(E e)返回 boolean 有什么意义?- 为什么成员判断问题非常适合 Set?
- 自定义对象是否重复,为什么不能只凭肉眼判断字段?
- 为什么修改 Set 中可变对象的关键字段需要特别谨慎?
7.2 代码阅读
不运行下面代码:
import java.util.HashSet;
import java.util.Set;
public class SetRead {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
boolean a = set.add("Java");
boolean b = set.add("MySQL");
boolean c = set.add("Java");
System.out.println(a);
System.out.println(b);
System.out.println(c);
System.out.println(set.size());
System.out.println(set.contains("Java"));
}
}
回答:
- 三次
add()分别可能返回什么? - 最终集合中有几个元素?
- 为什么?
contains("Java")的结果是什么?- 能否准确预测 HashSet 遍历元素的固定顺序?为什么?
7.3 手写代码
任务一:字符串去重
给定:
List<String> languages = List.of(
"Java",
"Python",
"Java",
"C++",
"Python",
"Go"
);
要求:
- 使用 Set 完成去重;
- 输出去重后的元素数量;
- 遍历所有唯一语言;
- 判断集合中是否存在
"Java"; - 删除
"Go"。
任务二:用户权限系统
设计:
Set<String> permissions
保存用户拥有的权限。
要求:
- 添加 4 个权限;
- 重复添加一个权限;
- 判断用户是否拥有删除权限;
- 删除一个权限;
- 遍历剩余权限。
任务三:学生学号去重
输入若干学号:
2026001
2026002
2026001
2026003
2026002
使用 Set 保存。
要求最终能够回答:
- 一共有多少个唯一学生?
- 指定学号是否存在?
- 为什么 Set 比普通 List 更符合该业务模型?
7.4 Debug
下面代码存在概念问题:
Set<String> set = new HashSet<>();
set.add("Java");
set.add("MySQL");
set.add("Spring");
for (int i = 0; i < set.size(); i++) {
System.out.println(set.get(i));
}
回答:
set.get(i)为什么无法成立?- 这反映了 List 与 Set 在抽象上的什么区别?
- 应该如何遍历 Set?
- 如果业务真的要求第 0、1、2 个位置,应该重新思考什么?
7.5 综合训练
设计一个博客标签系统。
要求:
- 一篇文章可以拥有多个标签;
- 同一个标签不能重复出现;
- 能判断文章是否包含某个标签;
- 能添加标签;
- 能删除标签;
- 能遍历全部标签。
进一步分析:
- 为什么 Set 比 List 更符合该需求?
- 如果不关心标签遍历顺序,应该优先考虑哪一种实现?
- 如果必须按照用户添加顺序显示标签,可以进一步考虑哪种实现?
- 如果需要标签自动排序,可以进一步考虑哪种实现?
本题只进行数据结构选型分析,不要求提前研究各实现的底层源码。
7.6 本章验收
关闭资料和 AI 自动补全:
- [ ] 能口述 Collection → List / Set 的体系关系。
- [ ] 能准确说出 Set 最核心的特点是不重复。
- [ ] 不再机械回答“所有 Set 都无序”。
- [ ] 能说明 HashSet、LinkedHashSet、TreeSet 的顺序差异。
- [ ] 能独立创建
Set<String>。 - [ ] 能使用
add()、remove()、contains()、size()。 - [ ] 能使用增强 for 或 Iterator 遍历 Set。
- [ ] 能解释为什么 Set 没有普通
get(index)。 - [ ] 能使用 Set 解决至少一个真实去重问题。
- [ ] 能说明下一章为什么需要进一步学习 HashSet 与哈希表。