Java

Tutorial Java: Agrupación y partición

Aprende a usar groupingBy y colectores downstream en Java Streams para agrupar y analizar datos de forma eficiente y avanzada.

Aprende Java y certifícate

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

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.

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.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende Java online

Ejercicios de esta lección Agrupación y partición

Evalúa tus conocimientos de esta lección Agrupación y partición con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Streams: match

Test

Gestión de errores y excepciones

Código

CRUD en Java de modelo Customer sobre un ArrayList

Proyecto

Clases abstractas

Test

Listas

Código

Métodos de la clase String

Código

Streams: reduce()

Test

API java.nio 2

Puzzle

Polimorfismo

Código

Pattern Matching

Código

Streams: flatMap()

Test

Llamada y sobrecarga de funciones

Puzzle

Métodos referenciados

Test

Métodos de la clase String

Código

Representación de Fecha

Puzzle

Operadores lógicos

Test

Inferencia de tipos con var

Código

Tipos de datos

Código

Estructuras de iteración

Puzzle

Streams: forEach()

Test

Objetos

Puzzle

Funciones lambda

Test

Uso de Scanner

Puzzle

Tipos de variables

Puzzle

Streams: collect()

Puzzle

Operadores aritméticos

Puzzle

Arrays y matrices

Código

Clases y objetos

Código

Interfaz funcional Consumer

Test

CRUD en Java de modelo Customer sobre un HashMap

Proyecto

Interfaces

Código

Enumeraciones Enums

Código

API Optional

Test

Interfaz funcional Function

Test

Encapsulación

Test

Interfaces

Código

Uso de API Optional

Puzzle

Representación de Hora

Test

Herencia básica

Test

Clases y objetos

Código

Interfaz funcional Supplier

Puzzle

HashMap

Puzzle

Sobrecarga de métodos

Test

Polimorfismo de tiempo de ejecución

Puzzle

OOP en Java

Proyecto

Sobrecarga de métodos

Código

CRUD de productos en Java

Proyecto

Clases sealed

Código

Creación de Streams

Test

Records

Código

Encapsulación

Código

Streams: min max

Puzzle

Herencia

Código

Métodos avanzados de la clase String

Puzzle

Funciones

Código

Polimorfismo de tiempo de compilación

Test

Reto sintaxis Java

Proyecto

Conjuntos

Código

Estructuras de control

Código

Recursión

Código

Excepciones

Puzzle

Herencia avanzada

Puzzle

Estructuras de selección

Test

Uso de interfaces

Test

Operadores

Código

Variables

Código

HashSet

Test

Objeto Scanner

Test

Streams: filter()

Puzzle

Operaciones de Streams

Puzzle

Interfaz funcional Predicate

Puzzle

Streams: sorted()

Test

Configuración de entorno

Test

Uso de variables

Test

Clases

Test

Streams: distinct()

Puzzle

Streams: count()

Test

ArrayList

Test

Mapas

Código

Datos de referencia

Test

Interfaces funcionales

Puzzle

Métodos básicos de la clase String

Test

Tipos de datos

Código

Clases abstractas

Código

Instalación

Test

Funciones

Código

Excepciones

Código

Estructuras de control

Código

Herencia de clases

Código

La clase Scanner

Código

Generics

Código

Streams: map()

Puzzle

Funciones y encapsulamiento

Test

Todas las lecciones de Java

Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Instalación De Java

Introducción Y Entorno

Configuración De Entorno Java

Introducción Y Entorno

Tipos De Datos

Sintaxis

Variables

Sintaxis

Operadores

Sintaxis

Estructuras De Control

Sintaxis

Funciones

Sintaxis

Recursión

Sintaxis

Arrays Y Matrices

Sintaxis

Excepciones

Programación Orientada A Objetos

Clases Y Objetos

Programación Orientada A Objetos

Encapsulación

Programación Orientada A Objetos

Herencia

Programación Orientada A Objetos

Clases Abstractas

Programación Orientada A Objetos

Interfaces

Programación Orientada A Objetos

Sobrecarga De Métodos

Programación Orientada A Objetos

Polimorfismo

Programación Orientada A Objetos

La Clase Scanner

Programación Orientada A Objetos

Métodos De La Clase String

Programación Orientada A Objetos

Excepciones

Programación Orientada A Objetos

Records

Programación Orientada A Objetos

Pattern Matching

Programación Orientada A Objetos

Inferencia De Tipos Con Var

Programación Orientada A Objetos

Enumeraciones Enums

Programación Orientada A Objetos

Generics

Programación Orientada A Objetos

Clases Sealed

Programación Orientada A Objetos

Listas

Framework Collections

Conjuntos

Framework Collections

Mapas

Framework Collections

Funciones Lambda

Programación Funcional

Interfaz Funcional Consumer

Programación Funcional

Interfaz Funcional Predicate

Programación Funcional

Interfaz Funcional Supplier

Programación Funcional

Interfaz Funcional Function

Programación Funcional

Métodos Referenciados

Programación Funcional

Creación De Streams

Programación Funcional

Operaciones Intermedias Con Streams: Map()

Programación Funcional

Operaciones Intermedias Con Streams: Filter()

Programación Funcional

Operaciones Intermedias Con Streams: Distinct()

Programación Funcional

Operaciones Finales Con Streams: Collect()

Programación Funcional

Operaciones Finales Con Streams: Min Max

Programación Funcional

Operaciones Intermedias Con Streams: Flatmap()

Programación Funcional

Operaciones Intermedias Con Streams: Sorted()

Programación Funcional

Operaciones Finales Con Streams: Reduce()

Programación Funcional

Operaciones Finales Con Streams: Foreach()

Programación Funcional

Operaciones Finales Con Streams: Count()

Programación Funcional

Operaciones Finales Con Streams: Match

Programación Funcional

Api Optional

Programación Funcional

Transformación

Programación Funcional

Reducción Y Acumulación

Programación Funcional

Mapeo

Programación Funcional

Streams Paralelos

Programación Funcional

Agrupación Y Partición

Programación Funcional

Filtrado Y Búsqueda

Programación Funcional

Api Java.nio 2

Entrada Y Salida Io

Fundamentos De Io

Entrada Y Salida Io

Leer Y Escribir Archivos

Entrada Y Salida Io

Httpclient Moderno

Entrada Y Salida Io

Clases De Nio2

Entrada Y Salida Io

Api Java.time

Api Java.time

Localtime

Api Java.time

Localdatetime

Api Java.time

Localdate

Api Java.time

Executorservice

Concurrencia

Virtual Threads (Project Loom)

Concurrencia

Future Y Completablefuture

Concurrencia

Spring Framework

Frameworks Para Java

Micronaut

Frameworks Para Java

Maven

Frameworks Para Java

Gradle

Frameworks Para Java

Lombok Para Java

Frameworks Para Java

Quarkus

Frameworks Para Java

Ecosistema Jakarta Ee De Java

Frameworks Para Java

Introducción A Junit 5

Testing

Accede GRATIS a Java y certifícate

Certificados de superación de Java

Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

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