多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java List集合运算:交集、并集、差集、补集实现与性能优化

Java List集合运算:交集、并集、差集、补集实现与性能优化 1. 集合运算从“是什么”到“为什么”在Java开发中处理数据集合是家常便饭。我们经常遇到这样的场景从两个用户列表中找出共同的好友交集合并两个渠道获取的订单列表并去重并集或者找出A部门有而B部门没有的员工差集。这些操作本质上就是集合的数学运算在编程中的具体体现。很多开发者尤其是刚接触集合框架的朋友可能会觉得这些概念有点抽象或者虽然知道List有retainAll、addAll这些方法但用起来总觉得哪里不对劲——比如直接对两个ArrayList调用这些方法原集合就被修改了这往往不是我们想要的结果。又或者面对大数据量时方法的性能表现如何是否还有更优雅、更高效的实现方式这篇内容我们就来彻底搞懂在Java中如何对List集合进行交集、并集、差集、补集这四种核心运算。我不会只给你几个干巴巴的API调用示例而是会带你理解每种运算的数学本质、在Java中的多种实现方式及其背后的权衡以及在实际编码中如何根据场景选择最合适的方案。你会发现掌握这些基础操作能让你在处理数据比对、合并、筛选等任务时更加得心应手代码也更加清晰健壮。2. 核心概念厘清数学定义与Java语境在深入代码之前我们必须先统一“语言”。数学上的集合定义是清晰的但到了Java的List这里情况变得稍微复杂一些因为List允许重复元素并且有序这与数学中集合的“互异性”和“无序性”有冲突。因此在Java中讨论List的集合运算时我们通常需要明确我们的意图。2.1 数学定义回顾交集由所有属于集合A且属于集合B的元素组成的集合。记作 A ∩ B。并集由所有属于集合A或属于集合B的元素组成的集合。记作 A ∪ B。差集由所有属于集合A但不属于集合B的元素组成的集合。记作 A - B 或 A \ B。补集补集是相对于一个全集而言的。设全集为U由所有属于U但不属于A的元素组成的集合称为A的补集。记作 Aᶜ 或 U - A。2.2 JavaList的特别说明List是一个有序、可重复的序列。当我们对两个List进行“集合运算”时通常隐含了以下一种或多种意图只关心元素是否存在不关心顺序和重复次数。这时我们通常将List视为一个“元素包”来处理结果也往往是一个新的集合Set或去重后的List。关心元素的顺序和/或重复次数。例如合并两个列表时希望保留所有出现的元素包括重复的。这时运算的定义就需要根据业务逻辑来调整。在本文的讨论中除非特别说明我们将主要关注第一种意图——即基于元素对象相等性equals方法的集合运算并且默认结果需要去重。这是业务中最常见的需求。对于需要保留重复和顺序的场景我们会单独指出。3. 方法一使用CollectionAPI 的“暴力”求解JavaCollection接口提供了几个现成的方法retainAll,addAll,removeAll。它们是最直接的但也是最容易踩坑的因为它们直接修改原集合。3.1 交集retainAll的陷阱与正确用法boolean retainAll(Collection? c)仅保留此集合中那些也包含在指定集合中的元素。换句话说它修改此集合使其成为两个集合的交集。踩坑实录ListString listA new ArrayList(Arrays.asList(a, b, c, a)); ListString listB new ArrayList(Arrays.asList(b, c, d)); // 错误做法直接操作原集合 listA.retainAll(listB); System.out.println(listA); // 输出[b, c]看listA被永久改变了这通常不是我们想要的。我们通常希望得到一个新的结果集合而不影响原始数据。正确做法创建副本ListString listA new ArrayList(Arrays.asList(a, b, c, a)); ListString listB new ArrayList(Arrays.asList(b, c, d)); // 正确做法操作副本 ListString intersection new ArrayList(listA); // 关键创建副本 intersection.retainAll(listB); System.out.println(交集 intersection); // 输出[b, c] System.out.println(原listA listA); // 输出[a, b, c, a] (未被修改)性能与特点分析时间复杂度对于ArrayListretainAll的内部实现通常需要遍历当前集合listA的副本并对每个元素检查是否存在于参数集合listB中。检查存在性contains方法在ArrayList中是O(n)的所以最坏情况下是O(n*m)效率较低。如果listB先被转换为HashSet检查存在性的复杂度可以降到平均O(1)。结果重复性retainAll会保留原集合副本中的重复元素。在上例中如果listA有两个”a”而listB有一个”a”结果中会有两个”a”。如果需要去重交集需要额外处理。结论retainAll简单直接但需要注意创建副本以避免副作用并且在大数据集上性能可能不佳。3.2 并集addAll与去重需求boolean addAll(Collection? extends E c)将指定集合中的所有元素添加到此集合中。直接合并保留所有重复ListString listA new ArrayList(Arrays.asList(a, b, c)); ListString listB new ArrayList(Arrays.asList(b, c, d)); ListString unionWithDuplicates new ArrayList(listA); unionWithDuplicates.addAll(listB); System.out.println(并集含重复 unionWithDuplicates); // 输出[a, b, c, b, c, d]去重并集数学意义上的并集更常见的需求是去重。我们可以利用Set元素唯一的特性。ListString listA new ArrayList(Arrays.asList(a, b, c)); ListString listB new ArrayList(Arrays.asList(b, c, d)); SetString unionSet new HashSet(listA); unionSet.addAll(listB); // HashSet的addAll会自动去重 ListString union new ArrayList(unionSet); // 如果需要List结果 System.out.println(并集去重 union); // 输出可能是 [a, b, c, d] (顺序可能不同)注意HashSet是无序的如果希望保留某种顺序可以使用LinkedHashSet。TreeSet则可以提供排序。3.3 差集removeAll的细节boolean removeAll(Collection? c)移除此集合中那些也包含在指定集合中的所有元素。即得到 A - B。基本用法ListString listA new ArrayList(Arrays.asList(a, b, c, a)); ListString listB new ArrayList(Arrays.asList(b, c)); ListString difference new ArrayList(listA); // 创建副本 difference.removeAll(listB); System.out.println(差集 A - B difference); // 输出[a, a]注意结果中保留了listA中未被移除的重复元素”a”。一个重要特性removeAll移除所有匹配项。listB中的每个元素都会在listA的副本中移除所有与之相等的项。它关注的是listB中的元素而不是listB作为一个整体。3.4 补集需要全集概念的运算补集 Aᶜ U - A。所以在Java中求A的补集前提是你得先定义好全集U。// 假设全集U ListString universalSet new ArrayList(Arrays.asList(a, b, c, d, e)); ListString setA new ArrayList(Arrays.asList(b, c)); ListString complement new ArrayList(universalSet); complement.removeAll(setA); // U - A System.out.println(A的补集 complement); // 输出[a, d, e]小结使用CollectionAPI的要点副作用牢记retainAll,addAll,removeAll会修改原集合。99%的情况都应该先创建副本再操作。性能对ArrayList直接使用这些方法在数据量大时性能较差因为涉及大量的线性查找contains或remove操作。去重这些方法本身不自动去重除了addAll到Set。数学意义上的集合运算通常需要去重结果要额外处理。适用场景适用于小型集合或对性能不敏感的场景代码简洁明了。4. 方法二借助Stream API的声明式编程Java 8引入的Stream API提供了一种更函数式、更声明式的方式来处理集合运算。它的优点是代码清晰易于并行化并且通常不会修改源数据。4.1 使用Stream求交集核心思路是过滤出listA中那些也存在于listB中的元素。ListString listA Arrays.asList(a, b, c, a); ListString listB Arrays.asList(b, c, d); // 交集保留重复 ListString intersectionWithDup listA.stream() .filter(listB::contains) // 过滤条件元素在listB中 .collect(Collectors.toList()); System.out.println(Stream交集保留重复: intersectionWithDup); // 输出[b, c] // 交集去重 ListString intersectionDistinct listA.stream() .filter(listB::contains) .distinct() // 去重 .collect(Collectors.toList()); System.out.println(Stream交集去重: intersectionDistinct); // 输出[b, c]性能提示上面的代码中listB::contains对于ArrayList依然是O(n)的线性查找。如果listB很大可以先将listB转换为HashSet来提升性能。SetString setB new HashSet(listB); // 预转换 ListString intersection listA.stream() .filter(setB::contains) // 此时contains是O(1) .distinct() .collect(Collectors.toList());4.2 使用Stream求并集核心思路是将两个流连接concat起来然后去重。ListString listA Arrays.asList(a, b, c); ListString listB Arrays.asList(b, c, d); ListString union Stream.concat(listA.stream(), listB.stream()) .distinct() // 关键去重以获得数学并集 .collect(Collectors.toList()); System.out.println(Stream并集: union); // 输出[a, b, c, d]4.3 使用Stream求差集核心思路是过滤出listA中那些不存在于listB中的元素。ListString listA Arrays.asList(a, b, c, a); ListString listB Arrays.asList(b, c); // 差集 A - B (保留A中的重复) ListString difference listA.stream() .filter(e - !listB.contains(e)) // 过滤条件元素不在listB中 .collect(Collectors.toList()); System.out.println(Stream差集 A-B: difference); // 输出[a, a]同样为了性能建议将listB转换为Set。4.4 使用Stream求补集与差集逻辑相同只是A是子集U是全集。ListString universalSet Arrays.asList(a, b, c, d, e); ListString setA Arrays.asList(b, c); SetString setAForCheck new HashSet(setA); // 性能优化 ListString complement universalSet.stream() .filter(e - !setAForCheck.contains(e)) .collect(Collectors.toList()); System.out.println(Stream补集: complement); // 输出[a, d, e]小结Stream API的要点无副作用Stream操作不会修改源数据更安全。代码清晰声明式的风格意图表达更明确。性能优化需要手动优化例如将用于contains判断的集合预转换为HashSet。并行流对于大数据集可以轻松地使用.parallelStream()来利用多核优势但要注意线程安全和顺序问题。灵活可以方便地组合distinct(),sorted()等中间操作满足复杂需求。5. 方法三基于Set的高效算法如果我们的最终目标是得到一个去重的数学集合结果并且不关心原始List的顺序那么最直接和最高效的方式就是直接使用Set接口的实现类如HashSet来进行运算。因为Set本身就去重并且其contains操作是O(1)时间复杂度。5.1 利用Set求交集Set接口也提供了retainAll方法但底层通常有优化。ListString listA Arrays.asList(a, b, c, a, e); ListString listB Arrays.asList(b, c, d, c); SetString setA new HashSet(listA); // [a, b, c, e] SetString setB new HashSet(listB); // [b, c, d] SetString intersectionSet new HashSet(setA); intersectionSet.retainAll(setB); // 修改intersectionSet为交集 System.out.println(Set交集: intersectionSet); // 输出[b, c] // 或者使用Stream但逻辑类似 SetString intersectionSet2 setA.stream() .filter(setB::contains) .collect(Collectors.toSet());5.2 利用Set求并集这是Set最自然的操作。SetString unionSet new HashSet(listA); unionSet.addAll(listB); // addAll会自动处理重复元素 System.out.println(Set并集: unionSet); // 输出[a, b, c, d, e]5.3 利用Set求差集与补集// 差集 A - B SetString differenceSet new HashSet(listA); differenceSet.removeAll(listB); // 从A中移除B中所有元素 System.out.println(Set差集 A-B: differenceSet); // 输出[a, e] // 补集假设全集U SetString universalSet new HashSet(Arrays.asList(a, b, c, d, e)); SetString setA new HashSet(Arrays.asList(b, c)); SetString complementSet new HashSet(universalSet); complementSet.removeAll(setA); System.out.println(Set补集: complementSet); // 输出[a, d, e]基于Set方案的核心优势性能卓越HashSet的add,remove,contains操作在平均情况下都是O(1)时间复杂度使得集合运算非常快尤其适合大数据量。结果自动去重符合数学集合的定义。代码简洁直接利用Set的API意图清晰。需要注意的缺点丢失顺序HashSet不保证顺序。如果需要插入顺序用LinkedHashSet如果需要自然排序用TreeSet但操作复杂度变为O(log n)。丢失重复信息原始List中的重复计数信息会被抹去。如果你的业务逻辑关心元素出现的次数此方案不适用。6. 实战场景深度剖析与选型指南了解了各种方法关键是如何选择。没有绝对最好的只有最适合当前场景的。下面我们通过几个典型场景来分析。6.1 场景一小型配置列表比对需求有两个系统配置项列表configListA和configListB数量在几十到几百条需要找出它们共同拥有的配置项交集以便进行同步。特点数据量小顺序不重要结果需要是List用于后续遍历。选型使用Stream API预转换Set优化。理由代码清晰易读体现代码意图。无副作用不修改原数据。虽然数据量小但用HashSet优化contains是一个好习惯几乎无额外成本。结果可以方便地转换为List。public ListConfig getCommonConfigs(ListConfig listA, ListConfig listB) { SetConfig setB new HashSet(listB); // 预转换O(n) return listA.stream() .filter(setB::contains) // O(1)查找 .distinct() .collect(Collectors.toList()); }注意确保Config类正确重写了equals和hashCode方法这是所有基于HashSet或对象相等性比较的操作的基础。6.2 场景二大数据量用户ID集运算需求从两个海量日志文件中提取出的用户ID列表ListLong数量级在百万以上需要快速找出只在A文件出现过的用户差集A-B。特点数据量极大性能是关键结果需要去重。选型直接使用HashSet。理由HashSet的removeAll操作虽然要遍历参数集合c并对每个元素在目标Set中做removeO(1)整体复杂度接近O(n)效率远高于在ArrayList上做O(n*m)的操作。内存占用相对可控存储对象引用和哈希表开销。public SetLong getUniqueUsersInA(ListLong userIdsA, ListLong userIdsB) { SetLong setA new HashSet(userIdsA); // O(n) SetLong setB new HashSet(userIdsB); // O(m) setA.removeAll(setB); // 接近 O(n) 操作 return setA; }进阶考虑如果单个HashSet内存放不下可以考虑使用布隆过滤器进行初步筛选或者使用支持外存的数据结构/数据库。6.3 场景三保留顺序与重复的合并需求合并两个订单流水列表需要保留所有订单记录包括重复的订单号可能代表多次操作并且按照时间顺序排列。这其实不是严格的数学并集而是列表拼接。特点需要保留顺序和所有元素。选型直接使用List.addAll()或Stream.concat()然后排序。理由数学集合运算不适用此场景。这是简单的列表合并操作。ListOrder mergedList new ArrayList(listA); mergedList.addAll(listB); // 如果需要按时间排序 mergedList.sort(Comparator.comparing(Order::getTimestamp)); // 或者使用Stream ListOrder mergedList2 Stream.concat(listA.stream(), listB.stream()) .sorted(Comparator.comparing(Order::getTimestamp)) .collect(Collectors.toList());6.4 通用选型决策树你可以根据以下流程快速决策结果是否需要严格去重数学集合否- 你可能需要的是列表合并、过滤等操作而非集合运算。考虑直接操作List或使用Stream进行条件过滤。是- 进入下一步。数据量有多大小1万- 三种方法均可。优先考虑Stream API代码清晰或CollectionAPI需创建副本。如果追求极简代码且不介意顺序用Set。大1万- 优先使用HashSet方案。性能优势明显。是否需要保留元素的原始顺序插入顺序是- 使用LinkedHashSet替代HashSet。或者使用Stream API在最后收集到List时如果源List有序Stream会尽力保持顺序除非使用并行流或unordered。否-HashSet是最佳选择。是否介意修改原始集合是- 避免直接使用retainAll,removeAll,addAll操作原集合。使用Stream API或在新Set/List副本上操作。否- 可以直接操作但这种情况极少。7. 避坑指南与性能优化实践在实际使用中除了选择正确的方法还有一些细节决定了代码的健壮性和效率。7.1 坑点一equals与hashCode的契约这是所有基于对象相等性操作的生命线。如果你的自定义对象没有正确重写equals和hashCode方法那么Set的去重、contains判断、retainAll等操作都会行为异常。class User { private Long id; private String name; // 构造器、getter/setter省略 // 必须重写 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return Objects.equals(id, user.id); // 通常用业务主键判断相等 } Override public int hashCode() { return Objects.hash(id); // 必须与equals使用的字段一致 } }黄金法则如果重写了equals必须重写hashCode并且保证两个相等的对象必须有相同的哈希码。IDE如IntelliJ IDEA, Eclipse可以自动生成这两个方法。7.2 坑点二并行流parallelStream的陷阱在求差集或交集时如果使用并行流并且将List直接用于contains判断可能会导致错误。// 潜在问题listB不是线程安全的集合在并行流中调用contains可能出错 ListString difference listA.parallelStream() .filter(e - !listB.contains(e)) // 危险 .collect(Collectors.toList());正确做法要么使用顺序流stream()要么先将用于判断的集合转换为一个线程安全的集合或一个ConcurrentHashMap支持的Set但更简单通用的做法是预转换为一个普通的HashSet。因为HashSet在只读情况下是线程安全的前提是构造完成后不再修改而ArrayList的contains不是。SetString safeSetB new HashSet(listB); // 在并行流外部创建 ListString safeDifference listA.parallelStream() .filter(e - !safeSetB.contains(e)) // 安全 .collect(Collectors.toList());7.3 性能优化空间换时间的艺术对于集合运算最大的性能瓶颈在于查找contains。无论是retainAll、removeAll还是filter中的条件都在频繁查找。核心策略将用于查找的List转换为HashSet。构造HashSet是O(n)操作但后续每次查找都是O(1)总体复杂度从O(n*m)降为O(nm)。谁转成Set通常将较小的那个集合或者被用于多次查找的集合转换为Set。在差集A-B中将B转换为Set在交集中通常也将其中一个如B转换为Set。7.4 针对超大数据量的思考当数据量大到无法全部装入内存时上述方法都失效。此时需要考虑外部排序与归并将两个集合排序后写入文件然后像合并排序一样线性遍历两个文件找出交集、差集等。这需要磁盘I/O但内存占用小。布隆过滤器用于近似判断一个元素是否存在于一个超大规模集合中。例如先用一个布隆过滤器快速判断A中元素“很可能不在B中”过滤掉大部分剩下的少量疑似元素再用精确查找如数据库查询确认。这可以极大减少不必要的精确比对。数据库求解直接将数据导入数据库如MySQL, Hive利用SQL的INTERSECT、UNION、EXCEPT等集合操作。让专业的数据库优化器去处理。8. 第三方库的视野Guava与Apache Commons Collections除了JDK自带API一些优秀的第三方库提供了更丰富、更易用的集合工具类。8.1 Google GuavaGuava的Sets和Lists工具类非常强大。import com.google.common.collect.Sets; import com.google.common.collect.Lists; SetString set1 Sets.newHashSet(a, b, c); SetString set2 Sets.newHashSet(b, c, d); // 交集 SetString intersection Sets.intersection(set1, set2); // 返回一个视图惰性求值 // 并集 SetString union Sets.union(set1, set2); // 差集 SetString difference Sets.difference(set1, set2); // set1 - set2 // 对称差集 (只在其中一个集合中出现的元素) SetString symmetricDifference Sets.symmetricDifference(set1, set2); // 对于ListGuava更多提供的是过滤、转换等工具直接集合运算较少。 ListString filteredList Lists.newArrayList(Collections2.filter(listA, setB::contains));Guava的优点在于其返回的集合视图如intersection是惰性的底层数据变化会反映到视图中并且API设计非常优雅。8.2 Apache Commons Collections4CollectionUtils类提供了很多静态方法。import org.apache.commons.collections4.CollectionUtils; ListString listA Arrays.asList(a, b, c); ListString listB Arrays.asList(b, c, d); // 交集 CollectionString intersection CollectionUtils.intersection(listA, listB); // 并集 CollectionString union CollectionUtils.union(listA, listB); // 差集 CollectionString subtraction CollectionUtils.subtract(listA, listB);这些方法返回新的Collection且通常能较好地处理null。但需要注意返回的集合类型可能不是List或Set。使用建议如果你的项目已经引入了这些库可以优先使用它们API更专业且经过充分测试。如果是新项目对于简单的操作JDK的Stream和Set基本够用对于复杂的集合操作引入Guava是一个不错的选择。
返回列表