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]
表格:不同組合會得到什麼結果
| 收集器 | 結果 |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. 巢狀分組常見錯誤
錯誤 №1:對巢狀 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:在錯誤的層級做彙總。
有時會把彙總型收集器(averagingInt、counting)放在錯誤的 groupingBy 外層,導致結果不如預期。請務必留意括號對應位置!
錯誤 №5:在 collect 過程中嘗試修改元素。
不要在分組過程中修改原始集合或物件——這可能導致很難追蹤的錯誤。
GO TO FULL VERSION