词频统计看起来是个简单问题:把一段字符串按空格切分,然后逐个累加计数就行了。可一旦要求输出顺序必须与单词在原文中首次出现的顺序一致,不少人写出来的结果就乱了套。根本原因在于 HashMap 本身不保证任何遍历顺序,元素在内部是按哈希值散列存放的,遍历时得到的顺序既不是插入顺序也不是字母顺序。要解决这个痛点,核心思路是把存储容器换成能记住插入顺序的结构,或者在遍历输出前额外排序。下面从原理、实现到进阶优化,把这个需求完整拆解一遍。

一、为什么 HashMap 会打乱单词的首次出现顺序
先看一段典型的问题代码,很多教程都会这么写:
Map<String, Integer> map = new HashMap<>();
for (String word : text.split(" ")) {
map.put(word, map.getOrDefault(word, 0) + 1);
}
System.out.println(map);假设输入是 the Quick brown fox the lazy dog quick,这段代码输出的顺序很可能是 brown=1, quick=2, the=2, Quick=1... 之类的乱序结果。这不是 bug,而是 HashMap 的设计决定的:它为了追求查询效率,用哈希函数把 key 分散到不同的桶里,桶的分布与插入时间毫无关系。
更关键的一点是题目要求大小写敏感。The 和 the 是两个不同的单词,Quick 和 quick 也是。只要不去调用 toLowerCase() 做归一化,Map 的 key 就会自然把它们区分开,这个要求本身就降低了实现难度,但也意味着首次出现顺序的判定要以原始大小写为准。the 先出现,那么输出时 the 就排在前面,即使后面来了个 The,它作为独立 key 也按它自己的首次出现位置排。
二、用 LinkedHashMap 按首次出现顺序统计
LinkedHashMap 是解决顺序问题的首选。它在 HashMap 的基础上维护了一条双向链表,记录每个条目的插入先后。对一个已经存在的 key 重复 put,只会更新 value,不会改变它在这条链表上的位置,这正好符合按首次出现排序的语义。
import java.util.LinkedHashMap;
import java.util.Map;
public class WordFrequency {
public static Map<String, Integer> countWords(String text) {
Map<String, Integer> freq = new LinkedHashMap<>();
if (text == null || text.isEmpty()) {
return freq;
}
// \\s+ 匹配一个或多个空白字符(空格、制表符等)
String[] words = text.trim().split("\\s+");
for (String word : words) {
Integer old = freq.get(word);
freq.put(word, old == null ? 1 : old + 1);
}
return freq;
}
public static void main(String[] args) {
String text = "The quick brown fox jumps over the lazy dog The end";
Map<String, Integer> result = countWords(text);
result.forEach((k, v) -> System.out.println(k + " 出现了 " + v + " 次"));
}
}运行上面的代码,输出顺序是 The、quick、brown……与原文首次出现顺序完全一致,其中 The 计 2 次,the 计 1 次,大小写敏感的要求得到满足。注意这里用的是 \\s+ 而不是单个空格来分割,能同时处理连续多个空格和首尾空白,比 split(" ") 稳健得多。
需要说明的是,LinkedHashMap 维护链表会带来一点点额外的内存和插入开销,但在词频统计这种场景下完全可以忽略。它的遍历性能和 HashMap 基本持平,属于无脑可选的方案。
三、用 merge 或 compute 让计数更简洁
JDK 8 之后,Map 接口提供了 merge 和 compute 两个默认方法,可以把取旧值、判空、加一的逻辑压缩成一行,代码可读性明显更好。
// 写法一:merge,key 不存在时放入初始值 1,存在时执行合并逻辑
for (String word : words) {
freq.merge(word, 1, Integer::sum);
}
// 写法二:compute,等价但语义略有不同
for (String word : words) {
freq.compute(word, (k, v) -> v == null ? 1 : v + 1);
}merge 的三个参数分别是 key、初始值和合并函数。当 key 第一次出现时直接存入初始值 1;再次遇到时,把旧值和初始值交给 Integer::sum 相加后写回。这种写法省去了显式的 null 判断,也避免了自动装箱拆箱过程中可能踩到的空指针坑。
如果追求极致简洁,还可以配合 Stream 的 Collectors.groupingBy,但要注意一个细节:直接用 groupingBy 默认返回 HashMap,顺序同样会乱。必须传入 LinkedHashMap::new 作为工厂才能保留首次出现顺序,这也是实际编码中最容易犯的错误之一:
import java.util.stream.Collectors;
import java.util.LinkedHashMap;
Map<String, Long> freq = text.trim().split("\\s+").length == 0
? new LinkedHashMap<>()
: java.util.Arrays.stream(text.trim().split("\\s+"))
.collect(Collectors.groupingBy(
w -> w,
LinkedHashMap::new,
Collectors.counting()));四、边界情况与注意事项
实际使用中还有几个容易忽略的点。第一,标点符号的处理。如果文本里可能出现逗号、句号,fox, 和 fox 会被当成两个单词。可以在分割前用 replaceAll("[^a-zA-Z0-9\\s]", " ") 把非字母数字字符替换成空格,不过要清楚这属于清洗策略,是否执行取决于你的业务定义。
第二,空字符串和 null 输入。代码开头对 text == null || text.isEmpty() 的判断是必要的,否则 "".split("\\s+") 会返回一个包含空字符串的数组,导致结果里混入一个计数为 1 的空单词。第三,如果统计的文本非常大,还可以考虑用 StringTokenizer 或手写逐字符扫描来减少临时数组开销,但对绝大多数场景,split 加 LinkedHashMap 已经足够高效。
总结一下:按首次出现顺序做大小写敏感的词频统计,核心就是两步,用 \\s+ 正则可靠地分割单词,用 LinkedHashMap 配合 merge 完成有序计数。理解了 HashMap 无序的本质和 LinkedHashMap 插入有序的机制,类似的顺序保留需求就都能举一反三地解决了。
Java词频统计LinkedHashMap大小写敏感修改时间:2026-09-08 10:15:04