读完这篇,你能做什么
能根据业务查询方式选择 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按顺序看,每一步做了什么
- 列出重复、顺序与键访问要求。
- 估算完整读写路径,而非单独一步。
- 选择对应接口与具体实现。
- 明确
null、修改和并发行为。 - 用真实规模与访问分布验证必要优化。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/ArrayList.java · java.util.ArrayList#grow(int)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
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 说明的行为和成本,而不是某个补丁版本内部的常量。
代码先调用谁,再调用谁
- add 检查逻辑大小与容量
- 容量不足进入 grow
ArraysSupport选择合法新长度Arrays.copyOf搬运并替换内部数组- 追加新元素并推进 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 是不可变值包装类型,因此这里无需进一步复制元素;换成可变实体时必须重新评估隔离要求。
为什么这样写是对的扫描左侧前缀后,结果恰好包含该前缀中也存在于右侧的不同编号,且顺序与首次出现一致。
右侧 m 项构建成员表,左侧 n 项各做固定次数成员和去重操作,最后复制 k 项输出;哈希操作成本前提需明确。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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键值关系
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
学习打卡输入为 [Java,SQL,Java,Redis]。分别为“保留每次打卡”“展示首次学习顺序”“检查是否已学”选择数据结构,并规定如何处理重复、是否保留顺序,不只写集合类名。
- 提交内容
- 三项接口约定与选择理由。
- 怎样算完成
- 按业务说明重复记录应如何处理
- 明确结果是否必须保证顺序
练到的知识点List 有序与重复 · Set 唯一元素
- 作业 02原理问答
把“课程编号映射课程信息”与“课程信息集合”画成两种模型,列出各自允许的查询操作;说明如何遍历映射的键、值与条目,以及接口关系对方法使用的约束。
- 提交内容
- 模型图及操作表。
- 怎样算完成
- 区分键和值各自约束
- 接口关系描述可验证
练到的知识点Map 键值关系 · Map 不继承 Collection
- 作业 03设计评审
消息中心要求按到达顺序展示且允许相同内容重复出现,运营又要按内容去重统计。制定两个视图各自的数据合同与更新关系。
- 提交内容
- 存储/统计视图图和验收序列。
- 怎样算完成
- 不同目标的重复语义明确
- 到达顺序有独立判据
练到的知识点List 有序与重复
综合应用
- 作业 04编程实践
实现打卡统计程序,输入课程名序列,输出每门课次数与第一次出现位置;用 merge 完成次数合并,明确
null名称政策,保留原输入。- 提交内容
- 完整 Java 代码、自写重复/空输入/非法输入测试及运行证据。
- 怎样算完成
- 次数与首次位置分别验证
- 不依赖未约定遍历顺序
- 输入不被意外修改
练到的知识点List 有序与重复 · Set 唯一元素 · Map 键值关系 · merge 数量汇总
- 作业 05代码推演
对长度为 1000 的列表分析三种任务:读第 900 项、已定位处插入、从头按索引访问所有元素。分别写出
ArrayList与LinkedList的定位和修改成本,说明实验应如何避免只测某一局部步骤。- 提交内容
- 操作分解表、成本推导与测量草案。
- 怎样算完成
- 定位与修改成本分开
- 复杂度前提明确
练到的知识点ArrayList 定位与移动 · LinkedList 定位成本
- 作业 06面试场景
一个报表程序让两个线程同时更新普通
HashMap中的课程次数,偶尔总数不足。设计能区分“输入漏读”“更新竞争”“等待未完成”的证据采集,不把一次无故障运行当作证明。- 提交内容
- 排查顺序、待采集证据和修复验收条件。
- 怎样算完成
- 区分数据与调度问题
- 说明集合与复合操作边界
练到的知识点merge 数量汇总 · 普通集合并发边界
- 作业 07编程实践
实现购物清单合并:每条记录含商品编号与数量,按首次录入位置展示,同编号再次出现时按预先声明的数量政策处理;不得通过商品名识别商品。
- 提交内容
- Java 程序、自写重复编号/空清单/同名商品测试与运行证据。
- 怎样算完成
- 顺序和汇总合同分别验证
- 输入列表保持约定状态
练到的知识点List 有序与重复 · Map 键值关系
- 作业 08面试场景
课程编号映射课程详情时,删除课程后报名记录仍保留编号。讨论
Map中“无键”“键映射null”和“课程已归档”三种表示的调用方合同。- 提交内容
- 三种状态表与读取接口设计。
- 怎样算完成
- 状态能被调用者区分
- 生命周期与缺失语义一致
练到的知识点Map 键值关系
进阶挑战 · 掌握前置后选做
- 作业 09编程实践
完成迭代器基础后:实现保留左侧重复的多重集合交集,输入 left=[a,b,a,c,a]、right=[a,a,b];自行规定输出顺序,限制单次扫描各输入,不使用双重全表搜索。
- 提交内容
- Java 实现、独立小规模参考测试、边界测试及运行证据。
- 怎样算完成
- 验证次数上限与顺序合同
- 说明时间空间成本
- 支持不同
List实现
练到的知识点List 有序与重复 · Set 唯一元素 · ArrayList 定位与移动 · LinkedList 定位成本
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 跨域迁移
把消息中心需求迁移为数据库表或 JSON 文件:哪些顺序和键唯一性约定需要显式保存?选一个小例子完成写出再读回的纸面验证,尚未学数据库可只使用 JSON 草图。
- 提交内容
- 格式草案和往返语义检查表。
- 怎样算完成
- 模型迁移后保留原合同
- 指出格式本身没有承担的约束
练到的知识点List 有序与重复 · Map 键值关系
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:Collections (新标签页)↗
阅读不可修改视图、排序、二分查找与同步包装的前提条件。
- 实践参考文档英文
Java 17 API:HashMap (新标签页)↗
阅读散列分布、迭代、fail-fast 与非线程安全前提,区分实现和约定。
- 深入参考文档英文
Java 17 API:Arrays (新标签页)↗
查阅
copyOf、equals、sort、binarySearch和asList的具体契约。 - 深入参考文档英文
OpenJDK 17u java.util.ArrayList (新标签页)↗
定位
ArrayList.grow的容量选择与Arrays.copyOf,区分公开复杂度和具体增长实现。 - 深入参考文档英文
Java17 集合框架 (新标签页)↗
阅读接口分类与通用集合实现的职责。
- 深入参考文档英文
Java17 ArrayList API (新标签页)↗
确认索引、追加、结构修改与并发使用说明。
- 深入参考文档英文
Java17 LinkedHashSet API (新标签页)↗
查阅插入顺序和重复插入行为。