Módulo 16: Colecciones y genéricos

Objetivos del módulo

  • Conocer las limitaciones de los arrays y el framework de colecciones de Java
  • Entender los genéricos (<T>), el operador diamante y por qué evitan los casts
  • Trabajar con listas (ArrayList, LinkedList): operaciones, recorridos, iteradores y borrado seguro
  • Usar conjuntos (HashSet, LinkedHashSet, TreeSet) y entender la importancia de equals() y hashCode()
  • Usar mapas (HashMap, LinkedHashMap, TreeMap), recorrerlos y contar frecuencias
  • Implementar pilas y colas con ArrayDeque y colas de prioridad con PriorityQueue
  • Ordenar con Comparable y Comparator (comparing, thenComparing, reversed)
  • Aprovechar la clase Collections, las colecciones inmutables (List.of…) y las colecciones secuenciadas de Java 21
  • Crear clases y métodos genéricos propios
  • Elegir la colección adecuada para cada problema

1. Por qué necesitamos colecciones

En el módulo 08 trabajaste con arrays. Son rápidos y sencillos, pero tienen un tamaño fijo (si no sabes cuántos datos habrá, tienes que reservar “de más” o crear un array mayor y copiar), insertar o borrar en medio te obliga a desplazar elementos a mano, y no ofrecen operaciones como “¿contiene?”, “borrar este valor” o “buscar por clave”. Compara:

String[] nombres = new String[2];        // tamaño decidido de antemano
nombres = Arrays.copyOf(nombres, 3);     // "crecer" = crear otro array y copiar

List<String> lista = new ArrayList<>();  // crece sola
lista.add("Ana");
lista.add("Eva");
lista.add(1, "Marta");                   // insertar en medio: desplaza al resto -> [Ana, Marta, Eva]
lista.remove("Ana");                     // borrar por valor -> [Marta, Eva]

📘 Concepto: Una colección es un objeto que agrupa otros objetos y ofrece operaciones para añadirlos, buscarlos, borrarlos y recorrerlos, creciendo y encogiendo automáticamente. Java incluye un conjunto completo de colecciones en el paquete java.util, conocido como el Java Collections Framework.

1.1. El framework de colecciones

El framework se organiza en interfaces (qué se puede hacer) y clases que las implementan (cómo se hace por dentro):

Jerarquía: Iterable, Collection, List, Set, Queue, Deque y sus implementaciones; Map con HashMap, LinkedHashMap y TreeMap Figura 1. Las interfaces y clases principales del framework de colecciones.

Interfaz Qué representa Admite duplicados Implementaciones habituales
List<E> Secuencia ordenada por posición (índice 0, 1, 2…) Sí ArrayList, LinkedList
Set<E> Conjunto de elementos únicos No HashSet, LinkedHashSet, TreeSet
Queue<E> / Deque<E> Cola (y cola doble: por los dos extremos) Sí ArrayDeque, PriorityQueue, LinkedList
Map<K, V> Parejas clave → valor, con claves únicas Claves no; valores sí HashMap, LinkedHashMap, TreeMap

💡 Consejo: Declara las variables con el tipo de la interfaz y crea el objeto con la clase: List<String> nombres = new ArrayList<>();. Es polimorfismo (módulo 13): si mañana quieres cambiar a LinkedList, solo cambias una palabra.


2. Genéricos: colecciones con tipo

2.1. El problema sin genéricos

Antes de Java 5 las colecciones guardaban Object. Se podía meter cualquier cosa y, al sacarla, había que hacer un cast… que podía fallar en tiempo de ejecución:

import java.util.ArrayList;

public class SinGenericos {
    public static void main(String[] args) {
        ArrayList lista = new ArrayList();      // "tipo crudo": sin <...>
        lista.add("hola");
        lista.add(42);                          // el compilador no protesta
        for (Object o : lista) {
            String s = (String) o;              // cast obligatorio... y peligroso
            System.out.println(s.toUpperCase());
        }
    }
}

Salida (los avisos del compilador, resumidos, y la ejecución):

SinGenericos.java:6: warning: [unchecked] unchecked call to add(E) as a member of the raw type ArrayList
SinGenericos.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type ArrayList
2 warnings
HOLA
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at SinGenericos.main(SinGenericos.java:9)

El compilador solo da avisos (warnings), el programa se ejecuta y el error aparece al ejecutar, en la segunda vuelta del bucle.

2.2. La solución: parámetros de tipo

📘 Concepto: Los genéricos permiten indicar entre < > el tipo de los elementos de una colección: List<String> es “una lista de String”. El compilador comprueba que solo metes String y, al sacar un elemento, ya sabe que es un String: no hace falta cast y los errores se detectan al compilar.

List<String> nombres = new ArrayList<>();   // el <> vacío es el "operador diamante" (Java 7)
nombres.add("Ana");
nombres.add(42);                             // ERROR de compilación: 42 no es un String
String primero = nombres.get(0);             // sin cast
Elemento Significado
List<String> Tipo parametrizado: lista de String
new ArrayList<>() Diamante: el compilador deduce <String> de la izquierda
Map<String, Integer> Dos parámetros: tipo de la clave y tipo del valor
List<int> No válido: los genéricos solo admiten objetos. Usa la clase envoltorio List<Integer> (módulo 11); el autoboxing convierte int ↔ Integer automáticamente

3. Listas: ArrayList y LinkedList

3.1. Operaciones básicas y recorridos

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListaBasica {
    public static void main(String[] args) {
        List<String> compra = new ArrayList<>();    // interfaz a la izquierda, clase a la derecha
        compra.add("pan");
        compra.add("leche");
        compra.add("huevos");
        compra.add(0, "café");                      // insertar en una posición
        compra.add("leche");                        // las listas admiten duplicados

        System.out.println("Lista: " + compra);
        System.out.println("Tamaño: " + compra.size());
        System.out.println("Elemento 2: " + compra.get(2));
        System.out.println("¿Hay huevos? " + compra.contains("huevos"));
        System.out.println("Primera leche en: " + compra.indexOf("leche")
                + ", última en: " + compra.lastIndexOf("leche"));

        compra.set(1, "pan integral");              // reemplazar
        compra.remove(compra.size() - 1);           // borrar por posición
        compra.remove("café");                      // borrar por valor
        System.out.println("Después de cambios: " + compra);

        // Tres formas de recorrer
        for (int i = 0; i < compra.size(); i++) {   // 1) con índice
            System.out.print(i + "=" + compra.get(i) + " ");
        }
        System.out.println();
        for (String producto : compra) {            // 2) for-each (la más habitual)
            System.out.print(producto.toUpperCase() + " ");
        }
        System.out.println();
        Iterator<String> it = compra.iterator();    // 3) iterador explícito
        while (it.hasNext()) {
            System.out.print("[" + it.next() + "] ");
        }
        System.out.println();

        compra.clear();
        System.out.println("¿Vacía? " + compra.isEmpty());
    }
}

Salida:

Lista: [café, pan, leche, huevos, leche]
Tamaño: 5
Elemento 2: leche
¿Hay huevos? true
Primera leche en: 2, última en: 4
Después de cambios: [pan integral, leche, huevos]
0=pan integral 1=leche 2=huevos 
PAN INTEGRAL LECHE HUEVOS 
[pan integral] [leche] [huevos] 
¿Vacía? true
Método Qué hace
add(e) / add(i, e) Añade al final / en la posición i (desplaza el resto)
get(i) / set(i, e) Lee / reemplaza la posición i
remove(i) / remove(objeto) Borra por posición / la primera aparición del objeto (usa equals)
size(), isEmpty(), clear() Tamaño, ¿vacía?, vaciar
contains(e), indexOf(e), lastIndexOf(e) Buscar (usan equals); indexOf devuelve -1 si no está
addAll(otra), subList(desde, hasta) Añadir todos los de otra colección; vista de un tramo

⚠️ Importante: Con List<Integer>, remove(1) borra la posición 1, no el valor 1. Para borrar el valor, conviértelo en objeto:

List<Integer> numeros = new ArrayList<>(List.of(10, 20, 30, 1));
numeros.remove(1);                     // remove(int índice): borra el 20 -> [10, 30, 1]
numeros.remove(Integer.valueOf(1));    // remove(Object): borra el valor 1 -> [10, 30]

3.2. Eliminar elementos mientras se recorre

Borrar elementos dentro de un for-each es un error clásico: la lista cambia “por debajo” del recorrido y Java lanza ConcurrentModificationException. Hay dos formas correctas:

📘 Concepto: Una lambda es una función breve escrita en el sitio donde se usa: n -> n > 4 significa “dado n, devuelve si n > 4”. Muchos métodos de las colecciones reciben lambdas. Una referencia a método como Alumno::getNota es una forma abreviada de la lambda a -> a.getNota(). Las estudiarás a fondo en el Curso Avanzado; aquí basta con leerlas así.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class EliminarRecorrido {
    public static void main(String[] args) {
        List<Integer> numeros = new ArrayList<>(List.of(3, 8, 5, 12, 7, 4));

        // Forma 1: Iterator.remove()
        Iterator<Integer> it = numeros.iterator();
        while (it.hasNext()) {
            if (it.next() % 2 == 0) {
                it.remove();           // borra el último elemento devuelto por next()
            }
        }
        System.out.println("Sin pares: " + numeros);

        // Forma 2: removeIf con una condición (lambda)
        numeros.removeIf(n -> n > 4);
        System.out.println("Sin mayores que 4: " + numeros);

        // Lo que NO se debe hacer: borrar dentro de un for-each
        List<String> nombres = new ArrayList<>(List.of("Ana", "Bea", "Carlos", "Dani"));
        for (String n : nombres) {
            if (n.startsWith("B")) {
                nombres.remove(n);
            }
        }
    }
}

Salida:

Sin pares: [3, 5, 7]
Sin mayores que 4: [3]
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
	at EliminarRecorrido.main(EliminarRecorrido.java:24)

removeIf es la forma más corta y clara; el Iterator es útil cuando la lógica es más compleja o haces otras cosas en el mismo recorrido.

3.3. LinkedList

LinkedList implementa List y también Deque, así que, además de todo lo anterior, añade operaciones por los extremos:

LinkedList<String> tareas = new LinkedList<>(List.of("estudiar"));
tareas.addFirst("desayunar");    // [desayunar, estudiar]
tareas.addLast("cenar");         // [desayunar, estudiar, cenar]
tareas.getFirst();               // "desayunar"
tareas.removeFirst();            // [estudiar, cenar]

3.4. ¿ArrayList o LinkedList?

Por dentro funcionan de forma muy distinta:

ArrayList guarda los elementos en un array contiguo; LinkedList, en nodos enlazados con referencias al anterior y al siguiente Figura 2. Estructura interna de ArrayList y de LinkedList.

Para comparar su rendimiento se usa la notación O grande: O(1) significa “tiempo constante, da igual el tamaño”; O(n), “proporcional al número de elementos”; O(log n), “crece muy despacio” (duplicar los datos solo añade un paso).

Operación ArrayList LinkedList
get(i) / set(i, e) O(1) O(n): recorre nodos
add(e) al final O(1) (casi siempre) O(1)
add(0, e) / remove(0) (al principio) O(n): desplaza todo O(1)
add(i, e) / remove(i) en medio O(n): desplaza O(n): primero hay que llegar
contains(e) / indexOf(e) O(n) O(n)
Memoria Compacta Un nodo con dos referencias extra por elemento

💡 Consejo: Usa ArrayList por defecto. Gracias a que sus datos están juntos en memoria, en la práctica es más rápida que LinkedList incluso en muchas operaciones donde la teoría dice lo contrario. Si necesitas trabajar por los extremos (pila o cola), usa ArrayDeque (sección 6).


4. Conjuntos: Set

Un Set no admite duplicados: si añades un elemento que ya está, add devuelve false y no hace nada. Es ideal para quitar repetidos o para comprobar rápidamente si algo pertenece a un grupo.

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class Conjuntos {
    public static void main(String[] args) {
        String[] palabras = {"pera", "manzana", "kiwi", "pera", "uva", "kiwi", "fresa"};

        Set<String> hash = new HashSet<>();
        Set<String> enlazado = new LinkedHashSet<>();
        Set<String> arbol = new TreeSet<>();
        for (String p : palabras) {
            hash.add(p);
            enlazado.add(p);
            arbol.add(p);
        }
        System.out.println("HashSet:       " + hash);       // orden no garantizado
        System.out.println("LinkedHashSet: " + enlazado);   // orden de inserción
        System.out.println("TreeSet:       " + arbol);      // orden natural (alfabético)
        System.out.println("Distintas: " + arbol.size() + " de " + palabras.length);

        System.out.println("add(\"uva\") otra vez: " + arbol.add("uva"));   // false: ya estaba

        // Operaciones de conjuntos (sobre copias, para no modificar los originales)
        Set<String> a = new TreeSet<>(Set.of("pera", "uva", "kiwi"));
        Set<String> b = new TreeSet<>(Set.of("kiwi", "fresa", "pera"));
        Set<String> union = new TreeSet<>(a);
        union.addAll(b);
        Set<String> interseccion = new TreeSet<>(a);
        interseccion.retainAll(b);
        Set<String> diferencia = new TreeSet<>(a);
        diferencia.removeAll(b);
        System.out.println("A ∪ B = " + union + ", A ∩ B = " + interseccion + ", A - B = " + diferencia);
    }
}

Salida:

HashSet:       [pera, manzana, uva, kiwi, fresa]
LinkedHashSet: [pera, manzana, kiwi, uva, fresa]
TreeSet:       [fresa, kiwi, manzana, pera, uva]
Distintas: 5 de 7
add("uva") otra vez: false
A ∪ B = [fresa, kiwi, pera, uva], A ∩ B = [kiwi, pera], A - B = [uva]
Clase Orden al recorrer add / contains Úsala cuando…
HashSet Sin orden garantizado (puede cambiar) O(1) Solo te importa “está o no está”
LinkedHashSet Orden de inserción O(1) Quieres quitar duplicados conservando el orden
TreeSet Ordenado (natural o con Comparator) O(log n) Necesitas los elementos ordenados, el menor, el mayor…

⚠️ Importante: No escribas programas que dependan del orden de un HashSet o un HashMap: no está garantizado y puede cambiar entre versiones de Java o al añadir elementos.

4.1. La importancia de equals() y hashCode()

¿Cómo sabe un HashSet que dos objetos son “el mismo”? Usa hashCode() para decidir en qué zona de su tabla interna buscar y equals() para comparar dentro de esa zona:

Cada elemento va al cubo que indica su hashCode; dentro del cubo se compara con equals Figura 3. Esquema de la tabla hash interna de HashSet y HashMap.

Si tu clase no sobrescribe equals() y hashCode(), Java usa los de Object, que comparan identidad (si es el mismo objeto en memoria). Resultado: dos objetos con los mismos datos se consideran distintos.

import java.util.HashSet;
import java.util.Set;

class PersonaSinEquals {
    private final String dni;
    PersonaSinEquals(String dni) { this.dni = dni; }
    @Override public String toString() { return dni; }
}

record Persona(String dni) { }      // equals y hashCode generados

public class EqualsHashCode {
    public static void main(String[] args) {
        Set<PersonaSinEquals> mal = new HashSet<>();
        mal.add(new PersonaSinEquals("123A"));
        mal.add(new PersonaSinEquals("123A"));
        System.out.println("Sin equals/hashCode: " + mal.size() + " elementos " + mal);

        Set<Persona> bien = new HashSet<>();
        bien.add(new Persona("123A"));
        bien.add(new Persona("123A"));
        System.out.println("Con equals/hashCode: " + bien.size() + " elemento " + bien);
        System.out.println("¿Contiene 123A? " + bien.contains(new Persona("123A")));
    }
}

Salida:

Sin equals/hashCode: 2 elementos [123A, 123A]
Con equals/hashCode: 1 elemento [Persona[dni=123A]]
¿Contiene 123A? true

⚠️ Importante: Cualquier clase que vayas a guardar en un HashSet o usar como clave de un HashMap debe sobrescribir equals() y hashCode() de forma coherente (módulo 12), o ser un record, que ya los trae. Para TreeSet y TreeMap lo que cuenta es compareTo() o el Comparator (sección 7). Y no modifiques un objeto mientras está dentro de un HashSet: su hashCode cambiaría y el conjunto ya no lo encontraría.


5. Mapas: Map

Un mapa asocia claves únicas con valores, como un diccionario (palabra → definición) o una agenda (nombre → teléfono). Buscar por clave es inmediato, sin recorrer nada.

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;

public class Mapas {
    public static void main(String[] args) {
        Map<String, Integer> edades = new HashMap<>();
        edades.put("Lucía", 31);
        edades.put("Andrés", 25);
        edades.put("Marta", 40);
        Integer anterior = edades.put("Andrés", 26);     // misma clave: reemplaza
        System.out.println("Valor anterior de Andrés: " + anterior);

        System.out.println("Edad de Marta: " + edades.get("Marta"));
        System.out.println("Edad de Pablo: " + edades.get("Pablo"));          // null
        System.out.println("Edad de Pablo (por defecto): " + edades.getOrDefault("Pablo", 0));
        System.out.println("¿Está Lucía? " + edades.containsKey("Lucía")
                + "; ¿alguien con 40? " + edades.containsValue(40));
        edades.putIfAbsent("Lucía", 99);                 // no cambia: ya existe
        edades.remove("Marta");
        System.out.println("Tamaño: " + edades.size());

        // Recorridos
        Map<String, Integer> ordenado = new TreeMap<>(edades);   // ordena por clave
        ordenado.put("Beatriz", 19);
        System.out.println("Claves: " + ordenado.keySet());
        System.out.println("Valores: " + ordenado.values());
        for (Map.Entry<String, Integer> e : ordenado.entrySet()) {
            System.out.println("  " + e.getKey() + " tiene " + e.getValue() + " años");
        }

        // LinkedHashMap: recuerda el orden de inserción
        Map<String, String> capitales = new LinkedHashMap<>();
        capitales.put("Portugal", "Lisboa");
        capitales.put("Francia", "París");
        capitales.put("Italia", "Roma");
        System.out.println(capitales);
    }
}

Salida:

Valor anterior de Andrés: 25
Edad de Marta: 40
Edad de Pablo: null
Edad de Pablo (por defecto): 0
¿Está Lucía? true; ¿alguien con 40? true
Tamaño: 2
Claves: [Andrés, Beatriz, Lucía]
Valores: [26, 19, 31]
  Andrés tiene 26 años
  Beatriz tiene 19 años
  Lucía tiene 31 años
{Portugal=Lisboa, Francia=París, Italia=Roma}
Método Qué hace
put(k, v) Asocia v a k. Si k ya existía, reemplaza y devuelve el valor anterior
get(k) Valor asociado a k, o null si no existe
getOrDefault(k, def) Valor de k, o def si no existe
containsKey(k) / containsValue(v) ¿Existe la clave? ¿Algún valor igual a v? (el segundo es O(n))
putIfAbsent(k, v) Solo añade si la clave no existía
remove(k) Borra la entrada y devuelve su valor
keySet() / values() / entrySet() Vistas de las claves, los valores y las parejas (Map.Entry)
Clase Orden de las claves Úsala cuando…
HashMap Sin orden garantizado Caso general (el más usado)
LinkedHashMap Orden de inserción Quieres recorrer en el orden en que añadiste
TreeMap Ordenadas (natural o Comparator) Necesitas las claves ordenadas

💡 Consejo: Para recorrer un mapa usando claves y valores, recorre entrySet(), no keySet() + get(k): es más claro y más eficiente.

5.1. Contar frecuencias

Contar cuántas veces aparece cada cosa es uno de los usos más frecuentes de un mapa:

import java.util.Map;
import java.util.TreeMap;

public class Frecuencias {
    public static void main(String[] args) {
        String texto = "abracadabra";
        Map<Character, Integer> cuenta = new TreeMap<>();
        for (char c : texto.toCharArray()) {
            cuenta.put(c, cuenta.getOrDefault(c, 0) + 1);   // patrón "contar"
        }
        System.out.println(cuenta);
    }
}

Salida:

{a=5, b=2, c=1, d=1, r=2}

cuenta.put(c, cuenta.getOrDefault(c, 0) + 1) se lee: “el nuevo valor es el que había (o 0 si no había) más uno”. Existe una forma más corta, cuenta.merge(c, 1, Integer::sum): si la clave no existe pone 1, y si existe suma 1 al valor anterior.


6. Pilas y colas: Deque, Queue y PriorityQueue

  • Una pila (stack) es LIFO (last in, first out): como una pila de platos, se coloca y se quita por arriba. Ejemplos: el botón “atrás” del navegador, deshacer en un editor, la pila de llamadas del módulo 15.
  • Una cola (queue) es FIFO (first in, first out): como la cola del supermercado, se entra por detrás y se sale por delante.
  • Una cola de prioridad sirve siempre primero al elemento de mayor prioridad, no al que llegó antes: como las urgencias de un hospital.

ArrayDeque (cola doble: se puede usar por los dos extremos) sirve para las dos primeras:

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.PriorityQueue;
import java.util.Queue;

public class PilaCola {
    public static void main(String[] args) {
        // Pila (LIFO): el último en entrar es el primero en salir
        Deque<String> pila = new ArrayDeque<>();
        pila.push("página 1");
        pila.push("página 2");
        pila.push("página 3");
        System.out.println("Pila: " + pila + ", cima: " + pila.peek());
        System.out.println("Atrás -> " + pila.pop() + ", atrás -> " + pila.pop());
        System.out.println("Queda: " + pila);

        // Cola (FIFO): el primero en entrar es el primero en salir
        Queue<String> cola = new ArrayDeque<>();
        cola.offer("cliente A");
        cola.offer("cliente B");
        cola.offer("cliente C");
        System.out.println("Cola: " + cola + ", siguiente: " + cola.peek());
        System.out.println("Atendido: " + cola.poll() + ", atendido: " + cola.poll());
        System.out.println("Queda: " + cola);

        // Cola de prioridad: sale siempre el menor (o según un Comparator)
        PriorityQueue<Integer> urgencias = new PriorityQueue<>();
        urgencias.offer(5);
        urgencias.offer(1);
        urgencias.offer(3);
        urgencias.offer(2);
        System.out.print("Por prioridad:");
        while (!urgencias.isEmpty()) {
            System.out.print(" " + urgencias.poll());
        }
        System.out.println();
    }
}

Salida:

Pila: [página 3, página 2, página 1], cima: página 3
Atrás -> página 3, atrás -> página 2
Queda: [página 1]
Cola: [cliente A, cliente B, cliente C], siguiente: cliente A
Atendido: cliente A, atendido: cliente B
Queda: [cliente C]
Por prioridad: 1 2 3 5
Uso Añadir Quitar Consultar sin quitar
Pila (Deque) push(e) pop() peek()
Cola (Queue) offer(e) poll() peek()

💡 Consejo: poll() y peek() devuelven null si la estructura está vacía; pop(), remove() y element() lanzan NoSuchElementException. No uses la antigua clase Stack: está desaconsejada; ArrayDeque es su sustituta.


7. Ordenar: Comparable y Comparator

Para ordenar, Java necesita saber cómo comparar dos elementos. Hay dos mecanismos:

  Comparable<T> Comparator<T>
Dónde se define Dentro de la clase (implements Comparable<Alumno>) Fuera, como un objeto aparte
Método int compareTo(T otro) int compare(T a, T b)
Cuántos Uno: el orden natural Tantos como quieras
Ejemplos en Java String (alfabético), Integer (numérico), LocalDate Comparator.comparing(...), Comparator.reverseOrder()

Ambos métodos devuelven un negativo si el primero va antes, 0 si son equivalentes y un positivo si va después.

import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

class Alumno implements Comparable<Alumno> {
    private final String nombre;
    private final String grupo;
    private final double nota;

    Alumno(String nombre, String grupo, double nota) {
        this.nombre = nombre;
        this.grupo = grupo;
        this.nota = nota;
    }

    public String getNombre() { return nombre; }
    public String getGrupo() { return grupo; }
    public double getNota() { return nota; }

    // Orden NATURAL: por nombre
    @Override
    public int compareTo(Alumno otro) {
        return this.nombre.compareTo(otro.nombre);
    }

    @Override
    public String toString() {
        return nombre + "(" + grupo + ", " + nota + ")";
    }
}

public class Ordenar {
    public static void main(String[] args) {
        List<Alumno> clase = new ArrayList<>(List.of(
                new Alumno("Marta", "B", 7.5), new Alumno("Álex", "A", 9.0),
                new Alumno("Carlos", "A", 6.0), new Alumno("Beatriz", "B", 9.0),
                new Alumno("Diego", "A", 7.5)));

        Collections.sort(clase);                                  // orden natural (Comparable)
        System.out.println("Por nombre:  " + clase);

        clase.sort(Comparator.comparing(Alumno::getNota));        // un Comparator
        System.out.println("Por nota:    " + clase);

        clase.sort(Comparator.comparing(Alumno::getNota).reversed());
        System.out.println("Nota desc.:  " + clase);

        clase.sort(Comparator.comparing(Alumno::getGrupo)
                .thenComparing(Alumno::getNota, Comparator.reverseOrder())
                .thenComparing(Alumno::getNombre));
        System.out.println("Grupo, nota desc., nombre: " + clase);

        // Un Comparator escrito como lambda: por longitud del nombre
        clase.sort((a1, a2) -> Integer.compare(a1.getNombre().length(), a2.getNombre().length()));
        System.out.println("Por longitud del nombre: " + clase);

        // Orden alfabético "de diccionario" en español (Á junto a A)
        clase.sort(Comparator.comparing(Alumno::getNombre, Collator.getInstance(Locale.of("es", "ES"))));
        System.out.println("Por nombre (español): " + clase);
    }
}

Salida:

Por nombre:  [Beatriz(B, 9.0), Carlos(A, 6.0), Diego(A, 7.5), Marta(B, 7.5), Álex(A, 9.0)]
Por nota:    [Carlos(A, 6.0), Diego(A, 7.5), Marta(B, 7.5), Beatriz(B, 9.0), Álex(A, 9.0)]
Nota desc.:  [Beatriz(B, 9.0), Álex(A, 9.0), Diego(A, 7.5), Marta(B, 7.5), Carlos(A, 6.0)]
Grupo, nota desc., nombre: [Álex(A, 9.0), Diego(A, 7.5), Carlos(A, 6.0), Beatriz(B, 9.0), Marta(B, 7.5)]
Por longitud del nombre: [Álex(A, 9.0), Diego(A, 7.5), Marta(B, 7.5), Carlos(A, 6.0), Beatriz(B, 9.0)]
Por nombre (español): [Álex(A, 9.0), Beatriz(B, 9.0), Carlos(A, 6.0), Diego(A, 7.5), Marta(B, 7.5)]
Construcción Significado
Collections.sort(lista) / lista.sort(null) Orden natural (Comparable)
lista.sort(Comparator.comparing(Alumno::getNota)) Por un atributo
.reversed() Invierte el orden
.thenComparing(Alumno::getNombre) Criterio de desempate
.thenComparing(Alumno::getNota, Comparator.reverseOrder()) Desempate en orden inverso
(a, b) -> Integer.compare(a.x(), b.x()) Comparator escrito como lambda

⚠️ Importante: Fíjate en la primera línea de la salida: Álex aparece al final. String.compareTo() compara códigos Unicode, y la Á va después de todas las letras sin tilde. Para ordenar texto “como en el diccionario” usa un Collator del idioma, como en la última línea del ejemplo.

💡 Consejo: Nunca compares con una resta (return this.edad - otro.edad;): con números grandes puede desbordarse. Usa Integer.compare(a, b) o Double.compare(a, b).


8. La clase Collections y las colecciones inmutables

8.1. Utilidades de Collections

La clase Collections (con s final; no confundir con la interfaz Collection) tiene métodos estáticos para trabajar con colecciones, igual que Arrays los tiene para arrays:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

public class UtilidadesCollections {
    public static void main(String[] args) {
        List<Integer> numeros = new ArrayList<>(List.of(42, 7, 19, 7, 88, 3));

        System.out.println("Máximo: " + Collections.max(numeros) + ", mínimo: " + Collections.min(numeros));
        System.out.println("Veces que aparece el 7: " + Collections.frequency(numeros, 7));

        Collections.sort(numeros);
        System.out.println("Ordenada: " + numeros);
        System.out.println("¿Dónde está el 19? " + Collections.binarySearch(numeros, 19));
        Collections.reverse(numeros);
        System.out.println("Invertida: " + numeros);
        Collections.shuffle(numeros, new Random(42));   // semilla fija: siempre el mismo orden
        System.out.println("Barajada: " + numeros);

        List<Integer> soloLectura = Collections.unmodifiableList(numeros);
        System.out.println("Vista de solo lectura: " + soloLectura);
        numeros.add(100);                               // la original sí cambia...
        System.out.println("...y la vista lo refleja: " + soloLectura);
        soloLectura.add(1);                             // UnsupportedOperationException
    }
}

Salida:

Máximo: 88, mínimo: 3
Veces que aparece el 7: 2
Ordenada: [3, 7, 7, 19, 42, 88]
¿Dónde está el 19? 3
Invertida: [88, 42, 19, 7, 7, 3]
Barajada: [42, 88, 7, 3, 7, 19]
Vista de solo lectura: [42, 88, 7, 3, 7, 19]
...y la vista lo refleja: [42, 88, 7, 3, 7, 19, 100]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1093)
	at UtilidadesCollections.main(UtilidadesCollections.java:25)

binarySearch es la búsqueda binaria del módulo 08 (requiere la lista ordenada). unmodifiableList devuelve una vista de solo lectura: no se puede modificar a través de ella, pero refleja los cambios de la lista original.

8.2. List.of, Set.of y Map.of (Java 9)

Para crear colecciones pequeñas con valores fijos existen los métodos de fábrica of, que crean colecciones inmutables:

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class Inmutables {
    public static void main(String[] args) {
        List<String> dias = List.of("lunes", "martes", "miércoles");
        Map<String, Integer> notas = Map.of("Ana", 8, "Luis", 6);
        Set<Integer> primos = Set.of(2, 3, 5, 7);
        System.out.println(dias + " " + notas.get("Ana") + " " + primos.contains(5));

        List<String> copia = new ArrayList<>(dias);     // copia modificable
        copia.add("jueves");
        System.out.println("Copia modificable: " + copia);
        List<String> congelada = List.copyOf(copia);    // copia inmutable
        System.out.println("Copia inmutable: " + congelada);

        try {
            dias.add("jueves");
        } catch (UnsupportedOperationException e) {
            System.out.println("No se puede modificar: " + e);
        }
        try {
            Set.of(1, 2, 2);
        } catch (IllegalArgumentException e) {
            System.out.println("Set.of con duplicados: " + e.getMessage());
        }
    }
}

Salida:

[lunes, martes, miércoles] 8 true
Copia modificable: [lunes, martes, miércoles, jueves]
Copia inmutable: [lunes, martes, miércoles, jueves]
No se puede modificar: java.lang.UnsupportedOperationException
Set.of con duplicados: duplicate element: 2

List.copyOf(c) crea una copia inmutable (no refleja cambios posteriores del original), a diferencia de Collections.unmodifiableList, que es una vista. Ninguna de las colecciones creadas con of o copyOf admite null.

⚠️ Importante: El orden de recorrido de Set.of y Map.of no está definido y puede cambiar entre ejecuciones. Si necesitas orden, usa new TreeSet<>(Set.of(...)) o un LinkedHashMap. Y si necesitas una lista que puedas modificar, crea una copia: new ArrayList<>(List.of(...)).

8.3. Colecciones secuenciadas (Java 21)

Java 21 añadió las interfaces SequencedCollection, SequencedSet y SequencedMap para las colecciones que tienen un orden de encuentro definido (listas, Deque, LinkedHashSet, TreeSet, LinkedHashMap, TreeMap…). Ofrecen métodos uniformes para trabajar con el primer y el último elemento:

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedMap;
import java.util.SequencedSet;

public class Secuenciadas {
    public static void main(String[] args) {
        List<String> lista = new ArrayList<>(List.of("b", "c", "d"));
        lista.addFirst("a");                          // Java 21
        lista.addLast("e");
        System.out.println(lista + " primero=" + lista.getFirst() + " último=" + lista.getLast());
        System.out.println("Al revés: " + lista.reversed());   // vista invertida, no copia

        SequencedSet<Integer> visitas = new LinkedHashSet<>(List.of(30, 10, 20));
        System.out.println("Primera visita: " + visitas.getFirst() + ", al revés: " + visitas.reversed());

        SequencedMap<String, Integer> ranking = new LinkedHashMap<>();
        ranking.put("Eva", 120);
        ranking.put("Iván", 95);
        ranking.putFirst("Sara", 150);                // inserta al principio
        System.out.println(ranking + " primero=" + ranking.firstEntry() + " último=" + ranking.lastEntry());
    }
}

Salida:

[a, b, c, d, e] primero=a último=e
Al revés: [e, d, c, b, a]
Primera visita: 30, al revés: [20, 10, 30]
{Sara=150, Eva=120, Iván=95} primero=Sara=150 último=Iván=95

Antes de Java 21 había que escribir lista.get(0) o lista.get(lista.size() - 1), y cada tipo de colección tenía sus propios nombres para lo mismo.

⚠️ Importante: Un HashSet o un HashMap no son secuenciados (no tienen orden definido), así que no tienen estos métodos.


9. Clases y métodos genéricos propios

Las colecciones son genéricas, pero tú también puedes escribir tus propias clases y métodos genéricos. El parámetro de tipo (por convención, una letra mayúscula: T de type, E de element, K de key, V de value) actúa como un “hueco” que se rellena al usar la clase.

import java.util.List;

// Clase genérica con un parámetro de tipo
class Caja<T> {
    private T contenido;

    public void guardar(T contenido) {
        this.contenido = contenido;
    }

    public T sacar() {
        return contenido;
    }

    public boolean estaVacia() {
        return contenido == null;
    }
}

// Clase genérica con dos parámetros de tipo
class Par<K, V> {
    private final K clave;
    private final V valor;

    public Par(K clave, V valor) {
        this.clave = clave;
        this.valor = valor;
    }

    public K getClave() { return clave; }
    public V getValor() { return valor; }

    @Override
    public String toString() {
        return "(" + clave + ", " + valor + ")";
    }
}

public class Genericos {

    // Método genérico con tipo ACOTADO: T debe poder compararse
    static <T extends Comparable<T>> T maximo(List<T> lista) {
        T max = lista.get(0);
        for (T elemento : lista) {
            if (elemento.compareTo(max) > 0) {
                max = elemento;
            }
        }
        return max;
    }

    public static void main(String[] args) {
        Caja<String> cajaTexto = new Caja<>();
        cajaTexto.guardar("un libro");
        String s = cajaTexto.sacar();             // sin cast
        System.out.println("Caja de texto: " + s.toUpperCase());

        Caja<Integer> cajaNumero = new Caja<>();
        System.out.println("¿Vacía? " + cajaNumero.estaVacia());
        cajaNumero.guardar(42);                   // autoboxing: int -> Integer
        int n = cajaNumero.sacar() + 1;
        System.out.println("Caja de número + 1 = " + n);
        // cajaNumero.guardar("hola");            // ERROR de compilación

        Par<String, Double> nota = new Par<>("Matemáticas", 8.5);
        System.out.println(nota + " -> clave " + nota.getClave() + ", valor " + nota.getValor());

        System.out.println("Máximo: " + maximo(List.of(3, 17, 8)) + " y " + maximo(List.of("pera", "zanahoria", "kiwi")));
    }
}

Salida:

Caja de texto: UN LIBRO
¿Vacía? true
Caja de número + 1 = 43
(Matemáticas, 8.5) -> clave Matemáticas, valor 8.5
Máximo: 17 y zanahoria

Si intentas guardar un texto en una Caja<Integer>, el error aparece al compilar, con un mensaje muy claro:

GenericoError.java:9: error: method guardar in class Caja<T> cannot be applied to given types;
        caja.guardar("hola");
            ^
  required: Integer
  found:    String
  reason: argument mismatch; String cannot be converted to Integer
  where T is a type-variable:
    T extends Object declared in class Caja
1 error
error: compilation failed
Sintaxis Significado
class Caja<T> { ... } Clase genérica con un parámetro de tipo
class Par<K, V> { ... } Dos parámetros de tipo
static <T> T primero(List<T> l) Método genérico: el <T> va antes del tipo de retorno
<T extends Comparable<T>> Tipo acotado: T debe implementar Comparable<T> (así podemos llamar a compareTo)
List<? extends Number> Comodín: “una lista de algún subtipo de Number” (lo verás en el Curso Avanzado)

📘 Concepto: Los genéricos existen solo en tiempo de compilación: el compilador comprueba los tipos y luego los “borra” (type erasure). Por eso no puedes hacer new T() ni new T[10], y por eso List<int> no es válido.


10. Elegir la colección adecuada

Árbol de decisión: secuencia (ArrayList, ArrayDeque, PriorityQueue), elementos únicos (HashSet, LinkedHashSet, TreeSet) o clave-valor (HashMap, LinkedHashMap, TreeMap) Figura 4. Cómo elegir la colección adecuada.


11. Ejemplos completos

11.1. Agenda de contactos

Un TreeMap cuya clave es el nombre en minúsculas permite buscar sin distinguir mayúsculas, impedir contactos repetidos y listar en orden alfabético:

import java.util.Map;
import java.util.TreeMap;

record Contacto(String nombre, String telefono, String email) { }

class AgendaContactos {
    // Clave: nombre en minúsculas (para buscar sin distinguir mayúsculas)
    private final Map<String, Contacto> contactos = new TreeMap<>();

    public boolean anadir(Contacto c) {
        String clave = c.nombre().toLowerCase();
        if (contactos.containsKey(clave)) {
            return false;
        }
        contactos.put(clave, c);
        return true;
    }

    public Contacto buscar(String nombre) {
        return contactos.get(nombre.toLowerCase());
    }

    public boolean borrar(String nombre) {
        return contactos.remove(nombre.toLowerCase()) != null;
    }

    public void listar() {
        for (Contacto c : contactos.values()) {
            System.out.printf("  %-16s %-11s %s%n", c.nombre(), c.telefono(), c.email());
        }
    }
}

public class Agenda {
    public static void main(String[] args) {
        AgendaContactos agenda = new AgendaContactos();
        agenda.anadir(new Contacto("Pedro Gil", "600111222", "pedro@ejemplo.es"));
        agenda.anadir(new Contacto("Ana Ruiz", "611333444", "ana@ejemplo.es"));
        agenda.anadir(new Contacto("Andrés Sanz", "622555666", "andres@ejemplo.es"));
        System.out.println("¿Añadido ana ruiz repetido? "
                + agenda.anadir(new Contacto("ANA RUIZ", "699999999", "otra@ejemplo.es")));

        System.out.println("Agenda (ordenada por nombre):");
        agenda.listar();
        System.out.println("Buscar 'pedro gil': " + agenda.buscar("pedro gil"));
        System.out.println("¿Borrado Pedro Gil? " + agenda.borrar("Pedro Gil"));
        System.out.println("Buscar 'pedro gil': " + agenda.buscar("pedro gil"));
    }
}

Salida:

¿Añadido ana ruiz repetido? false
Agenda (ordenada por nombre):
  Ana Ruiz         611333444   ana@ejemplo.es
  Andrés Sanz      622555666   andres@ejemplo.es
  Pedro Gil        600111222   pedro@ejemplo.es
Buscar 'pedro gil': Contacto[nombre=Pedro Gil, telefono=600111222, email=pedro@ejemplo.es]
¿Borrado Pedro Gil? true
Buscar 'pedro gil': null

11.2. Inventario de un almacén

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class Inventario {
    private final Map<String, Integer> stock = new TreeMap<>();

    public void entrada(String producto, int unidades) {
        stock.put(producto, stock.getOrDefault(producto, 0) + unidades);
    }

    public boolean salida(String producto, int unidades) {
        int actual = stock.getOrDefault(producto, 0);
        if (unidades > actual) {
            return false;
        }
        if (actual == unidades) {
            stock.remove(producto);        // sin existencias: lo quitamos del mapa
        } else {
            stock.put(producto, actual - unidades);
        }
        return true;
    }

    public List<String> bajoMinimos(int minimo) {
        List<String> resultado = new ArrayList<>();
        for (Map.Entry<String, Integer> e : stock.entrySet()) {
            if (e.getValue() < minimo) {
                resultado.add(e.getKey() + " (" + e.getValue() + ")");
            }
        }
        return resultado;
    }

    public static void main(String[] args) {
        Inventario inv = new Inventario();
        inv.entrada("tornillos", 500);
        inv.entrada("tuercas", 120);
        inv.entrada("arandelas", 8);
        inv.entrada("tuercas", 30);
        System.out.println("Stock: " + inv.stock);
        System.out.println("Salida de 600 tornillos: " + inv.salida("tornillos", 600));
        System.out.println("Salida de 495 tornillos: " + inv.salida("tornillos", 495));
        System.out.println("Salida de 8 arandelas: " + inv.salida("arandelas", 8));
        System.out.println("Stock: " + inv.stock);
        System.out.println("Bajo mínimos (< 10): " + inv.bajoMinimos(10));
    }
}

Salida:

Stock: {arandelas=8, tornillos=500, tuercas=150}
Salida de 600 tornillos: false
Salida de 495 tornillos: true
Salida de 8 arandelas: true
Stock: {tornillos=5, tuercas=150}
Bajo mínimos (< 10): [tornillos (5)]

11.3. Contar palabras de un texto

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ContarPalabras {
    public static void main(String[] args) {
        String texto = """
                En un lugar de la Mancha, de cuyo nombre no quiero acordarme,
                no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero,
                adarga antigua, rocín flaco y galgo corredor.
                """;

        Map<String, Integer> frecuencias = new HashMap<>();
        for (String palabra : texto.toLowerCase().split("[^\\p{L}]+")) {   // separa por lo que no sea letra
            if (!palabra.isEmpty()) {
                frecuencias.put(palabra, frecuencias.getOrDefault(palabra, 0) + 1);
            }
        }
        System.out.println("Palabras distintas: " + frecuencias.size());

        // Ordenar las entradas: más frecuentes primero; a igualdad, alfabéticamente
        List<Map.Entry<String, Integer>> entradas = new ArrayList<>(frecuencias.entrySet());
        entradas.sort(Map.Entry.<String, Integer>comparingByValue().reversed()
                .thenComparing(Map.Entry.comparingByKey()));

        System.out.println("Las 5 más frecuentes:");
        for (int i = 0; i < 5; i++) {
            Map.Entry<String, Integer> e = entradas.get(i);
            System.out.printf("  %-10s %d%n", e.getKey(), e.getValue());
        }
    }
}

Salida:

Palabras distintas: 27
Las 5 más frecuentes:
  de         4
  en         2
  no         2
  un         2
  acordarme  1

La expresión regular [^\\p{L}]+ (módulo 09) significa “uno o más caracteres que no sean letras”, así que separa por espacios y signos de puntuación y respeta las tildes y la ñ. Para ordenar por frecuencia, se copian las entradas del mapa en una lista y se ordena con un Comparator doble: primero por valor descendente y, a igualdad, por clave.

11.4. Cola de atención

import java.util.ArrayDeque;
import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Queue;

record Paciente(String nombre, int gravedad, int llegada) { }   // gravedad: 1 (leve) a 5 (crítica)

public class ColaAtencion {
    public static void main(String[] args) {
        Paciente[] llegadas = {
            new Paciente("Rosa", 2, 1), new Paciente("Tomás", 5, 2), new Paciente("Inés", 3, 3),
            new Paciente("Hugo", 5, 4), new Paciente("Celia", 1, 5)
        };

        // Ventanilla normal: por orden de llegada (FIFO)
        Queue<Paciente> ventanilla = new ArrayDeque<>();
        // Urgencias: primero la mayor gravedad; a igual gravedad, quien llegó antes
        Queue<Paciente> urgencias = new PriorityQueue<>(
                Comparator.comparingInt(Paciente::gravedad).reversed()
                        .thenComparingInt(Paciente::llegada));

        for (Paciente p : llegadas) {
            ventanilla.offer(p);
            urgencias.offer(p);
        }

        System.out.print("Ventanilla:");
        while (!ventanilla.isEmpty()) {
            System.out.print(" " + ventanilla.poll().nombre());
        }
        System.out.println();

        System.out.print("Urgencias: ");
        while (!urgencias.isEmpty()) {
            Paciente p = urgencias.poll();
            System.out.print(" " + p.nombre() + "(" + p.gravedad() + ")");
        }
        System.out.println();
    }
}

Salida:

Ventanilla: Rosa Tomás Inés Hugo Celia
Urgencias:  Tomás(5) Hugo(5) Inés(3) Rosa(2) Celia(1)

La misma secuencia de llegadas se atiende de forma distinta según la estructura: ArrayDeque respeta el orden de llegada; PriorityQueue con un Comparator atiende primero la mayor gravedad y, a igualdad, a quien llegó antes (Tomás antes que Hugo).


12. Errores comunes

Error Síntoma Solución
Tipos crudos (new ArrayList() sin <>) Avisos y ClassCastException Usa siempre genéricos
Borrar dentro de un for-each ConcurrentModificationException Iterator.remove() o removeIf
lista.remove(1) en una List<Integer> queriendo borrar el valor Se borra la posición 1 lista.remove(Integer.valueOf(1))
Objetos propios en HashSet/HashMap sin equals/hashCode Duplicados, contains devuelve false Sobrescríbelos o usa un record
map.get(k) de una clave inexistente usado como int NullPointerException al desempaquetar getOrDefault(k, 0) o containsKey
Modificar una colección de List.of UnsupportedOperationException Copia: new ArrayList<>(List.of(...))
Depender del orden de HashSet/HashMap Orden “aleatorio” LinkedHashSet/LinkedHashMap o TreeSet/TreeMap

13. Ejercicios

Ejercicio 1: Quitar duplicados conservando el orden

Escribe dos métodos genéricos: sinDuplicados(List<T>), que devuelva una lista sin repetidos conservando el orden de primera aparición, y repetidos(List<T>), que devuelva el conjunto de elementos que aparecen más de una vez. Pruébalos con una lista de correos y otra de números.

Ver solución
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class E1Duplicados {

    static <T> List<T> sinDuplicados(List<T> lista) {
        return new ArrayList<>(new LinkedHashSet<>(lista));   // conserva el primer orden de aparición
    }

    static <T> Set<T> repetidos(List<T> lista) {
        Set<T> vistos = new LinkedHashSet<>();
        Set<T> repes = new LinkedHashSet<>();
        for (T e : lista) {
            if (!vistos.add(e)) {      // add devuelve false si ya estaba
                repes.add(e);
            }
        }
        return repes;
    }

    public static void main(String[] args) {
        List<String> correos = List.of("ana@x.es", "luis@x.es", "ana@x.es", "eva@x.es", "luis@x.es", "ana@x.es");
        System.out.println("Sin duplicados: " + sinDuplicados(correos));
        System.out.println("Repetidos: " + repetidos(correos));
        System.out.println("Números: " + sinDuplicados(List.of(5, 1, 5, 2, 1, 9)));
    }
}

Salida:

Sin duplicados: [ana@x.es, luis@x.es, eva@x.es]
Repetidos: [ana@x.es, luis@x.es]
Números: [5, 1, 2, 9]

new LinkedHashSet<>(lista) elimina duplicados conservando el orden, y new ArrayList<>(...) lo vuelve a convertir en lista. En repetidos se aprovecha que add devuelve false cuando el elemento ya estaba. Los métodos son genéricos (<T>), así que funcionan con String, Integer o cualquier tipo con equals y hashCode correctos.

Ejercicio 2: Notas por alumno

A partir de un array de registros con el formato "Luis:7.5", construye un Map<String, List<Double>> con todas las notas de cada alumno (ordenado por nombre), muestra sus notas y su media, e indica quién tiene la mejor media.

Ver solución
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class E2NotasAlumnos {
    public static void main(String[] args) {
        String[] registros = {"Luis:7.5", "Ana:9", "Luis:5", "Marta:6.5", "Ana:8", "Luis:6"};

        Map<String, List<Double>> notas = new TreeMap<>();
        for (String r : registros) {
            String[] partes = r.split(":");
            String alumno = partes[0];
            double nota = Double.parseDouble(partes[1]);
            List<Double> lista = notas.get(alumno);
            if (lista == null) {                 // primera nota de este alumno
                lista = new ArrayList<>();
                notas.put(alumno, lista);
            }
            lista.add(nota);
        }

        String mejor = null;
        double mejorMedia = -1;
        for (Map.Entry<String, List<Double>> e : notas.entrySet()) {
            double suma = 0;
            for (double n : e.getValue()) {
                suma += n;
            }
            double media = suma / e.getValue().size();
            System.out.printf("%-6s %-18s media %.2f%n", e.getKey(), e.getValue(), media);
            if (media > mejorMedia) {
                mejorMedia = media;
                mejor = e.getKey();
            }
        }
        System.out.println("Mejor media: " + mejor);
    }
}

Salida:

Ana    [9.0, 8.0]         media 8.50
Luis   [7.5, 5.0, 6.0]    media 6.17
Marta  [6.5]              media 6.50
Mejor media: Ana

Es el patrón agrupar: para cada registro se busca la lista del alumno y, si no existe todavía (get devuelve null), se crea y se guarda en el mapa. Como la lista es un objeto, al hacer lista.add(nota) se modifica la que está dentro del mapa (es la misma referencia, módulo 10). En el Curso Avanzado verás que notas.computeIfAbsent(alumno, k -> new ArrayList<>()).add(nota) hace lo mismo en una línea.

Ejercicio 3: Paréntesis equilibrados

Escribe boolean equilibrado(String expr) que compruebe, usando una pila, si los paréntesis (), corchetes [] y llaves {} de una expresión están bien emparejados y anidados.

Ver solución
import java.util.ArrayDeque;
import java.util.Deque;

public class E3Parentesis {

    static boolean equilibrado(String expr) {
        Deque<Character> pila = new ArrayDeque<>();
        for (char c : expr.toCharArray()) {
            switch (c) {
                case '(', '[', '{' -> pila.push(c);
                case ')', ']', '}' -> {
                    if (pila.isEmpty()) {
                        return false;               // cierre sin apertura
                    }
                    char abierto = pila.pop();
                    if ((c == ')' && abierto != '(') || (c == ']' && abierto != '[')
                            || (c == '}' && abierto != '{')) {
                        return false;               // no casan
                    }
                }
                default -> { }                      // cualquier otro carácter se ignora
            }
        }
        return pila.isEmpty();                      // no debe quedar nada abierto
    }

    public static void main(String[] args) {
        String[] pruebas = {"(a + b) * [c - {d / e}]", "((1 + 2)", "{[()]}", "([)]", "x)", ""};
        for (String p : pruebas) {
            System.out.printf("%-26s %s%n", "\"" + p + "\"", equilibrado(p) ? "equilibrado" : "NO equilibrado");
        }
    }
}

Salida:

"(a + b) * [c - {d / e}]"  equilibrado
"((1 + 2)"                 NO equilibrado
"{[()]}"                   equilibrado
"([)]"                     NO equilibrado
"x)"                       NO equilibrado
""                         equilibrado

Cada apertura se apila; cada cierre debe coincidir con la última apertura pendiente, que es justo la cima de la pila. Hay tres formas de fallar: un cierre sin apertura (pila vacía), un cierre que no casa con la apertura (([)]) y aperturas que quedan sin cerrar al final (((1 + 2)). Los compiladores usan esta misma técnica.

Ejercicio 4: Catálogo ordenado

Crea un record Producto(String nombre, String categoria, double precio) y ordena un catálogo por categoría, después por precio de mayor a menor y, a igual precio, por nombre. Muéstralo agrupado por categoría.

Ver solución
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

record Producto(String nombre, String categoria, double precio) { }

public class E4Productos {
    public static void main(String[] args) {
        List<Producto> catalogo = new ArrayList<>(List.of(
                new Producto("Ratón", "Informática", 15.99),
                new Producto("Sartén", "Hogar", 24.50),
                new Producto("Teclado", "Informática", 45.00),
                new Producto("Lámpara", "Hogar", 24.50),
                new Producto("Monitor", "Informática", 189.00),
                new Producto("Cojín", "Hogar", 9.95)));

        catalogo.sort(Comparator.comparing(Producto::categoria)
                .thenComparing(Producto::precio, Comparator.reverseOrder())
                .thenComparing(Producto::nombre));

        String categoriaActual = "";
        for (Producto p : catalogo) {
            if (!p.categoria().equals(categoriaActual)) {
                categoriaActual = p.categoria();
                System.out.println(categoriaActual + ":");
            }
            System.out.printf("  %-8s %7.2f €%n", p.nombre(), p.precio());
        }
    }
}

Salida:

Hogar:
  Lámpara    24.50 €
  Sartén     24.50 €
  Cojín       9.95 €
Informática:
  Monitor   189.00 €
  Teclado    45.00 €
  Ratón      15.99 €

Con records, las referencias a métodos son los propios accesores (Producto::precio). Como la lista está ordenada por categoría, para agruparla basta con imprimir la cabecera cada vez que la categoría cambia. Para encontrar el más barato bastaría con Collections.min(catalogo, Comparator.comparing(Producto::precio)).

Ejercicio 5: Pila genérica

Implementa una clase genérica Pila<T> con apilar, desapilar, cima y estaVacia, usando internamente un ArrayList. desapilar y cima deben lanzar NoSuchElementException si la pila está vacía. Úsala para simular el “deshacer” de un editor.

Ver solución
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;

class Pila<T> {
    private final List<T> elementos = new ArrayList<>();

    public void apilar(T elemento) {
        elementos.add(elemento);                 // la cima es el final de la lista
    }

    public T desapilar() {
        if (estaVacia()) {
            throw new NoSuchElementException("La pila está vacía");
        }
        return elementos.removeLast();           // Java 21
    }

    public T cima() {
        if (estaVacia()) {
            throw new NoSuchElementException("La pila está vacía");
        }
        return elementos.getLast();
    }

    public boolean estaVacia() {
        return elementos.isEmpty();
    }

    @Override
    public String toString() {
        return "Pila" + elementos + " <- cima";
    }
}

public class E5PilaGenerica {
    public static void main(String[] args) {
        Pila<String> deshacer = new Pila<>();
        deshacer.apilar("escribir 'Hola'");
        deshacer.apilar("poner negrita");
        deshacer.apilar("borrar línea");
        System.out.println(deshacer);
        System.out.println("Deshacer: " + deshacer.desapilar());
        System.out.println("Siguiente a deshacer: " + deshacer.cima());

        Pila<Integer> numeros = new Pila<>();
        numeros.apilar(1);
        System.out.println("Saco " + numeros.desapilar());
        try {
            numeros.desapilar();
        } catch (NoSuchElementException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Salida:

Pila[escribir 'Hola', poner negrita, borrar línea] <- cima
Deshacer: borrar línea
Siguiente a deshacer: poner negrita
Saco 1
Error: La pila está vacía

Es una clase envoltorio que reutiliza ArrayList por composición y solo expone las operaciones de una pila: nadie puede acceder al elemento del medio. La cima es el final de la lista, porque removeLast() en un ArrayList es O(1), mientras que quitar el primero obligaría a desplazar todo.

Ejercicio 6: Grupos de anagramas

Dos palabras son anagramas si tienen las mismas letras en distinto orden (amor, roma, mora). Agrupa una lista de palabras en un Map<String, List<String>> cuya clave sea la “firma” de la palabra (sus letras ordenadas) y muestra solo los grupos con más de una palabra.

Ver solución
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class E6Anagramas {

    static String firma(String palabra) {
        char[] letras = palabra.toLowerCase().toCharArray();
        Arrays.sort(letras);
        return new String(letras);
    }

    public static void main(String[] args) {
        String[] palabras = {"amor", "roma", "mora", "ramo", "perro", "saco", "cosa", "caso", "mar", "ram", "luz"};

        Map<String, List<String>> grupos = new TreeMap<>();
        for (String p : palabras) {
            String clave = firma(p);
            if (!grupos.containsKey(clave)) {
                grupos.put(clave, new ArrayList<>());
            }
            grupos.get(clave).add(p);
        }

        for (Map.Entry<String, List<String>> e : grupos.entrySet()) {
            if (e.getValue().size() > 1) {
                System.out.println(e.getKey() + " -> " + e.getValue());
            }
        }
    }
}

Salida:

acos -> [saco, cosa, caso]
amor -> [amor, roma, mora, ramo]
amr -> [mar, ram]

La clave del ejercicio es elegir bien la clave del mapa: todas las palabras de un mismo grupo producen la misma firma (amor, roma, mora y ramo → amor). Con TreeMap, los grupos salen ordenados por firma. Es la misma idea que el ejercicio 2 (agrupar), con una clave calculada.

Ejercicios propuestos

✏️ Ejercicio 7: Lista de la compra. Escribe un programa de consola con un menú para añadir productos, borrar uno por nombre, mostrar la lista numerada y vaciarla, usando un ArrayList<String>. Impide añadir productos repetidos sin distinguir mayúsculas.

✏️ Ejercicio 8: Lotería primitiva. Genera combinaciones de 6 números distintos entre 1 y 49 usando un TreeSet<Integer> y Random. Después, dada una combinación ganadora, calcula cuántos aciertos tiene cada combinación con retainAll.

✏️ Ejercicio 9: Traductor. Crea un Map<String, String> español → inglés con al menos 15 palabras y traduce frases palabra a palabra; las desconocidas se muestran entre corchetes. Añade una opción para invertir el diccionario (inglés → español) y detecta si hay traducciones repetidas que impidan invertirlo.

✏️ Ejercicio 10: Ranking de jugadores. Crea un record Jugador(String nombre, int puntos, int partidas) y muestra el ranking ordenado por puntos (descendente), después por menos partidas jugadas y, por último, por nombre en orden alfabético español (Collator). Muestra también el podio con subList(0, 3).

✏️ Ejercicio 11: Historial del navegador. Simula los botones “atrás” y “adelante” de un navegador con dos ArrayDeque usados como pilas. Visitar una página nueva vacía la pila de “adelante”. Lee órdenes del teclado (ir <url>, atras, adelante, fin) y muestra la página actual tras cada una.

✏️ Ejercicio 12: Clase genérica Estadistica<T extends Number>. Guarda una lista de números de cualquier tipo numérico y ofrece media(), maximo() y minimo() (devueltos como double, usando doubleValue()). Pruébala con Integer, Double y Long.


Resumen

Concepto Sintaxis / ejemplo Idea clave
Genéricos List<String> l = new ArrayList<>(); Tipos comprobados al compilar, sin casts
Lista ArrayList (por defecto), LinkedList Ordenada por posición, admite duplicados
Recorrer y borrar for-each, Iterator; it.remove(), removeIf Nunca remove dentro de un for-each
Conjunto HashSet, LinkedHashSet, TreeSet Sin duplicados; necesita equals/hashCode (o compareTo)
Mapa HashMap, LinkedHashMap, TreeMap Clave → valor; recorrer con entrySet()
Pila / cola ArrayDeque: push/pop, offer/poll LIFO / FIFO
Prioridad PriorityQueue (+ Comparator) Sale primero el más prioritario
Orden natural implements Comparable<T> + compareTo Un único orden, dentro de la clase
Comparator Comparator.comparing(X::getY).thenComparing(...).reversed() Tantos órdenes como quieras
Inmutables List.of, Set.of, Map.of, List.copyOf No se modifican; no admiten null
Secuenciadas (Java 21) getFirst(), getLast(), reversed(), addFirst() Primer y último elemento de forma uniforme
Clase genérica class Caja<T> { T contenido; } Reutilizable con cualquier tipo
Método genérico static <T extends Comparable<T>> T maximo(List<T> l) <T> antes del tipo de retorno; tipos acotados