第16章 集合框架:List 与 Set

第16章 集合框架:List 与 Set

数组长度固定,无法满足动态需求。集合(Collection)是 Java 提供的"可动态增长"的容器。本节重点学习 List 与 Set 两大接口及常用实现。

一、集合框架概览

先看数组的痛点------长度一旦确定就改不了:String[] arr = new String[3] 只能放 3 个元素,第 4 个直接 ArrayIndexOutOfBoundsException。集合就是为了解决"长度动态变化"而生的:想加就加、想删就删,不用管容量。整个集合框架分两大阵营:

复制代码
Collection(单列集合)
├── List        有序、可重复
│   ├── ArrayList        底层数组,查询快增删慢
│   ├── LinkedList       底层链表,增删快查询慢
│   └── Vector           线程安全(老式,已少用)
├── Set         无序(部分有序)、不可重复
│   ├── HashSet          底层 HashMap,无序
│   ├── LinkedHashSet    底层 LinkedHashMap,按插入顺序
│   └── TreeSet          底层红黑树,自动排序
└── Queue        队列(先进先出)

Map(双列集合,键值对)→ 下一节讲

最常用的三个实现:ArrayList、HashSet、HashMap,记住"List 有序可重复、Set 无序不可重复"。

二、List 接口

List 是有序集合,元素可重复,可以通过下标访问。它有两个关键特性:元素按插入顺序排列每个元素有下标------这决定了它"像数组一样好查"。

ArrayList(最常用)

底层是可变数组,查询快、增删慢(中间插入要移动元素):

java 复制代码
import java.util.ArrayList;
import java.util.List;

List<String> list = new ArrayList<>();

list.add("Java");          // 追加元素
list.add("Python");
list.add("Go");
list.add(1, "C++");        // 在指定下标插入

System.out.println(list);          // [Java, C++, Python, Go]
System.out.println(list.size());   // 4
System.out.println(list.get(0));   // Java:按下标取
System.out.println(list.contains("Go"));  // true

list.remove(0);            // 按下标删除
list.remove("Go");         // 按内容删除

为什么"查询快、增删慢"?数组在内存里是连续空间 ,按下标直接定位(O(1));而中间插入/删除要整体"挪位置"(O(n))。查找还有 indexOf(x) / lastIndexOf(x)(第一次/最后一次出现的位置),找不到返回 -1。

LinkedList

底层是双向链表,增删快、查询慢:

java 复制代码
LinkedList<String> linkedList = new LinkedList<>();
linkedList.addFirst("头");    // 头部添加
linkedList.addLast("尾");     // 尾部添加
linkedList.removeFirst();     // 删除头部

LinkedList 实现了 Deque 接口,常用作栈或队列

java 复制代码
// 当栈用(后进先出)
Deque<String> stack = new LinkedList<>();
stack.push("A");
stack.push("B");
System.out.println(stack.pop());   // B
System.out.println(stack.peek());  // A:只看栈顶

// 当队列用(先进先出)
Queue<String> queue = new LinkedList<>();
queue.offer("1号");
queue.offer("2号");
System.out.println(queue.poll());   // 1号

选择建议

  • 大多数场景(遍历、按下标查)→ ArrayList
  • 频繁在头部/中间增删 → LinkedList

实际开发里 90% 场景用 ArrayList 就够,LinkedList 更多用在队列、栈。

三、遍历 List 的四种方式

java 复制代码
// JDK 11 和 17 里可以直接用 List.of 快速创建不可变列表
// (JDK 8 没有这个写法,要用 Arrays.asList 或手动 add)
List<String> list = List.of("Java", "Python", "Go");

// 方式一:普通 for(需要下标)
for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

// 方式二:增强 for(最常用)
for (String lang : list) {
    System.out.println(lang);
}

// 方式三:Iterator 迭代器
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

// 方式四:Lambda(JDK 8+,简洁)
list.forEach(lang -> System.out.println(lang));

增强 for 遍历时不能同时修改集合 (会抛 ConcurrentModificationException),要删除元素用 Iterator 的 remove() 或 for 倒序删除。

四、Set 接口

Set 是不可重复的集合,不能按下标访问。Set 的"去重"能力靠的是哈希,先看最常用的 HashSet。

HashSet(最常用)

底层是 HashMap,无序(不保证迭代顺序),去重核心:

java 复制代码
Set<String> set = new HashSet<>();

set.add("apple");
set.add("banana");
set.add("apple");      // 重复元素:添加失败,不报错

System.out.println(set);       // [banana, apple](顺序不固定)
System.out.println(set.size());  // 2:去重成功

为什么"无序"?元素存到哪个位置由 hashCode() 算出的哈希桶决定,跟插入顺序无关,别依赖迭代顺序

去重的原理

HashSet 判断元素是否重复:先看 hashCode(),hashCode 相同再看 equals()

因此:自定义类的对象放进 HashSet,必须同时重写 hashCode()equals(),否则两个内容相同的对象会被当成不同元素。

java 复制代码
class Student {
    String name;
    int age;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Student)) return false;
        Student s = (Student) o;
        return age == s.age && name.equals(s.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);   // IDEA 可自动生成
    }
}
java 复制代码
Set<Student> students = new HashSet<>();
students.add(new Student("张三", 18));
students.add(new Student("张三", 18));   // 重写后被认为是重复,去重成功
System.out.println(students.size());     // 1

如果不重写会怎样? 用 Object 默认的 hashCode/equals(比较内存地址),两个 new 出来的对象地址不同,被当成两个元素(见易错点 3 的演示)。

LinkedHashSet 与 TreeSet

java 复制代码
// LinkedHashSet:按插入顺序,可去重且有序
Set<String> lhs = new LinkedHashSet<>();
lhs.add("c"); lhs.add("a"); lhs.add("b");
System.out.println(lhs);     // [c, a, b]:保持插入顺序

// TreeSet:自动排序(元素必须可比较)
Set<Integer> ts = new TreeSet<>();
ts.add(5); ts.add(1); ts.add(3);
System.out.println(ts);      // [1, 3, 5]:升序

TreeSet 要求元素实现 Comparable 接口,或构造时传入比较器:

java 复制代码
// 自定义比较器:按年龄升序
Set<Student> byAge = new TreeSet<>((s1, s2) -> Integer.compare(s1.age, s2.age));
byAge.add(new Student("张三", 20));
byAge.add(new Student("李四", 18));
for (Student s : byAge) {
    System.out.println(s.name + ": " + s.age);   // 李四: 18 / 张三: 20
}

三个 Set 怎么选?只要去重用 HashSet,去重又保序用 LinkedHashSet,自动排序用 TreeSet

五、集合元素去重实战

保留 List 中的不重复元素:

java 复制代码
List<String> list = Arrays.asList("a", "b", "a", "c", "b");
Set<String> unique = new HashSet<>(list);   // 利用 Set 去重
System.out.println(unique);   // [a, b, c]

六、集合工具类 Collections

Collections 是操作集合的静态工具类,排序、反转、打乱、查找全都有:

java 复制代码
List<Integer> nums = new ArrayList<>(Arrays.asList(3, 1, 4, 1, 5));

Collections.sort(nums);          // 排序
Collections.reverse(nums);       // 反转
Collections.shuffle(nums);       // 随机打乱
Collections.max(nums);           // 最大值
Collections.min(nums);           // 最小值
Collections.frequency(nums, 1);  // 统计出现次数

七、扩展知识

1. ArrayList 的扩容机制

ArrayList 底层的数组是"装不满"的------它有容量(capacity)和实际大小(size)。当 size 达到容量上限时会自动扩容 :新容量约为旧容量的 1.5 倍 (JDK 8/11/17 都是 oldCapacity + (oldCapacity >> 1)),用 Arrays.copyOf 把旧数组整体拷贝 到新数组。默认初始容量是 10,前 10 个元素不扩容,第 11 个才触发。

经验:频繁 add 大量数据时,用 new ArrayList<>(预估容量) 能显著减少扩容拷贝的开销。

2. LinkedList vs ArrayList 终极对比

维度 ArrayList LinkedList
底层结构 连续数组 双向链表
按下标查询 get(i) O(1),直接定位 O(n),要挨个找
头部插入/删除 O(n),整体挪动 O(1),改指针
中间插入/删除 O(n),挪动后半段 O(n),先找到位置
额外内存 每个节点多存前后指针
典型场景 遍历、随机访问 频繁头尾增删、队列/栈

结论:LinkedList 遍历要用迭代器/增强 for,别用 get(i)------get(i) 每次都从头遍历,整体 O(n²)。

3. HashSet 底层就是 HashMap

打开 HashSet 源码会发现它内部维护了一个 HashMap:add 的元素作为 key 存入,value 统一用占位对象 PRESENT。元素唯一性 = key 唯一性,复用 HashMap 的去重逻辑。

set.add(x) 的返回值就是"这次有没有真正加进去":

java 复制代码
Set<String> set = new HashSet<>();
System.out.println(set.add("a"));   // true:加进去了
System.out.println(set.add("a"));   // false:已存在,加失败

4. 迭代器与 fail-fast 机制

集合内部维护修改计数器 modCount,每次 add/remove 都会 +1。迭代器创建时记录当时的 modCount,迭代中一旦发现计数变了,立即抛 ConcurrentModificationException------这就是 fail-fast(快速失败):

java 复制代码
List<String> list = new ArrayList<>();
list.add("a"); list.add("b"); list.add("c");

// ❌ 错误示范:迭代中调用 list.add
for (String s : list) {
    list.add("x");      // 抛 ConcurrentModificationException
}

正确的删除姿势见易错点 1:用迭代器自己的 remove()it.remove() 会同步维护 modCount,所以安全)。

八、易错点

1. 遍历时删除元素 → 抛异常

java 复制代码
// ❌ 增强 for 中删除:抛 ConcurrentModificationException
List<String> list = new ArrayList<>();
list.add("a"); list.add("b"); list.add("c");
for (String s : list) {
    if (s.equals("b")) list.remove(s);
}

// ✅ 用迭代器的 remove()
List<String> list2 = new ArrayList<>();
list2.add("a"); list2.add("b"); list2.add("c");
Iterator<String> it = list2.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();
}
System.out.println(list2);   // [a, c]

// ✅ 或者 for 循环倒序删除(见易错点 5)

2. List.of 创建的列表不能增删

java 复制代码
// 需 JDK 11/17 才能运行(JDK 8 没有 List.of)
List<String> fixed = List.of("a", "b", "c");

// ❌ fixed.add("d");      // 抛 UnsupportedOperationException
// ❌ fixed.remove("a");   // 同样抛异常

// ✅ 想要可变列表,先拷贝一份
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
mutable.add("d");
System.out.println(mutable);   // [a, b, c, d]

顺带:JDK 8 里常用的 Arrays.asList 也是定长的------不能 add/remove(抛 UnsupportedOperationException),但可以 set 修改已有元素。

3. HashSet 存自定义对象没重写 hashCode/equals

java 复制代码
// ❌ 没重写:两个"张三"被当成不同元素,都存进去了
class StudentNoHash {
    String name;
    StudentNoHash(String name) { this.name = name; }
}
Set<StudentNoHash> s1 = new HashSet<>();
s1.add(new StudentNoHash("张三"));
s1.add(new StudentNoHash("张三"));
System.out.println(s1.size());   // 2:去重失败!

// ✅ 重写 hashCode + equals 之后(见上文的 Student 类)
Set<Student> s2 = new HashSet<>();
s2.add(new Student("张三", 18));
s2.add(new Student("张三", 18));
System.out.println(s2.size());   // 1:去重成功

4. 用下标访问 LinkedList,性能惨不忍睹

java 复制代码
// ❌ LinkedList.get(i) 每次都要从头遍历,整体 O(n²)
for (int i = 0; i < linkedList.size(); i++) {
    System.out.println(linkedList.get(i));
}

// ✅ 迭代器 / 增强 for:O(n)
for (String s : linkedList) {
    System.out.println(s);
}

5. 正序删除会漏删(下标前移)

java 复制代码
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "b", "c"));

// ❌ 正序删除"b":删掉第一个 b 后元素前移,第二个 b 被跳过
for (int i = 0; i < list.size(); i++) {
    if (list.get(i).equals("b")) list.remove(i);
}
System.out.println(list);   // [a, b, c]:漏删了一个 b!

// ✅ 倒序删除:不涉及下标前移问题
for (int i = list.size() - 1; i >= 0; i--) {
    if (list.get(i).equals("b")) list.remove(i);
}
System.out.println(list);   // [a, c]

九、小结

  • List 有序可重复(ArrayList 查询快 / LinkedList 增删快)
  • Set 不可重复(HashSet 无序 / LinkedHashSet 保序 / TreeSet 排序)
  • 自定义类进 HashSet 必须重写 hashCode + equals
  • 遍历集合别在增强 for 中修改集合,删除用 Iterator.remove() 或倒序 for
  • ArrayList 自动扩容(默认容量 10,1.5 倍增长),大数据量提前指定容量
  • LinkedList 遍历要用迭代器,别用 get(i);JDK 11/17 的 List.of 不可变、不能增删

下一节学习集合框架:Map。

下一篇:第17章 集合框架:Map

相关推荐
薛定猫AI1 小时前
【深度解析】DeepSeek Harness:可插拔智能体运行时如何连接大模型与真实计算机
java·运维·网络
rannn_1111 小时前
【力扣hot100】回溯专题|全排列、子集、字母组合、组合总和、括号生成、单词搜索、分割回文串、N皇后
java·算法·leetcode·回溯
nxb5562 小时前
云原生:控制器
java·开发语言·云原生
咖啡八杯2 小时前
统一返回对象设计:AjaxResult 与 R 的取舍
java·spring boot·框架·若依·返回值
小手cool3 小时前
JSON.parseObject 返回 List 时不能强制转换的解决方案
java·json·list
凯酱4 小时前
MyBatis 中 OGNL 的使用:从入门到实战
java·tomcat·mybatis
吴声子夜歌10 小时前
Java面试——算法
java·算法·面试
evans在进步10 小时前
LeetCode 64:最小路径和——Java 原地动态规划详解
java·leetcode·动态规划
凤山老林11 小时前
Spring Boot 3.x AOT 编译实战:从原理、踩坑到生产落地
java·spring boot·后端