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, а с Java 9 — Map.ofNullable и методы Optional.

Ошибка №3: Слишком сложные вложения.
Если группировка становится слишком глубокой (3–4 уровня вложенности), возможно, стоит пересмотреть структуру данных или разбить задачу на более мелкие этапы.

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

Ошибка №5: Попытка изменить элементы внутри collect.
Не изменяйте исходные коллекции или объекты в процессе группировки — это может привести к трудноуловимым багам.

1
Задача
JAVA 25 SELF, 31 уровень, 3 лекция
Недоступна
Построение организационной структуры компании 📊
Построение организационной структуры компании 📊
1
Задача
JAVA 25 SELF, 31 уровень, 3 лекция
Недоступна
Отчёт об отличниках в Университете Просвещённых Умов ✨
Отчёт об отличниках в Университете Просвещённых Умов ✨
Комментарии (3)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Anonymous #1185098 Уровень 32
23 января 2026
Кажется лучше сначала отфильтровать данные по условию > 4.0

Map<Integer, Map<String, Long>> structure = students.stream()
        .filter(student -> student.getAverageGrade() > 4.0)
        .collect(
                Collectors.groupingBy(
                        Student::getStudyCourse,
                        LinkedHashMap::new,
                        Collectors.groupingBy(
                                Student::getStudentSpecialty,
                                LinkedHashMap::new,
                                Collectors.counting()
                        )
                )
        );
24 декабря 2025
Наконец-то задачи, над которыми нужно сидеть больше трёх минут 🙏
Артемий Уровень 66
17 октября 2025

// Вложенная группировка Stream API:
        // 1) groupingBy по отделу
        // 2) внутри отдела — groupingBy по должности
        // 3) вместо объектов Employee — собираем только имена (mapping -> List<String>)
        Map<String, Map<String, List<String>>> byDeptAndPosition = employees.stream()
            .collect(Collectors.groupingBy(Employee::getDepartmentName,
                Collectors.groupingBy(Employee::getPositionTitle,
                    Collectors.mapping(Employee::getEmployeeName, Collectors.toList())));



        // Красивый вывод результата в читаемом виде
        // System.out.println("Организационная структура (отдел -> должность -> имена):");
        byDeptAndPosition.forEach((dept, posMap) -> {
            System.out.println(dept + ": "); posMap.forEach((pos, names) ->
                System.out.println(pos + " -> " + names));
        });
В первой задаче компилятор принял вот такой код. Хотя ругается на него