Stream.flatMap 提取 N-Gram 的核心在于将“句子→多个N元组”的一对多映射自然嵌入流式链:先分词再滑动窗口,配合预处理(小写、清洗、可选停用词过滤),最后统计频次并取高频Top-K;中文需外接分词器替代空格切分。

用 Stream.flatMap 提取 N-Gram,关键不在“扁平化”本身,而在于它如何把“一句话 → 多个N元组”这个一对多映射自然地嵌入流式处理链中。它让原本需要嵌套循环的滑动窗口逻辑,变成可读、可组合、可并行的声明式操作。
把句子流拆成词序列,再滑动切出N-Gram
原始输入通常是 Stream<string></string>(每行一句),但 N-Gram 需要先分词、再按窗口滑动。flatMap 正好衔接这两步:
- 对每句调用
split("\s+")得到词数组,再用Arrays.stream()转为子流 —— 这是 map 阶段 - flatMap 自动合并所有子流,输出一个扁平的
Stream<string></string>(所有单词) - 但这还不够:N-Gram 是相邻词的组合,不是单个词。所以真正起作用的是“先转成单词流,再用自定义 collector 或后续 flatMap 构建窗口”
更实用的做法是:在分词后,不直接 flatMap 到单个词,而是 flatMap 到该句能生成的所有 N-Gram 字符串:
.flatMap(line -> {
String[] words = line.toLowerCase().split("\s+");
if (words.length < n) return Stream.empty();
return IntStream.range(0, words.length - n + 1)
.mapToObj(i -> String.join(" ", Arrays.copyOfRange(words, i, i + n)));
})过滤与归一化必须前置,否则影响统计质量
N-Gram 的价值高度依赖预处理质量。这些操作不能放在 flatMap 之后“补救”,而应在 flatMap 内部或紧邻前完成:
- 统一小写:
line.toLowerCase()避免 "The" 和 "the" 被算作不同 ngram - 基础清洗:用正则替换掉多余标点,如
line.replaceAll("[^\w\s]", " "),再 trim 和 split - 停用词剔除(可选):若在生成前过滤掉 "the"/"is"/"of" 等,需先构建词数组,再移除非目标词,再滑动 —— 这会改变窗口边界,要小心索引
统计频次 + 提取高频N-Gram只需三步链式调用
拿到 flatMap 输出的 Stream<string></string>(每个元素是一个 N-Gram 字符串)后,统计就变得标准且高效:
- 用
Collectors.groupingBy(Function.identity(), Collectors.counting())直接得到Map<String, Long> - 转为流后过滤低频项:
.filter(entry -> entry.getValue() >= minFreq) - 按频次降序取 Top-K:
.sorted(Map.Entry.<string long>comparingByValue().reversed()).limit(k)</string>
整个流程无需中间集合,内存友好,适合处理 GB 级文本行数据。
中文场景需额外注意分词环节
上面例子默认空格分词,对英文有效;中文必须先分词,否则 “自然语言处理” 会被切成单字或按字节切,失去语义。这时 flatMap 的作用是桥接外部分词器:
- 引入轻量分词库(如 HanLP、IKAnalyzer),封装为
Function<String, List<String>> tokenizer - flatMap 中调用:
.flatMap(line -> tokenizer.apply(line).stream()),得到词流 - 再用同上的滑动窗口逻辑生成中文 Bigram/Trigram,例如 ["自然", "语言"] → "自然 语言"
不依赖空格,也不硬切字,才能保证 N-Gram 具备真实语言学意义。


















