1. groupingBy anidado: sintaxis y principio de funcionamiento
En la práctica, rara vez basta con agrupar los datos por un único criterio. Por ejemplo, si tienes una lista de empleados de una empresa, a menudo quieres saber no solo cuántas personas hay en cada departamento, sino cuántas hay en cada departamento por cada puesto. O, si tienes una base de estudiantes, cuántos estudiantes hay en cada curso por cada especialidad.
Las agrupaciones anidadas permiten construir «mapas dentro de mapas» — la estructura «departamento → puesto → lista de empleados», o «curso → especialidad → lista de estudiantes».
Sin el Stream API, estas tareas se resolverían con varios bucles anidados y la construcción manual de una Map dentro de otra Map. Con streams y colectores, se hace en una o dos líneas.
Una agrupación anidada es cuando pasas al segundo argumento del método Collectors.groupingBy otro colector, por ejemplo, otro groupingBy. Como resultado, obtienes un mapa en el que el valor de cada clave es otro mapa.
Patrón general
Map<Klyuch1, Map<Klyuch2, List<T>>> result =
stream.collect(Collectors.groupingBy(
obekt -> klyuch1,
Collectors.groupingBy(obekt -> klyuch2)
));
Ejemplo: empleados por departamento y puesto
Supongamos que tenemos la clase:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... constructores, getters, toString
}
Lista de empleados:
List<Employee> employees = List.of(
new Employee("Ivan", "IT", "Desarrollador", 120_000),
new Employee("Mariya", "IT", "Tester", 90_000),
new Employee("Pyotr", "HR", "Gerente", 80_000),
new Employee("Ol’ga", "IT", "Desarrollador", 130_000),
new Employee("Svetlana", "HR", "Reclutador", 70_000)
);
Agrupamos por departamento y puesto:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
¿Qué hemos obtenido?
Un mapa en el que la clave es el departamento y el valor es un mapa (clave — puesto, valor — lista de empleados).
Visualización de la estructura
IT:
Desarrollador: [Ivan, Ol’ga]
Tester: [Mariya]
HR:
Gerente: [Pyotr]
Reclutador: [Svetlana]
2. Agrupación con agregación: combinamos groupingBy y agregadores
¡Las agrupaciones anidadas no se limitan a recolectar listas! Puedes agregar datos dentro de cada subgrupo desde el principio.
Ejemplo: salario máximo por departamento
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
Aquí, para cada departamento obtenemos al empleado con el salario máximo (el resultado está envuelto en Optional, porque un departamento puede resultar vacío).
Agrupación anidada + agregación
Supongamos que queremos conocer el salario máximo por cada puesto en cada departamento:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
¿Qué significa esto?
- Para cada departamento, un mapa de puestos.
- Para cada puesto, el empleado con el salario máximo (o un Optional vacío si no hay nadie).
3. Agrupación con transformación: mapping dentro de groupingBy
A veces no necesitamos agrupar objetos completos, sino obtener solo determinados campos dentro de los grupos.
Ejemplo: nombres de empleados por departamento
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
Resultado:
IT: [Ivan, Mariya, Ol’ga]
HR: [Pyotr, Svetlana]
mapping anidado
Podemos combinar mapping con un groupingBy anidado:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
Resultado:
IT:
Desarrollador: [Ivan, Ol’ga]
Tester: [Mariya]
HR:
Gerente: [Pyotr]
Reclutador: [Svetlana]
4. Agrupación + agregación de datos numéricos
A menudo se requiere no solo agrupar, sino calcular suma, media o conteo por grupos.
Ejemplo: salario medio por departamento
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
Agregación anidada
Salario medio por puesto en cada departamento:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + agregación
A veces es conveniente dividir la colección en dos grupos por un criterio booleano y, dentro, agregar también.
Ejemplo: cuántos empleados con salario superior a 100_000 en cada departamento
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
Resultado:
Para cada departamento — un mapa: true/false → cantidad de empleados.
6. Ejercicios prácticos: aplicamos agrupaciones anidadas
Ejercicio 1. Estudiantes por curso y especialidad
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... getters, constructor
}
List<Student> students = ... // supongamos que ya existe
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
Ejercicio 2. Nota media por curso
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
Ejercicio 3. Solo nombres por grupos
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. Matices útiles
Cómo leer y extraer datos de Map anidadas
Trabajar con mapas anidados puede resultar poco familiar al principio. Aquí tienes un ejemplo básico:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("Departamento: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" Puesto: " + pos + " -> " + emps);
}
}
Esquema de la agrupación anidada
Map<Departamento, Map<Puesto, List<Employee>>>
│
├── "IT"
│ ├── "Desarrollador" → [Ivan, Ol’ga]
│ └── "Tester" → [Mariya]
└── "HR"
├── "Gerente" → [Pyotr]
└── "Reclutador" → [Svetlana]
Tabla: qué obtendrás tras diferentes combinaciones
| Colector | Resultado |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. Errores típicos al trabajar con agrupaciones anidadas
Error n.º 1: comprensión incorrecta de la estructura de las Map anidadas.
Tras las agrupaciones anidadas, es fácil confundirse sobre qué hay exactamente en el valor de cada mapa. Mira siempre la firma del resultado — la IDE te sugerirá el tipo. Si no estás seguro, imprime el resultado en pantalla con System.out.println(grouped) o usa el depurador.
Error n.º 2: NullPointerException al extraer datos.
Si la clave no existe (por ejemplo, en el departamento no hay empleados de un determinado puesto), Map.get(key) devolverá null. Comprueba la existencia de la clave con containsKey o, desde Java 8, puedes usar Map.getOrDefault, y desde Java 9 — Map.ofNullable y los métodos de Optional.
Error n.º 3: anidaciones demasiado complejas.
Si la agrupación se vuelve demasiado profunda (3–4 niveles de anidación), quizá convenga revisar la estructura de datos o dividir la tarea en etapas más pequeñas.
Error n.º 4: agregación en el nivel equivocado.
A veces se coloca por error un colector agregador (averagingInt, counting) no dentro del groupingBy necesario, sino fuera — y se obtiene un resultado inesperado. ¡Presta siempre mucha atención a la colocación de los paréntesis!
Error n.º 5: intentar modificar elementos dentro de collect.
No modifiques las colecciones u objetos originales durante la agrupación — puede conducir a errores difíciles de detectar.
GO TO FULL VERSION