Módulo 02: Genéricos y colecciones avanzadas

Objetivos del módulo

  • Escribir clases, interfaces y métodos genéricos, y entender la inferencia de tipos
  • Usar tipos acotados (<T extends Comparable<? super T>>) y cotas múltiples
  • Dominar los comodines ?, ? extends y ? super y aplicar el principio PECS
  • Comprender el borrado de tipos y sus consecuencias: tipos crudos, arrays genéricos, limitaciones
  • Implementar estructuras de datos genéricas propias que se puedan recorrer con for-each
  • Conocer el funcionamiento interno de HashMap y sus implicaciones en equals y hashCode
  • Usar TreeMap, NavigableMap, NavigableSet, LinkedHashMap (como caché LRU), EnumMap y EnumSet
  • Distinguir colecciones inmutables, no modificables y de tamaño fijo
  • Aprovechar las colecciones secuenciadas de Java 21
  • Construir comparadores complejos con Comparator y usar las utilidades de Collections y Arrays
  • Elegir la colección adecuada según su complejidad algorítmica

1. Repaso: por qué existen los genéricos

En el curso de Fundamentos usaste List<String> y Map<String, Integer>. Recordemos qué problema resuelven. Antes de Java 5 las colecciones guardaban Object:

List lista = new ArrayList();          // sin genéricos (Java 1.4)
lista.add("Ana");
lista.add(42);                          // nadie lo impide
String s = (String) lista.get(1);       // ClassCastException... en ejecución

📘 Concepto: Los genéricos permiten parametrizar clases, interfaces y métodos con tipos. List<String> es “una lista de String”: el compilador comprueba que solo entren cadenas y te ahorra los casts. Los errores pasan de ejecución a compilación, que es donde son baratos de corregir.

Vocabulario que usaremos en todo el módulo:

Término Ejemplo Significado
Tipo genérico List<E> Clase o interfaz con parámetros de tipo
Parámetro de tipo E, T, K, V La “variable” que representa un tipo
Argumento de tipo String en List<String> El tipo concreto con el que se usa
Tipo parametrizado List<String> El genérico con sus argumentos
Tipo crudo (raw) List El genérico usado sin argumentos (solo por compatibilidad)
Comodín List<?>, List<? extends Number> “Algún tipo” desconocido, con o sin cota

Convenciones de nombres de los parámetros de tipo: T (tipo), E (elemento de una colección), K y V (clave y valor), R (resultado), N (número), y S, U para el segundo y tercer tipo.


2. Clases y métodos genéricos

2.1. Clases e interfaces genéricas

Una clase (o record, o interfaz) declara sus parámetros de tipo tras el nombre, como record Par<A, B>(A primero, B segundo) del ejemplo de la sección 2.2. Las interfaces genéricas son muy habituales en la biblioteca estándar: Comparable<T>, Iterable<T>, Comparator<T>, Function<T, R>… Al implementarlas puedes fijar el tipo o mantenerlo genérico:

interface Repositorio<T, ID> {
    Optional<T> buscarPorId(ID id);
    List<T> buscarTodos();
    void guardar(T entidad);
}

class RepositorioEmpleados implements Repositorio<Empleado, Integer> { ... }     // tipo fijado
class RepositorioEnMemoria<T, ID> implements Repositorio<T, ID> { ... }          // sigue siendo genérico

💡 Consejo: Esa interfaz Repositorio<T, ID> no es un ejemplo inventado: es casi exactamente la CrudRepository<T, ID> de Spring Data que usarás en el módulo 19.

2.2. Métodos genéricos e inferencia

Un método genérico declara sus propios parámetros de tipo antes del tipo de retorno, y puede estar en una clase que no sea genérica:

static <T> T ultimo(List<T> lista) { ... }

El compilador infiere T a partir de los argumentos o del tipo de destino, así que casi nunca hay que indicarlo. Cuando hace falta, se usa un testigo de tipo: MetodosGenericos.<Number>repetir(1, 2).

💻 Ejemplo: Métodos genéricos, inferencia y tipos acotados.

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

public class MetodosGenericos {

    // Clase genérica con dos parámetros de tipo
    record Par<A, B>(A primero, B segundo) {
        <C> Par<A, C> conSegundo(C nuevo) {          // método genérico dentro de una clase genérica
            return new Par<>(primero, nuevo);
        }
    }

    // Método genérico: <T> va antes del tipo de retorno
    static <T> T ultimo(List<T> lista) {
        return lista.get(lista.size() - 1);
    }

    static <T> List<T> repetir(T valor, int veces) {
        List<T> resultado = new ArrayList<>();
        for (int i = 0; i < veces; i++) resultado.add(valor);
        return resultado;
    }

    // Tipo acotado: T debe ser comparable consigo mismo
    static <T extends Comparable<T>> T maximo(Collection<T> elementos) {
        T max = null;
        for (T e : elementos) {
            if (max == null || e.compareTo(max) > 0) max = e;
        }
        return max;
    }

    // Varias cotas: una clase (como mucho) y después interfaces, separadas por &
    static <T extends Number & Comparable<T>> double rango(List<T> numeros) {
        T min = numeros.getFirst(), max = numeros.getFirst();
        for (T n : numeros) {
            if (n.compareTo(min) < 0) min = n;
            if (n.compareTo(max) > 0) max = n;
        }
        return max.doubleValue() - min.doubleValue();         // doubleValue() viene de Number
    }

    static <T, R> List<R> transformar(List<T> origen, Function<T, R> f) {
        List<R> destino = new ArrayList<>();
        for (T t : origen) destino.add(f.apply(t));
        return destino;
    }

    public static void main(String[] args) {
        // Inferencia: el compilador deduce T a partir de los argumentos
        String s = ultimo(List.of("a", "b", "c"));
        Integer n = ultimo(List.of(1, 2, 3));
        System.out.println(s + " " + n);

        // Inferencia por el tipo de destino (target typing)
        List<String> vacia = List.of();
        List<Double> ceros = repetir(0.0, 3);
        System.out.println(vacia + " " + ceros);

        // Testigo de tipo explícito (rara vez necesario)
        List<Number> numeros = MetodosGenericos.<Number>repetir(1, 2);
        numeros.add(2.5);
        System.out.println("Testigo explícito: " + numeros);

        System.out.println("Máximo: " + maximo(List.of("pera", "manzana", "uva")));
        System.out.println("Máximo: " + maximo(List.of(7, 42, 3)));
        System.out.println("Rango: " + rango(List.of(4, 18, 9, 1)));
        System.out.println("Longitudes: " + transformar(List.of("Java", "Kotlin"), String::length));

        var p = new Par<>("Ana", 6500);                           // Par<String, Integer>
        Par<String, Boolean> p2 = p.conSegundo(true);
        System.out.println(p + " -> " + p2);

        Map<String, List<Par<String, Integer>>> complejo = Map.of("ventas", List.of(p));
        System.out.println(complejo);
    }
}
c 3
[] [0.0, 0.0, 0.0]
Testigo explícito: [1, 1, 2.5]
Máximo: uva
Máximo: 42
Rango: 17.0
Longitudes: [4, 6]
Par[primero=Ana, segundo=6500] -> Par[primero=Ana, segundo=true]
{ventas=[Par[primero=Ana, segundo=6500]]}

Fíjate en el operador diamante <> (new Par<>("Ana", 6500), new ArrayList<>()): desde Java 7 el compilador deduce los argumentos de tipo del constructor. Y en List<String> vacia = List.of() el tipo se infiere del destino, no de los argumentos (no hay ninguno).

2.3. Tipos acotados

Sin cota, de un T solo puedes usar los métodos de Object. Con <T extends Comparable<T>> le dices al compilador “T es algo comparable”, y por eso dentro del método puedes llamar a compareTo.

Declaración Significado Qué puedes hacer con un T
<T> Cualquier tipo Métodos de Object
<T extends Number> Number o un subtipo doubleValue(), intValue()…
<T extends Comparable<T>> Comparable consigo mismo compareTo(T)
<T extends Comparable<? super T>> Comparable consigo mismo o mediante un supertipo compareTo(T); es la forma correcta (sección 3.4)
<T extends Number & Comparable<T>> Ambas cosas a la vez Métodos de los dos

⚠️ Importante: En los parámetros de tipo, extends significa “es subtipo de” tanto para clases como para interfaces (no existe implements en este contexto). En una cota múltiple, la clase (si la hay) va primero: <T extends Number & Comparable<T>>, nunca al revés.


3. Comodines y el principio PECS

3.1. Los genéricos son invariantes

Esta es la clave para entender los comodines: aunque Integer sea un subtipo de Number, List<Integer> no es un subtipo de List<Number>.

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

public class Invarianza {
    public static void main(String[] args) {
        // Los arrays son covariantes: un Integer[] ES un Number[]... y eso es peligroso
        Number[] array = new Integer[3];
        try {
            array[0] = 3.14;                        // compila, pero falla en ejecución
        } catch (ArrayStoreException e) {
            System.out.println("Array covariante: " + e);
        }

        // Los genéricos son invariantes: List<Integer> NO es List<Number>
        List<Integer> enteros = new ArrayList<>();
        // List<Number> numeros = enteros;          // NO compila: el error se detecta antes
        List<? extends Number> lectura = enteros;   // con comodín sí
        System.out.println("Comodín: " + lectura.size());
    }
}
Array covariante: java.lang.ArrayStoreException: java.lang.Double
Comodín: 0

¿Por qué esa restricción? Si List<Integer> fuera un List<Number>, podrías meter un Double en una lista de enteros, exactamente lo que pasa con el array (que falla en ejecución). Los genéricos lo impiden en compilación. Si descomentas las líneas problemáticas, este es el error real:

ErrorComodin.java:7: error: incompatible types: List<Integer> cannot be converted to List<Number>
        List<Number> numeros = enteros;
                               ^
ErrorComodin.java:9: error: incompatible types: int cannot be converted to CAP#1
        lectura.add(5);
                    ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
2 errors

El segundo error es interesante: en una List<? extends Number> no se puede añadir ni siquiera un entero. CAP#1 es el nombre que el compilador da al “tipo desconocido que es subtipo de Number”: podría ser Double, y entonces un Integer no cabría.

3.2. Los tres comodines

Comodín Se lee Acepta Puedes leer como Puedes añadir
List<?> Lista de algo Cualquier List<X> Object Solo null
List<? extends Number> Lista de algún subtipo de Number List<Number>, List<Integer>, List<Double>… Number Solo null
List<? super Integer> Lista de algún supertipo de Integer List<Integer>, List<Number>, List<Object> Object Integer (y subtipos)

3.3. PECS: Producer Extends, Consumer Super

📘 Concepto: La regla PECS (Joshua Bloch, Effective Java) dice cuándo usar cada comodín en un parámetro: si el parámetro es un productor del que solo lees elementos de tipo T, usa ? extends T. Si es un consumidor en el que solo escribes elementos de tipo T, usa ? super T. Si haces las dos cosas, no uses comodín.

Diagrama del principio PECS: productor con extends, consumidor con super y flujo de datos entre ellos

Figura 1. PECS: los datos salen de un productor (extends) y entran en un consumidor (super).

💻 Ejemplo: Los tres comodines y PECS en acción.

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class Comodines {

    // ? extends Number: acepta List<Integer>, List<Double>... Solo LEEMOS (productor)
    static double sumar(Collection<? extends Number> numeros) {
        double total = 0;
        for (Number n : numeros) total += n.doubleValue();
        // numeros.add(1);            // NO compila: no sabemos de qué subtipo concreto es la lista
        return total;
    }

    // ? super Integer: acepta List<Integer>, List<Number>, List<Object>. Solo ESCRIBIMOS (consumidor)
    static void rellenar(List<? super Integer> destino, int hasta) {
        for (int i = 1; i <= hasta; i++) destino.add(i);
        Object o = destino.get(0);    // leer solo devuelve Object
    }

    // ? a secas: no importa el tipo; solo usamos métodos que no dependen de él
    static void imprimir(Collection<?> c) {
        System.out.println(c.size() + " elementos: " + c);
    }

    // PECS en acción: origen produce T, destino consume T
    static <T> void copiar(List<? extends T> origen, List<? super T> destino) {
        for (T t : origen) destino.add(t);
    }

    // La firma "de libro" del máximo: admite tipos cuya comparación está definida en un supertipo
    static <T extends Comparable<? super T>> T maximo(List<? extends T> lista) {
        T max = lista.getFirst();
        for (T t : lista) if (t.compareTo(max) > 0) max = t;
        return max;
    }

    static class Animal implements Comparable<Animal> {
        final String nombre; final int peso;
        Animal(String nombre, int peso) { this.nombre = nombre; this.peso = peso; }
        public int compareTo(Animal o) { return Integer.compare(peso, o.peso); }
        public String toString() { return nombre + "(" + peso + ")"; }
    }
    static class Perro extends Animal {               // Perro es Comparable<Animal>, no Comparable<Perro>
        Perro(String nombre, int peso) { super(nombre, peso); }
    }

    public static void main(String[] args) {
        List<Integer> enteros = List.of(1, 2, 3);
        List<Double> reales = List.of(1.5, 2.5);
        System.out.println("Suma enteros: " + sumar(enteros));
        System.out.println("Suma reales: " + sumar(reales));

        List<Number> numeros = new ArrayList<>();
        List<Object> objetos = new ArrayList<>();
        rellenar(numeros, 3);
        rellenar(objetos, 2);
        System.out.println(numeros + " " + objetos);

        imprimir(List.of("a", "b"));
        imprimir(enteros);

        List<Object> destino = new ArrayList<>(List.of("inicio"));
        copiar(enteros, destino);
        System.out.println("Copiado: " + destino);

        List<Perro> perros = List.of(new Perro("Rex", 30), new Perro("Toby", 12), new Perro("Lua", 21));
        System.out.println("Más pesado: " + maximo(perros));   // con <T extends Comparable<T>> NO compilaría

        // La API estándar usa PECS por todas partes
        List<Integer> l = new ArrayList<>(List.of(5, 10, 4, 100));
        Collections.addAll(l, 9, 7);                            // addAll(Collection<? super T> c, T... e)
        Comparator<Object> porTexto = Comparator.comparing(Object::toString);
        l.sort(porTexto);                                       // sort(Comparator<? super E> c)
        System.out.println("Ordenada como texto: " + l);
    }
}
Suma enteros: 6.0
Suma reales: 4.0
[1, 2, 3] [1, 2]
2 elementos: [a, b]
3 elementos: [1, 2, 3]
Copiado: [inicio, 1, 2, 3]
Más pesado: Rex(30)
Ordenada como texto: [10, 100, 4, 5, 7, 9]

3.4. ¿Por qué Comparable<? super T>?

Perro hereda compareTo(Animal) de Animal: es un Comparable<Animal>, no un Comparable<Perro>. Con la firma ingenua <T extends Comparable<T>>, maximo(perros) no compila, porque Perro no cumple Perro extends Comparable<Perro>. Con <T extends Comparable<? super T>> basta con que Perro sea comparable con algún supertipo de sí mismo, y Animal lo es. Por la misma razón es la firma de Collections.max, Collections.sort y Stream.sorted:

public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)

Un caso real que verás en el curso: LocalDate implementa Comparable<ChronoLocalDate> (una interfaz que es supertipo suyo), no Comparable<LocalDate>. Sin el ? super, no podrías calcular el máximo de una lista de fechas con tu propio método.

💡 Consejo: PECS solo se aplica a parámetros. No uses comodines en tipos de retorno: obligarías a quien llama a trabajar con List<? extends Algo>, que es incómodo. Devuelve tipos concretos (List<T>).


4. Borrado de tipos (type erasure)

📘 Concepto: Los genéricos de Java existen solo en compilación. El compilador comprueba los tipos, inserta los casts necesarios y luego borra la información genérica: en el bytecode, List<String> y List<Integer> son simplemente List, y cada T se sustituye por su cota (o por Object). Se diseñó así en Java 5 para que el código genérico fuera compatible con todo el código anterior.

Comparación del código fuente genérico con el equivalente tras el borrado de tipos

Figura 2. Lo que escribes frente a lo que ejecuta la JVM.

💻 Ejemplo: El borrado de tipos, en vivo.

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.IntFunction;
import java.util.function.Supplier;

public class Borrado {

    static class Caja<T extends Comparable<T>> {
        private T valor;
        void guardar(T v) { valor = v; }
        T sacar() { return valor; }
    }

    // No se puede hacer "new T()" ni "new T[n]": se pasa una fábrica
    static <T> List<T> crearVarios(Supplier<T> fabrica, int n) {
        List<T> lista = new ArrayList<>();
        for (int i = 0; i < n; i++) lista.add(fabrica.get());
        return lista;
    }

    static <T> T[] aArray(List<T> lista, IntFunction<T[]> generador) {
        T[] array = generador.apply(lista.size());
        for (int i = 0; i < lista.size(); i++) array[i] = lista.get(i);
        return array;
    }

    // Un Class<T> conserva el tipo en ejecución ("type token")
    static <T> T convertir(Object o, Class<T> tipo) {
        if (tipo.isInstance(o)) return tipo.cast(o);
        throw new IllegalArgumentException(o + " no es " + tipo.getSimpleName());
    }

    @SuppressWarnings({"rawtypes", "unchecked"})
    static void contaminar(List lista) {      // tipo crudo: el compilador ya no comprueba nada
        lista.add(42);
    }

    public static void main(String[] args) throws Exception {
        List<String> textos = new ArrayList<>();
        List<Integer> numeros = new ArrayList<>();
        System.out.println("¿Misma clase? " + (textos.getClass() == numeros.getClass()));
        System.out.println("Clase en ejecución: " + textos.getClass().getName());

        // Tras el borrado, T se sustituye por su cota (Comparable) o por Object
        for (Method m : Caja.class.getDeclaredMethods()) {
            System.out.println("  " + m.getName() + " -> " + Arrays.toString(m.getParameterTypes())
                    + " devuelve " + m.getReturnType().getSimpleName());
        }

        // Contaminación del montón (heap pollution) con tipos crudos
        List<String> nombres = new ArrayList<>(List.of("Ana"));
        contaminar(nombres);
        System.out.println("La lista contiene: " + nombres);       // [Ana, 42] ¡sin error!
        try {
            String s = nombres.get(1);                              // aquí el cast oculto falla
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("ClassCastException: " + e.getMessage());
        }

        System.out.println(crearVarios(StringBuilder::new, 2).size() + " StringBuilder creados");
        String[] array = aArray(List.of("x", "y"), String[]::new);
        System.out.println("Array: " + Arrays.toString(array) + " de tipo " + array.getClass().getSimpleName());
        System.out.println("convertir: " + convertir("hola", String.class).toUpperCase());
        try {
            convertir(3, String.class);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
¿Misma clase? true
Clase en ejecución: java.util.ArrayList
  guardar -> [interface java.lang.Comparable] devuelve void
  sacar -> [] devuelve Comparable
La lista contiene: [Ana, 42]
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')
2 StringBuilder creados
Array: [x, y] de tipo String[]
convertir: HOLA
3 no es String

4.1. Consecuencias del borrado

Como el tipo no existe en ejecución, el compilador rechaza todo lo que lo necesitaría. Este fichero no compila:

import java.util.List;

public class ErrorBorrado<T> {
    T crear() { return new T(); }
    boolean esLista(Object o) { return o instanceof List<String>; }
    void procesar(List<String> l) { }
    void procesar(List<Integer> l) { }
    static T compartido;
}
ErrorBorrado.java:7: error: name clash: procesar(List<Integer>) and procesar(List<String>) have the same erasure
    void procesar(List<Integer> l) { }
         ^
ErrorBorrado.java:8: error: non-static type variable T cannot be referenced from a static context
    static T compartido;
           ^
ErrorBorrado.java:4: error: unexpected type
    T crear() { return new T(); }
                           ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in class ErrorBorrado
ErrorBorrado.java:5: error: Object cannot be safely cast to List<String>
    boolean esLista(Object o) { return o instanceof List<String>; }
                                       ^
4 errors
No puedes… Por qué Alternativa
new T() No se sabe qué clase instanciar Pasar un Supplier<T> (StringBuilder::new) o un Class<T>
new T[n] El array necesita su tipo en ejecución IntFunction<T[]> (String[]::new), Object[] con cast, o usar List<T>
o instanceof List<String> En ejecución solo existe List o instanceof List<?>
Sobrecargar m(List<String>) y m(List<Integer>) Tras el borrado tienen la misma firma Nombres distintos
Campos static de tipo T T es de cada instancia; lo estático es de la clase Un método genérico estático
Tipos primitivos: List<int> El borrado lleva a Object Envoltorios (List<Integer>) o streams primitivos (IntStream)
Capturar catch (T e) o crear excepciones genéricas La JVM necesita la clase exacta Jerarquía de excepciones normal

4.2. Tipos crudos y heap pollution

Un tipo crudo (List a secas) desactiva todas las comprobaciones. Existe solo para poder usar código anterior a Java 5. El ejemplo muestra el peligro: contaminar mete un Integer en una List<String> sin ningún error y el fallo aparece lejos, al hacer nombres.get(1), en una línea que parece correcta. A esto se le llama contaminación del montón (heap pollution).

⚠️ Importante: Nunca uses tipos crudos en código nuevo. Si el compilador te da advertencias unchecked o rawtypes (con -Xlint:all las verás todas), corrígelas en lugar de silenciarlas. @SuppressWarnings("unchecked") solo está justificado cuando demuestras que el cast es seguro, como en la pila de la sección siguiente.

4.3. Genéricos y arrays

Los arrays y los genéricos se llevan mal: los arrays son covariantes y conocen su tipo en ejecución (reificados); los genéricos son invariantes y se borran. Por eso no existe new List<String>[10] y los métodos con T... (varargs genéricos) producen la advertencia possible heap pollution (que se silencia con @SafeVarargs cuando el método no guarda ni expone el array).

💡 Consejo: Regla práctica de Effective Java: prefiere listas a arrays en código genérico. Si necesitas un array internamente (por rendimiento, como ArrayList), guarda un Object[] y haz el cast al leer, justificándolo.


5. Una estructura de datos genérica propia: la pila

Vamos a implementar una pila (LIFO) genérica basada en un array que crece, que se pueda recorrer con for-each (implementando Iterable<E>) y que use PECS en sus métodos masivos.

import java.util.Arrays;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;

/** Pila genérica (LIFO) basada en un array que crece. Recorrible con for-each. */
public class Pila<E> implements Iterable<E> {

    private static final int CAPACIDAD_INICIAL = 4;
    private Object[] elementos = new Object[CAPACIDAD_INICIAL];   // no se puede crear un E[]
    private int tamano;
    private int modificaciones;                                    // para detectar cambios al iterar

    public void apilar(E e) {
        Objects.requireNonNull(e, "La pila no admite null");
        if (tamano == elementos.length) {
            elementos = Arrays.copyOf(elementos, tamano * 2);      // duplica la capacidad
        }
        elementos[tamano++] = e;
        modificaciones++;
    }

    @SuppressWarnings("unchecked")                                 // seguro: solo guardamos E
    public E desapilar() {
        if (estaVacia()) throw new NoSuchElementException("Pila vacía");
        E e = (E) elementos[--tamano];
        elementos[tamano] = null;                                  // evita fugas de memoria
        modificaciones++;
        return e;
    }

    @SuppressWarnings("unchecked")
    public E cima() {
        if (estaVacia()) throw new NoSuchElementException("Pila vacía");
        return (E) elementos[tamano - 1];
    }

    public boolean estaVacia() { return tamano == 0; }

    public int tamano() { return tamano; }

    /** Apila todos los elementos de otra colección: PECS, la origen "produce" E. */
    public void apilarTodos(Iterable<? extends E> origen) {
        for (E e : origen) apilar(e);
    }

    /** Vacía la pila en un destino que "consume" E. */
    public void volcarEn(java.util.Collection<? super E> destino) {
        while (!estaVacia()) destino.add(desapilar());
    }

    @Override
    public Iterator<E> iterator() {                                // de la cima a la base
        return new Iterator<>() {
            private int indice = tamano - 1;
            private final int esperadas = modificaciones;

            @Override
            public boolean hasNext() { return indice >= 0; }

            @Override
            @SuppressWarnings("unchecked")
            public E next() {
                if (esperadas != modificaciones) throw new ConcurrentModificationException();
                if (!hasNext()) throw new NoSuchElementException();
                return (E) elementos[indice--];
            }
        };
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("[");
        for (E e : this) sb.append(sb.length() > 1 ? ", " : "").append(e);
        return sb.append("] <- cima a la izquierda").toString();
    }

    public static void main(String[] args) {
        Pila<String> pila = new Pila<>();
        for (String s : "uno dos tres cuatro cinco".split(" ")) pila.apilar(s);
        System.out.println(pila + " tamaño=" + pila.tamano());
        System.out.println("cima=" + pila.cima() + ", desapilo " + pila.desapilar());

        for (String s : pila) System.out.print(s.toUpperCase() + " ");   // gracias a Iterable
        System.out.println();

        Pila<Number> numeros = new Pila<>();
        numeros.apilarTodos(java.util.List.of(1, 2, 3));                // List<Integer> -> Pila<Number>
        numeros.apilar(4.5);
        java.util.List<Object> destino = new java.util.ArrayList<>();
        numeros.volcarEn(destino);                                       // Pila<Number> -> List<Object>
        System.out.println("Volcado: " + destino + ", ¿vacía? " + numeros.estaVacia());

        try {
            for (String s : pila) pila.apilar(s + "!");
        } catch (ConcurrentModificationException e) {
            System.out.println("Modificar mientras se recorre: " + e.getClass().getSimpleName());
        }
    }
}
[cinco, cuatro, tres, dos, uno] <- cima a la izquierda tamaño=5
cima=cinco, desapilo cinco
CUATRO TRES DOS UNO 
Volcado: [4.5, 3, 2, 1], ¿vacía? true
Modificar mientras se recorre: ConcurrentModificationException

Decisiones de diseño que merece la pena entender:

  • Object[] en lugar de E[]: por el borrado no se puede crear un E[]. Los casts (E) son seguros porque solo apilar(E) mete elementos; por eso, y solo por eso, se suprime la advertencia.
  • elementos[tamano] = null al desapilar: si no, el array seguiría referenciando el objeto y el recolector de basura no podría liberarlo (una fuga de memoria clásica, descrita en Effective Java).
  • Crecimiento ×2: duplicar la capacidad hace que apilar sea O(1) amortizado (como en ArrayList, que crece un 50 %).
  • modificaciones: el iterador es fail-fast, como los de la biblioteca estándar: si la pila cambia mientras se recorre, lanza ConcurrentModificationException en lugar de dar resultados incoherentes.
  • PECS: gracias a Iterable<? extends E> y Collection<? super E>, una Pila<Number> acepta una List<Integer> y se vuelca en una List<Object>.

💡 Consejo: En código real no implementes tu propia pila: usa ArrayDeque<E> (push, pop, peek), que es más rápida que la vieja clase Stack (sincronizada y heredera de Vector). Implementar estructuras es un ejercicio excelente para entender los genéricos y las colecciones, que es justo lo que pretendemos aquí. En el ejercicio 2 harás una lista enlazada.


6. HashMap por dentro

HashMap es probablemente la estructura más usada de Java (y HashSet es un HashMap cuyos valores se ignoran). Entender cómo funciona explica por qué equals y hashCode deben ser coherentes y por qué nunca hay que modificar una clave.

Estructura interna de un HashMap: tabla de buckets, cálculo del índice, colisiones encadenadas y árbol

Figura 3. Tabla de *buckets de un HashMap con capacidad 16.*

El funcionamiento, paso a paso:

  1. Internamente hay un array de buckets (table), con capacidad inicial 16 y siempre potencia de 2.
  2. put(clave, valor) calcula clave.hashCode(), lo mezcla (h ^ (h >>> 16), para que los bits altos también influyan) y obtiene el índice con (capacidad - 1) & hash (equivale al resto de dividir entre la capacidad, pero más rápido).
  3. Si el bucket está vacío, guarda ahí el par. Si no, recorre los nodos del bucket comparando con equals: si encuentra la clave, sustituye el valor; si no, añade un nodo (colisión).
  4. Si un bucket acumula 8 o más nodos (y la tabla tiene al menos 64 posiciones), lo convierte en un árbol rojo-negro para que la búsqueda pase de O(n) a O(log n).
  5. Cuando el número de elementos supera capacidad × factor de carga (0,75 por defecto), duplica la tabla y redistribuye todo (rehash).

💻 Ejemplo: Los cálculos reales de HashMap, una clave con un hashCode pésimo y el error de modificar una clave.

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public class HashMapInterno {

    // Réplica del cálculo que hace java.util.HashMap para elegir el bucket
    static int hash(Object clave) {
        int h = clave.hashCode();
        return h ^ (h >>> 16);              // mezcla los 16 bits altos con los bajos
    }

    static int bucket(Object clave, int capacidad) {
        return (capacidad - 1) & hash(clave);   // equivale a hash % capacidad si capacidad es potencia de 2
    }

    // Clave con un hashCode pésimo: todas colisionan
    record ClaveMala(String id) {
        @Override public int hashCode() { return 7; }
    }

    // Clave mutable: un error clásico
    static class Dni {
        String numero;
        Dni(String numero) { this.numero = numero; }
        @Override public boolean equals(Object o) { return o instanceof Dni d && d.numero.equals(numero); }
        @Override public int hashCode() { return Objects.hash(numero); }
    }

    public static void main(String[] args) {
        String[] claves = {"Ana", "Luis", "Marta", "Pedro", "Laura", "Javier", "Carmen", "David"};
        System.out.println("Clave     hashCode()   bucket (capacidad 16)");
        for (String c : claves) {
            System.out.printf("%-8s %11d   %2d%n", c, c.hashCode(), bucket(c, 16));
        }

        // Rendimiento con colisiones: todas las claves en el mismo bucket
        Map<ClaveMala, Integer> malo = new HashMap<>();
        Map<String, Integer> bueno = new HashMap<>();
        int n = 20_000;
        long t0 = System.nanoTime();
        for (int i = 0; i < n; i++) malo.put(new ClaveMala("k" + i), i);
        for (int i = 0; i < n; i++) malo.get(new ClaveMala("k" + i));
        long t1 = System.nanoTime();
        for (int i = 0; i < n; i++) bueno.put("k" + i, i);
        for (int i = 0; i < n; i++) bueno.get("k" + i);
        long t2 = System.nanoTime();
        System.out.println("hashCode constante más lento que String: " + ((t1 - t0) > (t2 - t1) * 5));

        // Si modificas una clave después de insertarla, el mapa ya no la encuentra
        Set<Dni> dnis = new HashSet<>();
        Dni d = new Dni("12345678Z");
        dnis.add(d);
        d.numero = "87654321X";                                   // cambia su hashCode
        System.out.println("contains(d) tras modificarla: " + dnis.contains(d));
        System.out.println("tamaño: " + dnis.size() + " (el elemento sigue ahí, perdido)");
    }
}
Clave     hashCode()   bucket (capacidad 16)
Ana            65972    5
Luis         2379923    7
Marta       74114091    1
Pedro       76990316   10
Laura       73193391    3
Javier   -2083209097    3
Carmen    2011238818    3
David       65805908    8
hashCode constante más lento que String: true
contains(d) tras modificarla: false
tamaño: 1 (el elemento sigue ahí, perdido)

“Laura”, “Javier” y “Carmen” caen en el bucket 3: con 8 claves y 16 posiciones las colisiones son inevitables, y no pasa nada mientras sean pocas. Con un hashCode constante, en cambio, todas colisionan y el mapa degenera.

6.1. El contrato de equals y hashCode

Regla Consecuencia si no se cumple
Si a.equals(b), entonces a.hashCode() == b.hashCode() Dos claves “iguales” van a buckets distintos: duplicados y búsquedas que fallan
hashCode debe ser estable mientras el objeto está en el mapa Si la clave cambia, queda en un bucket que ya no le corresponde: contains da false
Dos objetos distintos pueden tener el mismo hashCode Es una colisión, legal; pero cuantas menos, mejor rendimiento

⚠️ Importante: Usa como claves de HashMap y elementos de HashSet objetos inmutables: String, envoltorios, LocalDate, records con componentes inmutables o enums. Los records generan equals y hashCode correctos automáticamente; en una clase normal, genéralos con el IDE o con Objects.equals y Objects.hash.

💡 Consejo: Si sabes cuántos elementos vas a meter, dimensiona el mapa y evitarás redimensionados: desde Java 19 existe HashMap.newHashMap(1000), que calcula la capacidad necesaria para 1000 elementos con el factor de carga por defecto.


7. Mapas y conjuntos ordenados: TreeMap, NavigableMap, NavigableSet

📘 Concepto: TreeMap y TreeSet guardan los elementos ordenados (orden natural o un Comparator) en un árbol rojo-negro, con operaciones en O(log n). Implementan NavigableMap y NavigableSet, que añaden búsquedas por proximidad (“la mayor clave menor o igual que…”) y vistas de rangos.

💻 Ejemplo: Tramos de IRPF simplificados y fechas de pedidos de la base de datos EMPRESA.

import java.time.LocalDate;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.TreeMap;
import java.util.TreeSet;

public class Navegables {
    public static void main(String[] args) {
        // Tramos de IRPF simplificados: desde qué base se aplica cada tipo
        NavigableMap<Integer, Double> tramos = new TreeMap<>();
        tramos.put(0, 19.0);
        tramos.put(12_450, 24.0);
        tramos.put(20_200, 30.0);
        tramos.put(35_200, 37.0);
        tramos.put(60_000, 45.0);

        for (int base : new int[]{9_000, 20_200, 41_000, 250_000}) {
            // floorEntry: la mayor clave <= base
            var tramo = tramos.floorEntry(base);
            System.out.printf("Base %,7d € -> tramo desde %,6d € al %.0f %%%n", base, tramo.getKey(), tramo.getValue());
        }
        System.out.println("ceilingKey(20_000) = " + tramos.ceilingKey(20_000));   // menor clave >= 20000
        System.out.println("higherKey(20_200)  = " + tramos.higherKey(20_200));    // estrictamente mayor
        System.out.println("lowerKey(20_200)   = " + tramos.lowerKey(20_200));     // estrictamente menor
        System.out.println("headMap(20_200)    = " + tramos.headMap(20_200));      // claves < 20200
        System.out.println("tailMap(35_200)    = " + tramos.tailMap(35_200, true));
        System.out.println("subMap             = " + tramos.subMap(12_450, true, 35_200, false));
        System.out.println("descendingMap      = " + tramos.descendingMap());

        // NavigableSet: fechas de pedidos de la base de datos EMPRESA
        NavigableSet<LocalDate> fechas = new TreeSet<>();
        for (String f : new String[]{"2026-01-12", "2026-02-09", "2026-03-03", "2026-04-21",
                "2026-05-30", "2026-06-15", "2026-07-01", "2026-07-08", "2026-07-10"}) {
            fechas.add(LocalDate.parse(f));
        }
        LocalDate hoy = LocalDate.of(2026, 6, 1);
        System.out.println("Último pedido antes del " + hoy + ": " + fechas.lower(hoy));
        System.out.println("Primer pedido desde el " + hoy + ": " + fechas.ceiling(hoy));
        System.out.println("Pedidos del segundo trimestre: "
                + fechas.subSet(LocalDate.of(2026, 4, 1), true, LocalDate.of(2026, 6, 30), true));
        System.out.println("pollFirst() = " + fechas.pollFirst() + ", quedan " + fechas.size());
    }
}
Base   9.000 € -> tramo desde      0 € al 19 %
Base  20.200 € -> tramo desde 20.200 € al 30 %
Base  41.000 € -> tramo desde 35.200 € al 37 %
Base 250.000 € -> tramo desde 60.000 € al 45 %
ceilingKey(20_000) = 20200
higherKey(20_200)  = 35200
lowerKey(20_200)   = 12450
headMap(20_200)    = {0=19.0, 12450=24.0}
tailMap(35_200)    = {35200=37.0, 60000=45.0}
subMap             = {12450=24.0, 20200=30.0}
descendingMap      = {60000=45.0, 35200=37.0, 20200=30.0, 12450=24.0, 0=19.0}
Último pedido antes del 2026-06-01: 2026-05-30
Primer pedido desde el 2026-06-01: 2026-06-15
Pedidos del segundo trimestre: [2026-04-21, 2026-05-30, 2026-06-15]
pollFirst() = 2026-01-12, quedan 8
Método Devuelve Para la clave k…
floorKey(k) / floorEntry(k) La mayor clave ≤ k “¿En qué tramo cae?”
ceilingKey(k) / ceilingEntry(k) La menor clave ≥ k “¿Cuál es el siguiente?”
lowerKey(k) / higherKey(k) Estrictamente < / > k  
firstKey(), lastKey(), pollFirstEntry(), pollLastEntry() Extremos (los poll además los eliminan)  
headMap(k), tailMap(k, incl), subMap(a, inclA, b, inclB) Vistas de un rango Reflejan los cambios del mapa original
descendingMap(), descendingKeySet() Vista en orden inverso  

En NavigableSet los métodos se llaman floor, ceiling, lower, higher, headSet, tailSet, subSet, pollFirst, pollLast y descendingSet.

⚠️ Importante: En un TreeMap o TreeSet la igualdad la decide el comparador, no equals: si compare(a, b) == 0, se consideran el mismo elemento y uno sustituye al otro. Un TreeSet ordenado solo por apellido perdería a los hermanos. Añade siempre un criterio de desempate (thenComparing).

Fíjate en el formato %,7d: la coma pide separador de miles, que en español es el punto (20.200).


8. LinkedHashMap y la caché LRU

LinkedHashMap es un HashMap que además mantiene una lista doblemente enlazada de sus entradas, así que recuerda un orden: por defecto, el de inserción; con el tercer parámetro del constructor a true, el de acceso (cada get mueve la entrada al final). Si además sobrescribes removeEldestEntry, tienes una caché LRU (Least Recently Used) en una docena de líneas:

import java.util.LinkedHashMap;
import java.util.Map;

/** Caché LRU (Least Recently Used): al llenarse, expulsa la entrada usada hace más tiempo. */
public class CacheLru<K, V> extends LinkedHashMap<K, V> {

    private final int capacidad;

    public CacheLru(int capacidad) {
        super(16, 0.75f, true);          // true = orden de ACCESO (no de inserción)
        this.capacidad = capacidad;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> masAntigua) {
        boolean expulsar = size() > capacidad;   // se llama después de cada put
        if (expulsar) System.out.println("  expulsado: " + masAntigua.getKey());
        return expulsar;
    }

    public static void main(String[] args) {
        CacheLru<Integer, String> cache = new CacheLru<>(3);
        cache.put(1001, "Talleres Martín");
        cache.put(1002, "Construcciones Levante");
        cache.put(1003, "María José Herrera");
        System.out.println(cache.keySet());

        cache.get(1001);                                  // 1001 pasa a ser la más reciente
        System.out.println("tras get(1001): " + cache.keySet());

        cache.put(1004, "Academia Nova");                 // se expulsa la menos usada: 1002
        System.out.println(cache.keySet());

        // Con orden de inserción (lo normal) el get no cambia el orden
        Map<String, Integer> insercion = new LinkedHashMap<>();
        insercion.put("b", 2); insercion.put("a", 1); insercion.put("c", 3);
        insercion.get("b");
        System.out.println("LinkedHashMap normal: " + insercion);
    }
}
[1001, 1002, 1003]
tras get(1001): [1002, 1003, 1001]
  expulsado: 1002
[1003, 1001, 1004]
LinkedHashMap normal: {b=2, a=1, c=3}

💡 Consejo: Esta caché no es segura entre hilos. En aplicaciones reales con concurrencia se usan bibliotecas como Caffeine (que también usa Spring Boot) o la caché de Hibernate (módulo 16). Pero el mecanismo es exactamente este.


9. EnumMap y EnumSet

Cuando las claves son constantes de un enum, usa EnumMap: internamente es un array indexado por ordinal(), más rápido y compacto que un HashMap, y recorre las claves en el orden de declaración del enum. EnumSet representa un conjunto de constantes con un campo de bits.

import java.util.EnumMap;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;

public class EnumMapDemo {

    enum Estado { PENDIENTE, ENVIADO, ENTREGADO, CANCELADO }

    record Pedido(int id, Estado estado) {}

    public static void main(String[] args) {
        List<Pedido> pedidos = List.of(new Pedido(5001, Estado.ENTREGADO), new Pedido(5008, Estado.CANCELADO),
                new Pedido(5020, Estado.ENVIADO), new Pedido(5021, Estado.ENVIADO),
                new Pedido(5022, Estado.PENDIENTE), new Pedido(5023, Estado.PENDIENTE),
                new Pedido(5024, Estado.PENDIENTE), new Pedido(5019, Estado.ENTREGADO));

        // EnumMap: claves de un enum, internamente un array indexado por ordinal()
        Map<Estado, Integer> recuento = new EnumMap<>(Estado.class);
        for (Estado e : Estado.values()) recuento.put(e, 0);       // así salen también los ceros
        for (Pedido p : pedidos) recuento.merge(p.estado(), 1, Integer::sum);
        System.out.println(recuento);                              // siempre en orden de declaración

        // EnumSet: conjunto de constantes, internamente un campo de bits
        EnumSet<Estado> abiertos = EnumSet.of(Estado.PENDIENTE, Estado.ENVIADO);
        EnumSet<Estado> cerrados = EnumSet.complementOf(abiertos);
        EnumSet<Estado> hastaEnviado = EnumSet.range(Estado.PENDIENTE, Estado.ENVIADO);
        System.out.println("abiertos=" + abiertos + " cerrados=" + cerrados + " rango=" + hastaEnviado);

        long pendientesDeServir = pedidos.stream().filter(p -> abiertos.contains(p.estado())).count();
        System.out.println("Pedidos abiertos: " + pendientesDeServir);
    }
}
{PENDIENTE=3, ENVIADO=2, ENTREGADO=2, CANCELADO=1}
abiertos=[PENDIENTE, ENVIADO] cerrados=[ENTREGADO, CANCELADO] rango=[PENDIENTE, ENVIADO]
Pedidos abiertos: 5

Map.merge(clave, valor, función) es la forma idiomática de contar o acumular: si la clave no existe, guarda valor; si existe, guarda función(valorActual, valor). Sus parientes son computeIfAbsent (crear el valor si falta, ideal para Map<K, List<V>>), computeIfPresent, compute, getOrDefault y putIfAbsent.


10. Colecciones inmutables, no modificables y de tamaño fijo

Tres conceptos que se confunden a menudo:

Tipo Cómo se crea ¿Se puede modificar? ¿Refleja cambios del original? ¿Admite null?
Inmutable List.of, Set.of, Map.of, List.copyOf, Stream.toList(), Collectors.toUnmodifiableList() No No (no hay original o es una copia) No (salvo Stream.toList())
Vista no modificable Collections.unmodifiableList(lista) No, a través de la vista Sí Sí
Tamaño fijo Arrays.asList(...) set sí; add y remove no Sí, está respaldada por el array Sí
Modificable new ArrayList<>(...), Collectors.toList() (en la práctica) Sí — Sí
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Inmutables {

    static void probar(String nombre, List<String> lista) {
        try {
            lista.add("X");
            System.out.println(nombre + ": se puede modificar");
        } catch (UnsupportedOperationException e) {
            System.out.println(nombre + ": UnsupportedOperationException");
        }
    }

    public static void main(String[] args) {
        List<String> original = new ArrayList<>(List.of("Ana", "Luis"));

        List<String> vista = Collections.unmodifiableList(original);   // VISTA no modificable
        List<String> copia = List.copyOf(original);                    // COPIA inmutable
        List<String> fija = java.util.Arrays.asList("a", "b");          // tamaño fijo

        probar("unmodifiableList", vista);
        probar("List.copyOf", copia);
        probar("Arrays.asList", fija);
        fija.set(0, "z");                                              // set sí funciona en asList
        System.out.println("Arrays.asList tras set: " + fija);

        original.add("Marta");                                         // cambiamos el original
        System.out.println("original=" + original);
        System.out.println("vista   =" + vista + "  <- refleja el cambio");
        System.out.println("copia   =" + copia + "  <- no cambia");

        // List.of y compañía no admiten null
        try {
            List.of("a", null);
        } catch (NullPointerException e) {
            System.out.println("List.of(null): NullPointerException");
        }
        // Map.of no admite claves repetidas
        try {
            Map.of("a", 1, "a", 2);
        } catch (IllegalArgumentException e) {
            System.out.println("Map.of repetida: " + e.getMessage());
        }

        // Desde streams
        List<String> inm1 = Stream.of("x", "y").toList();                         // Java 16
        List<String> inm2 = Stream.of("x", "y").collect(Collectors.toUnmodifiableList());
        List<String> mod = Stream.of("x", "y").collect(Collectors.toList());     // ArrayList en la práctica
        probar("Stream.toList()", inm1);
        probar("toUnmodifiableList", inm2);
        probar("Collectors.toList()", mod);

        // Inmutabilidad superficial: la lista no cambia, pero sus elementos sí
        List<StringBuilder> builders = List.of(new StringBuilder("hola"));
        builders.get(0).append(" mundo");
        System.out.println("Superficial: " + builders);
    }
}
unmodifiableList: UnsupportedOperationException
List.copyOf: UnsupportedOperationException
Arrays.asList: UnsupportedOperationException
Arrays.asList tras set: [z, b]
original=[Ana, Luis, Marta]
vista   =[Ana, Luis, Marta]  <- refleja el cambio
copia   =[Ana, Luis]  <- no cambia
List.of(null): NullPointerException
Map.of repetida: duplicate key: a
Stream.toList(): UnsupportedOperationException
toUnmodifiableList: UnsupportedOperationException
Collectors.toList(): se puede modificar
Superficial: [hola mundo]

💡 Consejo: Patrón profesional para que una clase no exponga su estado interno: guarda una copia defensiva al recibir la colección (this.lineas = List.copyOf(lineas)) y devuelve la lista inmutable en el getter. En un record, hazlo en el constructor compacto. Así nadie puede modificar el objeto desde fuera.

⚠️ Importante: Las colecciones de List.of y similares no garantizan un orden de iteración en Set.of y Map.of (de hecho, cambia entre ejecuciones de la JVM). No dependas de él; si necesitas orden, usa LinkedHashSet/LinkedHashMap o TreeSet/TreeMap.


11. Colecciones secuenciadas (Java 21)

📘 Concepto: Java 21 (JEP 431) añadió tres interfaces a la jerarquía de colecciones para las que tienen un orden de encuentro definido: SequencedCollection (con getFirst, getLast, addFirst, addLast, removeFirst, removeLast y reversed), SequencedSet y SequencedMap (con firstEntry, lastEntry, putFirst, putLast, pollFirstEntry, pollLastEntry, reversed, sequencedKeySet, sequencedValues y sequencedEntrySet).

Jerarquía de las colecciones secuenciadas: SequencedCollection, SequencedSet y SequencedMap con sus implementaciones

Figura 4. Las interfaces secuenciadas dentro de la jerarquía de colecciones.

Antes, “dame el primero y el último” se escribía de forma distinta en cada colección:

Operación Antes de Java 21 Java 21
Primero de un List lista.get(0) lista.getFirst()
Último de un List lista.get(lista.size() - 1) lista.getLast()
Último de un LinkedHashSet Recorrerlo entero conjunto.getLast()
Primera entrada de un LinkedHashMap mapa.entrySet().iterator().next() mapa.firstEntry()
Recorrer al revés ListIterator o copiar e invertir for (var e : c.reversed())
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedCollection;
import java.util.SequencedMap;
import java.util.SequencedSet;
import java.util.TreeMap;
import java.util.TreeSet;

public class Secuenciadas {

    // Un método que sirve para cualquier colección con orden definido
    static <E> String extremos(SequencedCollection<E> c) {
        return c.getClass().getSimpleName() + ": primero=" + c.getFirst() + ", último=" + c.getLast();
    }

    public static void main(String[] args) {
        List<String> lista = new ArrayList<>(List.of("b", "c"));
        Deque<String> cola = new ArrayDeque<>(List.of("b", "c"));
        SequencedSet<String> conjunto = new LinkedHashSet<>(List.of("b", "c"));
        TreeSet<String> ordenado = new TreeSet<>(List.of("c", "a", "b"));

        for (SequencedCollection<String> c : List.<SequencedCollection<String>>of(lista, cola, conjunto, ordenado)) {
            System.out.println(extremos(c));
        }

        lista.addFirst("a");
        lista.addLast("d");
        conjunto.addFirst("a");                 // en un LinkedHashSet, coloca el elemento al principio
        conjunto.addFirst("c");                 // si ya existía, lo MUEVE al principio
        System.out.println("lista=" + lista + " conjunto=" + conjunto);

        // reversed() devuelve una VISTA invertida, no una copia
        List<String> alReves = lista.reversed();
        lista.add("e");
        System.out.println("vista invertida: " + alReves);
        System.out.println("removeFirst=" + lista.removeFirst() + ", removeLast=" + lista.removeLast() + " -> " + lista);

        try {
            ordenado.addFirst("z");             // en un conjunto ordenado no tiene sentido
        } catch (UnsupportedOperationException e) {
            System.out.println("TreeSet.addFirst: UnsupportedOperationException");
        }

        // SequencedMap
        SequencedMap<String, Integer> stock = new LinkedHashMap<>();
        stock.put("Ratón", 200);
        stock.put("Teclado", 60);
        stock.putFirst("Monitor", 35);
        stock.putLast("Ratón", 190);           // ya existía: se actualiza y pasa al final
        System.out.println(stock);
        System.out.println("firstEntry=" + stock.firstEntry() + " lastEntry=" + stock.lastEntry());
        System.out.println("sequencedKeySet().reversed()=" + stock.sequencedKeySet().reversed());
        System.out.println("pollLastEntry=" + stock.pollLastEntry() + " -> " + stock);

        SequencedMap<Integer, String> porId = new TreeMap<>(java.util.Map.of(103, "Pedro", 100, "Ana", 101, "Luis"));
        System.out.println("TreeMap invertido: " + porId.reversed());
    }
}
ArrayList: primero=b, último=c
ArrayDeque: primero=b, último=c
LinkedHashSet: primero=b, último=c
TreeSet: primero=a, último=c
lista=[a, b, c, d] conjunto=[c, a, b]
vista invertida: [e, d, c, b, a]
removeFirst=a, removeLast=e -> [b, c, d]
TreeSet.addFirst: UnsupportedOperationException
{Monitor=35, Teclado=60, Ratón=190}
firstEntry=Monitor=35 lastEntry=Ratón=190
sequencedKeySet().reversed()=[Ratón, Teclado, Monitor]
pollLastEntry=Ratón=190 -> {Monitor=35, Teclado=60}
TreeMap invertido: {103=Pedro, 101=Luis, 100=Ana}

⚠️ Importante: reversed() es una vista: la lista invertida ve el elemento "e" añadido después. Y las operaciones que no tienen sentido en una colección ordenada por contenido (addFirst en un TreeSet) lanzan UnsupportedOperationException. HashSet y HashMap no son secuenciados porque su orden no está definido.


12. Comparator avanzado

En Fundamentos implementaste Comparable y escribiste algún Comparator. Desde Java 8, Comparator tiene métodos de fábrica y de composición que permiten expresar cualquier criterio de ordenación en una línea (las lambdas y las referencias a método se estudian a fondo en el módulo 03).

Método Ejemplo Efecto
comparing(extractor) comparing(Empleado::nombre) Ordena por la clave extraída (que debe ser Comparable)
comparing(extractor, comparador) comparing(Empleado::nombre, collator) Ordena la clave con otro comparador
comparingInt/Long/Double(extractor) comparingDouble(Empleado::salario) Igual, sin autoboxing
thenComparing(...) .thenComparing(Empleado::id) Criterio de desempate
reversed() comparingDouble(...).reversed() Invierte todo el comparador anterior
reverseOrder() / naturalOrder() thenComparing(Empleado::salario, reverseOrder()) Orden natural inverso / directo
nullsFirst(c) / nullsLast(c) nullsLast(naturalOrder()) Coloca los null al principio o al final
Map.Entry.comparingByKey() / comparingByValue() Map.Entry.<String, Integer>comparingByValue().reversed() Para ordenar entradas de mapas
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

public class Comparadores {

    record Empleado(int id, String nombre, String puesto, double salario, Integer departamento) {}

    static void mostrar(String titulo, List<Empleado> lista) {
        System.out.println("-- " + titulo);
        for (Empleado e : lista) {
            System.out.printf("   %-7s %-16s %7.0f  dep=%s%n", e.nombre(), e.puesto(), e.salario(), e.departamento());
        }
    }

    public static void main(String[] args) {
        List<Empleado> empleados = new ArrayList<>(List.of(
                new Empleado(110, "Carmen", "VENDEDOR", 2200, 20),
                new Empleado(120, "Pablo", "ANALISTA", 3500, 30),
                new Empleado(111, "David", "VENDEDOR", 2100, 20),
                new Empleado(160, "Tomás", "BECARIO", 900, null),
                new Empleado(121, "Raquel", "PROGRAMADOR", 2800, 30),
                new Empleado(141, "Álvaro", "ANALISTA", 2700, 50),
                new Empleado(112, "Elena", "VENDEDOR", 2300, 20)));

        // 1. Por una clave
        empleados.sort(Comparator.comparing(Empleado::nombre));
        mostrar("comparing(nombre)  [ojo con la Á]", empleados);

        // 2. Por varias claves: puesto ascendente y, a igual puesto, salario descendente
        empleados.sort(Comparator.comparing(Empleado::puesto)
                .thenComparing(Empleado::salario, Comparator.reverseOrder()));
        mostrar("puesto, luego salario desc", empleados);

        // 3. Versiones primitivas (evitan el autoboxing) y reversed()
        empleados.sort(Comparator.comparingDouble(Empleado::salario).reversed());
        mostrar("comparingDouble(salario).reversed()", empleados.subList(0, 3));

        // 4. Claves que pueden ser null: nullsFirst / nullsLast
        empleados.sort(Comparator.comparing(Empleado::departamento, Comparator.nullsLast(Comparator.naturalOrder()))
                .thenComparingInt(Empleado::id));
        mostrar("departamento con nulls al final, luego id", empleados);

        // 5. Comparar texto con reglas del idioma (tildes)
        var collator = java.text.Collator.getInstance(java.util.Locale.of("es", "ES"));
        empleados.sort(Comparator.comparing(Empleado::nombre, collator));
        System.out.println("-- con Collator: " + empleados.stream().map(Empleado::nombre).toList());

        // 6. Comparadores sobre entradas de mapas
        Map<String, Integer> ventas = Map.of("Carmen", 4, "David", 2, "Elena", 3);
        var ranking = new ArrayList<>(ventas.entrySet());
        ranking.sort(Map.Entry.<String, Integer>comparingByValue().reversed());
        System.out.println("-- ranking de pedidos: " + ranking);

        // 7. Usar un comparador para máximos y mínimos
        Empleado mejorPagado = empleados.stream().max(Comparator.comparingDouble(Empleado::salario)).orElseThrow();
        System.out.println("-- mejor pagado: " + mejorPagado.nombre());
    }
}
-- comparing(nombre)  [ojo con la Á]
   Carmen  VENDEDOR            2200  dep=20
   David   VENDEDOR            2100  dep=20
   Elena   VENDEDOR            2300  dep=20
   Pablo   ANALISTA            3500  dep=30
   Raquel  PROGRAMADOR         2800  dep=30
   Tomás   BECARIO              900  dep=null
   Álvaro  ANALISTA            2700  dep=50
-- puesto, luego salario desc
   Pablo   ANALISTA            3500  dep=30
   Álvaro  ANALISTA            2700  dep=50
   Tomás   BECARIO              900  dep=null
   Raquel  PROGRAMADOR         2800  dep=30
   Elena   VENDEDOR            2300  dep=20
   Carmen  VENDEDOR            2200  dep=20
   David   VENDEDOR            2100  dep=20
-- comparingDouble(salario).reversed()
   Pablo   ANALISTA            3500  dep=30
   Raquel  PROGRAMADOR         2800  dep=30
   Álvaro  ANALISTA            2700  dep=50
-- departamento con nulls al final, luego id
   Carmen  VENDEDOR            2200  dep=20
   David   VENDEDOR            2100  dep=20
   Elena   VENDEDOR            2300  dep=20
   Pablo   ANALISTA            3500  dep=30
   Raquel  PROGRAMADOR         2800  dep=30
   Álvaro  ANALISTA            2700  dep=50
   Tomás   BECARIO              900  dep=null
-- con Collator: [Álvaro, Carmen, David, Elena, Pablo, Raquel, Tomás]
-- ranking de pedidos: [Carmen=4, Elena=3, David=2]
-- mejor pagado: Pablo

Puntos a observar:

  • “Álvaro” queda el último con comparing(Empleado::nombre): String.compareTo compara códigos Unicode y la Á (U+00C1) va después de todas las letras sin tilde. Para ordenar texto como espera una persona, usa un Collator del idioma (punto 5).
  • reversed() invierte toda la cadena que tiene a su izquierda. Si quieres invertir solo un criterio, pásale Comparator.reverseOrder() a ese thenComparing (punto 2).
  • List.sort es estable: los elementos que el comparador considera iguales conservan su orden relativo. Por eso puedes ordenar en varias pasadas o confiar en el orden previo para los empates.
  • Sin nullsLast, comparar un departamento nulo lanzaría NullPointerException.
  • Map.Entry.<String, Integer>comparingByValue() necesita el testigo de tipo porque, al encadenar .reversed(), el compilador no puede inferir los tipos del destino.

13. Utilidades de Collections y Arrays

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

public class Utilidades {
    public static void main(String[] args) {
        // ----- Collections -----
        List<Integer> l = new ArrayList<>(List.of(5, 3, 8, 1, 9, 3, 7));
        System.out.println("max=" + Collections.max(l) + " min=" + Collections.min(l)
                + " frecuencia(3)=" + Collections.frequency(l, 3));
        Collections.sort(l);
        System.out.println("ordenada " + l + " binarySearch(8)=" + Collections.binarySearch(l, 8));
        Collections.reverse(l);
        System.out.println("reverse  " + l);
        Collections.rotate(l, 2);
        System.out.println("rotate 2 " + l);
        Collections.swap(l, 0, 1);
        System.out.println("swap     " + l);
        Collections.shuffle(l, new Random(42));            // semilla fija: resultado reproducible
        System.out.println("shuffle  " + l);
        System.out.println("nCopies  " + Collections.nCopies(3, "-"));
        System.out.println("disjoint " + Collections.disjoint(List.of(1, 2), List.of(3, 4)));
        List<String> nombres = new ArrayList<>();
        Collections.addAll(nombres, "Ana", "Luis", "Marta");
        System.out.println("addAll   " + nombres + " vacía=" + Collections.emptyList());

        // ----- Arrays -----
        int[] numeros = {5, 3, 8, 1, 9};
        Arrays.sort(numeros);
        System.out.println("Arrays.sort " + Arrays.toString(numeros)
                + " binarySearch(8)=" + Arrays.binarySearch(numeros, 8));
        int[] copia = Arrays.copyOfRange(numeros, 1, 4);
        System.out.println("copyOfRange " + Arrays.toString(copia));
        int[] cuadrados = new int[6];
        Arrays.setAll(cuadrados, i -> i * i);
        System.out.println("setAll      " + Arrays.toString(cuadrados));
        double[] notas = new double[4];
        Arrays.fill(notas, 5.0);
        System.out.println("fill        " + Arrays.toString(notas));

        int[] a = {1, 2, 3, 4}, b = {1, 2, 9, 4};
        System.out.println("equals=" + Arrays.equals(a, b) + " mismatch=" + Arrays.mismatch(a, b)
                + " compare=" + Arrays.compare(a, b));
        int[][] matriz = {{1, 2}, {3, 4}};
        System.out.println("deepToString " + Arrays.deepToString(matriz)
                + " deepEquals=" + Arrays.deepEquals(matriz, new int[][]{{1, 2}, {3, 4}}));
        System.out.println("suma con stream: " + Arrays.stream(numeros).sum());

        String[] palabras = {"pera", "Uva", "manzana", "kiwi"};
        Arrays.sort(palabras, String.CASE_INSENSITIVE_ORDER);
        System.out.println("sin mayúsculas: " + Arrays.toString(palabras));
        Arrays.sort(palabras, 0, 2, java.util.Comparator.comparing(String::length));
        System.out.println("rango 0-2 por longitud: " + Arrays.toString(palabras));
    }
}
max=9 min=1 frecuencia(3)=2
ordenada [1, 3, 3, 5, 7, 8, 9] binarySearch(8)=5
reverse  [9, 8, 7, 5, 3, 3, 1]
rotate 2 [3, 1, 9, 8, 7, 5, 3]
swap     [1, 3, 9, 8, 7, 5, 3]
shuffle  [9, 3, 7, 1, 5, 8, 3]
nCopies  [-, -, -]
disjoint true
addAll   [Ana, Luis, Marta] vacía=[]
Arrays.sort [1, 3, 5, 8, 9] binarySearch(8)=3
copyOfRange [3, 5, 8]
setAll      [0, 1, 4, 9, 16, 25]
fill        [5.0, 5.0, 5.0, 5.0]
equals=false mismatch=2 compare=-1
deepToString [[1, 2], [3, 4]] deepEquals=true
suma con stream: 26
sin mayúsculas: [kiwi, manzana, pera, Uva]
rango 0-2 por longitud: [kiwi, manzana, pera, Uva]

Además de lo que muestra el ejemplo, Collections ofrece los envoltorios unmodifiableX, synchronizedX y checkedX, y Arrays tiene parallelSort para arrays enormes. Recuerda que el == y el equals de un array comparan referencias: para comparar contenido usa Arrays.equals o Arrays.deepEquals.

⚠️ Importante: Collections.binarySearch y Arrays.binarySearch sobre datos no ordenados devuelven resultados sin sentido (sin error). Si no encuentran el elemento, devuelven -(punto de inserción) - 1, un número negativo.


14. Rendimiento de las colecciones

La complejidad algorítmica (notación O grande) indica cómo crece el coste de una operación con el número de elementos n:

Colección get(i) add (al final) add(0, e) / addFirst remove contains Orden
ArrayList O(1) O(1) amortizado O(n) O(n) O(n) Inserción
LinkedList O(n) O(1) O(1) O(1) con iterador, O(n) buscando O(n) Inserción
ArrayDeque — O(1) amortizado O(1) O(1) en extremos O(n) Inserción
HashSet / HashMap — O(1) promedio — O(1) O(1) promedio Ninguno
LinkedHashSet / LinkedHashMap — O(1) — O(1) O(1) Inserción (o acceso)
TreeSet / TreeMap — O(log n) — O(log n) O(log n) Ordenado
EnumSet / EnumMap — O(1) — O(1) O(1) Declaración del enum
PriorityQueue — O(log n) — O(log n) (poll) O(n) Montículo: el menor primero

💻 Ejemplo: Una medición sencilla (no es un benchmark riguroso: para eso existe JMH) que confirma la tabla.

import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class Rendimiento {

    static long ms(Runnable r) {
        long t = System.nanoTime();
        r.run();
        return (System.nanoTime() - t) / 1_000_000;
    }

    public static void main(String[] args) {
        int n = 50_000;
        List<Integer> arrayList = new ArrayList<>();
        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < n; i++) { arrayList.add(i); linkedList.add(i); }

        // Acceso por índice: O(1) frente a O(n)
        long tArray = ms(() -> { long s = 0; for (int i = 0; i < n; i++) s += arrayList.get(i); });
        long tLinked = ms(() -> { long s = 0; for (int i = 0; i < n; i++) s += linkedList.get(i); });
        System.out.println("get(i) x " + n + ": ArrayList " + tArray + " ms, LinkedList " + tLinked + " ms");

        // Insertar al principio: O(n) frente a O(1)
        long tArrayIni = ms(() -> { for (int i = 0; i < n; i++) arrayList.addFirst(i); });
        long tLinkedIni = ms(() -> { for (int i = 0; i < n; i++) linkedList.addFirst(i); });
        System.out.println("addFirst x " + n + ": ArrayList " + tArrayIni + " ms, LinkedList " + tLinkedIni + " ms");

        // Búsqueda: O(n) en lista, O(1) en HashSet, O(log n) en TreeSet
        Set<Integer> hash = new HashSet<>(arrayList);
        Set<Integer> tree = new TreeSet<>(arrayList);
        int busquedas = 2_000;
        long tLista = ms(() -> { for (int i = 0; i < busquedas; i++) arrayList.contains(-i); });
        long tHash = ms(() -> { for (int i = 0; i < busquedas; i++) hash.contains(-i); });
        long tTree = ms(() -> { for (int i = 0; i < busquedas; i++) tree.contains(-i); });
        System.out.println("contains x " + busquedas + ": ArrayList " + tLista + " ms, HashSet " + tHash
                + " ms, TreeSet " + tTree + " ms");
    }
}
get(i) x 50000: ArrayList 103 ms, LinkedList 35449 ms
addFirst x 50000: ArrayList 10203 ms, LinkedList 188 ms
contains x 2000: ArrayList 20235 ms, HashSet 0 ms, TreeSet 76 ms

Los milisegundos dependen totalmente del equipo y de su carga (esta ejecución se hizo en una máquina muy ocupada), pero las proporciones no engañan: LinkedList.get(i) es cientos de veces más lento que en ArrayList, addFirst es al revés, y buscar en un HashSet es prácticamente instantáneo frente a recorrer una lista.

💡 Consejo: Guía rápida de elección: ArrayList por defecto para listas; ArrayDeque para pilas y colas; HashMap/HashSet para buscar por clave; LinkedHashMap si importa el orden de inserción; TreeMap/TreeSet si necesitas orden o búsquedas por rango; EnumMap/EnumSet con enums. LinkedList casi nunca es la mejor opción en la práctica: su supuesta ventaja (insertar en medio) exige llegar antes a esa posición, que es O(n), y sus nodos dispersos en memoria aprovechan mal la caché del procesador.


15. Ejercicios

Ejercicio 1: Métodos genéricos con cotas

Escribe dos métodos genéricos estáticos con las firmas más flexibles posibles:

  • estaOrdenada(lista): indica si una lista está ordenada de menor a mayor según el orden natural de sus elementos. Debe funcionar con List<LocalDate>.
  • claveDelMaximo(mapa): devuelve la clave asociada al mayor valor de un Map<K, V> (o null si está vacío).

Pruébalos con los presupuestos de los departamentos de EMPRESA y con fechas de alta de empleados.

Ver solución
import java.time.LocalDate;
import java.util.List;
import java.util.Map;

public class Ej1Genericos {

    /** ¿Está la lista ordenada de menor a mayor? */
    static <T extends Comparable<? super T>> boolean estaOrdenada(List<? extends T> lista) {
        for (int i = 1; i < lista.size(); i++) {
            if (lista.get(i - 1).compareTo(lista.get(i)) > 0) return false;
        }
        return true;
    }

    /** Clave asociada al mayor valor de un mapa (null si el mapa está vacío). */
    static <K, V extends Comparable<? super V>> K claveDelMaximo(Map<K, V> mapa) {
        K mejor = null;
        V max = null;
        for (Map.Entry<K, V> e : mapa.entrySet()) {
            if (max == null || e.getValue().compareTo(max) > 0) {
                max = e.getValue();
                mejor = e.getKey();
            }
        }
        return mejor;
    }

    public static void main(String[] args) {
        System.out.println(estaOrdenada(List.of(1, 3, 3, 8)) + " " + estaOrdenada(List.of("b", "a")));
        // LocalDate implementa Comparable<ChronoLocalDate>, un SUPERTIPO: por eso hace falta "? super T"
        System.out.println(estaOrdenada(List.of(LocalDate.of(2025, 1, 15), LocalDate.of(2026, 7, 10))));

        Map<String, Double> presupuestos = Map.of("DIRECCIÓN", 500000.0, "VENTAS", 350000.0, "INFORMÁTICA", 420000.0);
        System.out.println("Mayor presupuesto: " + claveDelMaximo(presupuestos));
        Map<Integer, LocalDate> altas = Map.of(100, LocalDate.of(2010, 1, 15), 160, LocalDate.of(2025, 9, 1));
        System.out.println("Alta más reciente: empleado " + claveDelMaximo(altas));
        System.out.println("Mapa vacío: " + claveDelMaximo(Map.<String, Integer>of()));
    }
}
true false
true
Mayor presupuesto: DIRECCIÓN
Alta más reciente: empleado 160
Mapa vacío: null

Comparable<? super T> es imprescindible para LocalDate: con <T extends Comparable<T>> la llamada con fechas no compilaría. El parámetro List<? extends T> es un productor (solo leemos), así que también acepta listas de subtipos.

Ejercicio 2: Lista enlazada genérica

Implementa una ListaEnlazada<E> simplemente enlazada que implemente Iterable<E>, con referencias a la cabeza y a la cola, y los métodos add(e) (al final, en O(1)), addFirst(e), get(indice), remove(indice), removeIf(Predicate<? super E>), size() y toString() con el formato [a -> b -> c]. Valida los índices con Objects.checkIndex.

Ver solución
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.function.Predicate;

/** Lista simplemente enlazada genérica. */
public class ListaEnlazada<E> implements Iterable<E> {

    // Nodo: clase anidada estática y genérica (no necesita acceder a la lista)
    private static final class Nodo<E> {
        E valor;
        Nodo<E> siguiente;
        Nodo(E valor, Nodo<E> siguiente) { this.valor = valor; this.siguiente = siguiente; }
    }

    private Nodo<E> cabeza;
    private Nodo<E> cola;
    private int tamano;

    public void addFirst(E e) {
        cabeza = new Nodo<>(e, cabeza);
        if (cola == null) cola = cabeza;
        tamano++;
    }

    public void add(E e) {                     // al final, O(1) gracias a la referencia a la cola
        Nodo<E> nuevo = new Nodo<>(e, null);
        if (cola == null) cabeza = cola = nuevo;
        else { cola.siguiente = nuevo; cola = nuevo; }
        tamano++;
    }

    public E get(int indice) {                 // O(n): hay que recorrer
        return nodo(indice).valor;
    }

    public E remove(int indice) {
        Objects.checkIndex(indice, tamano);
        E eliminado;
        if (indice == 0) {
            eliminado = cabeza.valor;
            cabeza = cabeza.siguiente;
            if (cabeza == null) cola = null;
        } else {
            Nodo<E> anterior = nodo(indice - 1);
            eliminado = anterior.siguiente.valor;
            anterior.siguiente = anterior.siguiente.siguiente;
            if (anterior.siguiente == null) cola = anterior;
        }
        tamano--;
        return eliminado;
    }

    /** Elimina todos los elementos que cumplen la condición. */
    public int removeIf(Predicate<? super E> condicion) {
        int antes = tamano;
        while (cabeza != null && condicion.test(cabeza.valor)) { cabeza = cabeza.siguiente; tamano--; }
        if (cabeza == null) { cola = null; return antes; }
        Nodo<E> actual = cabeza;
        while (actual.siguiente != null) {
            if (condicion.test(actual.siguiente.valor)) { actual.siguiente = actual.siguiente.siguiente; tamano--; }
            else actual = actual.siguiente;
        }
        cola = actual;
        return antes - tamano;
    }

    public int size() { return tamano; }

    private Nodo<E> nodo(int indice) {
        Objects.checkIndex(indice, tamano);
        Nodo<E> n = cabeza;
        for (int i = 0; i < indice; i++) n = n.siguiente;
        return n;
    }

    @Override
    public Iterator<E> iterator() {
        return new Iterator<>() {
            private Nodo<E> actual = cabeza;
            public boolean hasNext() { return actual != null; }
            public E next() {
                if (actual == null) throw new NoSuchElementException();
                E v = actual.valor;
                actual = actual.siguiente;
                return v;
            }
        };
    }

    @Override
    public String toString() {
        StringJoiner sj = new StringJoiner(" -> ", "[", "]");
        for (E e : this) sj.add(String.valueOf(e));
        return sj.toString();
    }

    public static void main(String[] args) {
        ListaEnlazada<String> productos = new ListaEnlazada<>();
        productos.add("Ratón");
        productos.add("Teclado");
        productos.add("Monitor");
        productos.addFirst("Portátil");
        System.out.println(productos + " tamaño=" + productos.size() + " get(2)=" + productos.get(2));
        System.out.println("remove(1)=" + productos.remove(1) + " -> " + productos);

        ListaEnlazada<Integer> numeros = new ListaEnlazada<>();
        for (int i = 1; i <= 10; i++) numeros.add(i);
        System.out.println("eliminados pares: " + numeros.removeIf(n -> n % 2 == 0) + " -> " + numeros);
        numeros.add(99);                                      // la cola sigue bien enlazada
        int suma = 0;
        for (int n : numeros) suma += n;                      // for-each gracias a Iterable
        System.out.println(numeros + " suma=" + suma);
        try {
            numeros.get(50);
        } catch (IndexOutOfBoundsException e) {
            System.out.println("get(50): " + e.getMessage());
        }
    }
}
[Portátil -> Ratón -> Teclado -> Monitor] tamaño=4 get(2)=Teclado
remove(1)=Ratón -> [Portátil -> Teclado -> Monitor]
eliminados pares: 5 -> [1 -> 3 -> 5 -> 7 -> 9]
[1 -> 3 -> 5 -> 7 -> 9 -> 99] suma=124
get(50): Index 50 out of bounds for length 6

La parte delicada es mantener cola coherente: al borrar el último nodo (en remove y en removeIf) hay que actualizarla, o el siguiente add enlazaría con un nodo que ya no está en la lista. Por eso la prueba añade el 99 después de removeIf. Nodo es static porque no necesita acceder a la instancia de la lista: así cada nodo no guarda una referencia oculta a ella.

Ejercicio 3: Caché LRU con estadísticas

Crea una clase genérica Cache<K, V> con capacidad máxima, que reciba en el constructor una función “cargadora” (Function<? super K, ? extends V>) para calcular los valores ausentes, expulse el elemento usado hace más tiempo al llenarse y cuente aciertos y fallos. Úsala para simular consultas a la tabla productos con capacidad 3 y la secuencia de peticiones 1, 6, 1, 9, 1, 10, 6, 1, 9.

Ver solución
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;

public class Ej3Cache {

    /** Caché LRU genérica que calcula los valores ausentes y lleva estadísticas. */
    static class Cache<K, V> {
        private final Map<K, V> datos;
        private final Function<? super K, ? extends V> cargador;   // PECS: consume K, produce V
        private int aciertos, fallos;

        Cache(int capacidad, Function<? super K, ? extends V> cargador) {
            this.cargador = cargador;
            this.datos = new LinkedHashMap<>(16, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<K, V> e) {
                    return size() > capacidad;
                }
            };
        }

        V get(K clave) {
            V v = datos.get(clave);
            if (v != null) { aciertos++; return v; }
            fallos++;
            v = cargador.apply(clave);
            datos.put(clave, v);
            return v;
        }

        String estadisticas() {
            int total = aciertos + fallos;
            return "aciertos=%d fallos=%d tasa=%.0f%% contenido=%s".formatted(
                    aciertos, fallos, total == 0 ? 0.0 : 100.0 * aciertos / total, datos.keySet());
        }
    }

    // Simula una consulta lenta a la tabla productos
    static final Map<Integer, String> PRODUCTOS = Map.of(1, "Portátil UltraBook 14", 2, "Portátil Gaming 16",
            6, "Ratón inalámbrico", 9, "Monitor 24 FHD", 10, "Monitor 27 4K");

    static String consultarBaseDeDatos(Integer id) {
        System.out.println("   (consulta a la BD del producto " + id + ")");
        return PRODUCTOS.getOrDefault(id, "desconocido");
    }

    public static void main(String[] args) {
        Cache<Integer, String> cache = new Cache<>(3, Ej3Cache::consultarBaseDeDatos);
        int[] peticiones = {1, 6, 1, 9, 1, 10, 6, 1, 9};
        for (int id : peticiones) {
            System.out.println("get(" + id + ") = " + cache.get(id));
        }
        System.out.println(cache.estadisticas());
    }
}
   (consulta a la BD del producto 1)
get(1) = Portátil UltraBook 14
   (consulta a la BD del producto 6)
get(6) = Ratón inalámbrico
get(1) = Portátil UltraBook 14
   (consulta a la BD del producto 9)
get(9) = Monitor 24 FHD
get(1) = Portátil UltraBook 14
   (consulta a la BD del producto 10)
get(10) = Monitor 27 4K
   (consulta a la BD del producto 6)
get(6) = Ratón inalámbrico
get(1) = Portátil UltraBook 14
   (consulta a la BD del producto 9)
get(9) = Monitor 24 FHD
aciertos=3 fallos=6 tasa=33% contenido=[6, 1, 9]

En lugar de heredar de LinkedHashMap (como en la sección 8), aquí la caché contiene uno (composición), creado como clase anónima: así la clase pública solo expone get y estadisticas, y nadie puede llamar a put o remove saltándose la lógica. La firma Function<? super K, ? extends V> aplica PECS a una función: consume claves (super) y produce valores (extends); así acepta, por ejemplo, una Function<Object, String> para una Cache<Integer, CharSequence>.

Ejercicio 4: Reservas de una sala con TreeMap

Implementa una SalaReuniones que guarde las reservas (record Reserva(LocalTime inicio, LocalTime fin, String responsable)) en un NavigableMap por hora de inicio y ofrezca: reservar(inicio, fin, quien) que rechace las que se solapen (usa floorEntry y ceilingEntry: solo pueden solapar la reserva anterior y la siguiente), entre(desde, hasta) con las reservas de una franja, y primerHuecoDesde(hora).

Ver solución
import java.time.LocalTime;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;

public class Ej4Reservas {

    record Reserva(LocalTime inicio, LocalTime fin, String responsable) {}

    static class SalaReuniones {
        // Clave: hora de inicio. El TreeMap mantiene las reservas ordenadas
        private final NavigableMap<LocalTime, Reserva> reservas = new TreeMap<>();

        boolean reservar(LocalTime inicio, LocalTime fin, String quien) {
            if (!fin.isAfter(inicio)) throw new IllegalArgumentException("Intervalo vacío");
            // Solo pueden solapar la reserva anterior más cercana y la siguiente
            Map.Entry<LocalTime, Reserva> anterior = reservas.floorEntry(inicio);
            Map.Entry<LocalTime, Reserva> siguiente = reservas.ceilingEntry(inicio);
            if (anterior != null && anterior.getValue().fin().isAfter(inicio)) return false;
            if (siguiente != null && siguiente.getKey().isBefore(fin)) return false;
            reservas.put(inicio, new Reserva(inicio, fin, quien));
            return true;
        }

        /** Reservas que empiezan en [desde, hasta). */
        NavigableMap<LocalTime, Reserva> entre(LocalTime desde, LocalTime hasta) {
            return reservas.subMap(desde, true, hasta, false);
        }

        LocalTime primerHuecoDesde(LocalTime hora) {
            LocalTime candidata = hora;
            Map.Entry<LocalTime, Reserva> previa = reservas.floorEntry(candidata);
            if (previa != null && previa.getValue().fin().isAfter(candidata)) candidata = previa.getValue().fin();
            for (Reserva r : reservas.tailMap(candidata, true).values()) {
                if (r.inicio().isAfter(candidata)) break;      // hay hueco antes de r
                candidata = r.fin();
            }
            return candidata;
        }
    }

    public static void main(String[] args) {
        SalaReuniones sala = new SalaReuniones();
        String[][] peticiones = {
                {"09:00", "10:00", "Ana"}, {"10:00", "11:30", "Luis"}, {"09:30", "10:30", "Marta"},
                {"12:00", "13:00", "Pedro"}, {"11:00", "12:00", "Laura"}, {"11:30", "12:00", "Javier"}};
        for (String[] p : peticiones) {
            boolean ok = sala.reservar(LocalTime.parse(p[0]), LocalTime.parse(p[1]), p[2]);
            System.out.printf("%-6s %s-%s %s%n", p[2], p[0], p[1], ok ? "reservada" : "RECHAZADA (solapa)");
        }
        System.out.println("Mañana (9:00-12:00): " + sala.entre(LocalTime.of(9, 0), LocalTime.of(12, 0)).values()
                .stream().map(Reserva::responsable).toList());
        System.out.println("Primer hueco desde las 9:15: " + sala.primerHuecoDesde(LocalTime.of(9, 15)));
    }
}
Ana    09:00-10:00 reservada
Luis   10:00-11:30 reservada
Marta  09:30-10:30 RECHAZADA (solapa)
Pedro  12:00-13:00 reservada
Laura  11:00-12:00 RECHAZADA (solapa)
Javier 11:30-12:00 reservada
Mañana (9:00-12:00): [Ana, Luis, Javier]
Primer hueco desde las 9:15: 13:00

Gracias al orden del TreeMap, comprobar el solapamiento es O(log n) en lugar de recorrer todas las reservas. Las reservas que “se tocan” (Luis empieza a las 10:00, justo cuando acaba Ana) no se consideran solapadas porque se compara con isAfter/isBefore estrictos. Y el primer hueco desde las 9:15 son las 13:00 porque las reservas encadenadas de Ana, Luis, Javier y Pedro cubren sin interrupción hasta esa hora.

Ejercicio 5: Ranking de clientes con Comparator

Ordena los clientes de EMPRESA por comercial asignado (los que no tienen comercial, al final), dentro de cada comercial por límite de crédito descendente y, a igualdad, por nombre según las reglas del español. Muestra después las ciudades distintas ordenadas alfabéticamente con Collator.

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

public class Ej5Ranking {

    record Cliente(int id, String nombre, String ciudad, double limiteCredito, Integer comercial) {}

    public static void main(String[] args) {
        List<Cliente> clientes = new ArrayList<>(List.of(
                new Cliente(1001, "Talleres Martín S.L.", "Valencia", 10000, 110),
                new Cliente(1002, "Construcciones Levante S.A.", "Alicante", 25000, 110),
                new Cliente(1004, "Academia Nova", "Madrid", 8000, 111),
                new Cliente(1007, "Hotel Mar Azul S.L.", "Palma", 15000, 111),
                new Cliente(1009, "Laura Benítez Sanz", "Bilbao", 1000, null),
                new Cliente(1010, "Instituto Tecnológico Sur", "Granada", 20000, 112),
                new Cliente(1012, "Diseños Creativos S.L.", "Barcelona", 7000, 110),
                new Cliente(1013, "Carlos Ibáñez Rey", "Valencia", 1500, null),
                new Cliente(1014, "Farmacia Central", "Oviedo", 4000, 113)));

        Collator es = Collator.getInstance(Locale.of("es", "ES"));

        // Comercial (los null al final), dentro de cada comercial por crédito descendente y luego por nombre
        Comparator<Cliente> orden = Comparator
                .comparing(Cliente::comercial, Comparator.nullsLast(Comparator.naturalOrder()))
                .thenComparing(Comparator.comparingDouble(Cliente::limiteCredito).reversed())
                .thenComparing(Cliente::nombre, es);

        clientes.sort(orden);
        for (Cliente c : clientes) {
            System.out.printf("%-5s %8.0f  %s%n", c.comercial() == null ? "-" : c.comercial(), c.limiteCredito(), c.nombre());
        }

        // Ciudades distintas ordenadas según el idioma español
        List<String> ciudades = clientes.stream().map(Cliente::ciudad).distinct().sorted(es).toList();
        System.out.println(ciudades);
    }
}
110      25000  Construcciones Levante S.A.
110      10000  Talleres Martín S.L.
110       7000  Diseños Creativos S.L.
111      15000  Hotel Mar Azul S.L.
111       8000  Academia Nova
112      20000  Instituto Tecnológico Sur
113       4000  Farmacia Central
-         1500  Carlos Ibáñez Rey
-         1000  Laura Benítez Sanz
[Alicante, Barcelona, Bilbao, Granada, Madrid, Oviedo, Palma, Valencia]

La clave está en el segundo criterio: thenComparing(Comparator.comparingDouble(...).reversed()) invierte solo ese criterio, porque el reversed() se aplica al comparador interno. Si hubieras escrito .thenComparingDouble(Cliente::limiteCredito).reversed(), habrías invertido también el orden de los comerciales (y los null habrían pasado al principio). Collator implementa Comparator<Object>, por eso sirve tanto en thenComparing como en sorted.

Ejercicio 6: Contador de frecuencias genérico

Escribe una clase Contador<T> que cuente apariciones de elementos con Map.merge, con los métodos anadir(T), anadirTodos(Iterable<? extends T>), frecuencia(Object), masFrecuentes(int n) (las n entradas más frecuentes; a igualdad, la que apareció antes) y copiarDistintosEn(Collection<? super T>). Úsalo con los oficios de los empleados de EMPRESA y con los productos de unas líneas de pedido.

Ver solución
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Cuenta cuántas veces aparece cada elemento. */
public class Contador<T> {

    private final Map<T, Integer> cuentas = new LinkedHashMap<>();   // conserva el orden de aparición

    public void anadir(T elemento) {
        cuentas.merge(elemento, 1, Integer::sum);
    }

    public void anadirTodos(Iterable<? extends T> elementos) {        // productor: extends
        for (T e : elementos) anadir(e);
    }

    public int frecuencia(Object elemento) {
        return cuentas.getOrDefault(elemento, 0);
    }

    /** Los n más frecuentes; a igual frecuencia, el que apareció antes. */
    public List<Map.Entry<T, Integer>> masFrecuentes(int n) {
        List<Map.Entry<T, Integer>> lista = new ArrayList<>(cuentas.entrySet());
        lista.sort(Map.Entry.<T, Integer>comparingByValue().reversed());   // sort es estable
        return Collections.unmodifiableList(lista.subList(0, Math.min(n, lista.size())));
    }

    /** Copia los elementos distintos en un destino: consumidor, super. */
    public void copiarDistintosEn(Collection<? super T> destino) {
        destino.addAll(cuentas.keySet());
    }

    public static void main(String[] args) {
        Contador<String> oficios = new Contador<>();
        oficios.anadirTodos(List.of("DIRECTOR GENERAL", "DIRECTOR", "DIRECTOR", "DIRECTOR", "DIRECTOR", "DIRECTOR",
                "VENDEDOR", "VENDEDOR", "VENDEDOR", "VENDEDOR", "ADMINISTRATIVO", "ANALISTA", "PROGRAMADOR",
                "PROGRAMADOR", "PROGRAMADOR", "TÉCNICO SISTEMAS", "CONTABLE", "ADMINISTRATIVO", "DISEÑADOR",
                "ANALISTA", "OPERARIO", "OPERARIO", "ADMINISTRATIVO", "BECARIO"));
        System.out.println("Top 3: " + oficios.masFrecuentes(3));
        System.out.println("BECARIO: " + oficios.frecuencia("BECARIO") + ", PILOTO: " + oficios.frecuencia("PILOTO"));

        Contador<Integer> productos = new Contador<>();
        productos.anadirTodos(List.of(6, 9, 13, 6, 10, 9, 6, 13, 9, 10, 10, 6));
        List<Object> distintos = new ArrayList<>();
        productos.copiarDistintosEn(distintos);
        System.out.println("Productos más pedidos: " + productos.masFrecuentes(2) + " distintos=" + distintos);
    }
}
Top 3: [DIRECTOR=5, VENDEDOR=4, ADMINISTRATIVO=3]
BECARIO: 1, PILOTO: 0
Productos más pedidos: [6=4, 9=3] distintos=[6, 9, 13, 10]

El desempate “el que apareció antes” sale gratis de dos propiedades que has visto en el módulo: LinkedHashMap conserva el orden de inserción y List.sort es estable. frecuencia recibe Object (como Map.get o Collection.contains) porque preguntar por un elemento de otro tipo no es un error: simplemente la respuesta es 0.

Ejercicio 7 (propuesto): Cola de prioridad de tareas

Modela tareas como record Tarea(String titulo, int prioridad, LocalDateTime creada) y gestiona una PriorityQueue<Tarea> que atienda primero las de mayor prioridad y, a igualdad, las más antiguas. Implementa un método genérico static <T> List<T> extraerN(PriorityQueue<T> cola, int n) que saque las n primeras. Comprueba qué pasa si recorres la cola con un for-each en lugar de con poll() (¿salen ordenadas?).

Ejercicio 8 (propuesto): Repositorio<T, ID> en memoria

Define la interfaz genérica Repositorio<T, ID> de la sección 2.1 (con guardar, buscarPorId devolviendo Optional<T>, buscarTodos, borrar y contar) e impleméntala con un HashMap en RepositorioMemoria<T, ID>, que recibe en el constructor una Function<T, ID> para obtener el identificador de cada entidad. Úsala con records Empleado y Departamento de EMPRESA.

Ejercicio 9 (propuesto): Índice invertido

Construye un Map<String, NavigableSet<Integer>> que, para cada palabra del nombre de los productos de EMPRESA, guarde los identificadores de los productos que la contienen (por ejemplo, "portátil" → [1, 2, 3]). Usa computeIfAbsent, normaliza las palabras a minúsculas y permite buscar productos que contengan todas las palabras de una consulta (intersección de conjuntos con retainAll).

Ejercicio 10 (propuesto): Inmutabilidad profunda

Crea un record Pedido(int id, List<LineaPedido> lineas) que sea realmente inmutable: copia defensiva en el constructor compacto con List.copyOf y LineaPedido también como record. Escribe un main que demuestre que ni modificando la lista original ni la devuelta por lineas() se altera el pedido. Explica qué pasaría si LineaPedido tuviera un campo StringBuilder.

Ejercicio 11 (propuesto): Estadísticas por departamento con EnumMap

Define enum Departamento { DIRECCION, VENTAS, INFORMATICA, CONTABILIDAD, MARKETING, LOGISTICA, INVESTIGACION } y, con los empleados de EMPRESA, construye un EnumMap<Departamento, DoubleSummaryStatistics> con las estadísticas de salario de cada departamento (incluidos los vacíos). Muéstralo en orden de declaración y después en orden de salario medio descendente.

Ejercicio 12 (propuesto): Pila con capacidad máxima y genéricos acotados

Crea PilaAcotada<E extends Comparable<? super E>> a partir de la Pila de la sección 5, con una capacidad máxima fija (lanza IllegalStateException al superarla) y un método E maximo() que devuelva el mayor elemento en O(1). Pista: mantén una segunda pila con los máximos parciales.


Resumen

Concepto Sintaxis Idea clave
Clase genérica class Caja<T> { ... } Parámetros de tipo tras el nombre
Método genérico static <T> T ultimo(List<T> l) <T> antes del tipo de retorno; el compilador lo infiere
Tipo acotado <T extends Comparable<? super T>> Permite usar los métodos de la cota
Cota múltiple <T extends Number & Comparable<T>> La clase primero, luego interfaces
Comodín List<?> Lista de algo desconocido
Productor List<? extends T> Solo leer: Producer Extends
Consumidor List<? super T> Solo escribir: Consumer Super
Borrado de tipos List<String> → List Sin new T(), new T[], instanceof List<String>
Tipos crudos List Prohibidos en código nuevo: heap pollution
Iterable<E> Iterator<E> iterator() Permite el for-each en tus estructuras
HashMap Buckets, hashCode + equals O(1); claves inmutables y contrato coherente
TreeMap / NavigableMap floorKey, ceilingEntry, subMap Ordenado, O(log n), búsquedas por proximidad
Caché LRU new LinkedHashMap<>(16, 0.75f, true) + removeEldestEntry Orden de acceso
EnumMap / EnumSet new EnumMap<>(Estado.class) Rapidísimos, orden de declaración
Inmutable / vista / fija List.copyOf / unmodifiableList / Arrays.asList Copia, vista que refleja cambios, tamaño fijo
Secuenciadas (Java 21) getFirst(), getLast(), reversed(), putFirst() API uniforme para colecciones con orden
Comparator comparing(...).thenComparing(...).reversed() nullsLast, Collator para texto en español
Complejidad ArrayList O(1) get, HashSet O(1) contains, TreeSet O(log n) Elige según la operación más frecuente