CodeGym /Các khóa học /JAVA 25 SELF /Tổng hợp nâng cao: nhóm lồng nhau

Tổng hợp nâng cao: nhóm lồng nhau

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

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ả
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. 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.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION