CodeGym /课程 /JAVA 25 SELF /高级聚合:嵌套分组

高级聚合:嵌套分组

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

1. 嵌套 groupingBy:语法与工作原理

在实际业务中,很少只按一个维度分组就足够了。例如,若你有一份公司员工列表,通常不止想知道各部门有多少人,还想知道各部门中各个职位各有多少人。再比如学生库——想知道每个年级下各专业有多少学生。

嵌套分组可以构建这种“图中之图”的层级结构——例如“部门 → 职位 → 员工列表”,或“年级 → 专业 → 学生列表”。

没有 Stream API,这类任务通常要写多层嵌套循环并手动在 Map 里套 Map。借助流与收集器,往往一两行就能完成。

所谓嵌套分组,是指把另一个收集器(例如再来一个 groupingBy)作为方法 Collectors.groupingBy 的第二个参数。最终得到的 Map 中,每个键对应的值还是一张 Map。

通用模板

Map<Key1, Map<Klyuch2, List<T>>> result = 
    stream.collect(Collectors.groupingBy(
        object -> klyuch1,
        Collectors.groupingBy(object -> klyuch2)
    ));

示例:按部门和职位分组员工

假设我们有这样一个类:

class Employee {
    private String name;
    private String department;
    private String position;
    private int salary;
    // ... 构造器、getter、toString
}

员工列表:

List<Employee> employees = List.of(
    new Employee("Ivan", "IT", "Developer", 120_000),
    new Employee("Mariya", "IT", "Tester", 90_000),
    new Employee("Pyotr", "HR", "Manager", 80_000),
    new Employee("Ol’ga", "IT", "Developer", 130_000),
    new Employee("Svetlana", "HR", "Recruiter", 70_000)
);

按部门与职位进行分组:

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

我们得到了什么?
一张 Map:键为部门,值为另一张 Map(其键为职位,值为该职位的员工列表)。

结构可视化

IT:
  Developer: [Ivan, Ol’ga]
  Tester: [Mariya]
HR:
  Manager: [Pyotr]
  Recruiter: [Svetlana]

2. 分组并聚合:组合 groupingBy 与聚合器

嵌套分组并不只是在每个组里收集列表!你也可以在每个子组内直接做聚合。

示例:按部门求最高工资

Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
    ));

这里我们为每个部门得到工资最高的员工(结果包在 Optional 中,因为某个部门可能为空)。

嵌套分组 + 聚合

例如,想要知道每个部门中各职位的最高工资员工:

Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
        )
    ));

这意味着什么?

  • 对于每个部门——一张职位的 Map。
  • 对于每个职位——工资最高的员工(或一个空的 Optional,如果没有人)。

3. 分组并转换:mappinggroupingBy

有时我们不仅要分组,还只想保留组内的某些字段。

示例:按部门收集员工姓名

Map<String, List<String>> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.mapping(Employee::getName, Collectors.toList())
    ));

结果:

IT: [Ivan, Mariya, Ol’ga]
HR: [Pyotr, Svetlana]

嵌套的 mapping

可以把 mapping 与嵌套的 groupingBy 组合起来:

Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.mapping(Employee::getName, Collectors.toList())
        )
    ));

结果:

IT:
  Developer: [Ivan, Ol’ga]
  Tester: [Mariya]
HR:
  Manager: [Pyotr]
  Recruiter: [Svetlana]

4. 分组 + 数值聚合

很多时候不仅要分组,还要在组内计算总和、平均值或数量。

示例:按部门计算平均工资

Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.averagingInt(Employee::getSalary)
    ));

嵌套聚合

每个部门中,各职位的平均工资:

Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.averagingInt(Employee::getSalary)
        )
    ));

5. partitioningBy + 聚合

有时按布尔条件把集合分成两组更方便,并在组内继续聚合。

示例:每个部门中工资高于 100_000 的员工数量

Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.partitioningBy(
            e -> e.getSalary() > 100_000,
            Collectors.counting()
        )
    ));

结果:
对每个部门得到一张地图:true/false → 员工数量。

6. 实践练习:应用嵌套分组

练习 1:按年级与专业分组学生

class Student {
    private String name;
    private int course;
    private String speciality;
    private double grade;
    // ... getter、构造器
}

List<Student> students = ... // 假设已经存在

Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(Student::getSpeciality)
    ));

练习 2:按年级计算平均分

Map<Integer, Double> avgGradeByCourse = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.averagingDouble(Student::getGrade)
    ));

练习 3:只保留分组后的姓名

Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(
            Student::getSpeciality,
            Collectors.mapping(Student::getName, Collectors.toList())
        )
    ));

7. 一些实用细节

如何读取并从嵌套 Map 中取数

刚开始操作嵌套 Map 可能不太习惯。下面是一个基础示例:

for (var deptEntry : grouped.entrySet()) {
    String dept = deptEntry.getKey();
    Map<String, List<Employee>> byPosition = deptEntry.getValue();
    System.out.println("部门:" + dept);
    for (var posEntry : byPosition.entrySet()) {
        String pos = posEntry.getKey();
        List<Employee> emps = posEntry.getValue();
        System.out.println("  职位:" + pos + " -> " + emps);
    }
}

嵌套分组示意图

Map<Department, Map<Position, List<Employee>>>
      │
      ├── "IT"
      │      ├── "Developer" → [Ivan, Ol’ga]
      │      └── "Tester"    → [Mariya]
      └── "HR"
             ├── "Manager"   → [Pyotr]
             └── "Recruiter" → [Svetlana]

表格:不同组合的产出类型

收集器 结果
groupingBy(Employee::getDepartment)
Map<String, List<Employee>>
groupingBy(Employee::getDepartment, averagingInt(...))
Map<String, Double>
groupingBy(Employee::getDepartment, groupingBy(...))
Map<String, Map<String, List<Employee>>>
groupingBy(..., mapping(..., toList()))
Map<..., List<...>>
groupingBy(..., groupingBy(..., mapping(..., toList())))
Map<..., Map<..., List<...>>>

8. 使用嵌套分组时的常见错误

错误 1:误解嵌套 Map 的结构。
做完嵌套分组后,很容易搞不清每层 Map 的值到底是什么。务必查看结果类型的签名——IDE 会提示类型。如果不确定,可以用 System.out.println(grouped) 打印出来,或者使用调试器。

错误 2:取数时出现 NullPointerException。
当键不存在(例如某部门没有某个职位的员工)时,Map.get(key) 会返回 null。请先用 containsKey 检查,或者从 Java 8 起可以使用 Map.getOrDefault,从 Java 9 起——Map.ofNullable 以及 Optional 的相关方法。

错误 3:嵌套层级过深。
如果分组层级过深(3–4 层),也许应重新审视数据结构,或把任务拆分成更小的步骤。

错误 4:把聚合器放在了错误的层级。
有时会把聚合收集器(averagingIntcounting)放在了不该放的 groupingBy 外层,导致结果出乎意料。一定要留意括号与层级!

错误 5:在 collect 过程中修改元素。
不要在分组过程中修改原集合或对象——这可能导致难以察觉的 Bug。

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