Set 接口与去重集合 | JavaSE

Set 接口与去重集合

一、学习目标

完成本章学习后,你应该能够:

  • 能够解释 Set 接口在 Java 集合框架中的定位。
  • 能够准确说明 Set 最核心的语义是“不允许重复元素”。
  • 能够解释为什么 Set 通常不提供 get(index) 这样的索引访问方法。
  • 能够区分 HashSetLinkedHashSetTreeSet 在顺序语义上的差异。
  • 能够使用 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 知识问答

  1. Set 接口属于 Java 集合框架中的哪一体系?
  2. Set 最核心的公共特征是什么?
  3. 为什么不能简单把所有 Set 都描述成“无序”?
  4. HashSet 的顺序特点是什么?
  5. LinkedHashSet 的顺序特点是什么?
  6. TreeSet 的顺序特点是什么?
  7. 为什么 Set 通常没有 get(index)
  8. Set 的通用 API 为什么大量继承自 Collection?
  9. add(E e) 返回 boolean 有什么意义?
  10. 为什么成员判断问题非常适合 Set?
  11. 自定义对象是否重复,为什么不能只凭肉眼判断字段?
  12. 为什么修改 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"));
    }
}

回答:

  1. 三次 add() 分别可能返回什么?
  2. 最终集合中有几个元素?
  3. 为什么?
  4. contains("Java") 的结果是什么?
  5. 能否准确预测 HashSet 遍历元素的固定顺序?为什么?

7.3 手写代码

任务一:字符串去重

给定:

List<String> languages = List.of(
        "Java",
        "Python",
        "Java",
        "C++",
        "Python",
        "Go"
);

要求:

  1. 使用 Set 完成去重;
  2. 输出去重后的元素数量;
  3. 遍历所有唯一语言;
  4. 判断集合中是否存在 "Java"
  5. 删除 "Go"

任务二:用户权限系统

设计:

Set<String> permissions

保存用户拥有的权限。

要求:

  • 添加 4 个权限;
  • 重复添加一个权限;
  • 判断用户是否拥有删除权限;
  • 删除一个权限;
  • 遍历剩余权限。

任务三:学生学号去重

输入若干学号:

2026001
2026002
2026001
2026003
2026002

使用 Set 保存。

要求最终能够回答:

  1. 一共有多少个唯一学生?
  2. 指定学号是否存在?
  3. 为什么 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));
}

回答:

  1. set.get(i) 为什么无法成立?
  2. 这反映了 List 与 Set 在抽象上的什么区别?
  3. 应该如何遍历 Set?
  4. 如果业务真的要求第 0、1、2 个位置,应该重新思考什么?

7.5 综合训练

设计一个博客标签系统。

要求:

  • 一篇文章可以拥有多个标签;
  • 同一个标签不能重复出现;
  • 能判断文章是否包含某个标签;
  • 能添加标签;
  • 能删除标签;
  • 能遍历全部标签。

进一步分析:

  1. 为什么 Set 比 List 更符合该需求?
  2. 如果不关心标签遍历顺序,应该优先考虑哪一种实现?
  3. 如果必须按照用户添加顺序显示标签,可以进一步考虑哪种实现?
  4. 如果需要标签自动排序,可以进一步考虑哪种实现?

本题只进行数据结构选型分析,不要求提前研究各实现的底层源码。


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 与哈希表。