从 Java 8 流中获取第 n 个元素

2022-08-31 17:00:55

假设我有一个这样的列表:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

是否可以使用 Java 8 流从此列表中获取每两个元素即可获得以下内容?

[1, 3, 5, 7, 9]

甚至可能是每三个元素?

[1, 4, 7, 10]

基本上,我正在寻找一个函数来获取流中的每个n个元素:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> list2 = list.stream().takenth(3).collect(Collectors.toList());
System.out.println(list2);
// => [1, 4, 7, 10]

答案 1

引入Java流的主要动机之一是允许并行操作。这导致要求 Java 上的操作(如 和)独立于项目在流中的位置或其周围的项目。这样做的好处是可以轻松拆分流以进行并行处理。它的缺点是使某些操作更加复杂。mapfilter

因此,简单的答案是,没有简单的方法来做一些事情,例如获取第n个项目或将每个项目映射到所有先前项目的总和。

实现要求的最直接方法是使用要从中流式传输的列表的索引:

List<String> list = ...;
return IntStream.range(0, list.size())
    .filter(n -> n % 3 == 0)
    .mapToObj(list::get)
    .toList();

更复杂的解决方案是创建一个自定义收集器,将第 n 个项目收集到列表中。

class EveryNth<C> {
    private final int nth;
    private final List<List<C>> lists = new ArrayList<>();
    private int next = 0;

    private EveryNth(int nth) {
        this.nth = nth;
        IntStream.range(0, nth).forEach(i -> lists.add(new ArrayList<>()));
    }

    private void accept(C item) {
        lists.get(next++ % nth).add(item);
    }

    private EveryNth<C> combine(EveryNth<C> other) {
        other.lists.forEach(l -> lists.get(next++ % nth).addAll(l));
        next += other.next;
        return this;
    }

    private List<C> getResult() {
        return lists.get(0);
    }

    public static Collector<Integer, ?, List<Integer>> collector(int nth) {
        return Collector.of(() -> new EveryNth(nth), 
            EveryNth::accept, EveryNth::combine, EveryNth::getResult));
}

这可以按如下方式使用:

Stream.of("Anne", "Bill", "Chris", "Dean", "Eve", "Fred", "George")
    .parallel().collect(EveryNth.collector(3)).toList();

这将返回您预期的结果。["Anne", "Dean", "George"]

即使使用并行处理,这也是一种非常低效的算法。它将它接受的所有项目拆分为 n 个列表,然后只返回第一个列表。不幸的是,它必须通过累积过程保留所有项目,因为直到它们被组合在一起,它才知道哪个列表是第n个列表。

鉴于收集器解决方案的复杂性和低效率,如果可以的话,我肯定会建议坚持使用上面基于索引的解决方案。如果您没有使用支持的集合(例如,您被传递了 a 而不是 a ),那么您将需要使用或使用上面的解决方案来收集流。getStreamListCollectors.toListEveryNth


答案 2

编辑 - 2017年11月28日

正如用户@Emiel在注释中建议的那样,执行此操作的最佳方法是用于通过一系列索引来驱动列表:Stream.itearate

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

int skip = 3;
int size = list.size();
// Limit to carefully avoid IndexOutOfBoundsException
int limit = size / skip + Math.min(size % skip, 1);

List<Integer> result = Stream.iterate(0, i -> i + skip)
    .limit(limit)
    .map(list::get)
    .collect(Collectors.toList());

System.out.println(result); // [1, 4, 7, 10]

这种方法没有我之前的答案的缺点,下面是(出于历史原因,我决定保留它)。


另一种方法是使用以下方式:Stream.iterate()

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

int skip = 3;
int size = list.size();
// Limit to carefully avoid IndexOutOfBoundsException
int limit = size / skip + Math.min(size % skip, 1);

List<Integer> result = Stream.iterate(list, l -> l.subList(skip, l.size()))
    .limit(limit)
    .map(l -> l.get(0))
    .collect(Collectors.toList());

System.out.println(result); // [1, 4, 7, 10]

这个想法是创建一个子列表流,每个子列表跳过前一个子列表的第一个元素(在示例中)。NN=3

我们必须限制迭代次数,这样我们就不会尝试获取边界超出范围的子列表。

然后,我们将子列表映射到它们的第一个元素并收集结果。根据源列表,保留每个子列表的第一个元素按预期工作,因为每个子列表的起始索引都会将元素向右移动。N

这也是有效的,因为该方法返回原始列表的视图,这意味着它不会为每次迭代创建一个新的列表。List.sublist()List


编辑:过了一会儿,我了解到采用@sprinter的方法之一要好得多,因为围绕原始列表创建了一个包装器。这意味着流的第二个列表将是第一个列表的包装器,流的第三个列表将是第二个列表的包装器(已经是包装器!),依此类推...subList()

虽然这可能适用于中小型列表,但应该注意的是,对于非常大的源列表,将创建许多包装器。这可能最终变得昂贵,甚至生成.StackOverflowError


推荐