使用Java 8的Lambda表达式/转换将两个Map组合并展开

7
我有两个地图:
  • Map<A, Collection<B>> mapAB
  • Map<B, Collection<C>> mapBC
我想将它们转换为一个Map<A, Collection<C>> mapAC,并且我想知道是否有一种使用lambda和转换来平滑地解决这个问题的方法。在我的特定情况下,所有集合都是集合,但我希望解决一般集合的问题。
我想到的一个思路是首先将两个地图合并成一个Map<A, Map<B, Collection<C>>>,然后展开它,但我对任何方法都持开放态度。
数据说明:对于与一个A相关联的值集合,应仅出现一个B,对于mapBC也是如此(一个给定的C只映射到一个B)。因此,从给定的A到给定的C应该只有一条路径,虽然可能存在没有相应的A -> B映射的A -> B映射,以及存在没有相应的B -> C映射的B -> C映射。这些孤立点在结果中不会出现。
为了比较,以下是解决同一问题的纯命令式方法的示例:
Map<A, Collection<C>> mapAC = new HashMap<>();

for (Entry<A, Collection<B>> entry : mapAB.entrySet()) {
    Collection<C> cs = new HashSet<>();

    for (B b : entry.getValue()) {
        Collection<C> origCs = mapBC.get(b);
        if (origCs != null) {
            cs.addAll(origCs);
        }
    }

    if (!cs.isEmpty()) {
        mapAC.put(entry.getKey(), cs);
    }
}

你想要的是两个一对多关系的连接,没有中间列吗? - Mike Samuel
@MikeSamuel 是的,这绝对是一种看待它的方式。 - Matt Passell
1
您能添加一些数据示例吗?例如,如果我们有Map<Person, Set<Job>>Map<Job, Set<Tool>>,是否可能不同的人拥有相同的工作,或者少数工作使用相同的工具?因此,像p1 -> {j1, j2},p2-> {j2、j3}这样的东西是否可能?另外,job1 -> {tool1, tool2} job2 -> {tool2, tool3} job3 -> {tool4}呢?您期望得到什么结果?您希望Collection<X>也是一个Set,还是其中的元素可以存在多次? - Pshemo
@Pshemo,请查看问题中的新“数据注释”部分。 - Matt Passell
6个回答

3
我不太喜欢使用forEach方法,因为它写起来有些繁琐。使用更纯粹的方式可能会更好。
mapAB.entrySet().stream()
  .flatMap(
      entryAB -> entryAB.getValue().stream().flatMap(
          b -> mapBC.getOrDefault(b, Collections.<C>emptyList())
             .stream().map(
                 c -> new AbstractMap.SimpleEntry<>(entryAB.getKey(), c))))
  // we now have a Stream<Entry<A, C>>
  .groupingBy(
     Entry::getKey,
     mapping(Entry::getValue, toList()));

...或者说是交替地

mapA.entrySet().stream()
  .flatMap(
      entryAB -> entryAB.getValue().stream().map(
          b -> new AbstractMap.SimpleEntry<>(
              entryAB.getKey(), 
              mapBC.getOrDefault(b, Collections.<C>emptyList()))))
  // we now have a Stream<Entry<A, Collection<C>>>
  .groupingBy(
     Entry::getKey,
     mapping(Entry::getValue, 
       reducing(
          Collections.<C>emptyList(),
          (cs1, cs2) -> {
             List<C> merged = new ArrayList<>(cs1);
             merged.addAll(cs2);
             return merged;
          })));

我正在思考你的回答。第二个版本看起来很聪明,但有点难以解释。顺便说一下,我已经给地图取了名字(mapAB,mapBC)。我想编辑你的回答使用mapAB,但只涉及到改变了足够少的字符,以至于StackOverflow不允许我这样做。 - Matt Passell
1
已更新变量名称,并注意到 mapBC 可能不包含 mapAB 中出现的所有 B。 - Louis Wasserman

3

您没有说明如果第一个映射中的某个b在第二个映射中不存在时,您想要做什么,因此这可能并非您要寻找的准确答案。

mapAB.entrySet().stream()
  .filter(e -> e.getValue().stream().anyMatch(mapBC::containsKey))
  .collect(toMap(
       Map.Entry::getKey,
       e->e.getValue().stream()
           .filter(mapBC::containsKey)
           .map(mapBC::get)
           .flatMap(Collection::stream)
           .collect(toList())
  ));

顺便提一下,您可能需要提到代码假定您已经静态导入了 Collectors.toMap()Collectors.toList()。我很高兴假设您也已经导入了 Map.Entry,因此您还可以删除 Map. 前缀。 - Matt Passell
我本来想接受这个答案,但是后来我写了一个单元测试,发现如果mapAB中的一个条目指向一个B的集合,而这些B都不是mapBC中的键,那么你最终会得到一个指向空集合的mapAC条目。虽然这并不是世界末日,但我希望在这种情况下根本没有条目。 - Matt Passell
在外部收集之前添加以下过滤器可以解决问题,但这相当丑陋:filter(e -> !e.getValue().stream().filter(mapBC::containsKey). collect(Collectors.toSet()).isEmpty()) - Matt Passell
或者更简单地说,e->e.getValue().stream().anyMatch(mapBC::containsKey) - Misha

1

我的StreamEx库提供了一个EntryStream类,它是Map.Entry对象的流,并附带一些方便的操作。这是我使用我的库解决此问题的方法:

Map<A, Collection<C>> mapAC = EntryStream.of(mapAB)
    .flatMapValues(Collection::stream) // flatten values: now elements are Entry<A, B>
    .mapValues(mapBC::get) // map only values: now elements are Entry<A, Collection<C>>
    .nonNullValues() // remove entries with null values
    .flatMapValues(Collection::stream) // flatten values again: now we have Entry<A, C>
    .groupingTo(HashSet::new); // group them to Map using HashSet as value collections

这可能不如@Misha提供的优秀方案高效,因为会创建更多的中间对象,但在我的看法中,用这种方式编写和理解起来更容易。

0
这样怎么样:
    Map<A, Collection<B>> mapAB = new HashMap<>();
    Map<B, Collection<C>> mapBC = new HashMap<>();
    Map<A, Collection<C>> mapAC = new HashMap<>();

    mapAB.entrySet().stream().forEach(a -> {
        Collection<C> cs = new HashSet<>();
        a.getValue().stream().filter(b -> mapBC.containsKey(b)).forEach(b -> cs.addAll(mapBC.get(b)));
        mapAC.put(a.getKey(), cs);
    });

这绝对比我刚刚在问题中添加的命令式等价物更紧凑,但它确实有一个缺陷,我最初也未能在我的示例中处理。你的调用mapB.get(b)(你可以更新为mapBC - 参见上面的我的命名更新)可能返回null,因此你需要处理它。 - Matt Passell
你是正确的,请审查我的编辑...(添加了过滤器) - Uri Shalit
感谢您的编辑。不幸的是,它仍然表现出与Misha的答案相同的行为,在mapBC中没有映射到B的情况下创建空集合。请参阅该答案的评论以获取更多详细信息。 - Matt Passell

0
Map<A, Collection<C>> mapC =
    mapA.entrySet().stream().collect(Collectors.toMap(
        entry -> entry.getKey(),
        entry -> entry.getValue().stream().flatMap(b -> mapB.get(b).stream())
            .collect(Collectors.toSet())));

可以随意将Collectors.toSet()替换为toList(),甚至是toCollection()


我喜欢这种方法的清晰度,但请参阅我上面更新的问题。mapB.get(b)可能会返回null,所以你需要处理这个情况。还请查看我更新的变量命名。 - Matt Passell

0

我实际上并不反对命令式编程方法。由于你将其收集到内存中,使用lambda表达式并没有什么好处,除非它们能够带来更清晰的代码。在这里,命令式编程方法是完全可以的:

Map<A, Collection<C>> mapAC = new HashMap<>();

for (A key : mapAB.keySet()) {
    Collection<C> cs = new HashSet<>();
    mapAC.put(key, cs);

    for (B b : mapAP.get(key)) {
        cs.addAll(mapBC.get(b)==null ?  Collections.emptyList() : mapBC.get(b));
    }
} 

虽然我已经将您的if语句作为三元运算符进行了内联,但我认为在for循环中使用键更清晰。


mapAB和mapBC可能会变得非常庞大,因此我希望尽可能避免额外的查找。Java没有Groovy的Elvis运算符真是太可惜了,因为它可以完美地替代三元表达式,同时避免双重查找。 - Matt Passell

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接