集合去重问题全解析:面试高频考点与工程实践

一、集合去重的核心场景与需求

在分布式系统开发中,集合去重是高频出现的业务需求。典型场景包括:用户行为日志的唯一性过滤、订单ID的幂等性校验、缓存数据的去重存储等。以电商系统为例,用户浏览商品时产生的行为日志需要去重后存储,避免重复计算用户兴趣偏好。

从技术实现角度看,去重操作需满足两个核心要求:时间效率(处理百万级数据需毫秒级响应)和空间效率(避免内存溢出)。不同数据规模下的技术选型差异显著,例如:

  • 小规模数据(<1000条):优先选择代码简洁的实现方式
  • 中等规模数据(1000-100万条):需权衡时间复杂度与空间复杂度
  • 大规模数据(>100万条):必须采用分布式去重方案

二、基础去重方案实现与原理

1. HashSet去重法(O(1)时间复杂度)

  1. List<String> list = Arrays.asList("a", "b", "a", "c");
  2. Set<String> set = new HashSet<>(list);
  3. List<String> result = new ArrayList<>(set);

原理:HashSet基于哈希表实现,通过hashCode()equals()方法判断元素唯一性。当两个对象的hashCode相同且equals返回true时,视为重复元素。

优化点

  • 重写自定义对象的hashCode()equals()方法
  • 预先设置HashSet初始容量(new HashSet<>(expectedSize)
  • 避免在循环中频繁创建HashSet对象

2. Stream API去重(Java 8+)

  1. List<String> result = list.stream()
  2. .distinct()
  3. .collect(Collectors.toList());

底层实现distinct()操作内部维护一个临时Set,通过Spliterator遍历元素时进行去重。对于自定义对象,需确保正确实现hashCode()equals()

性能对比

  • 小数据量时与HashSet方案性能相当
  • 大数据量时(>10万条)比HashSet慢约30%
  • 代码可读性显著提升

三、进阶去重方案与工程实践

3. 基于TreeSet的自然排序去重

  1. Set<String> set = new TreeSet<>(list);
  2. List<String> result = new ArrayList<>(set);

适用场景:需要同时保证元素唯一性和排序顺序时。时间复杂度为O(n log n),空间复杂度O(n)。

工程优化

  • 自定义比较器实现复杂排序逻辑
  • 避免在高频调用路径中使用(如RPC接口)

4. 双重循环去重(O(n²)时间复杂度)

  1. List<String> result = new ArrayList<>();
  2. for (String item : list) {
  3. if (!result.contains(item)) {
  4. result.add(item);
  5. }
  6. }

典型误区:仅适用于教学演示,实际工程中禁止使用。在10万条数据测试中,耗时比HashSet方案多2个数量级。

5. 分布式环境下的布隆过滤器

当数据规模超过单机内存容量时,可采用布隆过滤器实现分布式去重:

  1. 初始化:创建位数组和多个哈希函数
  2. 插入元素:计算哈希值并设置对应位为1
  3. 查询元素:检查所有哈希位是否均为1

优势

  • 空间效率极高(1.8%误判率时仅需9.6bits/元素)
  • 查询时间恒定O(k),k为哈希函数数量

工程案例:某推荐系统使用布隆过滤器过滤已展示商品,使缓存命中率提升40%。

四、自定义对象去重实战

1. 重写hashCode与equals

  1. class User {
  2. private String id;
  3. private String name;
  4. @Override
  5. public boolean equals(Object o) {
  6. if (this == o) return true;
  7. if (o == null || getClass() != o.getClass()) return false;
  8. User user = (User) o;
  9. return Objects.equals(id, user.id);
  10. }
  11. @Override
  12. public int hashCode() {
  13. return Objects.hash(id);
  14. }
  15. }

关键原则

  • 相等对象必须具有相同hashCode
  • hashCode计算应尽可能均匀分布
  • 避免使用可变字段参与计算

2. 使用Comparator去重

  1. List<User> distinctUsers = users.stream()
  2. .filter(distinctByKey(User::getId))
  3. .collect(Collectors.toList());
  4. public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
  5. Set<Object> seen = ConcurrentHashMap.newKeySet();
  6. return t -> seen.add(keyExtractor.apply(t));
  7. }

适用场景:需要基于对象部分属性去重时。该方法利用ConcurrentHashMap的原子性保证线程安全。

五、性能测试与选型建议

在100万条字符串数据的测试中,不同方案耗时对比:
| 方案 | 耗时(ms) | 内存占用(MB) |
|——————————|—————|———————|
| HashSet | 12 | 45 |
| Stream.distinct() | 18 | 45 |
| TreeSet | 150 | 48 |
| 双重循环 | 12000 | 35 |
| 布隆过滤器(1%误判) | 8 | 1.2 |

选型矩阵
| 数据规模 | 单机/分布式 | 是否需要排序 | 推荐方案 |
|——————|——————|——————|——————————|
| <1000条 | 单机 | 否 | Stream.distinct() |
| 1000-10万条| 单机 | 否 | HashSet |
| >10万条 | 单机 | 是 | TreeSet |
| >100万条 | 分布式 | 否 | 布隆过滤器 |

六、常见面试问题解析

Q1:HashSet如何保证元素唯一性?
A:通过两步校验:1) 比较hashCode是否相同 2) 相同则调用equals方法确认。因此必须同时重写这两个方法。

Q2:Stream.distinct()的并行流性能如何?
A:并行流在大数据量时(>10万条)可提升30%性能,但需注意线程安全问题。自定义对象去重时建议使用Collectors.toConcurrentMap()替代。

Q3:如何实现内存高效的去重?
A:对于基本类型,可使用BitSet(每个元素仅占1bit);对于字符串,可先计算MD5哈希值再存储。某日志系统通过此方案减少75%内存占用。

掌握这些核心方案后,开发者不仅能轻松应对面试中的去重问题,更能在实际项目中根据业务场景选择最优实现。建议结合具体业务需求进行性能测试,建立适合团队的技术选型基准。