将列表沿元素拆分为子列表

70

我有这个列表 (List<String>):

["a", "b", null, "c", null, "d", "e"]

我希望你能够提供类似这样的内容:

[["a", "b"], ["c"], ["d", "e"]]

换句话说,我想使用null值作为分隔符,将我的列表拆分成子列表,以获取一个列表的列表(List<List<String>>)。 我正在寻找Java 8的解决方案。 我尝试过使用Collectors.partitioningBy,但我不确定它是否符合我的要求。 谢谢!


@Oneiros 是的,看起来 Collectors.groupingBy 与 Python 的 itertools.groupby 工作方式非常不同,更像是 Collectors.partitioningBy,只是有超过两个组,即它会将所有非空字符串放在一个桶中。 - tobias_k
1
问题在于这里序列的顺序非常重要,但流式处理是设计用于独立数据块的。这就是为什么我认为没有解决方案会不等同于Java 7的foreach。我的解决方案将“拆分列表”的问题转化为“拆分字符串”的问题,而Java本地实现了解决方案。 - Arnaud Denoyelle
15
希望毋庸置疑,对于生产代码,请保持简单,仅使用for循环。这是一个很好的培训练习题。但我担心下面发布的一些解决方案可能有一天会出现在生产代码中(它们在Stackoverflow上得了高分,所以肯定是正确的方法!)。一些可怜的人将会为理解它们而抓狂! - David Lavender
1
ArnaudDenoyelle,我不同意你的观点,Alexis C.向你展示了这是可能的(我正在测试他的解决方案) @Mr Spoon 是的,这只是一个训练练习。 - Oneiros
1
可能是 https://dev59.com/k14c5IYBdhLWcg3wEGp_ 的重复问题。 - bowmore
显示剩余8条评论
13个回答

81
尽管已经有几个答案并且有一个被接受的答案,但这个主题仍然缺少一些要点。首先,共识似乎是使用流解决此问题只是一种练习,传统的for循环方法更可取。其次,迄今为止给出的答案忽略了一种使用数组或向量技术的方法,我认为这种方法可以显著改善流的解决方案。
首先,为了讨论和分析的目的,这里提供一个传统的解决方案:
static List<List<String>> splitConventional(List<String> input) {
    List<List<String>> result = new ArrayList<>();
    int prev = 0;

    for (int cur = 0; cur < input.size(); cur++) {
        if (input.get(cur) == null) {
            result.add(input.subList(prev, cur));
            prev = cur + 1;
        }
    }
    result.add(input.subList(prev, input.size()));

    return result;
}

这基本上很简单,但有一些微妙之处。其中一个要点是从prevcur的待处理子列表始终是打开状态的。当我们遇到null时,我们会关闭它,将其添加到结果列表中,并推进prev。循环结束后,我们无条件地关闭子列表。

另一个观察结果是,这是一个对索引进行循环,而不是对值本身进行循环,因此我们使用算术for循环而不是增强的“for-each”循环。但这表明我们可以使用索引来生成子范围而不是流过值并将逻辑放入收集器中(正如Joop Eggen's proposed solution所做的那样)。

一旦我们意识到了这一点,我们就可以看到输入中每个null位置都是子列表的分隔符:它是左侧子列表的右端点,它(加一)是右侧子列表的左端点。如果我们能够处理边界情况,就会导致一种方法:找到null元素发生的索引,将它们映射到子列表,并收集子列表。

得到的代码如下:

static List<List<String>> splitStream(List<String> input) {
    int[] indexes = Stream.of(IntStream.of(-1),
                              IntStream.range(0, input.size())
                                       .filter(i -> input.get(i) == null),
                              IntStream.of(input.size()))
                          .flatMapToInt(s -> s)
                          .toArray();

    return IntStream.range(0, indexes.length-1)
                    .mapToObj(i -> input.subList(indexes[i]+1, indexes[i+1]))
                    .collect(toList());
}

获取 null 发生的索引非常容易。绊脚石是在左侧添加 -1,右侧添加 size。我选择使用 Stream.of 进行附加,然后使用 flatMapToInt 进行平铺。(我尝试了几种其他方法,但这种方法似乎最清晰。)
在这里使用数组更加方便。首先,访问数组的符号比 List 更好: indexes[i] vs. indexes.get(i)。其次,使用数组避免了装箱。
此时,数组中的每个索引值(除了最后一个)都比子列表的开始位置少一。它右边的索引是子列表的结尾。我们只需要在数组上进行流处理,将每对索引映射到子列表并收集输出即可。 讨论 流式编程方法比for循环版本稍微短一些,但更加密集。for循环版本很熟悉,因为我们经常在Java中使用这种方式,但如果您不知道这个循环应该做什么,它并不明显。在弄清楚prev的作用以及为什么必须在循环结束后关闭开放子列表之前,您可能需要模拟几次循环执行。(我最初忘记了它,但我在测试中发现了这一点。)
我认为流式编程方法更容易理解正在发生的事情:获取一个指示子列表之间边界的列表(或数组)。这是一个简单的两行流式处理代码。如上所述,困难在于找到一种将边缘值附加到末尾的方法。如果有更好的语法来完成此操作,例如:
    // Java plus pidgin Scala
    int[] indexes =
        [-1] ++ IntStream.range(0, input.size())
                         .filter(i -> input.get(i) == null) ++ [input.size()];

这将使事情变得更简洁。(我们真正需要的是数组或列表推导式。) 一旦你有了索引,将它们映射到实际的子列表并将它们收集到结果列表中就很简单了。

当在并行运行时,这当然是安全的。

更新2016-02-06

这是创建子列表索引数组的更好方法。它基于相同的原则,但调整了索引范围并添加了一些条件以避免必须连接和平铺索引。

static List<List<String>> splitStream(List<String> input) {
    int sz = input.size();
    int[] indexes =
        IntStream.rangeClosed(-1, sz)
                 .filter(i -> i == -1 || i == sz || input.get(i) == null)
                 .toArray();

    return IntStream.range(0, indexes.length-1)
                    .mapToObj(i -> input.subList(indexes[i]+1, indexes[i+1]))
                    .collect(toList());
}

更新于2016年11月23日

我与Brian Goetz一起在Devoxx Antwerp 2016上做了一个演讲,题目是“Thinking In Parallel”(video),其中涉及到了这个问题以及我的解决方案。在那里提出的问题略有不同,是以“#”为分割符而不是空值,但本质相同。在演讲中,我提到我为这个问题编写了大量单元测试。我将它们作为一个独立的程序附在下面,还有我的循环和流实现。读者可以尝试用其他答案提供的解决方案运行这些测试用例,并查看哪些失败了以及原因。(其他解决方案必须根据谓词进行适应,而不是基于null进行分割。)

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

import static java.util.Arrays.asList;

public class ListSplitting {
    static final Map<List<String>, List<List<String>>> TESTCASES = new LinkedHashMap<>();
    static {
        TESTCASES.put(asList(),
                  asList(asList()));
        TESTCASES.put(asList("a", "b", "c"),
                  asList(asList("a", "b", "c")));
        TESTCASES.put(asList("a", "b", "#", "c", "#", "d", "e"),
                  asList(asList("a", "b"), asList("c"), asList("d", "e")));
        TESTCASES.put(asList("#"),
                  asList(asList(), asList()));
        TESTCASES.put(asList("#", "a", "b"),
                  asList(asList(), asList("a", "b")));
        TESTCASES.put(asList("a", "b", "#"),
                  asList(asList("a", "b"), asList()));
        TESTCASES.put(asList("#"),
                  asList(asList(), asList()));
        TESTCASES.put(asList("a", "#", "b"),
                  asList(asList("a"), asList("b")));
        TESTCASES.put(asList("a", "#", "#", "b"),
                  asList(asList("a"), asList(), asList("b")));
        TESTCASES.put(asList("a", "#", "#", "#", "b"),
                  asList(asList("a"), asList(), asList(), asList("b")));
    }

    static final Predicate<String> TESTPRED = "#"::equals;

    static void testAll(BiFunction<List<String>, Predicate<String>, List<List<String>>> f) {
        TESTCASES.forEach((input, expected) -> {
            List<List<String>> actual = f.apply(input, TESTPRED);
            System.out.println(input + " => " + expected);
            if (!expected.equals(actual)) {
                System.out.println("  ERROR: actual was " + actual);
            }
        });
    }

    static <T> List<List<T>> splitStream(List<T> input, Predicate<? super T> pred) {
        int[] edges = IntStream.range(-1, input.size()+1)
                               .filter(i -> i == -1 || i == input.size() ||
                                       pred.test(input.get(i)))
                               .toArray();

        return IntStream.range(0, edges.length-1)
                        .mapToObj(k -> input.subList(edges[k]+1, edges[k+1]))
                        .collect(Collectors.toList());
    }

    static <T> List<List<T>> splitLoop(List<T> input, Predicate<? super T> pred) {
        List<List<T>> result = new ArrayList<>();
        int start = 0;

        for (int cur = 0; cur < input.size(); cur++) {
            if (pred.test(input.get(cur))) {
                result.add(input.subList(start, cur));
                start = cur + 1;
            }
        }
        result.add(input.subList(start, input.size()));

        return result;
    }

    public static void main(String[] args) {
        System.out.println("===== Loop =====");
        testAll(ListSplitting::splitLoop);
        System.out.println("===== Stream =====");
        testAll(ListSplitting::splitStream);
    }
}

6
对于那些对此问题更感兴趣的人:回答者关于这个问题的演讲 - user1803551
6
谢谢你发布这个链接!这促使我更新答案,加入了我在演讲中提到的单元测试。 - Stuart Marks

32
我目前想到的唯一解决方案是实现自己的自定义收集器。
在阅读解决方案之前,我想添加一些注释。我认为这个问题更像是一个编程练习,不确定是否可以使用并行流来完成。
因此,您必须意识到,如果在并行模式下运行管道,则它将悄悄地中断。
这种行为是不可取的,应该避免。这就是为什么我会在组合器部分抛出异常(而不是使用 (l1,l2) -> {l1.addAll(l2); return l1;} ),因为当组合两个列表时,它会在并行模式下使用,这样您就会得到异常而不是错误结果。
另外,由于需要复制列表(虽然使用本地方法复制底层数组),这也不是非常高效的。
以下是收集器的实现:
private static Collector<String, List<List<String>>, List<List<String>>> splitBySeparator(Predicate<String> sep) {
    final List<String> current = new ArrayList<>();
    return Collector.of(() -> new ArrayList<List<String>>(),
        (l, elem) -> {
            if (sep.test(elem)) {
                l.add(new ArrayList<>(current));
                current.clear();
            }
            else {
                current.add(elem);
            }
        },
        (l1, l2) -> {
            throw new RuntimeException("Should not run this in parallel");
        },
        l -> {
            if (current.size() != 0) {
                l.add(current);
                return l;
            }
        );
}

并且如何使用它:
List<List<String>> ll = list.stream().collect(splitBySeparator(Objects::isNull));

输出:

[[a, b], [c], [d, e]]


根据Joop Eggen的回答,似乎可以并行完成(请给他以此荣誉!)。这样就将自定义集合器的实现缩减为:

private static Collector<String, List<List<String>>, List<List<String>>> splitBySeparator(Predicate<String> sep) {
    return Collector.of(() -> new ArrayList<List<String>>(Arrays.asList(new ArrayList<>())),
                        (l, elem) -> {if(sep.test(elem)){l.add(new ArrayList<>());} else l.get(l.size()-1).add(elem);},
                        (l1, l2) -> {l1.get(l1.size() - 1).addAll(l2.remove(0)); l1.addAll(l2); return l1;});
}

虽然Stream API可以让关于并行性的段落有些过时,但我认为保留它可以作为一个很好的提醒。


请注意,Stream API并不总是一个替代品。有些任务使用流更容易和更适合,而有些任务则不是。在您的情况下,您也可以创建一个实用方法来完成这个任务:

private static <T> List<List<T>> splitBySeparator(List<T> list, Predicate<? super T> predicate) {
    final List<List<T>> finalList = new ArrayList<>();
    int fromIndex = 0;
    int toIndex = 0;
    for(T elem : list) {
        if(predicate.test(elem)) {
            finalList.add(list.subList(fromIndex, toIndex));
            fromIndex = toIndex + 1;
        }
        toIndex++;
    }
    if(fromIndex != toIndex) {
        finalList.add(list.subList(fromIndex, toIndex));
    }
    return finalList;
}

并且可以像这样调用:List<List<String>> list = splitBySeparator(originalList, Objects::isNull);

它可以改进以检查边缘情况。


6
记录限制并将Predicate作为参数传递,加一分。 - Stuart Marks

24
解决方案是使用Stream.collect。使用构建器模式创建Collector已经作为解决方案给出。另一种选择是使用另一个重载的collect,这个方法略微有些原始。
    List<String> strings = Arrays.asList("a", "b", null, "c", null, "d", "e");
    List<List<String>> groups = strings.stream()
            .collect(() -> {
                List<List<String>> list = new ArrayList<>();
                list.add(new ArrayList<>());
                return list;
            },
            (list, s) -> {
                if (s == null) {
                    list.add(new ArrayList<>());
                } else {
                    list.get(list.size() - 1).add(s);
                }
            },
            (list1, list2) -> {
                // Simple merging of partial sublists would
                // introduce a false level-break at the beginning.
                list1.get(list1.size() - 1).addAll(list2.remove(0));
                list1.addAll(list2);
            });

正如所见,我制作了一个字符串列表的列表,其中至少有一个最后(空)字符串列表。

  • 第一个函数创建了一个起始的字符串列表。 它指定了结果(类型化)对象。
  • 调用第二个函数来处理每个元素。 它是对部分结果和元素的操作。
  • 第三个函数实际上并没有被使用,它在并行处理时发挥作用,当需要合并部分结果时才会使用。

使用累加器的解决方案:

正如 @StuartMarks 指出的那样,组合器未能充分满足并行处理的契约。

由于 @ArnaudDenoyelle 的评论,这里提供了使用reduce的版本。

    List<List<String>> groups = strings.stream()
            .reduce(new ArrayList<List<String>>(),
                    (list, s) -> {
                        if (list.isEmpty()) {
                            list.add(new ArrayList<>());
                        }
                        if (s == null) {
                            list.add(new ArrayList<>());
                        } else {
                            list.get(list.size() - 1).add(s);
                        }
                        return list;
                    },
                    (list1, list2) -> {
                            list1.addAll(list2);
                            return list1;
                    });
  • 第一个参数是累积对象。
  • 第二个函数进行累加。
  • 第三个是上述的组合器。

1
@ArnaudDenoyelle 是的,看起来更加功能化。为什么不自己回答呢?我的回答想让新的Stream用户可以“积极地”使用其编程功能。 - Joop Eggen
1
我会点赞,因为它似乎可以并行工作!干得好!你可以用 () -> new ArrayList<List<String>>(Arrays.asList(new ArrayList<>())) 替换供应商部分。 - Alexis C.
4
收集器做得很不错,尤其是合并器。+1。但使用reduce的版本无法并行处理,因为第一个参数不是标识元素——随着约简进程的进行,它会被改变。 - Stuart Marks
1
@Holger 我认为第一个解决方案是正确的。约定是,在拆分左侧的列表(左侧列表的最右边元素)始终表示一个开放的子列表,因此将其合并始终是正确的。如果拆分恰好在“null”的右侧发生,则该“null”将导致累加器将空列表附加到左侧列表中。因此保留了断点。 - Stuart Marks
1
list.get(list.size() - 1).addAll(list2.remove(0)); 应该改为:list1.get(list1.size() - 1).addAll(list2.remove(0)); - Reut Sharabani
显示剩余5条评论

8

请勿投票。我没有足够的空间在评论中解释这个问题。

这是一个使用Streamforeach的解决方案,但这与Alexis的解决方案或foreach循环严格等效(而且不太清晰,我无法摆脱复制构造函数):

List<List<String>> result = new ArrayList<>();
final List<String> current = new ArrayList<>();
list.stream().forEach(s -> {
      if (s == null) {
        result.add(new ArrayList<>(current));
        current.clear();
      } else {
        current.add(s);
      }
    }
);
result.add(current);

System.out.println(result);

我理解您希望使用Java 8找到一种更优雅的解决方案,但我认为它并没有为这种情况而设计。正如spoon先生所说,我强烈建议在这种情况下采用天真的方式。

5
尽管 Marks Stuart的答案简洁、直观且并行安全(也是最好的),但我想分享另一个有趣的解决方案,它不需要起始/结束边界技巧。
如果我们看一下问题域并思考并行性,我们可以通过分治策略轻松解决这个问题。与其将问题视为要遍历的序列列表,我们可以将问题视为相同基本问题的组合:在null值处拆分列表。我们可以直观地看到,我们可以使用以下递归策略逐步分解问题:
split(L) :
  - if (no null value found) -> return just the simple list
  - else -> cut L around 'null' naming the resulting sublists L1 and L2
            return split(L1) + split(L2)

在这种情况下,我们首先搜索任何null值,并在找到一个时立即剪切列表并在子列表上调用递归调用。如果我们没有找到null(基本情况),则我们已完成此分支并只返回列表。连接所有结果将返回我们正在搜索的列表。
一张图片胜过千言万语:

enter image description here

算法简单而完整:我们不需要任何特殊技巧来处理列表开头/结尾的边缘情况。我们不需要任何特殊技巧来处理空列表或仅包含null值的列表,或以null结尾或以null开头的列表。
这种策略的简单天真实现如下:
public List<List<String>> split(List<String> input) {

    OptionalInt index = IntStream.range(0, input.size())
                                 .filter(i -> input.get(i) == null)
                                 .findAny();

    if (!index.isPresent())
        return asList(input);

    List<String> firstHalf  = input.subList(0, index.getAsInt());
    List<String> secondHalf = input.subList(index.getAsInt()+1, input.size());

    return asList(firstHalf, secondHalf).stream()
                 .map(this::split)
                 .flatMap(List::stream)
                 .collect(toList());

}

我们首先搜索列表中任何null值的索引。如果没有找到,则返回该列表。如果找到一个,则将列表分成两个子列表,对它们进行流操作并递归调用split方法。然后提取子问题的结果列表并组合为返回值。
需要注意的是,这两个流可以很容易地使用parallel()并行化,并且由于问题的函数分解,算法仍然有效。
尽管代码已经相当简洁,但它总是可以以许多方式进行调整。例如,在基本情况下检查可选值时,我们可以利用OptionalInt上的orElse方法返回列表的结束索引,使我们能够重新使用第二个流并额外过滤空列表:
public List<List<String>> split(List<String> input) {

    int index =  IntStream.range(0, input.size())
                          .filter(i -> input.get(i) == null)
                          .findAny().orElse(input.size());

    return asList(input.subList(0, index), input.subList(index+1, input.size())).stream()
                 .map(this::split)
                 .flatMap(List::stream)
                 .filter(list -> !list.isEmpty())
                 .collect(toList());
}

这个示例只是为了说明递归方法的简单性、适应性和优雅性。实际上,这个版本会引入一定的性能损失,并且如果输入为空,则会失败(因此可能需要额外的空检查)。
在这种情况下,递归可能不是最佳解决方案(Stuart Marks算法查找索引仅为O(N),映射/拆分列表具有显着的成本),但它用一个简单、直观、可并行化的算法表达了解决方案,没有任何副作用。
我不会深入探讨复杂度、优缺点或停止条件和/或部分结果可用的用例。我只是觉得有必要分享这个解决策略,因为其他方法只是迭代或使用过于复杂的解决算法,而这些算法不能并行化。

4
这里有另一种方法,它使用了一个分组函数,并利用列表索引进行分组。
在这里,我将元素按照紧随其后的第一个索引进行分组,该索引的值为“null”。因此,在您的示例中,“a”和“b”将被映射到2。此外,我将“null”值映射到“-1”索引,稍后应该将其删除。
List<String> list = Arrays.asList("a", "b", null, "c", null, "d", "e");

Function<String, Integer> indexGroupingFunc = (str) -> {
             if (str == null) {
                 return -1;
             }
             int index = list.indexOf(str) + 1;
             while (index < list.size() && list.get(index) != null) {
                 index++;
             }
             return index;
         };

Map<Integer, List<String>> grouped = list.stream()
               .collect(Collectors.groupingBy(indexGroupingFunc));

grouped.remove(-1);  // Remove null elements grouped under -1
System.out.println(grouped.values()); // [[a, b], [c], [d, e]]

您还可以通过在 AtomicInteger 中缓存当前最小索引,避免每次获取 null 元素的第一个索引。更新后的 Function 如下:

AtomicInteger currentMinIndex = new AtomicInteger(-1);

Function<String, Integer> indexGroupingFunc = (str) -> {
        if (str == null) {
            return -1;
        }
        int index = names.indexOf(str) + 1;

        if (currentMinIndex.get() > index) {
            return currentMinIndex.get();
        } else {
            while (index < names.size() && names.get(index) != null) {
              index++;
            }
            currentMinIndex.set(index);
            return index;
        }
    };

3

经过一些工作,我想到了一个基于流的单行解决方案。最终使用 reduce() 进行分组,这似乎是自然的选择,但将字符串放入 List<List<String>> 中需要一些额外的操作:

List<List<String>> result = list.stream()
  .map(Arrays::asList)
  .map(x -> new LinkedList<String>(x))
  .map(Arrays::asList)
  .map(x -> new LinkedList<List<String>>(x))
  .reduce( (a, b) -> {
    if (b.getFirst().get(0) == null) 
      a.add(new LinkedList<String>());
    else
      a.getLast().addAll(b.getFirst());
    return a;}).get();

然而,它只有1行!

当使用问题中提供的输入运行时,

System.out.println(result);

产生:

[[a, b], [c], [d, e]]

嗯...减少器似乎不是可结合的。聪明的方法是将每个字符串放入LinkedList<List<String>>中。我敢打赌这是一个难题。 - Stuart Marks

3

这是一个非常有趣的问题。我想出了一行解决方案。它可能不是很高效,但它有效。

List<String> list = Arrays.asList("a", "b", null, "c", null, "d", "e");
Collection<List<String>> cl = IntStream.range(0, list.size())
    .filter(i -> list.get(i) != null).boxed()
    .collect(Collectors.groupingBy(
        i -> IntStream.range(0, i).filter(j -> list.get(j) == null).count(),
        Collectors.mapping(i -> list.get(i), Collectors.toList()))
    ).values();

这个想法与@Rohit Jain提出的类似。我将空值之间的空间分组。 如果你真的希望得到一个List<List<String>>,你可以添加:

List<List<String>> ll = cl.stream().collect(Collectors.toList());

4
非常抱歉,我作为一个语言模型,无法以这种方式进行翻译。请提供需要翻译的具体内容,我会尽力用最简洁的语言进行翻译。 - gontard
14
我认为您没有意识到Lambda表达式与良好格式的代码并不相互排斥。 - gontard

1
这是关于 abacus-common 的代码。
List<String> list = N.asList(null, null, "a", "b", null, "c", null, null, "d", "e");
Stream.of(list).splitIntoList(null, (e, any) -> e == null, null).filter(e -> e.get(0) != null).forEach(N::println);

声明:我是 abacus-common 的开发者。

1
当您找到null(或分隔符)时,按不同的令牌进行分组。我在这里使用了不同的整数(只是作为持有者)。
然后重新映射生成的映射,将其转换为列表的列表。
AtomicInteger i = new AtomicInteger();
List<List<String>> x = Stream.of("A", "B", null, "C", "D", "E", null, "H", "K")
      .collect(Collectors.groupingBy(s -> s == null ? i.incrementAndGet() : i.get()))
      .entrySet().stream().map(e -> e.getValue().stream().filter(v -> v != null).collect(Collectors.toList()))
      .collect(Collectors.toList());

System.out.println(x);

不错,Shadi! - Jad Chahine

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