Agrupación y partición

Avanzado
Java
Java
Actualizado: 08/05/2025

¡Desbloquea el curso de Java completo!

IA
Ejercicios
Certificado
Entrar

Mira la lección en vídeo

Accede al vídeo completo de esta lección y a más contenido exclusivo con el Plan Plus.

Desbloquear Plan Plus

groupingBy() básico: Map<K,List>

El método groupingBy() es uno de los colectores más útiles y versátiles de la API Stream de Java. Este colector permite agrupar elementos de un stream según una función de clasificación, creando un mapa donde las claves son los resultados de aplicar dicha función a cada elemento, y los valores son listas que contienen todos los elementos que comparten la misma clave.

La forma básica de groupingBy() produce un Map<K,List<T>> donde:

  • K es el tipo de la clave (resultado de la función clasificadora)
  • List<T> es una lista de elementos originales que comparten esa clave

Sintaxis básica

Map<K, List<T>> result = stream.collect(Collectors.groupingBy(classifier));

Donde classifier es una función que extrae la clave de agrupación de cada elemento del stream.

Casos de uso comunes

Veamos algunos ejemplos prácticos para entender mejor cómo funciona este colector:

Agrupar palabras por su primera letra

List<String> palabras = List.of("manzana", "pera", "melón", "plátano", "mandarina", "piña");

Map<Character, List<String>> porPrimeraLetra = palabras.stream()
    .collect(Collectors.groupingBy(palabra -> palabra.charAt(0)));

System.out.println(porPrimeraLetra);
// Output: {p=[pera, plátano, piña], m=[manzana, melón, mandarina]}

En este ejemplo, la función clasificadora extrae el primer carácter de cada palabra, agrupando todas las palabras que comienzan con la misma letra.

Agrupar personas por edad

class Persona {
    private String nombre;
    private int edad;
    
    // Constructor, getters y setters
    public Persona(String nombre, int edad) {
        this.nombre = nombre;
        this.edad = edad;
    }
    
    public String getNombre() { return nombre; }
    public int getEdad() { return edad; }
    
    @Override
    public String toString() {
        return nombre;
    }
}

List<Persona> personas = List.of(
    new Persona("Ana", 25),
    new Persona("Carlos", 30),
    new Persona("Elena", 25),
    new Persona("David", 30),
    new Persona("Beatriz", 22)
);

Map<Integer, List<Persona>> porEdad = personas.stream()
    .collect(Collectors.groupingBy(Persona::getEdad));

porEdad.forEach((edad, grupo) -> {
    System.out.println("Edad " + edad + ": " + grupo);
});
// Output:
// Edad 22: [Beatriz]
// Edad 25: [Ana, Elena]
// Edad 30: [Carlos, David]

Aquí utilizamos una referencia a método (Persona::getEdad) como clasificador, lo que hace el código más limpio y legible.

Uso con objetos más complejos

El poder de groupingBy() se hace más evidente cuando trabajamos con estructuras de datos más complejas:

class Producto {
    private String nombre;
    private String categoria;
    private double precio;
    
    // Constructor y getters
    public Producto(String nombre, String categoria, double precio) {
        this.nombre = nombre;
        this.categoria = categoria;
        this.precio = precio;
    }
    
    public String getNombre() { return nombre; }
    public String getCategoria() { return categoria; }
    public double getPrecio() { return precio; }
    
    @Override
    public String toString() {
        return nombre + " (" + precio + "€)";
    }
}

List<Producto> productos = List.of(
    new Producto("Laptop", "Electrónica", 899.99),
    new Producto("Smartphone", "Electrónica", 499.99),
    new Producto("Camiseta", "Ropa", 19.99),
    new Producto("Pantalón", "Ropa", 39.99),
    new Producto("Zapatillas", "Calzado", 59.99)
);

Map<String, List<Producto>> porCategoria = productos.stream()
    .collect(Collectors.groupingBy(Producto::getCategoria));

porCategoria.forEach((categoria, listaProductos) -> {
    System.out.println(categoria + ":");
    listaProductos.forEach(producto -> System.out.println("  - " + producto));
});

Este código produce una salida como:

Electrónica:
  - Laptop (899.99€)
  - Smartphone (499.99€)
Ropa:
  - Camiseta (19.99€)
  - Pantalón (39.99€)
Calzado:
  - Zapatillas (59.99€)

Funciones clasificadoras personalizadas

Podemos crear clasificadores más complejos para agrupar según criterios específicos:

// Agrupar productos por rango de precio
Map<String, List<Producto>> porRangoPrecio = productos.stream()
    .collect(Collectors.groupingBy(producto -> {
        double precio = producto.getPrecio();
        if (precio < 50) return "Económico";
        else if (precio < 500) return "Medio";
        else return "Premium";
    }));

porRangoPrecio.forEach((rango, lista) -> {
    System.out.println("Rango " + rango + ":");
    lista.forEach(p -> System.out.println("  - " + p.getNombre() + " (" + p.getPrecio() + "€)"));
});

Trabajando con el mapa resultante

Una vez que tenemos nuestro Map<K,List<T>>, podemos manipularlo como cualquier otro mapa:

// Obtener todos los productos de una categoría específica
List<Producto> electronicos = porCategoria.getOrDefault("Electrónica", Collections.emptyList());

// Verificar si existe una categoría
boolean existeCategoria = porCategoria.containsKey("Juguetes");

// Iterar sobre las entradas del mapa
porCategoria.entrySet().forEach(entry -> {
    String categoria = entry.getKey();
    List<Producto> productosCategoria = entry.getValue();
    System.out.println("La categoría " + categoria + " tiene " + productosCategoria.size() + " productos");
});

Consideraciones de rendimiento

El colector groupingBy() es eficiente para la mayoría de los casos de uso, pero hay algunas consideraciones a tener en cuenta:

  • Para colecciones muy grandes, el mapa resultante podría ocupar mucha memoria.
  • Si la función clasificadora es computacionalmente costosa, el rendimiento puede verse afectado.
  • Por defecto, groupingBy() utiliza HashMap como implementación del mapa resultante, lo que proporciona un buen equilibrio entre rendimiento y uso de memoria.

Ejemplo práctico: análisis de datos

Veamos un ejemplo más completo que muestra cómo groupingBy() puede ser útil para el análisis de datos:

class Venta {
    private String producto;
    private String region;
    private int cantidad;
    private LocalDate fecha;
    
    // Constructor y getters
    public Venta(String producto, String region, int cantidad, LocalDate fecha) {
        this.producto = producto;
        this.region = region;
        this.cantidad = cantidad;
        this.fecha = fecha;
    }
    
    public String getProducto() { return producto; }
    public String getRegion() { return region; }
    public int getCantidad() { return cantidad; }
    public LocalDate getFecha() { return fecha; }
    public int getAño() { return fecha.getYear(); }
    public int getMes() { return fecha.getMonthValue(); }
}

List<Venta> ventas = List.of(
    new Venta("Laptop", "Norte", 5, LocalDate.of(2023, 1, 15)),
    new Venta("Smartphone", "Sur", 10, LocalDate.of(2023, 1, 20)),
    new Venta("Laptop", "Este", 3, LocalDate.of(2023, 2, 5)),
    new Venta("Tablet", "Oeste", 7, LocalDate.of(2023, 2, 10)),
    new Venta("Smartphone", "Norte", 8, LocalDate.of(2023, 3, 2))
);

// Agrupar ventas por región
Map<String, List<Venta>> ventasPorRegion = ventas.stream()
    .collect(Collectors.groupingBy(Venta::getRegion));

// Agrupar ventas por mes
Map<Integer, List<Venta>> ventasPorMes = ventas.stream()
    .collect(Collectors.groupingBy(Venta::getMes));

// Agrupar ventas por producto
Map<String, List<Venta>> ventasPorProducto = ventas.stream()
    .collect(Collectors.groupingBy(Venta::getProducto));

Este tipo de agrupación es extremadamente útil para generar informes y análisis de datos, permitiéndonos ver rápidamente cómo se distribuyen las ventas por diferentes dimensiones.

El método groupingBy() básico es solo el comienzo. En las siguientes secciones veremos cómo podemos combinar este colector con otros para realizar operaciones más avanzadas sobre los grupos, como contar elementos, calcular sumas o crear agrupaciones anidadas.

counting(), mapping(), summingInt()

Guarda tu progreso

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Cuando trabajamos con el método groupingBy() en Java, podemos ir más allá de simplemente agrupar elementos en listas. La API Stream nos permite aplicar colectores downstream para realizar operaciones adicionales sobre cada grupo. Estos colectores nos ayudan a transformar, agregar o resumir los elementos dentro de cada grupo de manera eficiente.

El colector counting()

El colector counting() nos permite contar el número de elementos en cada grupo, en lugar de mantener la lista completa de elementos. Esto es especialmente útil cuando solo nos interesa la cantidad de elementos y no los elementos en sí.

Map<String, Long> conteoProductosPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.counting()
    ));

conteoProductosPorCategoria.forEach((categoria, cantidad) -> {
    System.out.println(categoria + ": " + cantidad + " productos");
});
// Output:
// Electrónica: 2 productos
// Ropa: 2 productos
// Calzado: 1 producto

Observa cómo el tipo del valor en el mapa resultante cambia de List<Producto> a Long, reflejando el conteo de elementos en cada grupo.

El colector mapping()

El colector mapping() nos permite transformar los elementos de cada grupo antes de recopilarlos. Recibe dos parámetros:

  • Una función de mapeo que transforma cada elemento
  • Un colector que determina cómo se recopilarán los elementos transformados
// Obtener solo los nombres de los productos por categoría
Map<String, List<String>> nombresPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.mapping(
            Producto::getNombre,
            Collectors.toList()
        )
    ));

nombresPorCategoria.forEach((categoria, nombres) -> {
    System.out.println(categoria + ": " + nombres);
});
// Output:
// Electrónica: [Laptop, Smartphone]
// Ropa: [Camiseta, Pantalón]
// Calzado: [Zapatillas]

En este ejemplo, en lugar de almacenar objetos Producto completos, solo almacenamos sus nombres como strings.

También podemos combinar mapping() con otros colectores para obtener resultados más específicos:

// Obtener nombres únicos de productos por categoría
Map<String, Set<String>> nombresUnicosPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.mapping(
            Producto::getNombre,
            Collectors.toSet()
        )
    ));

El colector summingInt()

El colector summingInt() nos permite calcular la suma de un valor numérico para cada grupo. Es particularmente útil para calcular totales o agregados.

// Calcular el total de unidades vendidas por región
List<Venta> ventas = List.of(
    new Venta("Laptop", "Norte", 5),
    new Venta("Smartphone", "Sur", 10),
    new Venta("Laptop", "Norte", 3),
    new Venta("Tablet", "Oeste", 7),
    new Venta("Smartphone", "Norte", 8)
);

Map<String, Integer> unidadesPorRegion = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getRegion,
        Collectors.summingInt(Venta::getCantidad)
    ));

unidadesPorRegion.forEach((region, total) -> {
    System.out.println("Región " + region + ": " + total + " unidades");
});
// Output:
// Región Norte: 16 unidades
// Región Sur: 10 unidades
// Región Oeste: 7 unidades

Existen variantes para otros tipos numéricos como summingLong() y summingDouble():

// Calcular el valor total de ventas por categoría
class Producto {
    private String nombre;
    private String categoria;
    private double precio;
    private int unidadesVendidas;
    
    // Constructor y getters
    // ...
    
    public double getValorTotal() {
        return precio * unidadesVendidas;
    }
}

Map<String, Double> valorVentasPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.summingDouble(Producto::getValorTotal)
    ));

Combinando colectores para análisis más complejos

Podemos combinar estos colectores para realizar análisis más sofisticados:

// Calcular precio promedio por categoría
Map<String, Double> precioPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.averagingDouble(Producto::getPrecio)
    ));

// Encontrar el producto más caro por categoría
Map<String, Optional<Producto>> productoMasCaroPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        Collectors.maxBy(Comparator.comparing(Producto::getPrecio))
    ));

Ejemplo práctico: análisis de ventas

Veamos un ejemplo más completo que combina varios colectores para analizar datos de ventas:

class Venta {
    private String producto;
    private String categoria;
    private String region;
    private int cantidad;
    private double precioUnitario;
    
    // Constructor y getters
    public Venta(String producto, String categoria, String region, int cantidad, double precioUnitario) {
        this.producto = producto;
        this.categoria = categoria;
        this.region = region;
        this.cantidad = cantidad;
        this.precioUnitario = precioUnitario;
    }
    
    public String getProducto() { return producto; }
    public String getCategoria() { return categoria; }
    public String getRegion() { return region; }
    public int getCantidad() { return cantidad; }
    public double getPrecioUnitario() { return precioUnitario; }
    public double getImporteTotal() { return cantidad * precioUnitario; }
}

List<Venta> ventas = List.of(
    new Venta("Laptop XPS", "Electrónica", "Norte", 2, 1299.99),
    new Venta("Smartphone Galaxy", "Electrónica", "Sur", 5, 799.99),
    new Venta("Auriculares Bluetooth", "Accesorios", "Norte", 10, 89.99),
    new Venta("Monitor 4K", "Electrónica", "Este", 3, 349.99),
    new Venta("Teclado Mecánico", "Accesorios", "Oeste", 7, 129.99),
    new Venta("Ratón Inalámbrico", "Accesorios", "Norte", 15, 49.99)
);

// 1. Número de ventas por categoría
Map<String, Long> ventasPorCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getCategoria,
        Collectors.counting()
    ));

// 2. Total de unidades vendidas por categoría
Map<String, Integer> unidadesPorCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getCategoria,
        Collectors.summingInt(Venta::getCantidad)
    ));

// 3. Importe total de ventas por categoría
Map<String, Double> importePorCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getCategoria,
        Collectors.summingDouble(Venta::getImporteTotal)
    ));

// 4. Productos vendidos por categoría
Map<String, Set<String>> productosPorCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getCategoria,
        Collectors.mapping(
            Venta::getProducto,
            Collectors.toSet()
        )
    ));

// Mostrar resultados
System.out.println("Análisis de ventas por categoría:");
ventasPorCategoria.forEach((cat, count) -> {
    System.out.println("\nCategoría: " + cat);
    System.out.println("  Número de ventas: " + count);
    System.out.println("  Unidades vendidas: " + unidadesPorCategoria.get(cat));
    System.out.println("  Importe total: " + importePorCategoria.get(cat) + "€");
    System.out.println("  Productos: " + productosPorCategoria.get(cat));
});

Este código genera un informe completo de ventas por categoría, mostrando diferentes métricas para cada grupo.

Colectores personalizados

Además de los colectores predefinidos, podemos crear nuestros propios colectores para operaciones específicas:

// Colector personalizado para calcular la mediana de precios
Collector<Producto, ?, Double> medianaPrecios = Collector.of(
    ArrayList::new,                           // supplier
    List::add,                                // accumulator
    (left, right) -> {                        // combiner
        left.addAll(right);
        return left;
    },
    list -> {                                 // finisher
        list.sort(Comparator.comparing(Producto::getPrecio));
        int size = list.size();
        if (size == 0) return 0.0;
        if (size % 2 == 0) {
            return (list.get(size/2 - 1).getPrecio() + list.get(size/2).getPrecio()) / 2;
        } else {
            return list.get(size/2).getPrecio();
        }
    }
);

Map<String, Double> medianaPrecioPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::getCategoria,
        medianaPrecios
    ));

Los colectores downstream como counting(), mapping() y summingInt() amplían significativamente las capacidades de agrupación en Java, permitiéndonos realizar análisis de datos complejos de manera concisa y expresiva. Estas herramientas son fundamentales para trabajar con colecciones de datos en aplicaciones modernas de Java.

groupingBy() anidado: Map<K1,Map<K2,List>>

La agrupación anidada es una técnica avanzada que nos permite clasificar elementos en múltiples niveles jerárquicos. Mientras que el groupingBy() básico crea un mapa con un solo nivel de agrupación, el groupingBy() anidado nos permite crear estructuras de datos más complejas con múltiples niveles de clasificación.

Estructura de agrupación anidada

Cuando utilizamos groupingBy() anidado, obtenemos un mapa donde:

  • Las claves primarias (K1) representan el primer criterio de agrupación
  • Los valores son mapas secundarios donde:
  • Las claves secundarias (K2) representan el segundo criterio de agrupación
  • Los valores finales son listas de elementos originales (List<T>)

La estructura resultante es Map<K1, Map<K2, List<T>>>, lo que nos permite navegar por los datos en diferentes niveles de granularidad.

Sintaxis básica

Map<K1, Map<K2, List<T>>> result = stream.collect(
    Collectors.groupingBy(
        classifier1,                          // Función para la clave primaria
        Collectors.groupingBy(classifier2)    // Colector downstream para la clave secundaria
    )
);

Ejemplo práctico: ventas por región y categoría

Veamos un ejemplo concreto para entender mejor cómo funciona:

class Venta {
    private String producto;
    private String categoria;
    private String region;
    private double importe;
    
    // Constructor y getters
    public Venta(String producto, String categoria, String region, double importe) {
        this.producto = producto;
        this.categoria = categoria;
        this.region = region;
        this.importe = importe;
    }
    
    public String getProducto() { return producto; }
    public String getCategoria() { return categoria; }
    public String getRegion() { return region; }
    public double getImporte() { return importe; }
    
    @Override
    public String toString() {
        return producto + " (" + importe + "€)";
    }
}

List<Venta> ventas = List.of(
    new Venta("Laptop", "Electrónica", "Norte", 1200.0),
    new Venta("Smartphone", "Electrónica", "Sur", 800.0),
    new Venta("Tablet", "Electrónica", "Norte", 500.0),
    new Venta("Camiseta", "Ropa", "Este", 25.0),
    new Venta("Pantalón", "Ropa", "Norte", 45.0),
    new Venta("Zapatillas", "Calzado", "Sur", 60.0),
    new Venta("Reloj", "Accesorios", "Este", 120.0)
);

// Agrupar ventas por región y luego por categoría
Map<String, Map<String, List<Venta>>> ventasPorRegionYCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getRegion,                     // Clasificador primario: región
        Collectors.groupingBy(Venta::getCategoria)  // Clasificador secundario: categoría
    ));

Este código agrupa primero las ventas por región y luego, dentro de cada región, las agrupa por categoría. El resultado es una estructura jerárquica que nos permite analizar las ventas desde diferentes perspectivas.

Navegando por la estructura anidada

Para acceder a los datos en una estructura anidada, necesitamos navegar por los diferentes niveles:

// Imprimir la estructura completa
ventasPorRegionYCategoria.forEach((region, porCategoria) -> {
    System.out.println("Región: " + region);
    
    porCategoria.forEach((categoria, ventasCategoria) -> {
        System.out.println("  Categoría: " + categoria);
        
        ventasCategoria.forEach(venta -> {
            System.out.println("    - " + venta);
        });
    });
    
    System.out.println();
});

La salida sería algo como:

Región: Norte
  Categoría: Electrónica
    - Laptop (1200.0€)
    - Tablet (500.0€)
  Categoría: Ropa
    - Pantalón (45.0€)

Región: Sur
  Categoría: Electrónica
    - Smartphone (800.0€)
  Categoría: Calzado
    - Zapatillas (60.0€)

Región: Este
  Categoría: Ropa
    - Camiseta (25.0€)
  Categoría: Accesorios
    - Reloj (120.0€)

Acceso directo a grupos específicos

Podemos acceder directamente a un grupo específico utilizando las claves correspondientes:

// Obtener todas las ventas de electrónica en la región Norte
List<Venta> electronicaNorte = ventasPorRegionYCategoria
    .getOrDefault("Norte", Collections.emptyMap())
    .getOrDefault("Electrónica", Collections.emptyList());

System.out.println("Ventas de electrónica en el Norte:");
electronicaNorte.forEach(venta -> System.out.println("- " + venta));

Combinando con otros colectores downstream

Al igual que con el groupingBy() simple, podemos combinar el groupingBy() anidado con otros colectores para realizar operaciones más avanzadas:

// Calcular el importe total de ventas por región y categoría
Map<String, Map<String, Double>> importeTotalPorRegionYCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getRegion,
        Collectors.groupingBy(
            Venta::getCategoria,
            Collectors.summingDouble(Venta::getImporte)
        )
    ));

// Imprimir los resultados
importeTotalPorRegionYCategoria.forEach((region, porCategoria) -> {
    System.out.println("Región: " + region);
    
    porCategoria.forEach((categoria, importeTotal) -> {
        System.out.println("  " + categoria + ": " + importeTotal + "€");
    });
    
    System.out.println();
});

Este código calcula el importe total de ventas para cada combinación de región y categoría, produciendo una salida como:

Región: Norte
  Electrónica: 1700.0€
  Ropa: 45.0€

Región: Sur
  Electrónica: 800.0€
  Calzado: 60.0€

Región: Este
  Ropa: 25.0€
  Accesorios: 120.0€

Más de dos niveles de agrupación

Podemos extender este patrón para crear agrupaciones con más de dos niveles:

// Agregar año a la clase Venta
class Venta {
    // ... otros campos y métodos
    private int año;
    
    public Venta(String producto, String categoria, String region, double importe, int año) {
        // ... inicialización de otros campos
        this.año = año;
    }
    
    public int getAño() { return año; }
}

// Agrupar por año, región y categoría
Map<Integer, Map<String, Map<String, List<Venta>>>> ventasPorAñoRegionCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getAño,
        Collectors.groupingBy(
            Venta::getRegion,
            Collectors.groupingBy(Venta::getCategoria)
        )
    ));

Esta estructura de tres niveles nos permite analizar las ventas por año, luego por región y finalmente por categoría.

Caso práctico: análisis de empleados

Veamos un ejemplo más completo con datos de empleados:

class Empleado {
    private String nombre;
    private String departamento;
    private String oficina;
    private String nivel;
    private double salario;
    
    // Constructor y getters
    public Empleado(String nombre, String departamento, String oficina, 
                   String nivel, double salario) {
        this.nombre = nombre;
        this.departamento = departamento;
        this.oficina = oficina;
        this.nivel = nivel;
        this.salario = salario;
    }
    
    public String getNombre() { return nombre; }
    public String getDepartamento() { return departamento; }
    public String getOficina() { return oficina; }
    public String getNivel() { return nivel; }
    public double getSalario() { return salario; }
    
    @Override
    public String toString() {
        return nombre + " (" + nivel + ", " + salario + "€)";
    }
}

List<Empleado> empleados = List.of(
    new Empleado("Ana García", "Desarrollo", "Madrid", "Senior", 65000),
    new Empleado("Carlos López", "Desarrollo", "Barcelona", "Junior", 35000),
    new Empleado("Elena Martín", "Marketing", "Madrid", "Senior", 60000),
    new Empleado("David Sánchez", "Ventas", "Valencia", "Mid", 45000),
    new Empleado("Beatriz Ruiz", "Desarrollo", "Madrid", "Mid", 48000),
    new Empleado("Fernando Torres", "Marketing", "Barcelona", "Junior", 32000),
    new Empleado("Lucía Díaz", "Ventas", "Madrid", "Senior", 70000)
);

// Agrupar empleados por oficina y departamento
Map<String, Map<String, List<Empleado>>> porOficinaDepartamento = empleados.stream()
    .collect(Collectors.groupingBy(
        Empleado::getOficina,
        Collectors.groupingBy(Empleado::getDepartamento)
    ));

// Calcular salario promedio por oficina y nivel
Map<String, Map<String, Double>> salarioPromedioPorOficinaNivel = empleados.stream()
    .collect(Collectors.groupingBy(
        Empleado::getOficina,
        Collectors.groupingBy(
            Empleado::getNivel,
            Collectors.averagingDouble(Empleado::getSalario)
        )
    ));

// Contar empleados por departamento y nivel
Map<String, Map<String, Long>> conteoEmpleadosPorDepartamentoNivel = empleados.stream()
    .collect(Collectors.groupingBy(
        Empleado::getDepartamento,
        Collectors.groupingBy(
            Empleado::getNivel,
            Collectors.counting()
        )
    ));

Transformando los resultados con mapping()

Podemos combinar groupingBy() anidado con mapping() para transformar los elementos en cada grupo:

// Obtener nombres de empleados por oficina y departamento
Map<String, Map<String, List<String>>> nombresPorOficinaDepartamento = empleados.stream()
    .collect(Collectors.groupingBy(
        Empleado::getOficina,
        Collectors.groupingBy(
            Empleado::getDepartamento,
            Collectors.mapping(
                Empleado::getNombre,
                Collectors.toList()
            )
        )
    ));

// Imprimir los resultados
nombresPorOficinaDepartamento.forEach((oficina, porDepartamento) -> {
    System.out.println("Oficina: " + oficina);
    
    porDepartamento.forEach((departamento, nombres) -> {
        System.out.println("  " + departamento + ": " + nombres);
    });
    
    System.out.println();
});

Consideraciones de rendimiento y legibilidad

Las agrupaciones anidadas son potentes, pero hay que tener en cuenta algunas consideraciones:

  • Complejidad: A medida que aumentamos los niveles de anidación, la estructura se vuelve más compleja y difícil de manejar.
  • Rendimiento: Las agrupaciones múltiples pueden requerir más recursos de memoria y procesamiento.
  • Legibilidad: El código con múltiples niveles de agrupación puede ser difícil de leer y mantener.

Para mejorar la legibilidad, podemos definir tipos específicos para nuestras estructuras anidadas:

// Definir tipos para mejorar la legibilidad
typedef Map<String, List<Venta>> VentasPorCategoria;
typedef Map<String, VentasPorCategoria> VentasPorRegionCategoria;

// Usar los tipos definidos
VentasPorRegionCategoria ventasPorRegionYCategoria = ventas.stream()
    .collect(Collectors.groupingBy(
        Venta::getRegion,
        Collectors.groupingBy(Venta::getCategoria)
    ));

Ejemplo final: análisis de datos de estudiantes

Veamos un último ejemplo completo que muestra el poder de las agrupaciones anidadas:

class Estudiante {
    private String nombre;
    private String facultad;
    private String carrera;
    private int curso;
    private double notaMedia;
    
    // Constructor y getters
    public Estudiante(String nombre, String facultad, String carrera, 
                     int curso, double notaMedia) {
        this.nombre = nombre;
        this.facultad = facultad;
        this.carrera = carrera;
        this.curso = curso;
        this.notaMedia = notaMedia;
    }
    
    public String getNombre() { return nombre; }
    public String getFacultad() { return facultad; }
    public String getCarrera() { return carrera; }
    public int getCurso() { return curso; }
    public double getNotaMedia() { return notaMedia; }
}

List<Estudiante> estudiantes = List.of(
    new Estudiante("Ana", "Ciencias", "Física", 2, 8.7),
    new Estudiante("Carlos", "Ciencias", "Matemáticas", 3, 7.9),
    new Estudiante("Elena", "Ingeniería", "Informática", 2, 9.1),
    new Estudiante("David", "Ingeniería", "Informática", 4, 8.2),
    new Estudiante("Beatriz", "Ciencias", "Física", 3, 8.5),
    new Estudiante("Fernando", "Letras", "Historia", 2, 7.8),
    new Estudiante("Lucía", "Letras", "Filosofía", 4, 9.3)
);

// Calcular nota media por facultad y carrera
Map<String, Map<String, Double>> notaMediaPorFacultadCarrera = estudiantes.stream()
    .collect(Collectors.groupingBy(
        Estudiante::getFacultad,
        Collectors.groupingBy(
            Estudiante::getCarrera,
            Collectors.averagingDouble(Estudiante::getNotaMedia)
        )
    ));

// Contar estudiantes por facultad, carrera y curso
Map<String, Map<String, Map<Integer, Long>>> conteoEstudiantesPorFacultadCarreraCurso = 
    estudiantes.stream()
        .collect(Collectors.groupingBy(
            Estudiante::getFacultad,
            Collectors.groupingBy(
                Estudiante::getCarrera,
                Collectors.groupingBy(
                    Estudiante::getCurso,
                    Collectors.counting()
                )
            )
        ));

// Imprimir notas medias
System.out.println("Nota media por facultad y carrera:");
notaMediaPorFacultadCarrera.forEach((facultad, porCarrera) -> {
    System.out.println(facultad + ":");
    porCarrera.forEach((carrera, media) -> {
        System.out.printf("  %s: %.2f\n", carrera, media);
    });
});

Las agrupaciones anidadas son una herramienta fundamental para el análisis de datos en Java, permitiéndonos crear estructuras jerárquicas que facilitan la exploración y el análisis de información desde múltiples perspectivas. Aunque pueden resultar complejas, su poder y flexibilidad las convierten en una técnica esencial para cualquier desarrollador que trabaje con colecciones de datos.

Aprendizajes de esta lección de Java

  • Comprender el funcionamiento básico del método groupingBy() para agrupar elementos en mapas.
  • Aprender a utilizar colectores downstream como counting(), mapping() y summingInt() para realizar operaciones agregadas sobre grupos.
  • Aplicar agrupaciones anidadas con groupingBy() para crear estructuras jerárquicas de datos.
  • Manejar y navegar estructuras de datos complejas resultantes de agrupaciones múltiples.
  • Evaluar consideraciones de rendimiento y legibilidad al trabajar con agrupaciones y colectores en Java.

Completa este curso de Java y certifícate

Únete a nuestra plataforma de cursos de programación y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración