分组和减少对象列表

时间:2015-09-21 16:43:18

标签: java java-8

我有一个对象列表,其中有许多重复,有些字段需要合并。我想将它简化为仅使用Java 8 Streams的唯一对象列表(我知道如何通过old-skool方法执行此操作,但这是一个实验。)

这就是我现在所拥有的。我不是很喜欢这个,因为地图构建看起来无关紧要,而且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);
    }
}

4 个答案:

答案 0 :(得分:9)

如果您打破分组并减少步骤,您可以获得更清洁的东西:

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);
}

答案 1 :(得分:3)

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

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

使用流来处理地图的值并将其转换回ArrayList很容易。我在这个答案中表明了这一点,并提供了一种避免出现Optional<Foo>的方法,这种方法出现在其他答案中。

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);
    }
}

答案 2 :(得分:1)

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

不幸的是,没有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;
             }));
}

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

另一种方法是使用我的StreamEx库,它提供了一堆&#34;部分缩减&#34;方法包括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是一个中间操作,所以你可以用另一种方式收集。

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

答案 3 :(得分:1)

正如其他人已经指出的那样,中间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 -> f.name, mapping(f -> id, toList())之类的内容,然后将(String, List<Integer>)的条目映射到合并的项目就足够了。

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

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