1. 概述

Map 是一种高效的关键值存储和检索结构。在这个教程中,我们将研究在 Set 中查找具有重复值的 Map 键的不同方法。换句话说,我们的目标是将 Map<K, V> 转换为 Map<V, Set<K>>

2. 示例准备

设想我们有一个存储开发者与其主要操作系统的关联关系的地图:

Map<String, String> INPUT_MAP = Map.of(
  "Kai", "Linux",
  "Eric", "MacOS",
  "Kevin", "Windows",
  "Liam", "MacOS",
  "David", "Linux",
  "Saajan", "Windows",
  "Loredana", "MacOS"
);

现在,我们的目标是找到使用相同操作系统的不同开发者,并将它们的键放入一个 Set 中:

Map<String, Set<String>> EXPECTED = Map.of(
  "Linux", Set.of("Kai", "David"),
  "Windows", Set.of("Saajan", "Kevin"),
  "MacOS", Set.of("Eric", "Liam", "Loredana")
);

虽然我们主要处理非空键值对,但值得注意的是,HashMap 允许存在 null 值和一个 null 键。因此,让我们基于 INPUT_MAP 创建一个新的输入,以涵盖包含 null 键值的情况:

Map<String, String> INPUT_MAP_WITH_NULLS = new HashMap<String, String>(INPUT_MAP) {{
    put("Tom", null);
    put("Jerry", null);
    put(null, null);
}};

预期的结果地图如下:

Map<String, Set<String>> EXPECTED_WITH_NULLS = new HashMap<String, Set<String>>(EXPECTED) {{
    put(null, new HashSet<String>() {{
        add("Tom");
        add("Jerry");
        add(null);
    }});
}};

本教程将介绍各种方法。接下来,让我们深入代码。

3. Java 8 之前

在解决这个问题的第一个想法中,我们使用一个空的 HashMap<V, Set<K>> 并通过传统的 for-each 循环来填充它:

static <K, V> Map<V, Set<K>> transformMap(Map<K, V> input) {
    Map<V, Set<K>> resultMap = new HashMap<>();
    for (Map.Entry<K, V> entry : input.entrySet()) {
        if (!resultMap.containsKey(entry.getValue())) {
            resultMap.put(entry.getValue(), new HashSet<>());
        }
        resultMap.get(entry.getValue())
          .add(entry.getKey());
    }
    return resultMap;
}

如代码所示,transformMap() 是一个泛型方法。在循环中,我们使用每个条目的值作为结果映射的键,并将与同一键关联的值收集到一个 Set 中。

无论输入 Map 是否包含 null 键或值,transformMap() 都能完成任务:

Map<String, Set<String>> result = transformMap(INPUT_MAP);
assertEquals(EXPECTED, result);
 
Map<String, Set<String>> result2 = transformMap(INPUT_MAP_WITH_NULLS);
assertEquals(EXPECTED_WITH_NULLS, result2);

4. Java 8

Java 8 引入了许多新功能,简化了像流 API 这样的集合操作,并增强了 Map 接口的功能,如 computeIfAbsent()

在这个部分,我们将使用 Java 8 中引入的功能来处理问题。

4.1. 使用流和 groupingBy()

流 API 提供的 groupingBy() 收集器允许我们根据分类函数对流元素进行分组,并将它们存储在对应键下的列表中,作为 Map 的一部分。

因此,我们可以构建一个 Entry<K, V> 流,并使用 groupingBy() 将流收集到一个 Map<V, List<K>> 中。这与我们的需求非常接近。剩下的步骤就是将 List<K> 转换为 Set<K>

接下来,让我们实现这个想法并看看如何将 List<K> 转换为 Set<K>

Map<String, Set<String>> result = INPUT_MAP.entrySet()
  .stream()
  .collect(groupingBy(Map.Entry::getValue, mapping(Map.Entry::getKey, toSet())));
assertEquals(EXPECTED, result);

如上代码所示,我们使用了 mapping() 收集器将分组结果(List)转换为不同类型的 Set有关更多关于 groupingBy() 的详细信息,请参阅此处

transformMap() 解决方案相比,使用 groupingBy() 的方法更易于阅读和理解。作为一种函数式解决方案,这种方法允许我们流畅地应用进一步的操作。

然而,当使用 groupingBy() 解决 INPUT_MAP_WITH_NULLS 输入时,会抛出 NullPointerException

assertThrows(NullPointerException.class, () -> INPUT_MAP_WITH_NULLS.entrySet()
  .stream()
  .collect(groupingBy(Map.Entry::getValue, mapping(Map.Entry::getKey, toSet()))));

这是因为 groupingBy() 无法处理 null 键:

public static <T, K, D, A, M extends Map<K, D>> 
  Collector<T, ?, M> groupingBy(Function<? super T, ? extends K> classifier,...){
...
    BiConsumer<Map<K, A>, T> accumulator = (m, t) -> {
        K key = Objects.requireNonNull(classifier.apply(t), "element cannot be mapped to a null key");
...
}

接下来,我们将看看是否可以构建一个既能处理非 null 又能处理可变 null 键值的解决方案。

4.2. 使用 forEach()computeIfAbsent()

Java 8 的 forEach() 方法提供了一种简洁的方式来遍历集合。computeIfAbsent() 在指定的键尚未关联值或已映射为 null 时计算新的值。

接下来,我们将这两个方法结合起来解决我们的问题:

Map<String, Set<String>> result = new HashMap<>();
INPUT_MAP.forEach((key, value) -> result.computeIfAbsent(value, k -> new HashSet<>())
  .add(key));
assertEquals(EXPECTED, result);

这个解决方案遵循与 transformMap() 类似的原则。但是,借助 Java 8 的力量,实现更为优雅且紧凑。

此外,forEach() 加上 computeIfAbsent() 的解决方案适用于包含 null 键值的 Map

Map<String, Set<String>> result2 = new HashMap<>();
INPUT_MAP_WITH_NULLS.forEach((key, value) -> result2.computeIfAbsent(value, k -> new HashSet<>())
  .add(key));
assertEquals(EXPECTED_WITH_NULLS, result2);

5. 利用 Guava Multimap

Guava 是一个广泛使用的库,提供了 Multimap 接口,使单个键可以关联多个值。

现在,让我们基于 Multimap 来开发解决方案来解决这个问题。

5.1. 使用流 API 和 Multimap 收集器

我们已经看到 Java 流 API 如何赋予我们紧凑、简洁且功能强大的实现能力。Guava 提供了诸如 Multimaps.toMultimap()Multimap 收集器,可以方便地将流中的元素收集到 Multimap 中。

接下来,让我们看看如何结合流和 Multimaps.toMultimap() 来解决问题:

Map<String, Set<String>> result = INPUT_MAP.entrySet()
  .stream()
  .collect(collectingAndThen(Multimaps.toMultimap(Map.Entry::getValue, Map.Entry::getKey, HashMultimap::create), Multimaps::asMap));
assertEquals(EXPECTED, result);

toMultimap() 方法接受三个参数:

  • 获取键的 Function
  • 获取值的 Function
  • Multimap 提供者 - 提供一个 Multimap 对象。在这个例子中,我们使用 HashMultimap 获取 SetMultimap

因此,toMultimap()Map.Entry<K, V> 对象收集到一个 SetMultimap<V, K> 中。然后,我们使用 collectingAndThen() 执行 Multimaps.asMap() 将收集到的 SetMultimap<V, K> 转换为 Map<V, Set<K>>

这种方法适用于包含 null 键值的 Map

Map<String, Set<String>> result2 = INPUT_MAP_WITH_NULLS.entrySet()
  .stream()
  .collect(collectingAndThen(Multimaps.toMultimap(Map.Entry::getValue, Map.Entry::getKey, HashMultimap::create), Multimaps::asMap));
assertEquals(EXPECTED_WITH_NULLS, result2);

5.2. 使用 invertFrom()forMap()

另一种选择是使用 Multimaps.invertFrom()Multimaps.forMap() 来达成目标:

SetMultimap<String, String> multiMap = Multimaps.invertFrom(Multimaps.forMap(INPUT_MAP), HashMultimap.create());
Map<String, Set<String>> result = Multimaps.asMap(multiMap);
assertEquals(EXPECTED, result);
 
SetMultimap<String, String> multiMapWithNulls = Multimaps.invertFrom(Multimaps.forMap(INPUT_MAP_WITH_NULLS), HashMultimap.create());
Map<String, Set<String>> result2 = Multimaps.asMap(multiMapWithNulls);
assertEquals(EXPECTED_WITH_NULLS, result2);

forMap() 方法返回一个指定 Map<K, V>Multimap<K, V> 视图。顾名思义,invertFrom()Multimap<K, V> 视图转换为 Multimap<V, K> 并将其转移到第二个参数提供的目标 Multimap。最后,我们再次使用 Multimaps.asMap()Multimap<V, K> 转换为 Map<V, Set<K>>

同样,这种方法也适用于包含 null 键值的 Map

6. 总结

在这篇文章中,我们探讨了在 Set 中查找具有重复值的 Map 键的不同方法,包括传统的循环解决方案、基于 Java 8 的解决方案以及利用 Guava 的方法。

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


原始标题:Find Map Keys with Duplicate Values in Java | Baeldung