CodeGym /课程 /JAVA 25 SELF /groupingBy 和 partitioningBy 方法(Collectors)

groupingBy 和 partitioningBy 方法(Collectors)

JAVA 25 SELF
第 31 级 , 课程 2
可用

1. 引言

在现实开发中几乎总要对数据进行分组。例如,把学生按年级划分,把商品按类别划分,把成年人与儿童区分开,等等。

在没有 Stream API 的时代,这类任务多靠手写:遍历集合,检查特征,把元素添加到 Map 中对应的列表。这是一段典型的“老派”代码:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
    String dept = employee.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}

能用,但就像你在手工把意面分类装罐。如果需要按多个特征分组?或者还要再按组统计汇总?代码会迅速膨胀并变得难以阅读。

Stream API 和专用收集器(groupingBypartitioningBy)能让你一两行就搞定——看起来像个真正的 Java 魔法师。

2. 收集器 groupingBy:按特征分组

核心思想

groupingBy 是一种收集器,它把元素流转换成 Map:键是特征函数的结果,值是符合该特征的元素列表。

方法签名:

Collectors.groupingBy(Function<T, K>)
  • T — 流中元素的类型,
  • K — 特征函数返回的键(分组)类型。

简单示例:按部门分组员工

假设我们有一个类:

public class Employee {
    private final String name;
    private final String department;
    // ... 构造函数和 getter 方法
    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }
    public String getName() { return name; }
    public String getDepartment() { return department; }
}

以及员工集合:

List<Employee> employees = List.of(
    new Employee("Alisa", "IT"),
    new Employee("Bob", "HR"),
    new Employee("Klara", "IT"),
    new Employee("Denis", "Finance"),
    new Employee("Eva", "HR")
);

按部门分组:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

得到了什么?

  • 键:部门名称(String)。
  • 值:该部门的员工列表(List<Employee>)。

打印结果:

byDepartment.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::getName).toList());
});

输出:

IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]

底层如何工作?

首先为流中的每个元素计算键(例如 getDepartment())。如果该键已存在于 Map 中,就把元素添加到对应的列表;如果不存在——就创建一个新列表。

类比

想象你在把信件按文件夹分类:对每封信检查是否已有目标名称的文件夹,如果没有——新建一个然后把这封信放进去。

3. 收集器 partitioningBy:拆分成两组

有时你并不需要“按值分组”,而只是要按某个逻辑特征把集合一分为二(true/false)。例如,按工资高于或低于某个阈值拆分员工,或把学生分成“通过/未通过”。

为此有一个特殊的收集器——partitioningBy

方法签名:

Collectors.partitioningBy(Predicate<T>)

Predicate —— 返回布尔值的函数。

示例:按工资水平拆分员工

假设有如下定义:

public class Employee {
    private final String name;
    private final int salary;
    // ... 构造函数和 getter 方法
    public Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String getName() { return name; }
    public int getSalary() { return salary; }
}

以及列表:

List<Employee> employees = List.of(
    new Employee("Alisa", 120_000),
    new Employee("Bob", 80_000),
    new Employee("Klara", 150_000),
    new Employee("Denis", 95_000)
);

拆成“高薪”和“普通”两组:

Map<Boolean, List<Employee>> partitioned = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
  • true —— 工资大于 100_000 的员工。
  • false —— 其他员工。

打印:

System.out.println("高薪: " +
    partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("普通: " +
    partitioned.get(false).stream().map(Employee::getName).toList());

结果:

高薪: [Alisa, Klara]
普通: [Bob, Denis]

何时用 partitioningBy,何时用 groupingBy

  • 如果分组数量多于两组——使用 groupingBy
  • 如果只是基于布尔特征的两组——使用 partitioningBy:更快也更清晰。

4. 嵌套分组:按多个特征分组

有时希望不仅按单个特征分组,还要把一个分组“嵌入”到另一个分组中。例如,先按部门对员工分组,在每个部门内再按职级分组。

示例:

假设我们的 Employee 类还有一个字段 position

public class Employee {
    private final String name;
    private final String department;
    private final String position;
    // ... 构造函数和 getter 方法
}

嵌套分组:

Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)));
  • 外层键——部门。
  • 内层键——职位。
  • 值——员工列表。

如何获取 IT 部门中职位为“Developer”的员工?

List<Employee> itDevs = byDeptAndPosition
    .getOrDefault("IT", Map.of())
    .getOrDefault("Developer", List.of());

可视化示意

Map<Department, Map<Position, List<Employee>>>
└─ "IT"
    ├─ "Developer" -> [Alisa, Klara]
    └─ "QA"        -> [Boris]
└─ "HR"
    └─ "Recruiter" -> [Denis]

5. 分组的实践示例

示例 1:按字符串长度分组

List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");

Map<Integer, List<String>> byLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));

输出:

3: [cat, dog, bee, ant]
8: [elephant, dolphin]

示例 2:按奇偶对数字分组

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

Map<String, List<Integer>> byParity = numbers.stream()
    .collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));

System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}

示例 3:对字符串使用 partitioningBy

将字符串分成以 "A" 开头的与其余:

List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");

Map<Boolean, List<String>> byA = names.stream()
    .collect(Collectors.partitioningBy(s -> s.startsWith("A")));

System.out.println("A-names: " + byA.get(true));  // [Alice, Anna]
System.out.println("Other: " + byA.get(false));   // [Bob, Charlie]

5. 实用细节

如何使用分组结果

很多时候分组后并不只是要一个 Map,而是还要进一步处理:

  • 遍历所有分组并输出信息。
  • 找出元素数量最多的分组。
  • 对每个分组计算统计值,例如总和或平均数——这会在下一讲详解。

示例:输出每个部门的员工数量

byDepartment.forEach((dept, emps) ->
    System.out.println(dept + ": " + emps.size() + " 名员工"));

与手写实现的对比

加深印象:按部门分组的“老办法”大概是这样:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
    String dept = e.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}

而用 Stream API

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

结论:代码更少、错误更少、可读性更高。

小技巧

如果希望分组结果不是 List,而是 Set —— 可作为第二个参数传入 Collectors.toSet()

Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())

也可以直接聚合:例如得到每个部门的员工数量:

Collectors.groupingBy(Employee::getDepartment, Collectors.counting())

但这些会在下一讲详细展开!

使用 partitioningBy 后总会有两个键:truefalse。即便其中一组为空也一样。

进行嵌套分组后,结构会变成一棵“树”:Map 里面套 Map,以此类推。

6. 分组与 partitioning 的常见错误

错误 # 1:结果类型错误。 新手常以为 groupingBy 的结果就是 List<T>,而不是 Map<K, List<T>>。于是去调用列表的方法,结果编译报错。请记住:分组的结果总是 Map

错误 # 2:访问不存在的分组时出现 NullPointerException。 如果你按不存在的键去取列表——会得到 null。请使用 getOrDefault(key, List.of()),或者先通过 containsKey 检查键是否存在。

错误 # 3:用 partitioningBy 处理多分组问题。 partitioningBy 只适用于两组(true/false)。如果分组超过两类——请使用 groupingBy

错误 # 4:在流中修改集合。 不要尝试在流中修改原始集合或 Map —— 这会导致各种意料之外的问题。请把所有处理逻辑交给 Stream API 与收集器完成。

错误 # 5:忽视嵌套分组的结构。 进行嵌套的 groupingBy 后,结果是 Map 中嵌套 Map。取值时别忘了正确地按层级访问(例如使用 getOrDefault),否则可能得到 ClassCastException

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION