1. groupingBy lồng nhau: cú pháp và nguyên lý hoạt động
Trong thực tế, hiếm khi chỉ cần nhóm dữ liệu theo một tiêu chí. Ví dụ, nếu bạn có danh sách nhân viên của công ty, bạn thường muốn biết không chỉ số lượng người trong mỗi phòng ban, mà còn là số lượng theo từng chức vụ trong mỗi phòng ban. Hoặc nếu bạn có cơ sở dữ liệu sinh viên — có bao nhiêu sinh viên ở mỗi khóa theo từng chuyên ngành.
Nhóm lồng nhau cho phép xây dựng những “bản đồ trong bản đồ” — cấu trúc “phòng ban → chức vụ → danh sách nhân viên”, hoặc “khóa → chuyên ngành → danh sách sinh viên”.
Không có Stream API, các tác vụ như vậy sẽ phải giải bằng nhiều vòng lặp lồng nhau và tự tay xây dựng Map bên trong Map. Với streams và collectors, việc này chỉ trong một hai dòng.
Nhóm lồng nhau là khi đối số thứ hai của phương thức Collectors.groupingBy là một collector khác, ví dụ lại là một groupingBy. Kết quả là một bản đồ mà giá trị cho mỗi khóa là một bản đồ khác.
Mẫu chung
Map<Klyuch1, Map<Klyuch2, List<T>>> result =
stream.collect(Collectors.groupingBy(
obekt -> klyuch1,
Collectors.groupingBy(obekt -> klyuch2)
));
Ví dụ: nhân viên theo phòng ban và chức vụ
Giả sử chúng ta có lớp:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... constructor, getter, toString
}
Danh sách nhân viên:
List<Employee> employees = List.of(
new Employee("Ivan", "IT", "Lập trình viên", 120_000),
new Employee("Mariya", "IT", "Kỹ sư kiểm thử", 90_000),
new Employee("Pyotr", "HR", "Quản lý", 80_000),
new Employee("Ol’ga", "IT", "Lập trình viên", 130_000),
new Employee("Svetlana", "HR", "Chuyên viên tuyển dụng", 70_000)
);
Nhóm theo phòng ban và chức vụ:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
Chúng ta nhận được gì?
Một Map mà khóa là phòng ban, giá trị là một Map khác (khóa là chức vụ, giá trị là danh sách nhân viên).
Trực quan hóa cấu trúc
IT:
Lập trình viên: [Ivan, Ol’ga]
Kỹ sư kiểm thử: [Mariya]
HR:
Quản lý: [Pyotr]
Chuyên viên tuyển dụng: [Svetlana]
2. Nhóm với gộp: kết hợp groupingBy và các bộ gộp
Nhóm lồng nhau không chỉ dừng ở việc thu thập danh sách! Bạn có thể gộp dữ liệu ngay trong từng nhóm con.
Ví dụ: lương tối đa theo phòng ban
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
Ở đây với mỗi phòng ban chúng ta nhận được nhân viên có mức lương cao nhất (kết quả được bọc trong Optional, vì có thể có phòng ban rỗng).
Nhóm lồng nhau + gộp
Giả sử muốn biết lương tối đa theo từng chức vụ trong mỗi phòng ban:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
Điều đó có nghĩa là gì?
- Với mỗi phòng ban — một Map các chức vụ.
- Với mỗi chức vụ — nhân viên có lương cao nhất (hoặc Optional rỗng nếu không có ai).
3. Nhóm với biến đổi: mapping bên trong groupingBy
Đôi khi không chỉ nhóm đối tượng, mà bạn cần chỉ lấy các trường nhất định trong mỗi nhóm.
Ví dụ: tên nhân viên theo phòng ban
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
Kết quả:
IT: [Ivan, Mariya, Ol’ga]
HR: [Pyotr, Svetlana]
mapping lồng nhau
Có thể kết hợp mapping với groupingBy lồng nhau:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
Kết quả:
IT:
Lập trình viên: [Ivan, Ol’ga]
Kỹ sư kiểm thử: [Mariya]
HR:
Quản lý: [Pyotr]
Chuyên viên tuyển dụng: [Svetlana]
4. Nhóm + gộp dữ liệu số
Thường cần không chỉ nhóm mà còn tính tổng, trung bình hoặc số lượng theo nhóm.
Ví dụ: lương trung bình theo phòng ban
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
Gộp lồng nhau
Lương trung bình theo chức vụ trong từng phòng ban:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + gộp
Đôi khi tiện lợi khi chia bộ sưu tập thành hai nhóm theo một tiêu chí boolean, và bên trong — cũng gộp.
Ví dụ: có bao nhiêu nhân viên với lương cao hơn 100_000 trong mỗi phòng ban
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
Kết quả:
Với mỗi phòng ban — một Map: true/false → số lượng nhân viên.
6. Bài tập thực hành: áp dụng nhóm lồng nhau
Bài 1. Sinh viên theo khóa học và chuyên ngành
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... getter, constructor
}
List<Student> students = ... // giả sử đã có
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
Bài 2. Điểm trung bình theo khóa
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
Bài 3. Chỉ tên theo nhóm
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. Những lưu ý hữu ích
Cách đọc và trích xuất dữ liệu từ Map lồng nhau
Làm việc với các Map lồng nhau lúc đầu có thể hơi lạ. Đây là ví dụ cơ bản:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("Phòng ban: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" Chức vụ: " + pos + " -> " + emps);
}
}
Sơ đồ nhóm lồng nhau
Map<Phòng ban, Map<Chức vụ, List<Employee>>>
│
├── "IT"
│ ├── "Lập trình viên" → [Ivan, Ol’ga]
│ └── "Kỹ sư kiểm thử" → [Mariya]
└── "HR"
├── "Quản lý" → [Pyotr]
└── "Chuyên viên tuyển dụng" → [Svetlana]
Bảng: kết quả nhận được với các kết hợp khác nhau
| Bộ thu thập | Kết quả |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. Các lỗi thường gặp khi làm việc với nhóm lồng nhau
Lỗi số 1: Hiểu sai cấu trúc của các Map lồng nhau.
Sau khi nhóm lồng nhau, rất dễ nhầm lẫn giá trị của mỗi Map thực sự là gì. Luôn nhìn vào kiểu trả về — IDE sẽ gợi ý kiểu. Nếu không chắc chắn, hãy in kết quả ra màn hình bằng System.out.println(grouped) hoặc dùng trình gỡ lỗi.
Lỗi số 2: NullPointerException khi trích xuất dữ liệu.
Nếu không có khóa (ví dụ trong phòng ban không có nhân viên ở một chức vụ nhất định), Map.get(key) sẽ trả về null. Hãy kiểm tra sự tồn tại của khóa qua containsKey hoặc, bắt đầu từ Java 8, có thể dùng Map.getOrDefault, và từ Java 9 — Map.ofNullable và các phương thức của Optional.
Lỗi số 3: Lồng nhau quá phức tạp.
Nếu mức lồng nhau trở nên quá sâu (3–4 cấp), có lẽ nên xem lại cấu trúc dữ liệu hoặc chia bài toán thành các bước nhỏ hơn.
Lỗi số 4: Gộp sai cấp độ.
Đôi khi người ta đặt nhầm collector gộp (averagingInt, counting) không vào bên trong groupingBy cần thiết mà ở bên ngoài — và nhận kết quả ngoài mong đợi. Luôn cẩn thận đặt dấu ngoặc đúng chỗ!
Lỗi số 5: Cố gắng thay đổi phần tử trong quá trình collect.
Đừng thay đổi các bộ sưu tập hoặc đối tượng nguồn trong quá trình nhóm — điều này có thể dẫn đến các lỗi khó phát hiện.
GO TO FULL VERSION