JavaRush /Курси /JAVA 25 SELF /Поглиблена агрегація: вкладені групування

Поглиблена агрегація: вкладені групування

JAVA 25 SELF
Рівень 31 , Лекція 3
Відкрита

1. Вкладений groupingBy: синтаксис і принцип роботи

У житті рідко достатньо згрупувати дані лише за однією ознакою. Наприклад, якщо у вас є список працівників компанії, часто хочеться дізнатися не просто, скільки людей у кожному відділі, а й скільки в кожному відділі на кожній посаді. Або, якщо у вас є база студентів, — то скільки студентів на кожному курсі за кожною спеціальністю.

Вкладені групування дозволяють будувати такі «мапи в мапах» — наприклад, структури «відділ → посада → список працівників» або «курс → спеціальність → список студентів».

Без Stream API такі завдання вирішувалися б кількома вкладеними циклами та ручною побудовою Map всередині Map. З потоками та колекторами це робиться у один‑два рядки.

Вкладене групування — це коли другим аргументом методу Collectors.groupingBy ви передаєте ще один колектор, наприклад, ще один groupingBy. У результаті ви отримуєте мапу, де значенням для кожного ключа є ще одна мапа.

Загальний шаблон

Map<Ключ1, Map<Ключ2, List<T>>> result = 
    stream.collect(Collectors.groupingBy(
        объект -> ключ1,
        Collectors.groupingBy(объект -> ключ2)
    ));

Приклад: працівники за відділом і посадою

Припустімо, у нас є клас:

class Employee {
    private String name;
    private String department;
    private String position;
    private int salary;
    // ... конструктори, геттери, toString
}

Список працівників:

List<Employee> employees = List.of(
    new Employee("Іван", "ІТ", "Розробник", 120_000),
    new Employee("Марія", "ІТ", "Тестувальник", 90_000),
    new Employee("Петро", "HR", "Менеджер", 80_000),
    new Employee("Ольга", "ІТ", "Розробник", 130_000),
    new Employee("Світлана", "HR", "Рекрутер", 70_000)
);

Групуємо за відділом і посадою:

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

Що отримали?
Мапа, де ключ — відділ, значення — мапа (ключ — посада, значення — список працівників).

Візуалізація структури

ІТ:
  Розробник: [Іван, Ольга]
  Тестувальник: [Марія]
HR:
  Менеджер: [Петро]
  Рекрутер: [Світлана]

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())
    ));

Результат:

ІТ: [Іван, Марія, Ольга]
HR: [Петро, Світлана]

Вкладене 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())
        )
    ));

Результат:

ІТ:
  Розробник: [Іван, Ольга]
  Тестувальник: [Марія]
HR:
  Менеджер: [Петро]
  Рекрутер: [Світлана]

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;
    // ... геттери, конструктор
}

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>>>
      │
      ├── "ІТ"
      │      ├── "Розробник"   → [Іван, Ольга]
      │      └── "Тестувальник" → [Марія]
      └── "HR"
             ├── "Менеджер"   → [Петро]
             └── "Рекрутер"   → [Світлана]

Таблиця: що отримаємо після різних комбінацій

Колектор Результат
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, а також Optional.ofNullable та методи Optional.

Помилка № 3: Надто складні вкладення.
Якщо групування стає надто глибоким (3–4 рівні вкладеності), можливо, варто переглянути структуру даних або розбити завдання на дрібніші етапи.

Помилка № 4: Агрегування не на тому рівні.
Іноді помилково розміщують агрегувальний колектор (averagingInt, counting) не всередині потрібного groupingBy, а зовні — і отримують неочікуваний результат. Завжди уважно розставляйте дужки!

Помилка № 5: Спроба змінювати елементи всередині collect.
Не змінюйте вихідні колекції або обʼєкти в процесі групування — це може призвести до важковловимих помилок.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ