1. 概述

在Java编程中,HashMap 是存储和管理键值对的强大工具。然而,有时我们的数据可能会包含重复的值。

在这个教程中,我们将探讨如何从 HashMap 中移除重复值。

2. 问题介绍

HashMap 允许多个键具有相同的值,因此在某些情况下,重复是不可避免的。让我们看一个例子:

Map<String, String> initDevMap() {
    Map<String, String> devMap = new HashMap<>();
    devMap.put("Tom", "Linux");
    devMap.put("Kent", "Linux");

    devMap.put("Bob", "MacOS");
    devMap.put("Eric", "MacOS");

    devMap.put("Peter", "Windows");
    devMap.put("Saajan", "Windows");
    devMap.put("Jan", "Windows");

    devMap.put("Kevin", "FreeBSD");
    return devMap;
}

在上面的例子中,initDevMap() 方法初始化了一个新的 HashMap,包含了开发者姓名与其使用的操作系统(OS)的关联。假设我们想要从映射中移除重复的 OS 名称。那么,最终的映射将只有四个条目。

一个直接的方法可能是遍历映射,跟踪值,并在找到重复值时删除映射条目。此外,我们可能希望使用 Iterator 来遍历映射并移除条目,以避免 并发修改异常

然而,在本教程中,我们将学习一种不同的方法。同时,我们将涵盖两种去重场景:

  • 如果结果只包含唯一的值,我们不在乎哪些条目被移除。
  • 在去重时,我们需要遵循特定规则,如保留字母顺序(A-Z)中的第一个/最后一个键,保留最长/最短名称等。

3. 两次反转映射

我们知道 HashMap 不允许重复的键。因此,如果我们从 "开发者 -> OS" 的映射反转到 "OS -> 开发者",相同的 OS 名称就会被移除。然后,我们可以再次反转映射以获取最终结果。

接下来,让我们实现这个“两次反转”的想法,检查它是否按预期工作:

Map<String, String> devMap = initDevMap();
Map<String, String> tmpReverseMap = new HashMap<>();
Map<String, String> result = new HashMap<>();
for (String name : devMap.keySet()) {
    tmpReverseMap.put(devMap.get(name), name);
}
for (String os : tmpReverseMap.keySet()) {
    result.put(tmpReverseMap.get(os), os);
}
assertThat(result.values()).hasSize(4)
  .containsExactlyInAnyOrder("Windows", "MacOS", "Linux", "FreeBSD");

正如我们所见,我们使用了两个 for 循环来两次反转映射。然后,我们使用方便的 AssertJ 库验证结果。

4. 使用流API

如果我们使用 Java 8 或更高版本,可以使用流API实现“两次反转”方法:

Map<String, String> devMap = initDevMap();
Map<String, String> result = devMap.entrySet()
  .stream()
  .collect(toMap(Map.Entry::getValue, Map.Entry::getKey, (keyInMap, keyNew) -> keyInMap))
  .entrySet()
  .stream()
  .collect(toMap(Map.Entry::getValue, Map.Entry::getKey));
assertThat(result.values()).hasSize(4)
  .containsExactlyInAnyOrder("Windows", "MacOS", "Linux", "FreeBSD");

基于流的实现比两个 for 循环的实现更流畅。值得注意的是,我们在使用 Stream.Collectors.toMap() 方法进行反转操作时,使用了一个合并函数。第一次反转是为了移除重复值。由于存在重复值,反转键值后,我们会遇到键(OS名称)冲突。因此,我们在那里使用了一个合并函数来处理键冲突。另外,因为我们不在意去重后映射中保留哪个条目,所以让合并函数返回映射中已经存在的键。换句话说,我们丢弃后续出现的重复值。

5. 针对特定去重要求的调整

由于 HashMap 不保证条目的顺序,目前的解决方案无法决定去重后映射中保留哪条条目。

然而,通过调整合并函数,我们可以满足不同的需求。接下来,我们看一个例子。

假设我们有了一个新的要求:如果有多个开发者使用相同的 OS,应保留名字最长的开发者在映射中。那么,预期的结果如下:

Map<String, String> expected = ImmutableMap.of(
  "Eric", "MacOS",
  "Kent", "Linux",
  "Saajan", "Windows",
  "Kevin", "FreeBSD");

接下来,让我们看看如何实现这一点:

Map<String, String> result = devMap.entrySet()
  .stream()
  .collect(toMap(Map.Entry::getValue, Map.Entry::getKey, (k1, k2) -> k1.length() > k2.length() ? k1 : k2))
  .entrySet()
  .stream()
  .collect(toMap(Map.Entry::getValue, Map.Entry::getKey));
assertThat(result).hasSize(4)
  .isEqualTo(expected);

正如我们看到的,我们基于之前的流式实现,仅修改了 toMap() 中的合并函数,以便在发生冲突时返回较长的键。

6. 总结

在这篇文章中,我们学习了从 HashMap 中去除重复值的“两次反转”方法。同时,我们也看到了如何调整 toMap() 的合并函数以适应特定的去重要求。

如往常一样,示例的完整源代码可以在 GitHub 上找到。