如何在 Java 中比较两个列表(相同、缺失和相等的元素)
在 Java 中比较两个列表是很常见的需求:同步数据库记录、在测试中检查接口返回值、找出两次导出之间新增或删除了哪些 ID。根据问题的不同,需要用不同的方法:
- 两个列表是否相等?——考虑或不考虑顺序和重复项
- 哪些元素两个列表都有?——交集
- 哪些元素只在一个列表里?——差集
本文用标准库(Stream.toList() 和 record 需要 Java 16+)讲解每种情况,并给出 Apache Commons 和 Guava 的替代写法。所有代码片段都使用以下导入:
import java.util.*;
import java.util.stream.*;
只需要比较一次两个列表?把它们粘贴到免费的 两个列表比较工具,无需写代码就能看到仅在 A、仅在 B 和两者共有的项目。
1. 判断两个列表是否相等
只有当两个列表以相同顺序包含相同元素时,List.equals() 才返回 true:
List<Integer> a = List.of(1, 2, 3);
List<Integer> b = List.of(1, 2, 3);
List<Integer> c = List.of(3, 2, 1);
System.out.println(a.equals(b)); // true
System.out.println(a.equals(c)); // false – same elements, different order
它用元素自身的 equals() 方法比较,所以适用于字符串、数字以及正确实现了 equals() 的任何类。它也能跨实现类工作:元素相同的 ArrayList 和 LinkedList 是相等的。
2. 忽略顺序比较列表
忽略顺序的方式取决于重复项是否有意义。
元素相同且出现次数相同(重复项有意义)——把副本排序后比较:
List<Integer> x = Arrays.asList(3, 1, 2, 2);
List<Integer> y = Arrays.asList(2, 3, 2, 1);
List<Integer> sortedX = new ArrayList<>(x);
List<Integer> sortedY = new ArrayList<>(y);
Collections.sort(sortedX);
Collections.sort(sortedY);
System.out.println(sortedX.equals(sortedY)); // true
如果元素无法排序,就比较频次映射:
Map<Integer, Long> countX = x.stream()
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
Map<Integer, Long> countY = y.stream()
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
System.out.println(countX.equals(countY)); // true
不同元素相同(忽略重复项)——比较集合:
List<Integer> z = List.of(1, 2, 3);
System.out.println(new HashSet<>(x).equals(new HashSet<>(z))); // true
3. 用 retainAll 找出共同元素(交集)
retainAll() 会移除所有不在另一个集合中的元素。它会修改调用它的列表,所以要在副本上操作:
List<String> listA = List.of("apple", "banana", "cherry", "date");
List<String> listB = List.of("banana", "date", "fig");
List<String> common = new ArrayList<>(listA);
common.retainAll(new HashSet<>(listB));
System.out.println(common); // [banana, date]
复制到新的 ArrayList 还有一个原因:用 List.of() 或 Arrays.asList() 创建的列表不支持删除,对它们调用 retainAll() 会抛出 UnsupportedOperationException。
4. 用 removeAll 找出缺失元素(差集)
removeAll() 正好相反:它会移除所有在另一个集合中的元素,留下第一个列表独有的元素:
List<String> onlyInA = new ArrayList<>(listA);
onlyInA.removeAll(new HashSet<>(listB));
System.out.println(onlyInA); // [apple, cherry]
List<String> onlyInB = new ArrayList<>(listB);
onlyInB.removeAll(new HashSet<>(listA));
System.out.println(onlyInB); // [fig]
这两个方法都会保留起始列表的原有顺序,留下来的元素如果有重复也会保留。
5. 用 Stream 实现
Stream 不会修改源列表,需要额外过滤条件时写起来也很清晰:
Set<String> setB = new HashSet<>(listB);
List<String> onlyInA = listA.stream()
.filter(item -> !setB.contains(item))
.toList(); // Java 16+; use collect(Collectors.toList()) before that
List<String> inBoth = listA.stream()
.filter(setB::contains)
.toList();
System.out.println(onlyInA); // [apple, cherry]
System.out.println(inBoth); // [banana, date]
要得到只在其中一个列表中的元素(对称差),把两个方向的差集合并起来:
Set<String> setA = new HashSet<>(listA);
List<String> inOnlyOne = Stream.concat(
listA.stream().filter(item -> !setB.contains(item)),
listB.stream().filter(item -> !setA.contains(item)))
.toList();
System.out.println(inOnlyOne); // [apple, cherry, fig]
如果要忽略大小写,在构建集合前先统一格式——例如 listB.stream().map(String::toLowerCase).collect(Collectors.toSet())——检查时也把每一项转成小写。
6. 比较对象列表
集合用 equals() 比较对象,基于哈希的集合还会用到 hashCode()。Java record 会根据字段自动生成这两个方法,所以可以直接使用:
record Customer(int id, String name) {}
List<Customer> oldCustomers = List.of(new Customer(1, "Ann"), new Customer(2, "Bob"));
List<Customer> newCustomers = List.of(new Customer(2, "Bob"), new Customer(3, "Cy"));
Set<Customer> newSet = new HashSet<>(newCustomers);
List<Customer> removed = oldCustomers.stream()
.filter(c -> !newSet.contains(c))
.toList();
System.out.println(removed); // [Customer[id=1, name=Ann]]
对于普通类,要同时重写 equals() 和 hashCode()——如果只重写 equals(),HashSet 查找时将找不到匹配的对象。
很多时候你希望按某个键而不是全部字段来比较对象,这样客户改了名字也不会被当成另一个客户:
Set<Integer> newIds = newCustomers.stream()
.map(Customer::id)
.collect(Collectors.toSet());
List<Customer> removedById = oldCustomers.stream()
.filter(c -> !newIds.contains(c.id()))
.toList();
System.out.println(removedById); // [Customer[id=1, name=Ann]]
7. 工具库:Apache Commons 和 Guava
如果项目里已经在用这些库,可以一行代码完成集合运算。
Apache Commons Collections(org.apache.commons.collections4.CollectionUtils):
Collection<String> onlyInA = CollectionUtils.subtract(listA, listB); // [apple, cherry]
Collection<String> inBoth = CollectionUtils.intersection(listA, listB); // banana, date (any order)
Collection<String> inOnlyOne = CollectionUtils.disjunction(listA, listB); // apple, cherry, fig (any order)
Guava(com.google.common.collect.Sets)作用于集合,返回的是实时视图:
Set<String> setA = new HashSet<>(listA);
Set<String> setB = new HashSet<>(listB);
Set<String> onlyInA = Sets.difference(setA, setB);
Set<String> inBoth = Sets.intersection(setA, setB);
Set<String> inOnlyOne = Sets.symmetricDifference(setA, setB);
CollectionUtils 会考虑每个元素出现的次数;Guava 的 Sets 只处理不同的元素。如果底层集合之后会变化,可以用 new HashSet<>(view) 复制一份 Guava 视图。
8. 性能:不要在循环里查找 List
list.contains() 会逐个检查元素,所以 ArrayList.removeAll(otherList) 和 filter(otherList::contains) 会把一个列表的每个元素与另一个列表的每个元素都比较一遍。两个各有 10 万项的列表,最多可能需要 100 亿次比较。
像上面所有示例那样,先把另一个列表包装成 HashSet,每次查找就变成常数时间,整个比较大约只需 10 万步:
// Slow: O(n × m)
listA.stream().filter(listB::contains).toList();
// Fast: O(n + m)
Set<String> lookup = new HashSet<>(listB);
listA.stream().filter(lookup::contains).toList();
总结
| 目标 | 推荐做法 | 保留顺序? | 说明 |
|---|---|---|---|
| 相等,顺序相同 | a.equals(b) |
– | 可跨 List 实现类比较 |
| 相等,任意顺序,考虑重复 | 排序副本或比较频次映射 | – | 映射适用于无法排序的元素 |
| 相等,任意顺序,不考虑重复 | new HashSet<>(a).equals(new HashSet<>(b)) |
– | 忽略重复元素 |
| 共同元素 | copy.retainAll(new HashSet<>(b)) |
是 | 先复制;List.of 不可修改 |
| 仅在 A 中 | copy.removeAll(new HashSet<>(b)) 或 Stream 过滤 |
是 | Stream 写法不修改任何列表 |
| 只在一个列表中 | 用 Stream.concat 合并两个差集 |
是 | 或用 CollectionUtils.disjunction |
| 对象列表 | record,或 equals() + hashCode() |
是 | 或按 id 等键比较 |
先从 HashSet 查找加 Stream 过滤开始——速度快,不修改列表,几乎能覆盖所有情况。在代码之外快速检查一次的话,在线列表比较工具 可以在浏览器里完成同样的事。在用 Python?请看 如何在 Python 中比较两个列表。