【问题标题】:Group and Reduce list of objects分组和减少对象列表
【发布时间】:2016-08-25 02:33:44
【问题描述】:

我有一个包含许多重复对象和一些需要合并的字段的对象列表。我想将其简化为仅使用 Java 8 Streams 的唯一对象列表(我知道如何通过老式方法做到这一点,但这是一个实验。)

这就是我现在所拥有的。我不太喜欢这样,因为地图构建似乎无关紧要,而 values() 集合是支持地图的视图,您需要将其包装在新的 ArrayList<>(...) 中以获得更具体的集合。有没有更好的方法,也许使用更通用的归约操作?

    @Test
public void reduce() {
    Collection<Foo> foos = Stream.of("foo", "bar", "baz")
                     .flatMap(this::getfoos)
                     .collect(Collectors.toMap(f -> f.name, f -> f, (l, r) -> {
                         l.ids.addAll(r.ids);
                         return l;
                     })).values();

    assertEquals(3, foos.size());
    foos.forEach(f -> assertEquals(10, f.ids.size()));
}

private Stream<Foo> getfoos(String n) {
    return IntStream.range(0,10).mapToObj(i -> new Foo(n, i));
}

public static class Foo {
    private String name;
    private List<Integer> ids = new ArrayList<>();

    public Foo(String n, int i) {
        name = n;
        ids.add(i);
    }
}

【问题讨论】:

  • 是否可以在不使用中间映射的情况下实现这个“old skool”(通常不使用 lambda/streams)?我认为,由于重复可能出现在输入中的任何地方,它们都必须在某个地方缓冲,直到所有输入都被处理完。

标签: java java-8


【解决方案1】:

如果你打破分组和减少步骤,你可以得到更干净的东西:

Stream<Foo> input = Stream.of("foo", "bar", "baz").flatMap(this::getfoos);

Map<String, Optional<Foo>> collect = input.collect(Collectors.groupingBy(f -> f.name, Collectors.reducing(Foo::merge)));

Collection<Optional<Foo>> collected = collect.values();

这假定您的 Foo 类中有一些方便的方法:

public Foo(String n, List<Integer> ids) {
    this.name = n;
    this.ids.addAll(ids);
}

public static Foo merge(Foo src, Foo dest) {
    List<Integer> merged = new ArrayList<>();
    merged.addAll(src.ids);
    merged.addAll(dest.ids);
    return new Foo(src.name, merged);
}

【讨论】:

  • 这相当于几乎一样的东西——只是你在途中创建了很多新的Foo对象,而你的列表是Optional&lt;Foo&gt;的列表而不是Foo的列表,这不是很干净。
  • @ryber 当然,但在现实世界中很容易产生意想不到的问题,特别是如果你的减少是并行运行的。我建议减少流操作中的可变性。请参阅:docs.oracle.com/javase/8/docs/api/java/util/stream/…
  • @RealSkeptic,对于任意数据模型,您不一定会有reduce 的逻辑“身份”类型,因此结果中的Optional 是可以预期的。
  • Java 9 有一个额外的 unwrap 方法,可以将选项流解包为当前 Ts 流。我的代码库中有一个类似的手卷方法。
  • 如果输入为空,通常没有标识值的缩减将返回一个空的Optional。但是在groupingBy 的上下文中减少不应该导致空的Optional,因为只有存在值时才会创建映射条目。所以这只是 API 中的噪音。对于此示例,OP 的三参数 toMap() 调用可能比 groupingBy/reducing 更可取。
【解决方案2】:

正如 cmets 中已经指出的,当您想要识别独特的对象时,使用地图是一件非常自然的事情。如果您需要做的只是找到唯一的对象,您可以使用Stream::distinct 方法。此方法隐藏了涉及映射的事实,但显然它确实在内部使用了映射,正如 this question 所暗示的那样,这表明您应该实现 hashCode 方法或 distinct 可能无法正常运行。

distinct 方法的情况下,不需要合并,可以在处理所有输入之前返回一些结果。在您的情况下,除非您可以对问题中未提及的输入做出额外的假设,否则您确实需要在返回任何结果之前完成所有输入的处理。因此,这个答案确实使用了地图。

不过,使用流来处理映射的值并将其转回 ArrayList 很容易。我在这个答案中展示了这一点,并提供了一种避免出现Optional&lt;Foo&gt; 的方法,该Optional&lt;Foo&gt; 出现在其他答案之一中。

public void reduce() {
    ArrayList<Foo> foos = Stream.of("foo", "bar", "baz").flatMap(this::getfoos)
            .collect(Collectors.collectingAndThen(Collectors.groupingBy(f -> f.name,
            Collectors.reducing(Foo.identity(), Foo::merge)),
            map -> map.values().stream().
                collect(Collectors.toCollection(ArrayList::new))));

    assertEquals(3, foos.size());
    foos.forEach(f -> assertEquals(10, f.ids.size()));
}

private Stream<Foo> getfoos(String n) {
    return IntStream.range(0, 10).mapToObj(i -> new Foo(n, i));
}

public static class Foo {
    private String name;
    private List<Integer> ids = new ArrayList<>();

    private static final Foo BASE_FOO = new Foo("", 0);

    public static Foo identity() {
        return BASE_FOO;
    }

    // use only if side effects to the argument objects are okay
    public static Foo merge(Foo fooOne, Foo fooTwo) {
        if (fooOne == BASE_FOO) {
            return fooTwo;
        } else if (fooTwo == BASE_FOO) {
            return fooOne;
        }
        fooOne.ids.addAll(fooTwo.ids);
        return fooOne;
    }

    public Foo(String n, int i) {
        name = n;
        ids.add(i);
    }
}

【讨论】:

  • 为什么这一切map.values().stream().collect(blahblah)?好旧的map -&gt; new ArrayList&lt;&gt;(map.values()) 会更简单、更快。
  • @Tagir Valeev:如果应用于结果的唯一操作是 size()forEach(),则根本没有理由将 map.values() 集合复制到新列表中。
【解决方案3】:

如果输入元素以随机顺序提供,那么使用中间映射可能是最好的解决方案。但是如果你事先知道所有同名的foo都是相邻的(这个条件在你的测试中实际上是满足的),算法可以大大简化:你只需要将当前元素与如果名称相同,则将它们合并。

不幸的是,没有 Stream API 方法可以让您轻松有效地执行此类操作。一种可能的解决方案是像这样编写自定义收集器:

public static List<Foo> withCollector(Stream<Foo> stream) {
    return stream.collect(Collector.<Foo, List<Foo>>of(ArrayList::new,
             (list, t) -> {
                 Foo f;
                 if(list.isEmpty() || !(f = list.get(list.size()-1)).name.equals(t.name))
                     list.add(t);
                 else
                     f.ids.addAll(t.ids);
             },
             (l1, l2) -> {
                 if(l1.isEmpty())
                     return l2;
                 if(l2.isEmpty())
                     return l1;
                 if(l1.get(l1.size()-1).name.equals(l2.get(0).name)) {
                     l1.get(l1.size()-1).ids.addAll(l2.get(0).ids);
                     l1.addAll(l2.subList(1, l2.size()));
                 } else {
                     l1.addAll(l2);
                 }
                 return l1;
             }));
}

我的测试表明,无论是在顺序模式还是并行模式下,这个收集器总是比收集到 map 快(最多 2 倍,具体取决于重复名称的平均数量)。

另一种方法是使用我的StreamEx 库,它提供了一堆“部分缩减”方法,包括collapse

public static List<Foo> withStreamEx(Stream<Foo> stream) {
    return StreamEx.of(stream)
            .collapse((l, r) -> l.name.equals(r.name), (l, r) -> {
                l.ids.addAll(r.ids);
                return l;
            }).toList();
}

此方法接受两个参数:BiPredicate 应用于两个相邻元素,如果元素应合并则应返回 true,BinaryOperator 执行合并。这个解决方案在顺序模式下比自定义收集器慢一点(并行的结果非常相似),但它仍然比toMap 解决方案快得多,而且它更简单,更灵活,因为collapse 是一个中间操作,所以你可以用其他方式收集。

同样,这两种解决方案仅在已知具有相同名称的 foo 相邻时才有效。按 foo 名称对输入流进行排序,然后使用这些解决方案是个坏主意,因为排序会大大降低性能,使其比toMap 解决方案慢。

【讨论】:

    【解决方案4】:

    正如其他人已经指出的那样,中间的Map 是不可避免的,因为这是找到要合并的对象的方式。此外,您不应在缩减期间修改源数据。

    尽管如此,您无需创建多个 Foo 实例即可实现两者:

    List<Foo> foos = Stream.of("foo", "bar", "baz")
                     .flatMap(n->IntStream.range(0,10).mapToObj(i -> new Foo(n, i)))
    
                     .collect(collectingAndThen(groupingBy(f -> f.name),
                        m->m.entrySet().stream().map(e->new Foo(e.getKey(),
                           e.getValue().stream().flatMap(f->f.ids.stream()).collect(toList())))
                        .collect(toList())));
    

    这假设你添加了一个构造函数

        public Foo(String n, List<Integer> l) {
            name = n;
            ids=l;
        }
    

    到你的Foo 类,如果Foo 真的应该能够保存一个ID 列表,它应该有。附带说明一下,拥有一个用作单个项目的类型以及一个用于合并结果的容器对我来说似乎是不自然的。这就是为什么编码变得如此复杂的原因。

    如果源项目只有一个id,使用groupingBy(f -&gt; f.name, mapping(f -&gt; id, toList()) 之类的东西,然后将(String, List&lt;Integer&gt;) 的条目映射到合并的项目就足够了。

    由于情况并非如此,而且 Java 8 缺少 flatMapping 收集器,因此将平面映射步骤移至第二步,使其看起来更加复杂。

    但是在这两种情况下,第二步都没有过时,因为它是实际创建结果项的地方,并且将地图转换为所需的列表类型是免费的。

    【讨论】:

    • 不可变对象当然很好,但应该注意的是,当前的解决方案比 OP 的代码慢两倍左右。使用flatMapping 收集器可能会更好...
    • @Tagir Valeev:在这种情况下,这与对象是否不可变无关。只是减少不应该修改源对象。我认为,您可以想象如果仍在使用源对象,这将如何适得其反……
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-11-14
    • 2019-12-11
    • 2019-03-26
    • 1970-01-01
    • 2018-09-03
    相关资源
    最近更新 更多