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 上找到。