CodeGym /課程 /JAVA 25 SELF /進階彙總:巢狀分組

進階彙總:巢狀分組

JAVA 25 SELF
等級 31 , 課堂 3
開放

1. 巢狀 groupingBy:語法與運作原理

在實務中,很少只按照單一維度分組就足夠。比如有一份公司員工名單,我們通常不僅想知道每個部門的人數,還想知道每個部門各職位的人數。又或者在學生資料庫中——我們想知道各學年在各科系的人數。

巢狀分組能構建這種「地圖中的地圖」——如「部門 → 職位 → 員工清單」或「年級 → 科系 → 學生清單」。

沒有 Stream API 的話,這類任務通常要用多層巢狀迴圈,手動在一個 Map 裡面再放另一個 Map。使用串流與收集器,往往一兩行就能完成。

所謂巢狀分組,就是把另一個收集器(例如再來一個 groupingBy)作為 Collectors.groupingBy 的第二個參數。結果會得到一張地圖,其中每個鍵所對應的值又是另一張地圖。

通用樣板

Map<Klyuch1, Map<Klyuch2, List<T>>> result = 
    stream.collect(Collectors.groupingBy(
        obekt -> klyuch1,
        Collectors.groupingBy(obekt -> 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", "開發人員", 120_000),
    new Employee("Mariya", "IT", "測試人員", 90_000),
    new Employee("Pyotr", "HR", "經理", 80_000),
    new Employee("Ol’ga", "IT", "開發人員", 130_000),
    new Employee("Svetlana", "HR", "招募專員", 70_000)
);

按部門與職位分組:

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

我們得到了什麼?
一張地圖:鍵是部門;值是另一張地圖(鍵是職位,值是員工清單)。

結構視覺化

IT:
  開發人員: [Ivan, Ol’ga]
  測試人員: [Mariya]
HR:
  經理: [Pyotr]
  招募專員: [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))
        )
    ));

這代表什麼?

  • 每個部門對應一張職位對應表。
  • 每個職位對應「薪資最高的員工」(若沒有人則為空的 Optional)。

3. 分組並轉換:mapping 內嵌於 groupingBy

有時我們不只想把物件分組,而是只要群組內某個欄位。

範例:各部門的員工姓名

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:
  開發人員: [Ivan, Ol’ga]
  測試人員: [Mariya]
HR:
  經理: [Pyotr]
  招募專員: [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 中的資料

一開始使用巢狀地圖可能不太習慣。以下是基本範例:

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<部門, Map<職位, List<Employee>>>
      │
      ├── "IT"
      │      ├── "開發人員" → [Ivan, Ol’ga]
      │      └── "測試人員" → [Mariya]
      └── "HR"
             ├── "經理"   → [Pyotr]
             └── "招募專員"   → [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 結構的誤解。
在巢狀分組後,很容易搞不清每一層的值到底是什麼。一定要看結果型別的簽名——IDE 會提示型別。如果不確定,請用 System.out.println(grouped) 輸出看看,或使用偵錯工具。

錯誤 №2:取值時發生 NullPointerException。
若鍵不存在(例如該部門沒有某個職位),呼叫 Map.get(key) 會回傳 null。請用 containsKey 檢查是否存在,或從 Java 8 起可用 Map.getOrDefault,而從 Java 9 起——可用 Map.ofNullableOptional 的相關方法。

錯誤 №3:巢狀層級過深。
如果分組層級太深(3–4 層),也許應該重新思考資料結構,或把任務拆成較小的步驟。

錯誤 №4:在錯誤的層級做彙總。
有時會把彙總型收集器(averagingIntcounting)放在錯誤的 groupingBy 外層,導致結果不如預期。請務必留意括號對應位置!

錯誤 №5:在 collect 過程中嘗試修改元素。
不要在分組過程中修改原始集合或物件——這可能導致很難追蹤的錯誤。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION