qinghuan
← Java SE / 全部笔记

单元 20 / Java SE

P0 · 必会主线L1 · 基础

List、Set、Map:先看业务,再选集合

从订单明细、任务去重和购物车入手,判断该用 List、Set 还是 Map,再检查顺序和修改规则。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能根据业务查询方式选择 List、Set 或 Map,并说明选择的前提。

学习理由、准备要求与知识点

为什么学它

先按重复、顺序和按键访问选容器,才能讨论性能与并发实现。

先读这些内容

知识点清单

  • List 有序与重复
  • Set 唯一元素
  • Map 键值关系
  • Map 不继承 Collection
  • merge 数量汇总
  • ArrayList 定位与移动
  • LinkedList 定位成本
  • 普通集合并发边界

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

选集合,先看你准备怎样存和找数据:要保留重复项和位置,用 List;只关心有没有某一项并希望去重,用 Set;要凭商品编号查数量,用 Map。Set 按自己的相等规则判断重复。Map 保存键和对应的值,同一个键再次写入通常会更新值;它不是 Collection 的子接口,不能只把三者理解成快慢不同的容器。

例如,订单明细用 List 保留每一行,已处理的任务编号用 Set 检查是否出现过,购物车用 Map 记录商品编号和数量。还要说清“有顺序”指什么:按位置、按首次加入、按最近访问,还是按大小排序。HashMap 不保证遍历顺序;LinkedHashMap 和 TreeMap 也有不同规则。测试应检查文档承诺的顺序,不能依赖某次碰巧看到的输出。

选择哪种实现,会影响每次操作要做多少工作。ArrayList 可以直接按下标读取;从空列表连续在尾部追加时,总花费通常与追加次数成正比,平均每次是固定量级,这叫摊还常数,但某次扩容可能较慢。

中间插删则要移动后续元素。LinkedList 如果迭代器已经到达操作位置,改变链接较便宜;但按下标找位置仍要逐项走过去。不能只比较插入那一步而忽略查找位置的花费。日常顺序存储通常先选简单实现,再根据实际怎样读取和修改数据调整。

文中这几个词,先认一认

摊还成本
把多次操作的总花费平均分到每次;一次扩容可能很慢,但连续追加整体仍可能很省时。
成员判断
检查集合里有没有某一项,例如某个任务编号是否已处理。
插入顺序
按键第一次加入的先后排列,不是按键或值的大小排序。
迭代器
依次取出集合元素的工具,能否删除等操作由集合的规则决定。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

把业务需要转成操作组合,比背单个方法复杂度更有用。若任务是对每个左侧编号检查右侧是否存在,右侧用 List.contains 会反复线性查找,两个输入规模相乘;先把右侧建成 HashSet 通常能把重复扫描换成快速成员判断,但要付出额外存储和构建成本。

动态数组里,size 是已有元素数,capacity 是底层数组目前能装多少项。追加时还有空位就直接写,没有才建更大的数组并搬过去。若每次按一定比例增加容量,昂贵的搬运不会每次发生,连续追加的总成本可以保持较低;这就是摊还分析。反过来,每次只多开一个空位,就要反复搬动所有旧元素,总搬运量会增长到平方级。

遍历时怎样删除也要遵守集合规则。迭代器支持的 remove 或 removeIf 可以按规则删除;一边遍历,一边随意从原集合增删,可能触发快速失败异常。这类异常用于尽早暴露错误,不是线程安全保障,也不保证发现所有并发问题。并发容器、加锁包装和不可修改快照在遍历时各有规则,需要分别核对。

正在加载流程图…

先定语义,再计算完整操作成本,最后检查修改与并发要求。
查看流程图源码(Mermaid)
flowchart TD
 A["明确业务语义"] --> B{"核心操作"}
 B -->|"位置与重复"| C["List"]
 B -->|"成员与去重"| D["Set"]
 B -->|"键到值"| E["Map"]
 C --> F["再检查定位、移动与扩容成本"]
 D --> G["再选择哈希、插入顺序或排序"]
 E --> G
 F --> H["核对修改与并发契约"]
 G --> H

按顺序看,每一步做了什么

  1. 列出重复、顺序与键访问要求。
  2. 估算完整读写路径,而非单独一步。
  3. 选择对应接口与具体实现。
  4. 明确 null、修改和并发行为。
  5. 用真实规模与访问分布验证必要优化。

03对照实现代码

动态数组扩容为何支持摊还追加 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/ArrayList.java · java.util.ArrayList#grow(int)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.util.Arrays;
public final class SourceModel {
    private int[] values = new int[0];
    private int size;
    public void add(int value) {
        if (size == values.length) {
            // 教学容器主动限制容量,不模拟 JDK 的最大数组处理。
            long preferred = Math.max(4L, values.length + Math.max(1L, values.length / 2L));
            if (preferred > 1_000_000) throw new IllegalStateException("teaching limit");
            values = Arrays.copyOf(values, (int) preferred);
        }
        values[size++] = value; // 只有新元素成功写入才推进逻辑大小。
    }
    public int size() { return size; }
}

本机 ArrayList.grow 根据旧容量、最小所需增长和偏好增长调用 ArraysSupport.newLength,再通过 Arrays.copyOf 搬运。普通路径的偏好增长约为旧容量的一半,但实际结果还受最小需求与大数组边界影响。模型采用类似几何增长并设置教学上限,不声称完整复现 JDK 的容量策略。

逻辑大小与容量必须分开。容量多出的槽位是为未来追加准备的空间,不是已经属于列表的元素;get 和迭代应依据 size,而不是底层数组长度。模型只实现 add 和 size,故意不提供完整 List 接口,避免让几行代码被误认为可直接替代生产集合。

数组扩容要复制已有元素,元素越多,这一次 add 就可能越慢,达到线性时间。摊还分析看的不是某一次,而是从空列表开始连续追加的总花费:容量按一定比例增大时,历次搬运量形成几何级数,总量与最终元素数同阶。所以平均到这一串追加上很省,并不保证每次追加一样快,也不是按随机输入概率求平均。

具体扩大多少是实现细节,不宜用反射读取私有数组后依赖精确容量。提前知道数量时,可以合理设置初始容量来减少搬运,但估得太大会多占内存。长期设计更应依赖公开 API 说明的行为和成本,而不是某个补丁版本内部的常量。

代码先调用谁,再调用谁

  1. add 检查逻辑大小与容量
  2. 容量不足进入 grow
  3. ArraysSupport 选择合法新长度
  4. Arrays.copyOf 搬运并替换内部数组
  5. 追加新元素并推进 size

04动手写一个例子

有序去重交集:按左侧首次顺序输出

输入两个非空引用的 Integer 列表,元素不得为 null。返回同时出现在两侧的编号,每个编号只出现一次,顺序按左侧首次出现。空列表合法,输入不被修改。

输入 1
左[3,1,3,2],右[2,3]
输出 1
[3,2]
输入 2
左[1,1],右[1,1]
输出 2
[1]
输入 3
左[],右[1]
输出 3
[]

右侧只需要回答是否存在,不需要保留重复次数与原始顺序,因此先建 HashSet。左侧决定输出顺序,按原顺序扫描;命中右侧集合后放入 LinkedHashSet,既能去重又保持第一次加入的先后。两种集合分别承担不同职责,而不是随意堆叠容器。

如果用普通 HashSet 保存结果,内容虽然可能正确,顺序却没有保证;如果直接往 ArrayList 添加每次命中项,又会保留左侧重复。也可以使用额外已输出集合加结果列表,实现同一语义。选择最能直接表达契约的组合,有助于读者从类型看到算法意图。

两侧都要完整验证 null 元素,即使另一侧为空也不应跳过接口约定。最后创建 ArrayList 作为结果容器,调用者修改输出不会改变输入。算法只复制 Integer 引用,但 Integer 是不可变值包装类型,因此这里无需进一步复制元素;换成可变实体时必须重新评估隔离要求。

为什么这样写是对的扫描左侧前缀后,结果恰好包含该前缀中也存在于右侧的不同编号,且顺序与首次出现一致。

时间 良好散列分布下期望 O(n+m)。额外空间 O(m+k),k 为输出的不同编号数。

右侧 m 项构建成员表,左侧 n 项各做固定次数成员和去重操作,最后复制 k 项输出;哈希操作成本前提需明确。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class Main {
    static List<Integer> intersect(List<Integer> left, List<Integer> right) {
        if (left == null || right == null) throw new IllegalArgumentException("lists");
        Set<Integer> members = new HashSet<>();
        for (Integer value : right) {
            if (value == null) throw new IllegalArgumentException("right element");
            members.add(value);
        }
        Set<Integer> output = new LinkedHashSet<>();
        for (Integer value : left) {
            // 左侧顺序驱动输出,同时保持完整的元素校验。
            if (value == null) throw new IllegalArgumentException("left element");
            if (members.contains(value)) output.add(value);
        }
        return new ArrayList<>(output); // 输出容器与两个输入独立。
    }
    static void check(List<Integer> wanted, List<Integer> left, List<Integer> right) {
        if (!wanted.equals(intersect(left,right))) throw new AssertionError(wanted);
    }
    public static void main(String[] args) {
        // 普通顺序、重复、空输入、无交集及整数边界。
        check(List.of(3,2),List.of(3,1,3,2),List.of(2,3));
        check(List.of(1),List.of(1,1),List.of(1,1));
        check(List.of(),List.of(),List.of(1));
        check(List.of(),List.of(1),List.of(2));
        check(List.of(Integer.MIN_VALUE),List.of(Integer.MIN_VALUE),List.of(Integer.MIN_VALUE));
        List<Integer> invalid = new ArrayList<>(); invalid.add(null);
        boolean rejected = false;
        try { intersect(invalid,List.of()); } catch (IllegalArgumentException e) { rejected = true; }
        if (!rejected) throw new AssertionError("null element");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 空输入仍返回可独立使用的空结果。
  • 左右重复均不影响结果唯一性。
  • 结果保留左侧顺序,不依赖右侧顺序。
  • 右侧为空时仍拒绝左侧 null 元素。
进阶选读 · 可选深入:有序输入的双指针交集

场景两侧已经按相同规则排序,且可以顺序读取。

做法比较两个游标当前值,小的一方前进,相等时输出并跳过重复;可避免散列表存储,但结果语义将是排序顺序。

复杂度O(n+m) 时间,除输出外 O(1) 状态。

好处与代价需要可信排序前提;若先排序原始无序数据,会增加成本且改变首次出现顺序。

05项目里怎么用

数据规模小且逻辑简单时,不必为省一次线性扫描引入复杂索引。建立集合有内存、分配与哈希计算成本;大量重复查询时才更容易摊薄构建成本。评估时记录数据量、重复率、查询次数与是否需要顺序,避免用孤立微基准给所有场景下结论。

不可修改集合与不可变快照需要区分。List.of 禁止结构修改并拒绝 null,但元素可能仍可变;包装已有列表也可能继续观察到底层变化。把集合返回给其它模块前,明确容器所有权与元素可变性,不能用方法返回类型 List 就暗示它可写或不可写。

换成 ConcurrentHashMap 后,仍要检查完整操作是否允许被别的线程插入。例如先 containsKey 再 put,并不会自动成为一次不可拆开的操作;可按需求使用 putIfAbsent、compute,或在更大范围同步。先写清必须保证的业务规则,再选集合,不能看到 Concurrent 就认定整条流程都正确。

容易踩的坑

  • 把 HashMap 某次遍历顺序当作稳定契约。
  • 只计算 LinkedList 改链接成本,忽略定位。
  • 认为快速失败迭代等于线程安全。
  • 每次追加都只扩一个槽位,导致重复复制。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
ArrayList索引直接且通常布局紧凑中间修改要移动元素顺序存储和按下标读取
哈希集合或映射常见成员与键查询高效额外空间且需稳定相等规则大量重复查找与去重
有序映射或集合支持排序和范围操作比较成本及树结构开销需要最小最大、范围或排序遍历

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01摊还常数是否表示每次 add 都是常数?

展开参考答案与推导

不表示。扩容那次仍需复制已有元素,成本可能线性;摊还把一串追加的总成本分配到每次操作,几何增长使总复制量与最终规模同阶。

02Map 为什么不是 Collection?

展开参考答案与推导

Map 表达键到值的映射,不是单一元素序列或集合接口。它通过 keySet、values、entrySet 提供不同视图,操作契约与 Collection 的单元素添加语义不同。

03为什么交集算法需要两种集合?

展开参考答案与推导

右侧集合只负责成员判断,结果集合还负责去重和保持左侧首次顺序。一个负责查询,一个负责输出语义;若换成其它组合,也必须分别维持这些职责。

换个情况,还会处理吗

01交集内容正确但顺序偶尔变化,先查哪里?

展开参考答案与推导

检查结果是否存入无顺序保证的 HashSet,或遍历了右侧集合而不是左侧列表。测试应比较完整结果列表,不只比较集合内容,否则顺序契约不会被验证。

02尾部追加有偶发延迟尖峰,可能是什么?

展开参考答案与推导

可能触发容量增长与数组复制,也可能有垃圾回收等其它因素。结合分配和规模证据判断,必要时合理预设容量;摊还复杂度不保证单次延迟平滑。

03换成并发 Map 后重复初始化仍发生,为什么?

展开参考答案与推导

多个分离动作仍可交错执行。应检查初始化是否通过原子映射操作完成,以及副作用是否允许重复;容器线程安全只覆盖它承诺的方法,不自动覆盖整个业务流程。

写代码试一试

01改成交集保留左侧重复

输入 / 条件
左[1,1,2],右[1]
输出 / 预期
[1,1]

提示 结果使用列表。

展开参考答案与推导

右侧仍用成员 Set,结果改为 ArrayList,每次命中都追加。此时去重不再是需求,应删除相应断言并明确顺序仍按左侧扫描;不能只改容器而不更新契约。

02只求是否存在任一共同元素

输入 / 条件
左[3,1],右[1,2]
输出 / 预期
true

提示 命中后可早停,但先考虑校验契约。

展开参考答案与推导

若接口要求完整输入校验,应先验证所有元素后再执行早停查询;若允许只检查已访问部分,需要文档明确。性能优化不能悄悄改变非法输入处理规则。

03验证输出修改不影响输入

输入 / 条件
左[1],右[1],清空输出
输出 / 预期
两侧仍为[1]

提示 独立结果容器。

展开参考答案与推导

使用可变输入列表获得结果后清空结果,再断言两侧内容不变。这个测试证明容器隔离;对可变元素还需另测字段修改传播,不能由容器独立推断深不可变。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能根据业务查询方式选择 List、Set 或 Map,并说明选择的前提。

本篇重点加练

重点强化顺序、重复与键值关系的建模,避免集合选择改变业务结果。

  • List 有序与重复
  • Map 键值关系

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    学习打卡输入为 [Java,SQL,Java,Redis]。分别为“保留每次打卡”“展示首次学习顺序”“检查是否已学”选择数据结构,并规定如何处理重复、是否保留顺序,不只写集合类名。

    提交内容
    三项接口约定与选择理由。
    怎样算完成
    • 按业务说明重复记录应如何处理
    • 明确结果是否必须保证顺序

    练到的知识点List 有序与重复 · Set 唯一元素

  2. 作业 02原理问答

    把“课程编号映射课程信息”与“课程信息集合”画成两种模型,列出各自允许的查询操作;说明如何遍历映射的键、值与条目,以及接口关系对方法使用的约束。

    提交内容
    模型图及操作表。
    怎样算完成
    • 区分键和值各自约束
    • 接口关系描述可验证

    练到的知识点Map 键值关系 · Map 不继承 Collection

  3. 作业 03设计评审

    消息中心要求按到达顺序展示且允许相同内容重复出现,运营又要按内容去重统计。制定两个视图各自的数据合同与更新关系。

    提交内容
    存储/统计视图图和验收序列。
    怎样算完成
    • 不同目标的重复语义明确
    • 到达顺序有独立判据

    练到的知识点List 有序与重复

综合应用

  1. 作业 04编程实践

    实现打卡统计程序,输入课程名序列,输出每门课次数与第一次出现位置;用 merge 完成次数合并,明确 null 名称政策,保留原输入。

    提交内容
    完整 Java 代码、自写重复/空输入/非法输入测试及运行证据。
    怎样算完成
    • 次数与首次位置分别验证
    • 不依赖未约定遍历顺序
    • 输入不被意外修改

    练到的知识点List 有序与重复 · Set 唯一元素 · Map 键值关系 · merge 数量汇总

  2. 作业 05代码推演

    对长度为 1000 的列表分析三种任务:读第 900 项、已定位处插入、从头按索引访问所有元素。分别写出 ArrayList 与 LinkedList 的定位和修改成本,说明实验应如何避免只测某一局部步骤。

    提交内容
    操作分解表、成本推导与测量草案。
    怎样算完成
    • 定位与修改成本分开
    • 复杂度前提明确

    练到的知识点ArrayList 定位与移动 · LinkedList 定位成本

  3. 作业 06面试场景

    一个报表程序让两个线程同时更新普通 HashMap 中的课程次数,偶尔总数不足。设计能区分“输入漏读”“更新竞争”“等待未完成”的证据采集,不把一次无故障运行当作证明。

    提交内容
    排查顺序、待采集证据和修复验收条件。
    怎样算完成
    • 区分数据与调度问题
    • 说明集合与复合操作边界

    练到的知识点merge 数量汇总 · 普通集合并发边界

  4. 作业 07编程实践

    实现购物清单合并:每条记录含商品编号与数量,按首次录入位置展示,同编号再次出现时按预先声明的数量政策处理;不得通过商品名识别商品。

    提交内容
    Java 程序、自写重复编号/空清单/同名商品测试与运行证据。
    怎样算完成
    • 顺序和汇总合同分别验证
    • 输入列表保持约定状态

    练到的知识点List 有序与重复 · Map 键值关系

  5. 作业 08面试场景

    课程编号映射课程详情时,删除课程后报名记录仍保留编号。讨论 Map 中“无键”“键映射 null”和“课程已归档”三种表示的调用方合同。

    提交内容
    三种状态表与读取接口设计。
    怎样算完成
    • 状态能被调用者区分
    • 生命周期与缺失语义一致

    练到的知识点Map 键值关系

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成迭代器基础后:实现保留左侧重复的多重集合交集,输入 left=[a,b,a,c,a]、right=[a,a,b];自行规定输出顺序,限制单次扫描各输入,不使用双重全表搜索。

    提交内容
    Java 实现、独立小规模参考测试、边界测试及运行证据。
    怎样算完成
    • 验证次数上限与顺序合同
    • 说明时间空间成本
    • 支持不同 List 实现

    练到的知识点List 有序与重复 · Set 唯一元素 · ArrayList 定位与移动 · LinkedList 定位成本

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把消息中心需求迁移为数据库表或 JSON 文件:哪些顺序和键唯一性约定需要显式保存?选一个小例子完成写出再读回的纸面验证,尚未学数据库可只使用 JSON 草图。

    提交内容
    格式草案和往返语义检查表。
    怎样算完成
    • 模型迁移后保留原合同
    • 指出格式本身没有承担的约束

    练到的知识点List 有序与重复 · Map 键值关系

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗