1. 背景

前一段时间在工作中遇到了这样一个场景:我手上的一个业务需要依赖其它部门的业务数据,来在己方的服务上做一些定制化的策略和规则。所以这一份数据,我们会持久化到我们的数据库中。但是由于这些数据在其它部门的库中是是可变的。所以在我方跑一些实时流程等的时候就需要对数据做一次匹配,即要求能匹配上远程数据的本地数据才算有效数据。说白了,即做一个求交集的处理。

为了方便大家理解,我画了两张图,其实就是每次执行逻辑时会执行有效数据匹配的处理。
image.png
下面我们一起看看我们对于这种场景的有效处理方式,以及算法的时间性能等问题。
注:至于如何校正本地数据库中的数据,以及什么时机去校正。这里我们不去关心,我也可以在后面算法介绍完毕后提及一下。

2. 求交集算法

我们简化各种逻辑,专注算法本身,先来模拟一个场景:例如我们的数据实体是一个学生类(如下)。localDataList 集合中的数据代表本地数据,remoteDataList
�集合中的数据代表远程数据。

  • 问题:本地数存在滞后,例如存储的 id 分别为 1 2 3 4,但远程数据的已经变为了 2 3 5(1 删除了,5 是新增的)。
  • 处理:做一次求交集的处理(只有 2 3 是有效数据)。

    1. @Data
    2. @NoArgsConstructor
    3. @AllArgsConstructor
    4. public class Student {
    5. /**
    6. * 主键 id
    7. */
    8. private Long id;
    9. /**
    10. * 年龄
    11. */
    12. private String name;
    13. /**
    14. * 年龄
    15. */
    16. private Integer age;
    17. }
    1. public class Demo {
    2. public static void main(String[] args) {
    3. // 本地数据
    4. List<Student> localDataList = new ArrayList<>();
    5. localDataList.add(new Student(1L,"张三",18));
    6. localDataList.add(new Student(2L,"李四",20));
    7. localDataList.add(new Student(3L,"王五",22));
    8. localDataList.add(new Student(4L,"赵六",24));
    9. // 远程数据
    10. List<Student> remoteDataList = new ArrayList<>();
    11. remoteDataList.add(new Student(2L,"李四",20));
    12. remoteDataList.add(new Student(3L,"王五",22));
    13. remoteDataList.add(new Student(5L,"孙七",26));
    14. }
    15. }

    2.1 两层 for 循环的暴力解法

    2.1.1 最简单粗暴的方式(两层 for 循环)

    虽然基本不会有人真的在项目中这么用,但是它确实是解决问题的一种直观手段。
    image.png
    思路:依次拿本地数据集合的每一项在远程数据集合中进行一次全遍历,当判断到有相等的对象(lombok 注解帮助我们重写了 equals 和 hashCode方法)则会将其添加到 Set 中,然后转为 List 返回

  • 为什么转 Set 会在文章最后的补充中提及。

代码:

  1. /**
  2. * 两层 for 循环比对方法
  3. *
  4. * @param localDataList 本地数据集合(或存在脏数据的一方)
  5. * @param remoteDataList 远程数据集合(准确的一方)
  6. * @param <T> 泛型 类型统一即可
  7. * @return
  8. */
  9. public static <T> List<T> getIntersectionList(List<T> localDataList, List<T> remoteDataList) {
  10. Set<T> tempSet = new HashSet<>();
  11. for (T localData : localDataList) {
  12. for (T remoteData : remoteDataList) {
  13. if (localData.equals(remoteData)){
  14. tempSet.add(localData);
  15. }
  16. }
  17. }
  18. return new ArrayList<>(tempSet);
  19. }

运行结果:

  1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

总结一下:

  • 优点:逻辑非常直观,易理解,可读性高,难度低。
  • 缺点:循环次数过多,效率严重低下。(只有能保证两个集合数量极少的情况才或许可考虑)

所以,这这样看来,只要你能接受它的效率,其实暴力循环可以解决很多场景的问题。

2.1.2 粗暴的方法也能简单优化(基于两层 for 优化)

如果实在想不到更好的办法,硬着头皮用两层 for 循环,但是也得在此基础上,做点优化才好。
image.png
思路:两层循环中,当某一次匹配成功了,就不应该继续向下循环了,例如上图中,拿着本地数据中的 3 ,去远程数据中寻找,当执行到第二次的时候,就已经找到了,所以第三次那个遍历完全是多余的。可以直接换本地数据中的 4 进行遍历了。
代码:

  1. /**
  2. * 两层 for 循环比对方法优化
  3. *
  4. * @param localDataList 本地数据集合(或存在脏数据的一方)
  5. * @param remoteDataList 远程数据集合(准确的一方)
  6. * @param <T> 泛型 类型统一即可
  7. * @return
  8. */
  9. public static <T> List<T> getIntersectionList2(List<T> localDataList, List<T> remoteDataList) {
  10. Set<T> tempSet = new HashSet<>();
  11. for (T localData : localDataList) {
  12. for (T remoteData : remoteDataList) {
  13. if (localData.equals(remoteData)){
  14. tempSet.add(localData);
  15. break;
  16. }
  17. }
  18. }
  19. return new ArrayList<>(tempSet);
  20. }
  • 其实这份代码就是在判断到已经匹配到数据了,就会跳出内层循环,直接去拿本地数据的下一个数据再去做遍历,如果数据量大的情况下,还是能做到挺大的优化的
  • 就在这份代码中,我分别打印了一下循环的次数。优化前:12次 优化后:9次。

运行结果:

  1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

总结一下:

  • 优点:性能略有提升
  • 缺点:需要多考虑一步

    2.1.3 粗暴的方法再优化(基于两层 for 再优化)

    其实上面的方法还可以继续优化
    image.png
    思路:如果某次循环中,本地的数据以及和远程某个数据匹配上了,那么下次本地的下一个数据和远程数据匹配时,就没有必要再次遇到刚才那个数据了。因为是没意义的。
    代码:

    1. /**
    2. * 两层 for 循环比对方法再优化
    3. *
    4. * @param localDataList 本地数据集合(或存在脏数据的一方)
    5. * @param remoteDataList 远程数据集合(准确的一方)
    6. * @param <T> 泛型 类型统一即可
    7. * @return
    8. */
    9. public static <T> List<T> getIntersectionList3(List<T> localDataList, List<T> remoteDataList) {
    10. Set<T> tempSet = new HashSet<>();
    11. for (T localData : localDataList) {
    12. for (T remoteData : remoteDataList) {
    13. if (localData.equals(remoteData)){
    14. remoteDataList.remove(remoteData);
    15. tempSet.add(localData);
    16. break;
    17. }
    18. }
    19. }
    20. return new ArrayList<>(tempSet);
    21. }
  • 我背后也打印了一下循环次数,这次只有 6 次,对于两层 for 循环的方式,我们简单的从 12 -> 9 -> 6 ,效果还算明显。

运行结果:

  1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

总结一下:

  • 优点:性能略有较为明显的提升
  • 缺点:需要多考虑两步

注意:

  • 这里其实是有一个小坑的,不过我们这个方法中其实已经规避了这一点。就是关于 remove 的并发修改异常。我们后面会专门补充这个问题。

    2.1 巧用 Set 解法

    2.1.1 Set 集合的 contains 方法

    如果数据量特别大的情况下, 使用多层 for 毕竟是下策。
    image.png
    思路:因为 Set 方法的 contains 方法可以用来判断是否存某个元素,而且时间复杂度为 O(1),所以我们可以拿一个集合转为 Set,遍历另一个 List 集合,判断每个 List 中的元素是否存在 Set 中,存在的元素存储到另一个空 Set 中(此处是为了去重),然后转为 list 返回。
    代码:

    1. /**
    2. * 利用 Set 集合的 contains 方法
    3. *
    4. * @param localDataList 本地数据集合(或存在脏数据的一方)
    5. * @param remoteDataList 远程数据集合(准确的一方)
    6. * @param <T> 泛型 类型统一即可
    7. * @return
    8. */
    9. public static <T> List<T> getIntersectionList4(List<T> localDataList, List<T> remoteDataList) {
    10. Set<T> remoteDataSet = new HashSet<>(remoteDataList);
    11. Set<T> localDataSet = new HashSet<>();
    12. for (T localData : localDataList) {
    13. if (remoteDataSet.contains(localData)){
    14. localDataSet.add(localData);
    15. }
    16. }
    17. return new ArrayList<>(localDataSet);
    18. }

    运行结果:

    1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

    总结一下:

  • 优点:性能有极大的提升,时间复杂度直接降低到 O(n) 可作为为最终方法之一。

  • 缺点:需要熟悉 List 和 Set 的特点,灵活运用

补充:

  1. 为什么不用 List 的 contains 方法?(可阅读源码)
    • ArrayList 本质是通过数组实现的,查找一个元素是否包含,会去对元素进行遍历,时间复杂度是O(n)
    • HashSet 的包含是通过 HashMap 的 KeySet 实现,时间复杂度是O(1)
  2. 是否有别的方式?

    • 答案是肯定的,例如还可以通过向一个 Set 中添加元素,然后在其中用一个变量计数,如果数变了,说明有新的,不重复的元素被添加进入了。等等方式。

      2.3 神奇的 Map

      2.3.1 Map 的 get 方法

      思路:利用 Map 的 key value 特点,将一个 List 中一个字段作为 key,例如我们将主键 id 作为 key,这样遍历另一个集合的时候,就可以通过 getId 的方式拿到这个数据,我这里是直接将获取到的 Student 加入到返回的 Set 中了,如果想要的是本地那份数据中的 Student,那么就将循环中的 localData 加入到 Set 中,这个可以自己选择
      代码: ```java /**
    • 利用 Map 方法 *
    • @param localDataList 本地数据集合(或存在脏数据的一方)
    • @param remoteDataList 远程数据集合(准确的一方)
    • @return */ public static List getIntersectionList6(List localDataList, List remoteDataList) { Map remoteDataMap = new HashMap<>(); Set remoteDataSet = new HashSet<>(remoteDataList);

      for (Student remoteData : remoteDataSet) { remoteDataMap.put(remoteData.getId(), remoteData); }

      Set resultSet = new HashSet<>(); for (Student localData : localDataList) { if (remoteDataMap.get(localData.getId()) != null) {

      1. Student student = remoteDataMap.get(localData.getId());
      2. resultSet.add(student);

      } } return new ArrayList<>(resultSet); }

      1. **运行结果:**
      2. ```java
      3. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

      总结一下:

  • 优点:性能同样很优好,可作为为最终方法之一。
  • 缺点:需要熟悉 List 和 Set 和 Map 的特点,灵活运用

    2.3.2 Map 的 containsKey 方法

    思路:Map 同样提供了很好用的方法 containsKey 来判断是否存在某个 key,基本思路与上一个大同小异。
    代码:

    1. /**
    2. * 利用 Map 的 containsKey 方法
    3. *
    4. * @param localDataList 本地数据集合(或存在脏数据的一方)
    5. * @param remoteDataList 远程数据集合(准确的一方)
    6. * @return
    7. */
    8. public static List<Student> getIntersectionList6(List<Student> localDataList, List<Student> remoteDataList) {
    9. Map<Long, Student> remoteDataMap = new HashMap<>();
    10. Set<Student> remoteDataSet = new HashSet<>(remoteDataList);
    11. for (Student remoteData : remoteDataSet) {
    12. remoteDataMap.put(remoteData.getId(), remoteData);
    13. }
    14. Set<Student> resultSet = new HashSet<>();
    15. for (Student localData : localDataList) {
    16. if (remoteDataMap.containsKey(localData.getId())) {
    17. resultSet.add(localData);
    18. }
    19. }
    20. return new ArrayList<>(resultSet);
    21. }

    运行结果:

    1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

    总结一下:

  • 优点:性能同样很优好,可作为为最终方法之一。

  • 缺点:需要熟悉 List 和 Set 和 Map 的特点,灵活运用

    2.3.3 stream 转 Map

    思路:思路与上面没打的区别,但是我们上面是用循环的方式将 List/Set 转为了 Map, 同样还可以使用 stream 来转换,不过这种方式除非是并行流,否则效率不会特别高。
    代码:

    1. /**
    2. * 利用 Map 的 containsKey 方法(stream 转 map)
    3. *
    4. * @param localDataList 本地数据集合(或存在脏数据的一方)
    5. * @param remoteDataList 远程数据集合(准确的一方)
    6. * @return
    7. */
    8. public static List<Student> getIntersectionList7(List<Student> localDataList, List<Student> remoteDataList) {
    9. Map<Long, Student> remoteDataMap = remoteDataList.stream().collect(Collectors.toMap(Student::getId, v -> v));
    10. Set<Student> resultSet = new HashSet<>();
    11. for (Student localData : localDataList) {
    12. if (remoteDataMap.containsKey(localData.getId())) {
    13. resultSet.add(localData);
    14. }
    15. }
    16. return new ArrayList<>(resultSet);
    17. }

    运行结果:

    1. [Student(id=2, name=李四, age=20), Student(id=3, name=王五, age=22)]

    总结一下:

  • 优点:数据少的时候,基本很慢,可读性比较高,但转 Map 的性能有待商榷。

  • 缺点:涉及到 JDK 1.8 的特性,还可以封装工具类,对知识要求度比较高。

    3. 时间测试

    测试方式:

    1. long startTime = System.currentTimeMillis();
    2. // 此处是待测试的方法
    3. long endTime = System.currentTimeMillis();
    4. System.out.println("程序1:XXX 方法:" + (endTim - startTime) + "ms");

    我们可以简单测试一下,我分别将两个集合的数量放大到了各自 1w 个,看看执行结果:

    1. 程序1:两层 for 循环:7318ms
    2. 程序2:两层 for 循环(break):1117ms
    3. 程序3:两层 for 循环(break + remove1306ms
    4. 程序3:两层 for 循环(break + remove)颠倒顺序 1240ms
    5. 程序4Set 集合的 contains 方法:14ms
    6. 程序5:利用 Map14ms
    7. 程序6:利用 Map containsKey 方法:8ms
    8. 程序7stream map74ms
  • 可以看出来,性能最好的是 利用 Map 的 containsKey 方法,其次是普通 Map ,然后是 Set 集合的 contains 方法,至于 stream 转 map 在数量较大的情况下还算理想,但是如果数量特别少,甚至效率不如两层 for 循环。最后两层 for 循环三个演进,依次会略好一些,但是总体仍然不堪大用(除了一些数量极少的情况)。

  • 为什么程序3 中 break + remove 的方式甚至比单单 break 的方式还差呢?这是因为它受到元素的位置影响,也就是不同元素的位置摆放顺序,以及两个 list 遍历的里外关系都有关系,所以平均效率不高。

    4. 说明

    重点还是掌握思想,至于是求交集还是差集还是去一一匹配,这些操作思想都是一致的。一般来说都会用 Set 或者 Map 或者一些更好的工具类,不过了解 for 的演进过程对自己也是很有帮助。

我文中多次用了 Set 和 List 的转换,是因为我只想要交集的那一条,即使有很多条重复的数据也要去重,只保留一条。怎么会有重复呢?
是因为我们从数据库中查询到的数据是带着所有字段的例如 id name age,但是我们从远程请求到的结果可能只是 age 字段,我们就可以将这个 age 字段封装到与数据库实体一致的对象中,然后重写它们的 age 的 hashCode 和 equals 方法,这样比较的时候就只会通过 age 字段比较,同时我们刻意返回的交集对象还是来自本地数据库中的,所以就能既达到根据 age 字段求交集的目的,又能带着 id name 等字段回来。方便利用。

说明:文中很多地方值得优化,但是重点还是体会思想,有什么误差以后回过头再来修改。
最后说一句,这些小算法,确实很有用。